返回提交历史
Modified
g4f-go/download.go
+42
-17
Modified
g4f-go/main.go
+22
-3
Modified
g4f-go/runtime.go
+27
-0
Modified
g4f/Provider/DeepInfra.py
+1
-1
Modified
g4f/Provider/audio/OpenAIFM.py
+1
-1
Modified
g4f/Provider/local/Ollama.py
+1
-3
Modified
g4f/Provider/needs_auth/Airforce.py
+1
-1
Modified
g4f/Provider/needs_auth/Nvidia.py
+1
-2
Modified
g4f/requests/raise_for_status.py
+14
-16
Modified
projects/discord-bot/.env.example
+40
-0
Modified
projects/discord-bot/README.md
+55
-0
Modified
projects/discord-bot/bot.py
+39
-0
XFEstudio/gpt4free
Flush changes
b741a0bf
代码差异
12 个文件
+244
-44
@@ -381,27 +381,52 @@ func IsTermuxSystem() bool {
381
381
return !os.IsNotExist(err)
382
382
}
383
383
384
// ensureTermuxRuntime installs python and build dependencies via Termux's
385
// package manager and returns the system python path. No CPython download is
386
// needed — Termux provides a native python package.
387
func ensureTermuxRuntime() (string, error) {
388
// Packages required for g4f and native extensions.
389
required := []string{"python", "clang", "make", "libxml2", "libxslt", "libjpeg-turbo", "libpng"}
390
var missing []string
391
for _, pkg := range required {
392
if !termuxPackageInstalled(pkg) {
393
missing = append(missing, pkg)
394
}
395
}
396
if len(missing) > 0 {
397
fmt.Printf("Installing Termux packages: %s\n", strings.Join(missing, ", "))
398
args := append([]string{"install", "-y"}, missing...)
399
cmd := exec.Command("pkg", args...)
400
cmd.Stdout = os.Stdout
401
cmd.Stderr = os.Stderr
402
if err := cmd.Run(); err != nil {
403
return "", fmt.Errorf("pkg install failed: %w", err)
404
}
405
}
406
407
py, err := exec.LookPath("python3")
408
if err != nil {
409
py, err = exec.LookPath("python")
410
}
411
if err != nil {
412
return "", fmt.Errorf("python not found in Termux PATH (run 'pkg install python')")
413
}
414
return py, nil
415
}
416
417
// termuxPackageInstalled checks if a Termux package is already installed
418
// using dpkg.
419
func termuxPackageInstalled(pkg string) bool {
420
cmd := exec.Command("dpkg", "-s", pkg)
421
return cmd.Run() == nil
422
}
423
384
424
// ensureRuntime is the top-level entry point. It downloads (if needed) and
385
425
// extracts the platform runtime into binDir, then returns the python
386
426
// executable/launcher path.
387
427
func ensureRuntime() (string, error) {
388
isTermux := IsTermuxSystem()
389
if isTermux {
390
// Check if key compilers/tools are installed
391
if !commandExists("clang") || !commandExists("make") {
392
fmt.Println("Missing required tools. Installing...")
393
394
packages := []string{"clang", "make", "libxml2", "libxslt", "libjpeg-turbo", "libpng"}
395
args := append([]string{"install", "-y"}, packages...)
396
397
cmd := exec.Command("pkg", args...)
398
cmd.Stdout = os.Stdout
399
cmd.Stderr = os.Stderr
400
401
if err := cmd.Run(); err != nil {
402
fmt.Printf("Error during package installation: %v\n", err)
403
}
404
}
428
if IsTermuxSystem() {
429
return ensureTermuxRuntime()
405
430
}
406
431
407
432
binDir := installDir()
@@ -108,9 +108,18 @@ func runMain() int {
108
108
fmt.Fprintln(os.Stderr, "g4f-go:", err)
109
109
return 1
110
110
}
111
start := time.Now()
112
if err := installG4F(binDir, exe, start); err != nil {
113
fmt.Fprintln(os.Stderr, "g4f-go:", err)
111
if !g4fIsInstalled(binDir) {
112
// First call: install g4f.
113
start := time.Now()
114
if err := installG4F(binDir, exe, start); err != nil {
115
fmt.Fprintln(os.Stderr, "g4f-go:", err)
116
}
117
} else if len(args) > 0 && isUpgradeCommand(args[0]) {
118
// Subsequent call with gui/api/dev: upgrade g4f.
119
start := time.Now()
120
if err := upgradeG4F(binDir, exe, start); err != nil {
121
fmt.Fprintln(os.Stderr, "g4f-go:", err)
122
}
114
123
}
115
124
116
125
// Default: forward everything to the g4f module.
@@ -130,6 +139,16 @@ func normalizeArgs(args []string) []string {
130
139
return args
131
140
}
132
141
142
// isUpgradeCommand reports whether the subcommand should trigger an
143
// automatic g4f upgrade (only when g4f is already installed).
144
func isUpgradeCommand(arg string) bool {
145
switch arg {
146
case "api", "gui", "dev":
147
return true
148
}
149
return false
150
}
151
133
152
// hasSubcommand reports whether args start with a known g4f-go subcommand.
134
153
func hasSubcommand(args []string) bool {
135
154
if len(args) == 0 {
@@ -108,6 +108,14 @@ func extractEmbedded(binDir string) error {
108
108
return nil
109
109
}
110
110
111
// g4fIsInstalled reports whether the g4f Python package is already installed
112
// in the downloaded runtime by checking the .installed stamp.
113
func g4fIsInstalled(binDir string) bool {
114
stamp := filepath.Join(binDir, ".g4f-runtime", ".installed")
115
_, err := os.Stat(stamp)
116
return err == nil
117
}
118
111
119
// installG4F installs the g4f Python package into the downloaded runtime and
112
120
// writes the .installed stamp. Uses the bundled pip (ensurepip wheels in
113
121
// pbs installs, bootstrapped via `python -m ensurepip`) so no network access
@@ -135,6 +143,25 @@ func installG4F(binDir, exe string, start time.Time) error {
135
143
return nil
136
144
}
137
145
146
// upgradeG4F upgrades the g4f Python package in the downloaded runtime.
147
// Called on subsequent runs (after the initial install) when the user passes
148
// a subcommand that benefits from the latest version (api, gui, dev).
149
func upgradeG4F(binDir, exe string, start time.Time) error {
150
fmt.Printf("Upgrading gpt4free...\n")
151
if err := ensurePip(binDir, exe); err != nil {
152
return err
153
}
154
code, err := runPython(noSignalCtx(), exe, []string{
155
"-m", "pip", "install",
156
"--no-input", "--upgrade", "g4f[slim]",
157
}, pipEnv(binDir)...)
158
if err != nil || code != 0 {
159
return fmt.Errorf("pip upgrade g4f failed (exit %d): %w", code, err)
160
}
161
fmt.Printf("gpt4free upgraded in %.1fs\n", time.Since(start).Seconds())
162
return nil
163
}
164
138
165
// ensurePip makes `python -m pip` available in the downloaded runtime.
139
166
// pbs installs ship ensurepip but no standalone pip; bootstrap once.
140
167
func ensurePip(binDir, exe string) error {
@@ -16,7 +16,7 @@ def _get_turnstile_token_sync(model: str) -> str:
16
16
"""
17
17
import time
18
18
19
for attempt in range(3):
19
for attempt in range(1):
20
20
session = SyncCDPSession(headless=False)
21
21
session.start_chrome()
22
22
@@ -46,7 +46,7 @@ class OpenAIFM(AsyncGeneratorProvider, ProviderModelMixin):
46
46
models = styles + voices
47
47
48
48
@classmethod
49
def get_grouped_models(cls):
49
def get_grouped_models(cls, **kwargs):
50
50
return [
51
51
{"group": "Styles", "models": cls.styles},
52
52
{"group": "Voices", "models": cls.voices},
@@ -8,13 +8,11 @@ from typing import Optional
8
8
9
9
from ..template import OpenaiTemplate
10
10
from ...requests import StreamSession, raise_for_status
11
from ...providers.response import Usage, Reasoning
12
11
from ...cookies import get_cookies
13
12
from ...tools.run_tools import AuthManager
14
13
from ...typing import AsyncResult, Messages
15
14
from ...config import AppConfig
16
15
from ...errors import MissingAuthError
17
from ... import debug
18
16
19
17
20
18
class Ollama(OpenaiTemplate):
@@ -27,7 +25,7 @@ class Ollama(OpenaiTemplate):
27
25
active_by_default = True
28
26
local_models: list[str] = []
29
27
model_aliases = {"gpt-oss-120b": "gpt-oss:120b", "gpt-oss-20b": "gpt-oss:20b"}
30
default_model = "nemotron-3-super"
28
default_model = "nemotron-3-nano:30b"
31
29
32
30
@classmethod
33
31
async def get_quota(cls, api_key: Optional[str] = None) -> Optional[dict]:
@@ -13,7 +13,7 @@ class Airforce(OpenaiTemplate):
13
13
working = True
14
14
active_by_default = True
15
15
use_image_size = True
16
default_model = "gpt-4o-mini"
16
default_model = "unmoderated-gpt"
17
17
18
18
@classmethod
19
19
async def create_async_generator(
@@ -1,7 +1,6 @@
1
1
from __future__ import annotations
2
2
3
3
from ..template import OpenaiTemplate
4
from ...config import DEFAULT_MODEL
5
4
6
5
7
6
class Nvidia(OpenaiTemplate):
@@ -12,5 +11,5 @@ class Nvidia(OpenaiTemplate):
12
11
url = "https://build.nvidia.com"
13
12
working = True
14
13
active_by_default = True
15
default_model = DEFAULT_MODEL
14
default_model = "nvidia/nemotron-3.5-lightning-30b-a3b"
16
15
add_user = False
@@ -40,7 +40,6 @@ async def raise_for_status_async(
40
40
):
41
41
if response.ok:
42
42
return
43
is_html = False
44
43
if message is None:
45
44
content_type = response.headers.get("content-type", "")
46
45
if content_type.startswith("application/json"):
@@ -58,13 +57,13 @@ async def raise_for_status_async(
58
57
except json.JSONDecodeError:
59
58
message = await response.text()
60
59
else:
61
message = (await response.text()).strip()
62
is_html = content_type.startswith(
60
message = await response.text()
61
if content_type.startswith(
63
62
"text/html"
64
) or message.lower().startswith("<!DOCTYPE".lower())
65
if message is None or is_html:
66
if response.status == 520:
67
message = "Unknown error (Cloudflare)"
63
) or message.strip().lower().startswith("<!DOCTYPE".lower()):
64
message = "HTML content"
65
if response.status == 520:
66
message = "Unknown error (Cloudflare)"
68
67
if response.status in (429, 402):
69
68
raise RateLimitError(f"Response {response.status}: {message}")
70
69
if response.status == 401:
@@ -81,7 +80,7 @@ async def raise_for_status_async(
81
80
raise MissingAuthError(f"Response {response.status}: Invalid API key")
82
81
else:
83
82
raise ResponseStatusError(
84
f"Response {response.status}: {'HTML content' if is_html else message}"
83
f"Response {response.status}: {message}"
85
84
)
86
85
87
86
@@ -93,15 +92,14 @@ def raise_for_status(
93
92
return raise_for_status_async(response, message)
94
93
if response.ok:
95
94
return
96
is_html = False
97
if message is None:
98
is_html = response.headers.get("content-type", "").startswith(
95
if response.headers.get("content-type", "").startswith(
99
96
"text/html"
100
) or response.text.startswith("<!DOCTYPE")
97
) or response.text.strip().lower().startswith("<!DOCTYPE".lower()):
98
message = "HTML content"
99
elif message is None:
101
100
message = response.text
102
if message is None or is_html:
103
if response.status_code == 520:
104
message = "Unknown error (Cloudflare)"
101
if response.status_code == 520:
102
message = "Unknown error (Cloudflare)"
105
103
if response.status_code in (429, 402):
106
104
raise RateLimitError(f"Response {response.status_code}: {message}")
107
105
if response.status_code == 401:
@@ -118,5 +116,5 @@ def raise_for_status(
118
116
raise MissingAuthError(f"Response {response.status_code}: Invalid API key")
119
117
else:
120
118
raise ResponseStatusError(
121
f"Response {response.status_code}: {'HTML content' if is_html else message}"
119
f"Response {response.status_code}: {message}"
122
120
)
@@ -32,3 +32,43 @@ G4F_MAX_HISTORY=12
32
32
# are silently blocked. Useful for announcement or trap channels where
33
33
# you don't want command spam.
34
34
# G4F_HONEYPOT_CHANNELS=111111111111111111
35
36
# ---------------------------------------------------------------------------
37
# Live feed
38
# ---------------------------------------------------------------------------
39
# When G4F_LIVE_FEED_CHANNEL is set, the bot posts a live activity feed
40
# to that Discord channel:
41
# - 🖼️ thumbnails of generated images
42
# - 🔧 tool calls (web search, scraping, image gen, ...)
43
# - 📝 file edits (apply_patch, file_write, file_delete)
44
# - ⚡ heavy token usage completions
45
# - 🚨 server errors (5xx)
46
# - 👋 new g4f.dev users
47
# - 📊 periodic activity summaries
48
#
49
# Leave unset to disable the feed.
50
# G4F_LIVE_FEED_CHANNEL=123456789012345678
51
52
# Base URL of the g4f API server (where /api/logs is served).
53
# G4F_API_BASE=http://localhost:8080
54
55
# Public base URL for Discord-accessible image/thumbnail links.
56
# Defaults to G4F_API_BASE. Set this if the API server is behind a tunnel
57
# or reverse proxy and Discord needs a different host to fetch images.
58
# G4F_PUBLIC_BASE=https://your-public-host.example
59
60
# g4f.dev base URL for new-user announcements. Set to empty to disable.
61
# G4F_MEMBERS_BASE=https://g4f.dev
62
63
# Seconds between feed polls (default: 15).
64
# G4F_FEED_POLL_INTERVAL=15
65
66
# Token count that flags a completion as "heavy" (default: 10000).
67
# G4F_HEAVY_TOKEN_THRESHOLD=10000
68
69
# Seconds between activity summaries (default: 3600 = 1 hour).
70
# G4F_FEED_SUMMARY_INTERVAL=3600
71
72
# Max embeds posted per poll cycle (default: 5). Prevents spam if many
73
# events arrive at once.
74
# G4F_FEED_MAX_POSTS_PER_CYCLE=5
@@ -12,6 +12,7 @@ A Discord bot powered by [gpt4free (g4f)](https://github.com/xtekky/gpt4free) th
12
12
- 🛠️ **MCP tool-calling** — the AI can autonomously call tools (web search, web scraping, image generation, text-to-audio, and more) via g4f's built-in MCP server. The bot executes the tool, feeds the result back, and loops until the AI has a final answer.
13
13
- ⚡ **Streaming responses** — edits the message in-place for a live "typing" effect
14
14
- 🔒 Per-user history isolation with configurable length
15
- 📡 **Live activity feed** — an optional channel that mirrors g4f activity in real time: image thumbnails, tool calls, file edits, heavy token usage, server errors, new g4f.dev users, and periodic summaries.
15
16
16
17
## Setup
17
18
@@ -134,11 +135,65 @@ See all available providers with:
134
135
g4f --help
135
136
```
136
137
138
## Live activity feed
139
140
The bot can mirror g4f activity into a dedicated Discord channel in real time. Enable it by setting `G4F_LIVE_FEED_CHANNEL` to a channel ID in your `.env`.
141
142
### What gets posted
143
144
| Event | Trigger | Example |
145
|---|---|---|
146
| 🖼️ Image Generated | Any `/v1/images/generate` or `/v1/media/generate` request | Embed with the thumbnail + full-size link |
147
| 🔧 Tool Calls | A chat completion whose response includes `tool_calls` | Lists tool names, model, prompt snippet |
148
| 📝 File Edit | A tool call to `apply_patch`, `file_write`, or `file_delete` | Highlighted separately from other tools |
149
| ⚡ Heavy Token Usage | A completion using ≥ `G4F_HEAVY_TOKEN_THRESHOLD` tokens | Shows prompt/completion/total token counts |
150
| 🚨 Server Error | Any request returning a `5xx` status | Path, status, duration |
151
| 👋 New g4f.dev User | A new user appears in `/members/api/recent-users` | Username, provider, tier, avatar |
152
| 📊 Activity Summary | Every `G4F_FEED_SUMMARY_INTERVAL` seconds | Rolling counts + top models/providers |
153
154
### How it works
155
156
The `LiveFeed` cog (in `live_feed.py`) polls two sources on a configurable interval (default 15 s):
157
158
1. **`{G4F_API_BASE}/api/logs`** — the g4f API server's request log. The cog remembers the last seen log id and only processes new entries. Image URLs pointing at `/media/` or `/images/` are rewritten to `/thumbnail/` (using `G4F_PUBLIC_BASE`) so Discord can fetch compact previews.
159
2. **`{G4F_MEMBERS_BASE}/members/api/recent-users`** — a public endpoint on the g4f.dev members worker that returns the most recently created users. The cog tracks seen `provider:username` keys and announces new ones.
160
161
To keep the channel readable, at most `G4F_FEED_MAX_POSTS_PER_CYCLE` embeds are posted per poll cycle (additional events are still counted toward the periodic summary).
162
163
### Setup
164
165
1. Create a dedicated channel in your Discord server (e.g. `#g4f-live`).
166
2. Copy its channel ID (right-click → Copy ID, with Developer Mode enabled).
167
3. Add to `.env`:
168
169
```bash
170
G4F_LIVE_FEED_CHANNEL=123456789012345678
171
G4F_API_BASE=http://localhost:8080 # where the g4f API runs
172
G4F_PUBLIC_BASE=https://your-public-host # optional, for Discord-accessible image links
173
G4F_MEMBERS_BASE=https://g4f.dev # set empty to disable new-user posts
174
```
175
176
4. Restart the bot. You should see `Live feed cog loaded → channel ...` in the logs.
177
178
### Configuration reference
179
180
| Variable | Default | Description |
181
|---|---|---|
182
| `G4F_LIVE_FEED_CHANNEL` | *(unset)* | Discord channel ID for the feed. Unset = disabled. |
183
| `G4F_API_BASE` | `http://localhost:8080` | g4f API base URL (must expose `/api/logs`). |
184
| `G4F_PUBLIC_BASE` | = `G4F_API_BASE` | Public base URL for Discord-accessible image/thumbnail links. |
185
| `G4F_MEMBERS_BASE` | `https://g4f.dev` | g4f.dev base URL for new-user posts. Empty = disabled. |
186
| `G4F_FEED_POLL_INTERVAL` | `15` | Seconds between polls. |
187
| `G4F_HEAVY_TOKEN_THRESHOLD` | `10000` | Token count that flags a completion as "heavy". |
188
| `G4F_FEED_SUMMARY_INTERVAL` | `3600` | Seconds between activity summaries. |
189
| `G4F_FEED_MAX_POSTS_PER_CYCLE` | `5` | Max embeds per poll cycle (anti-spam). |
190
137
191
## Project structure
138
192
139
193
```
140
194
projects/discord-bot/
141
195
├── bot.py # Main bot logic (commands, tool-calling loop)
196
├── live_feed.py # Live activity feed cog (image/tool/token/new-user events)
142
197
├── mcp_tools.py # MCP tool manager (definitions, execution, display)
143
198
├── .env.example # Template environment file
144
199
└── README.md # This file
@@ -32,6 +32,7 @@ from g4f.providers.any_provider import AnyProvider
32
32
from g4f.client import ClientFactory
33
33
34
34
from mcp_tools import MCPToolManager, ALL_AVAILABLE_TOOLS, SAFE_DEFAULT_TOOLS
35
from live_feed import LiveFeed
35
36
36
37
load_dotenv()
37
38
@@ -52,6 +53,22 @@ MAX_HISTORY = int(os.getenv("G4F_MAX_HISTORY", "12")) # messages per user
52
53
PROXY = os.getenv("G4F_PROXY") # optional, e.g. "socks5://127.0.0.1:1080"
53
54
MAX_TOOL_LOOPS = int(os.getenv("G4F_MAX_TOOL_LOOPS", "4")) # safety cap
54
55
56
# ---------------------------------------------------------------------------
57
# Live feed configuration
58
# ---------------------------------------------------------------------------
59
# When set, the bot posts a live activity feed to this Discord channel:
60
# image thumbnails, tool calls, file edits, heavy token usage, server
61
# errors, new g4f.dev users, and periodic summaries.
62
LIVE_FEED_CHANNEL = int(os.getenv("G4F_LIVE_FEED_CHANNEL", "0") or "0")
63
API_BASE = os.getenv("G4F_API_BASE", "http://localhost:8080")
64
PUBLIC_BASE = os.getenv("G4F_PUBLIC_BASE", API_BASE)
65
PUBLIC_API_KEY = os.getenv("G4F_PUBLIC_API_KEY", "")
66
MEMBERS_BASE = os.getenv("G4F_MEMBERS_BASE", "https://auth.g4f.dev")
67
FEED_POLL_INTERVAL = int(os.getenv("G4F_FEED_POLL_INTERVAL", "15"))
68
HEAVY_TOKEN_THRESHOLD = int(os.getenv("G4F_HEAVY_TOKEN_THRESHOLD", "10000"))
69
FEED_SUMMARY_INTERVAL = int(os.getenv("G4F_FEED_SUMMARY_INTERVAL", "3600"))
70
FEED_MAX_POSTS_PER_CYCLE = int(os.getenv("G4F_FEED_MAX_POSTS_PER_CYCLE", "5"))
71
55
72
# Simple validation for runtime model names.
56
73
ALLOWED_MODEL_CHARS = set(
57
74
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.:"
@@ -741,6 +758,28 @@ async def on_ready():
741
758
except Exception:
742
759
log.exception("Failed to sync slash commands")
743
760
761
# Load the live feed cog if a channel was configured.
762
if LIVE_FEED_CHANNEL:
763
existing = bot.get_cog("LiveFeed")
764
if existing is None:
765
await bot.add_cog(
766
LiveFeed(
767
bot=bot,
768
channel_id=LIVE_FEED_CHANNEL,
769
api_base=API_BASE,
770
public_base=PUBLIC_BASE,
771
api_key=PUBLIC_API_KEY,
772
members_base=MEMBERS_BASE or None,
773
poll_interval=FEED_POLL_INTERVAL,
774
heavy_token_threshold=HEAVY_TOKEN_THRESHOLD,
775
summary_interval=FEED_SUMMARY_INTERVAL,
776
max_posts_per_cycle=FEED_MAX_POSTS_PER_CYCLE,
777
)
778
)
779
log.info("Live feed cog loaded → channel %s", LIVE_FEED_CHANNEL)
780
else:
781
log.info("Live feed cog already loaded")
782
744
783
745
784
def main():
746
785
if not TOKEN: