qwenproxy-cli 1.2.7 → 1.2.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.pt-BR.md +10 -7
- package/package.json +3 -3
- package/src/api/server.ts +74 -27
- package/src/core/accounts.ts +63 -22
- package/src/core/config.ts +1 -1
- package/src/core/crypto-utils.ts +1 -1
- package/src/core/local-auth.ts +98 -0
- package/src/core/safe-remote-url.ts +128 -0
- package/src/core/server-log-buffer.ts +5 -3
- package/src/routes/upload.ts +19 -9
- package/src/services/playwright.ts +26 -18
- package/src/services/qwen.ts +27 -22
- package/src/sync/index.ts +23 -4
- package/src/sync/opencode.ts +37 -6
- package/src/sync-clients.ts +13 -2
- package/src/tui/proxy-client.ts +8 -5
- package/src/tui/server-manager.ts +3 -1
package/README.pt-BR.md
CHANGED
|
@@ -82,7 +82,9 @@ flowchart TD
|
|
|
82
82
|
|
|
83
83
|
### Autenticação
|
|
84
84
|
|
|
85
|
-
|
|
85
|
+
Em loopback, a autenticação é opcional e o token legado
|
|
86
|
+
`sk-qwenproxy-local` continua aceito. Se uma `API_KEY` real estiver definida,
|
|
87
|
+
as rotas protegidas exigem uma das formas:
|
|
86
88
|
|
|
87
89
|
- `Authorization: Bearer <API_KEY>` (OpenAI / Responses)
|
|
88
90
|
- `x-api-key: <API_KEY>` (clients bearer-style)
|
|
@@ -483,11 +485,12 @@ npm run typecheck # tipos
|
|
|
483
485
|
### Rede e segurança
|
|
484
486
|
|
|
485
487
|
|
|
486
|
-
| Variável
|
|
487
|
-
|
|
|
488
|
-
| `PORT`
|
|
489
|
-
| `HOST`
|
|
490
|
-
| `API_KEY`
|
|
488
|
+
| Variável | Default | Descrição |
|
|
489
|
+
| ------------ | ----------- | --------- |
|
|
490
|
+
| `PORT` | `7936` | Porta HTTP (padrão QWEN: 7936). Configurável via .env |
|
|
491
|
+
| `HOST` | `127.0.0.1` | Bind host. Fora de loopback exige uma chave forte |
|
|
492
|
+
| `API_KEY` | vazio | Opcional em loopback; protege as rotas com Bearer token |
|
|
493
|
+
| `CORS_ORIGIN`| vazio | Sem valor, permite apenas origens web de loopback |
|
|
491
494
|
|
|
492
495
|
|
|
493
496
|
### Contas e sessão
|
|
@@ -1028,4 +1031,4 @@ O **QwenProxy** é desenvolvido e mantido por **johngbl**, construído sobre as
|
|
|
1028
1031
|
- **Exclusão total do mantenedor:** nos limites máximos da lei, o autor e os contribuidores **não respondem** por quaisquer danos diretos, indiretos, incidentais, especiais ou consequenciais (perda de lucros, dados, contas, quota ou receita) decorrentes do uso ou da incapacidade de uso deste software, nem por mudanças, quebras ou bloqueios do serviço upstream, que podem ocorrer a qualquer momento e sem aviso prévio.
|
|
1029
1032
|
- **Sem prestação de serviço:** projeto voluntário, sem SLA e sem obrigação de atualizar ou corrigir.
|
|
1030
1033
|
|
|
1031
|
-
**Ao baixar, compilar ou executar este software você declara ter lido e aceito integralmente este aviso, isentando o mantenedor de qualquer obrigação, reclamação, ação, custo ou despesa (incluindo honorários advocatícios). Se não concordar, não utilize.** Acompanha e não substitui a licença [ISC](LICENSE).
|
|
1034
|
+
**Ao baixar, compilar ou executar este software você declara ter lido e aceito integralmente este aviso, isentando o mantenedor de qualquer obrigação, reclamação, ação, custo ou despesa (incluindo honorários advocatícios). Se não concordar, não utilize.** Acompanha e não substitui a licença [ISC](LICENSE).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "qwenproxy-cli",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.9",
|
|
4
4
|
"description": "High-performance OpenAI & Anthropic compatible API gateway for Qwen with multi-account rotation, interactive TUI, and resilient tool calling.",
|
|
5
5
|
"main": "src/index.ts",
|
|
6
6
|
"bin": {
|
|
@@ -26,8 +26,8 @@
|
|
|
26
26
|
"reset": "npx tsx src/reset-cooldowns.ts",
|
|
27
27
|
"purge": "npx tsx src/delete-chats.ts",
|
|
28
28
|
"test": "npm run test:mock && npm run test:live",
|
|
29
|
-
"test:mock": "tsx --test --test-concurrency=1 --env-file=.env.test src/tests/account-concurrency.test.ts src/tests/concurrency-latest-wins.test.ts src/tests/mutex.test.ts src/tests/context-truncation.test.ts src/tests/streaming-thinking-summary.test.ts src/tests/tool-call-guard.test.ts src/tests/utils-logger-full.test.ts src/tests/logger-toolcall-errors.test.ts src/tests/logger-toolcall-disabled.test.ts src/tests/utils-json-full.test.ts src/tests/utils-session-guard-full.test.ts src/tests/core-logic-full.test.ts src/tests/core-logic-accounts.test.ts src/tests/chat-validation-full.test.ts src/tests/chat-logic-full.test.ts src/tests/account-stickiness.test.ts src/tests/accounts-security.test.ts src/tests/advanced.test.ts src/tests/auth-playwright.test.ts src/tests/captcha-solver.test.ts src/tests/cache-scan-regex.test.ts src/tests/chat-cleanup.test.ts src/tests/chat-cleanup-auto.test.ts src/tests/chromium-args.test.ts src/tests/config-current.test.ts src/tests/context-meter.test.ts src/tests/concurrency.test.ts src/tests/delta.test.ts src/tests/fingerprint.test.ts src/tests/human-behavior.test.ts src/tests/index.test.ts src/tests/json-recovery.test.ts src/tests/jsonFix.test.ts src/tests/memory-cache.test.ts src/tests/model-alias.test.ts src/tests/model-registry-thinking.test.ts src/tests/models-endpoint.test.ts src/tests/multimodal.test.ts src/tests/oss-upload.test.ts src/tests/parser.test.ts src/tests/personalization-flow.test.ts src/tests/personalization-required.test.ts src/tests/retry-policy.test.ts src/tests/rotation.test.ts src/tests/server-lifecycle.test.ts src/tests/session-keeper.test.ts src/tests/session-state.test.ts src/tests/single-account-mode.test.ts src/tests/stream-retry.test.ts src/tests/tool-calls-endpoint.test.ts src/tests/tool-instructions-cache.test.ts src/tests/session-id.test.ts src/tests/watchdog-ram.test.ts src/tests/tool-recovery-t1t2.test.ts src/tests/abort-silent-t4.test.ts src/tests/t3-close-tag-escape.test.ts src/tests/t5-tool-cap.test.ts src/tests/logger-redaction.test.ts src/tests/stall-timeout-retryable.test.ts src/tests/t6-missing-quote-array-close.test.ts src/tests/watchdog-rss.test.ts src/tests/logical-thread-batch.test.ts src/tests/chat-pool-style.test.ts src/tests/queue-wait-cap.test.ts src/tests/context-eviction-order.test.ts src/tests/chat-in-progress-settle.test.ts src/tests/stream-lock-leak.test.ts src/tests/stream-semaphore.test.ts src/tests/waf-isolation.test.ts src/tests/legacy-completions.test.ts src/tests/response-format.test.ts src/tests/cors-headers.test.ts src/tests/stream-lock-terminal-cancel.test.ts src/tests/parser-truncated-tool-call.test.ts src/tests/metrics-prometheus.test.ts src/tests/context-death-cleanup.test.ts src/tests/personalization-direct-fetch.test.ts src/tests/completion-headers-parity.test.ts src/tests/parallel-escape-repro.test.ts src/tests/chat-in-progress-scale-cost.test.ts src/tests/temp-chat-mode.test.ts src/tests/profile-reset.test.ts src/tests/error-classification.test.ts src/tests/quota-cooldown.test.ts src/tests/account-ready-gate.test.ts src/tests/tool-cap-notice.test.ts src/tests/interaction-flows.test.ts src/tests/headers-gate-cooldown-fallback.test.ts src/tests/unwrapped-tool-calls.test.ts src/tests/anthropic.test.ts src/tests/sync-clients.test.ts src/tests/sync-top10-clients.test.ts src/tests/cli.test.ts src/tests/paths.test.ts src/tests/tool-manifest.test.ts src/tests/tui.test.ts src/tests/tui-settings.test.ts src/tests/personalization-deadline.test.ts src/tests/update-cli.test.ts src/tests/batch-accounts.test.ts src/tests/shared-browser.test.ts src/tests/auth-credential-classification.test.ts src/tests/responses-api.test.ts src/tests/media-generation.test.ts src/tests/infer-tool-name.test.ts src/tests/native-session-headers.test.ts src/tests/omni-thinking.test.ts src/tests/protect-stuck-mutex.test.ts src/tests/stream-error-notice.test.ts src/tests/parser-deep-coverage.test.ts src/tests/tui-logs-copy.test.ts",
|
|
30
|
-
"test:mock:fast": "tsx --test --test-concurrency=2 --env-file=.env.test src/tests/account-concurrency.test.ts src/tests/concurrency-latest-wins.test.ts src/tests/mutex.test.ts src/tests/context-truncation.test.ts src/tests/streaming-thinking-summary.test.ts src/tests/tool-call-guard.test.ts src/tests/utils-logger-full.test.ts src/tests/logger-toolcall-errors.test.ts src/tests/logger-toolcall-disabled.test.ts src/tests/utils-json-full.test.ts src/tests/utils-session-guard-full.test.ts src/tests/core-logic-full.test.ts src/tests/core-logic-accounts.test.ts src/tests/chat-validation-full.test.ts src/tests/chat-logic-full.test.ts src/tests/account-stickiness.test.ts src/tests/accounts-security.test.ts src/tests/advanced.test.ts src/tests/auth-playwright.test.ts src/tests/captcha-solver.test.ts src/tests/cache-scan-regex.test.ts src/tests/chat-cleanup.test.ts src/tests/chat-cleanup-auto.test.ts src/tests/chromium-args.test.ts src/tests/config-current.test.ts src/tests/context-meter.test.ts src/tests/concurrency.test.ts src/tests/delta.test.ts src/tests/fingerprint.test.ts src/tests/human-behavior.test.ts src/tests/index.test.ts src/tests/json-recovery.test.ts src/tests/jsonFix.test.ts src/tests/memory-cache.test.ts src/tests/model-alias.test.ts src/tests/model-registry-thinking.test.ts src/tests/models-endpoint.test.ts src/tests/multimodal.test.ts src/tests/oss-upload.test.ts src/tests/parser.test.ts src/tests/personalization-flow.test.ts src/tests/personalization-required.test.ts src/tests/retry-policy.test.ts src/tests/rotation.test.ts src/tests/server-lifecycle.test.ts src/tests/session-keeper.test.ts src/tests/session-state.test.ts src/tests/single-account-mode.test.ts src/tests/stream-retry.test.ts src/tests/tool-calls-endpoint.test.ts src/tests/tool-instructions-cache.test.ts src/tests/session-id.test.ts src/tests/watchdog-ram.test.ts src/tests/tool-recovery-t1t2.test.ts src/tests/abort-silent-t4.test.ts src/tests/t3-close-tag-escape.test.ts src/tests/t5-tool-cap.test.ts src/tests/logger-redaction.test.ts src/tests/stall-timeout-retryable.test.ts src/tests/t6-missing-quote-array-close.test.ts src/tests/watchdog-rss.test.ts src/tests/logical-thread-batch.test.ts src/tests/chat-pool-style.test.ts src/tests/queue-wait-cap.test.ts src/tests/context-eviction-order.test.ts src/tests/chat-in-progress-settle.test.ts src/tests/stream-lock-leak.test.ts src/tests/stream-semaphore.test.ts src/tests/waf-isolation.test.ts src/tests/legacy-completions.test.ts src/tests/response-format.test.ts src/tests/cors-headers.test.ts src/tests/stream-lock-terminal-cancel.test.ts src/tests/parser-truncated-tool-call.test.ts src/tests/metrics-prometheus.test.ts src/tests/context-death-cleanup.test.ts src/tests/personalization-direct-fetch.test.ts src/tests/completion-headers-parity.test.ts src/tests/parallel-escape-repro.test.ts src/tests/chat-in-progress-scale-cost.test.ts src/tests/temp-chat-mode.test.ts src/tests/profile-reset.test.ts src/tests/error-classification.test.ts src/tests/quota-cooldown.test.ts src/tests/account-ready-gate.test.ts src/tests/tool-cap-notice.test.ts src/tests/interaction-flows.test.ts src/tests/headers-gate-cooldown-fallback.test.ts src/tests/unwrapped-tool-calls.test.ts src/tests/anthropic.test.ts src/tests/sync-clients.test.ts src/tests/sync-top10-clients.test.ts src/tests/cli.test.ts src/tests/paths.test.ts src/tests/tool-manifest.test.ts src/tests/tui.test.ts src/tests/tui-settings.test.ts src/tests/personalization-deadline.test.ts src/tests/update-cli.test.ts src/tests/batch-accounts.test.ts src/tests/shared-browser.test.ts src/tests/auth-credential-classification.test.ts src/tests/responses-api.test.ts src/tests/media-generation.test.ts src/tests/infer-tool-name.test.ts src/tests/native-session-headers.test.ts src/tests/omni-thinking.test.ts src/tests/protect-stuck-mutex.test.ts src/tests/stream-error-notice.test.ts src/tests/parser-deep-coverage.test.ts src/tests/tui-logs-copy.test.ts",
|
|
29
|
+
"test:mock": "tsx --test --test-concurrency=1 --env-file=.env.test src/tests/account-concurrency.test.ts src/tests/concurrency-latest-wins.test.ts src/tests/mutex.test.ts src/tests/context-truncation.test.ts src/tests/streaming-thinking-summary.test.ts src/tests/tool-call-guard.test.ts src/tests/utils-logger-full.test.ts src/tests/logger-toolcall-errors.test.ts src/tests/logger-toolcall-disabled.test.ts src/tests/utils-json-full.test.ts src/tests/utils-session-guard-full.test.ts src/tests/core-logic-full.test.ts src/tests/core-logic-accounts.test.ts src/tests/chat-validation-full.test.ts src/tests/chat-logic-full.test.ts src/tests/account-stickiness.test.ts src/tests/accounts-security.test.ts src/tests/advanced.test.ts src/tests/auth-playwright.test.ts src/tests/captcha-solver.test.ts src/tests/cache-scan-regex.test.ts src/tests/chat-cleanup.test.ts src/tests/chat-cleanup-auto.test.ts src/tests/chromium-args.test.ts src/tests/config-current.test.ts src/tests/context-meter.test.ts src/tests/concurrency.test.ts src/tests/delta.test.ts src/tests/fingerprint.test.ts src/tests/human-behavior.test.ts src/tests/index.test.ts src/tests/json-recovery.test.ts src/tests/jsonFix.test.ts src/tests/memory-cache.test.ts src/tests/model-alias.test.ts src/tests/model-registry-thinking.test.ts src/tests/models-endpoint.test.ts src/tests/multimodal.test.ts src/tests/oss-upload.test.ts src/tests/parser.test.ts src/tests/personalization-flow.test.ts src/tests/personalization-required.test.ts src/tests/retry-policy.test.ts src/tests/rotation.test.ts src/tests/server-lifecycle.test.ts src/tests/session-keeper.test.ts src/tests/session-state.test.ts src/tests/single-account-mode.test.ts src/tests/stream-retry.test.ts src/tests/tool-calls-endpoint.test.ts src/tests/tool-instructions-cache.test.ts src/tests/session-id.test.ts src/tests/watchdog-ram.test.ts src/tests/tool-recovery-t1t2.test.ts src/tests/abort-silent-t4.test.ts src/tests/t3-close-tag-escape.test.ts src/tests/t5-tool-cap.test.ts src/tests/logger-redaction.test.ts src/tests/stall-timeout-retryable.test.ts src/tests/t6-missing-quote-array-close.test.ts src/tests/watchdog-rss.test.ts src/tests/logical-thread-batch.test.ts src/tests/chat-pool-style.test.ts src/tests/queue-wait-cap.test.ts src/tests/context-eviction-order.test.ts src/tests/chat-in-progress-settle.test.ts src/tests/stream-lock-leak.test.ts src/tests/stream-semaphore.test.ts src/tests/waf-isolation.test.ts src/tests/legacy-completions.test.ts src/tests/response-format.test.ts src/tests/cors-headers.test.ts src/tests/local-auth.test.ts src/tests/safe-remote-url.test.ts src/tests/stream-lock-terminal-cancel.test.ts src/tests/parser-truncated-tool-call.test.ts src/tests/metrics-prometheus.test.ts src/tests/context-death-cleanup.test.ts src/tests/personalization-direct-fetch.test.ts src/tests/completion-headers-parity.test.ts src/tests/parallel-escape-repro.test.ts src/tests/chat-in-progress-scale-cost.test.ts src/tests/temp-chat-mode.test.ts src/tests/profile-reset.test.ts src/tests/error-classification.test.ts src/tests/quota-cooldown.test.ts src/tests/account-ready-gate.test.ts src/tests/tool-cap-notice.test.ts src/tests/interaction-flows.test.ts src/tests/headers-gate-cooldown-fallback.test.ts src/tests/unwrapped-tool-calls.test.ts src/tests/anthropic.test.ts src/tests/sync-clients.test.ts src/tests/sync-top10-clients.test.ts src/tests/cli.test.ts src/tests/paths.test.ts src/tests/tool-manifest.test.ts src/tests/tui.test.ts src/tests/tui-settings.test.ts src/tests/personalization-deadline.test.ts src/tests/update-cli.test.ts src/tests/batch-accounts.test.ts src/tests/shared-browser.test.ts src/tests/auth-credential-classification.test.ts src/tests/responses-api.test.ts src/tests/media-generation.test.ts src/tests/infer-tool-name.test.ts src/tests/native-session-headers.test.ts src/tests/omni-thinking.test.ts src/tests/protect-stuck-mutex.test.ts src/tests/stream-error-notice.test.ts src/tests/parser-deep-coverage.test.ts src/tests/tui-logs-copy.test.ts",
|
|
30
|
+
"test:mock:fast": "tsx --test --test-concurrency=2 --env-file=.env.test src/tests/account-concurrency.test.ts src/tests/concurrency-latest-wins.test.ts src/tests/mutex.test.ts src/tests/context-truncation.test.ts src/tests/streaming-thinking-summary.test.ts src/tests/tool-call-guard.test.ts src/tests/utils-logger-full.test.ts src/tests/logger-toolcall-errors.test.ts src/tests/logger-toolcall-disabled.test.ts src/tests/utils-json-full.test.ts src/tests/utils-session-guard-full.test.ts src/tests/core-logic-full.test.ts src/tests/core-logic-accounts.test.ts src/tests/chat-validation-full.test.ts src/tests/chat-logic-full.test.ts src/tests/account-stickiness.test.ts src/tests/accounts-security.test.ts src/tests/advanced.test.ts src/tests/auth-playwright.test.ts src/tests/captcha-solver.test.ts src/tests/cache-scan-regex.test.ts src/tests/chat-cleanup.test.ts src/tests/chat-cleanup-auto.test.ts src/tests/chromium-args.test.ts src/tests/config-current.test.ts src/tests/context-meter.test.ts src/tests/concurrency.test.ts src/tests/delta.test.ts src/tests/fingerprint.test.ts src/tests/human-behavior.test.ts src/tests/index.test.ts src/tests/json-recovery.test.ts src/tests/jsonFix.test.ts src/tests/memory-cache.test.ts src/tests/model-alias.test.ts src/tests/model-registry-thinking.test.ts src/tests/models-endpoint.test.ts src/tests/multimodal.test.ts src/tests/oss-upload.test.ts src/tests/parser.test.ts src/tests/personalization-flow.test.ts src/tests/personalization-required.test.ts src/tests/retry-policy.test.ts src/tests/rotation.test.ts src/tests/server-lifecycle.test.ts src/tests/session-keeper.test.ts src/tests/session-state.test.ts src/tests/single-account-mode.test.ts src/tests/stream-retry.test.ts src/tests/tool-calls-endpoint.test.ts src/tests/tool-instructions-cache.test.ts src/tests/session-id.test.ts src/tests/watchdog-ram.test.ts src/tests/tool-recovery-t1t2.test.ts src/tests/abort-silent-t4.test.ts src/tests/t3-close-tag-escape.test.ts src/tests/t5-tool-cap.test.ts src/tests/logger-redaction.test.ts src/tests/stall-timeout-retryable.test.ts src/tests/t6-missing-quote-array-close.test.ts src/tests/watchdog-rss.test.ts src/tests/logical-thread-batch.test.ts src/tests/chat-pool-style.test.ts src/tests/queue-wait-cap.test.ts src/tests/context-eviction-order.test.ts src/tests/chat-in-progress-settle.test.ts src/tests/stream-lock-leak.test.ts src/tests/stream-semaphore.test.ts src/tests/waf-isolation.test.ts src/tests/legacy-completions.test.ts src/tests/response-format.test.ts src/tests/cors-headers.test.ts src/tests/local-auth.test.ts src/tests/safe-remote-url.test.ts src/tests/stream-lock-terminal-cancel.test.ts src/tests/parser-truncated-tool-call.test.ts src/tests/metrics-prometheus.test.ts src/tests/context-death-cleanup.test.ts src/tests/personalization-direct-fetch.test.ts src/tests/completion-headers-parity.test.ts src/tests/parallel-escape-repro.test.ts src/tests/chat-in-progress-scale-cost.test.ts src/tests/temp-chat-mode.test.ts src/tests/profile-reset.test.ts src/tests/error-classification.test.ts src/tests/quota-cooldown.test.ts src/tests/account-ready-gate.test.ts src/tests/tool-cap-notice.test.ts src/tests/interaction-flows.test.ts src/tests/headers-gate-cooldown-fallback.test.ts src/tests/unwrapped-tool-calls.test.ts src/tests/anthropic.test.ts src/tests/sync-clients.test.ts src/tests/sync-top10-clients.test.ts src/tests/cli.test.ts src/tests/paths.test.ts src/tests/tool-manifest.test.ts src/tests/tui.test.ts src/tests/tui-settings.test.ts src/tests/personalization-deadline.test.ts src/tests/update-cli.test.ts src/tests/batch-accounts.test.ts src/tests/shared-browser.test.ts src/tests/auth-credential-classification.test.ts src/tests/responses-api.test.ts src/tests/media-generation.test.ts src/tests/infer-tool-name.test.ts src/tests/native-session-headers.test.ts src/tests/omni-thinking.test.ts src/tests/protect-stuck-mutex.test.ts src/tests/stream-error-notice.test.ts src/tests/parser-deep-coverage.test.ts src/tests/tui-logs-copy.test.ts",
|
|
31
31
|
"test:live": "tsx --test src/tests/concurrentChat.test.ts src/tests/agenticStress.test.ts",
|
|
32
32
|
"benchmark:proxy": "npx tsx src/benchmarks/proxy-baseline.ts",
|
|
33
33
|
"typecheck": "tsc --noEmit"
|
package/src/api/server.ts
CHANGED
|
@@ -22,6 +22,13 @@ import { sendOpenAIError } from "./error-helpers.js";
|
|
|
22
22
|
import { AuthError, NotFoundError } from "../core/errors.js";
|
|
23
23
|
import type { QwenAccount } from "../core/accounts.js";
|
|
24
24
|
import { isAuthMockEnabled } from "../services/auth-playwright.js";
|
|
25
|
+
import {
|
|
26
|
+
assertBindAllowed,
|
|
27
|
+
ensureRuntimeApiKey,
|
|
28
|
+
getRuntimeApiKey,
|
|
29
|
+
isLoopbackHost,
|
|
30
|
+
isPlaceholderApiKey,
|
|
31
|
+
} from "../core/local-auth.ts";
|
|
25
32
|
|
|
26
33
|
import {
|
|
27
34
|
hookServerConsoleForLogging,
|
|
@@ -79,36 +86,58 @@ export function setCacheForTesting(nextCache: MemoryCache | undefined): void {
|
|
|
79
86
|
|
|
80
87
|
// Middleware must be registered BEFORE routes
|
|
81
88
|
|
|
82
|
-
// CORS
|
|
83
|
-
//
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
89
|
+
// Explicit CORS configuration wins. Without it, browser clients running on a
|
|
90
|
+
// loopback origin are allowed while arbitrary pages cannot drive the local API.
|
|
91
|
+
function getCorsOrigin(requestOrigin?: string): string {
|
|
92
|
+
const configured = (process.env.CORS_ORIGIN || "").trim();
|
|
93
|
+
if (configured) return configured;
|
|
94
|
+
if (!requestOrigin) return "";
|
|
95
|
+
|
|
96
|
+
try {
|
|
97
|
+
const origin = new URL(requestOrigin);
|
|
98
|
+
if (
|
|
99
|
+
(origin.protocol === "http:" || origin.protocol === "https:") &&
|
|
100
|
+
isLoopbackHost(origin.hostname)
|
|
101
|
+
) {
|
|
102
|
+
return requestOrigin;
|
|
103
|
+
}
|
|
104
|
+
} catch {
|
|
105
|
+
return "";
|
|
106
|
+
}
|
|
107
|
+
return "";
|
|
108
|
+
}
|
|
109
|
+
|
|
87
110
|
app.use("*", async (c, next) => {
|
|
88
|
-
c.header("
|
|
89
|
-
|
|
90
|
-
"Access-Control-Allow-
|
|
91
|
-
"
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
111
|
+
const corsOrigin = getCorsOrigin(c.req.header("Origin"));
|
|
112
|
+
if (corsOrigin) {
|
|
113
|
+
c.header("Access-Control-Allow-Origin", corsOrigin);
|
|
114
|
+
c.header("Vary", "Origin");
|
|
115
|
+
c.header(
|
|
116
|
+
"Access-Control-Allow-Methods",
|
|
117
|
+
"GET, POST, PUT, PATCH, DELETE, OPTIONS",
|
|
118
|
+
);
|
|
119
|
+
c.header(
|
|
120
|
+
"Access-Control-Allow-Headers",
|
|
121
|
+
"Authorization, Content-Type, X-Request-Id, x-api-key, OpenAI-Organization, OpenAI-Project, X-Client-Request-Id, X-QwenProxy-Chat-Mode",
|
|
122
|
+
);
|
|
123
|
+
c.header(
|
|
124
|
+
"Access-Control-Expose-Headers",
|
|
125
|
+
"X-Request-Id, X-Response-Time, openai-version, openai-processing-ms, x-ratelimit-limit-requests, x-ratelimit-remaining-requests, x-ratelimit-reset-requests, x-ratelimit-limit-tokens, x-ratelimit-remaining-tokens, x-ratelimit-reset-tokens",
|
|
126
|
+
);
|
|
127
|
+
}
|
|
101
128
|
if (c.req.method === "OPTIONS") {
|
|
102
|
-
|
|
103
|
-
|
|
129
|
+
if (!corsOrigin) {
|
|
130
|
+
return new Response(null, { status: 204 });
|
|
131
|
+
}
|
|
104
132
|
return new Response(null, {
|
|
105
133
|
status: 204,
|
|
106
134
|
headers: {
|
|
107
135
|
"Access-Control-Allow-Origin": corsOrigin,
|
|
136
|
+
Vary: "Origin",
|
|
108
137
|
"Access-Control-Allow-Methods":
|
|
109
138
|
"GET, POST, PUT, PATCH, DELETE, OPTIONS",
|
|
110
139
|
"Access-Control-Allow-Headers":
|
|
111
|
-
"Authorization, Content-Type, X-Request-Id, x-api-key, OpenAI-Organization, OpenAI-Project, X-Client-Request-Id",
|
|
140
|
+
"Authorization, Content-Type, X-Request-Id, x-api-key, OpenAI-Organization, OpenAI-Project, X-Client-Request-Id, X-QwenProxy-Chat-Mode",
|
|
112
141
|
"Access-Control-Expose-Headers":
|
|
113
142
|
"X-Request-Id, X-Response-Time, openai-version, openai-processing-ms, x-ratelimit-limit-requests, x-ratelimit-remaining-requests, x-ratelimit-reset-requests, x-ratelimit-limit-tokens, x-ratelimit-remaining-tokens, x-ratelimit-reset-tokens",
|
|
114
143
|
},
|
|
@@ -187,7 +216,10 @@ function extractProvidedApiKeys(c: Context): string[] {
|
|
|
187
216
|
}
|
|
188
217
|
|
|
189
218
|
function verifyApiKey(c: Context): Response | null {
|
|
190
|
-
const apiKey = process.env.API_KEY || config.apiKey;
|
|
219
|
+
const apiKey = (process.env.API_KEY || config.apiKey || "").trim();
|
|
220
|
+
if (isLoopbackHost(config.server.host) && isPlaceholderApiKey(apiKey)) {
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
191
223
|
if (!apiKey) return null;
|
|
192
224
|
|
|
193
225
|
const candidates = extractProvidedApiKeys(c);
|
|
@@ -286,8 +318,21 @@ for (const [from, to] of LEGACY_REDIRECTS) {
|
|
|
286
318
|
|
|
287
319
|
app.get("/health", async (c) => {
|
|
288
320
|
const status = await watchdog?.getStatus();
|
|
289
|
-
|
|
321
|
+
const publicBody = {
|
|
290
322
|
status: status?.overall || "unknown",
|
|
323
|
+
timestamp: Date.now(),
|
|
324
|
+
};
|
|
325
|
+
const apiKeyConfigured = Boolean(getRuntimeApiKey() || config.apiKey);
|
|
326
|
+
const authError = verifyApiKey(c);
|
|
327
|
+
if (apiKeyConfigured && authError) {
|
|
328
|
+
return c.json(publicBody);
|
|
329
|
+
}
|
|
330
|
+
if (!apiKeyConfigured) {
|
|
331
|
+
return c.json(publicBody);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
return c.json({
|
|
335
|
+
...publicBody,
|
|
291
336
|
ram: status?.ram || "unknown",
|
|
292
337
|
streams: status?.streams || "unknown",
|
|
293
338
|
heap: status?.heap
|
|
@@ -299,7 +344,6 @@ app.get("/health", async (c) => {
|
|
|
299
344
|
usagePercent: Number(status.heap.usagePercent.toFixed(2)),
|
|
300
345
|
}
|
|
301
346
|
: undefined,
|
|
302
|
-
timestamp: Date.now(),
|
|
303
347
|
readyAccounts: (await import("../core/account-manager.js")).getHeadersReadyAccountIds(),
|
|
304
348
|
activeAccounts: (await import("../services/playwright.js")).getActivePlaywrightAccountIds(),
|
|
305
349
|
metrics: {
|
|
@@ -354,10 +398,14 @@ app.get("/metrics", (c) => {
|
|
|
354
398
|
});
|
|
355
399
|
|
|
356
400
|
app.get("/logs", (c) => {
|
|
401
|
+
const error = verifyApiKey(c);
|
|
402
|
+
if (error) return error;
|
|
357
403
|
return c.json(getServerLogHistory());
|
|
358
404
|
});
|
|
359
405
|
|
|
360
406
|
app.get("/logs/live", (c) => {
|
|
407
|
+
const error = verifyApiKey(c);
|
|
408
|
+
if (error) return error;
|
|
361
409
|
const encoder = new TextEncoder();
|
|
362
410
|
return c.body(
|
|
363
411
|
new ReadableStream({
|
|
@@ -706,9 +754,8 @@ export async function startServer(options?: {
|
|
|
706
754
|
cache = new MemoryCache();
|
|
707
755
|
await cache.connect();
|
|
708
756
|
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
}
|
|
757
|
+
ensureRuntimeApiKey(config.server.host);
|
|
758
|
+
assertBindAllowed(config.server.host, getRuntimeApiKey() || config.apiKey);
|
|
712
759
|
|
|
713
760
|
const { loadAccounts, getAccountCredentials } =
|
|
714
761
|
await import("../core/accounts.ts");
|
package/src/core/accounts.ts
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import "dotenv/config";
|
|
2
2
|
import crypto from "crypto";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
3
5
|
import { getDatabase } from "./database.ts";
|
|
4
6
|
import { decrypt, encrypt } from "./crypto-utils.ts";
|
|
7
|
+
import { getAccountProfilePath, getProfilesDir } from "./paths.ts";
|
|
5
8
|
|
|
6
9
|
export interface QwenAccount {
|
|
7
10
|
id: string;
|
|
@@ -23,12 +26,30 @@ function parseEnvAccounts(): QwenAccount[] {
|
|
|
23
26
|
const envAccounts = process.env.QWEN_ACCOUNTS;
|
|
24
27
|
if (!envAccounts) return [];
|
|
25
28
|
|
|
26
|
-
const
|
|
29
|
+
const clean = (s: string) => s.trim().replace(/^[,;\s"'`]+|[,;\s"'`]+$/g, "").trim();
|
|
27
30
|
|
|
28
|
-
|
|
29
|
-
|
|
31
|
+
const lines = envAccounts.split(/[\r\n;]+/);
|
|
32
|
+
const rawEntries: string[] = [];
|
|
33
|
+
|
|
34
|
+
for (const rawLine of lines) {
|
|
35
|
+
const line = clean(rawLine);
|
|
36
|
+
if (!line || line.startsWith("#") || line.startsWith("//")) continue;
|
|
37
|
+
|
|
38
|
+
// Check if line contains multiple accounts separated by comma (e.g. "a@b.com:p1, c@d.com:p2")
|
|
39
|
+
if (line.includes(",") && (line.match(/@/g) || []).length > 1) {
|
|
40
|
+
for (const seg of line.split(",")) {
|
|
41
|
+
const trimmed = clean(seg);
|
|
42
|
+
if (trimmed) rawEntries.push(trimmed);
|
|
43
|
+
}
|
|
44
|
+
} else {
|
|
45
|
+
const trimmed = clean(line);
|
|
46
|
+
if (trimmed) rawEntries.push(trimmed);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return rawEntries
|
|
30
51
|
.map((entry, index) => {
|
|
31
|
-
const trimmed = entry
|
|
52
|
+
const trimmed = clean(entry);
|
|
32
53
|
const colonIdx = trimmed.indexOf(":");
|
|
33
54
|
if (colonIdx === -1) {
|
|
34
55
|
console.warn(
|
|
@@ -36,8 +57,8 @@ function parseEnvAccounts(): QwenAccount[] {
|
|
|
36
57
|
);
|
|
37
58
|
return null;
|
|
38
59
|
}
|
|
39
|
-
const email = trimmed.substring(0, colonIdx);
|
|
40
|
-
const password = trimmed.substring(colonIdx + 1);
|
|
60
|
+
const email = clean(trimmed.substring(0, colonIdx));
|
|
61
|
+
const password = clean(trimmed.substring(colonIdx + 1));
|
|
41
62
|
if (!email || !password) {
|
|
42
63
|
console.warn(
|
|
43
64
|
`[Accounts] Invalid QWEN_ACCOUNTS entry at index ${index}: "${trimmed}"`,
|
|
@@ -46,8 +67,8 @@ function parseEnvAccounts(): QwenAccount[] {
|
|
|
46
67
|
}
|
|
47
68
|
return {
|
|
48
69
|
id: generateId(email),
|
|
49
|
-
email
|
|
50
|
-
password
|
|
70
|
+
email,
|
|
71
|
+
password,
|
|
51
72
|
};
|
|
52
73
|
})
|
|
53
74
|
.filter((a): a is QwenAccount => a !== null);
|
|
@@ -103,10 +124,15 @@ function getCachedAccounts(): QwenAccount[] {
|
|
|
103
124
|
)
|
|
104
125
|
.all() as QwenAccount[];
|
|
105
126
|
|
|
106
|
-
accountsCache = rows.map((row) =>
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
127
|
+
accountsCache = rows.map((row) => {
|
|
128
|
+
let password = "";
|
|
129
|
+
try {
|
|
130
|
+
password = decrypt(row.password);
|
|
131
|
+
} catch {
|
|
132
|
+
password = "";
|
|
133
|
+
}
|
|
134
|
+
return { ...row, password };
|
|
135
|
+
});
|
|
110
136
|
accountsCacheTime = now;
|
|
111
137
|
return accountsCache;
|
|
112
138
|
}
|
|
@@ -160,26 +186,27 @@ export function parseBatchAccounts(rawInput: string): {
|
|
|
160
186
|
let email = "";
|
|
161
187
|
let password = "";
|
|
162
188
|
|
|
189
|
+
const clean = (s: string) => s.trim().replace(/^[,;\s"'`]+|[,;\s"'`]+$/g, "").trim();
|
|
163
190
|
if (seg.includes("---")) {
|
|
164
191
|
const parts = seg.split("---");
|
|
165
|
-
email = parts[0]
|
|
166
|
-
password = parts.slice(1).join("---")
|
|
192
|
+
email = clean(parts[0]);
|
|
193
|
+
password = clean(parts.slice(1).join("---"));
|
|
167
194
|
} else if (seg.includes("\t")) {
|
|
168
195
|
const parts = seg.split("\t");
|
|
169
|
-
email = parts[0]
|
|
170
|
-
password = parts.slice(1).join("\t")
|
|
196
|
+
email = clean(parts[0]);
|
|
197
|
+
password = clean(parts.slice(1).join("\t"));
|
|
171
198
|
} else if (seg.includes(" | ")) {
|
|
172
199
|
const parts = seg.split(" | ");
|
|
173
|
-
email = parts[0]
|
|
174
|
-
password = parts.slice(1).join(" | ")
|
|
200
|
+
email = clean(parts[0]);
|
|
201
|
+
password = clean(parts.slice(1).join(" | "));
|
|
175
202
|
} else if (seg.includes(":")) {
|
|
176
203
|
const colonIdx = seg.indexOf(":");
|
|
177
|
-
email = seg.slice(0, colonIdx)
|
|
178
|
-
password = seg.slice(colonIdx + 1)
|
|
204
|
+
email = clean(seg.slice(0, colonIdx));
|
|
205
|
+
password = clean(seg.slice(colonIdx + 1));
|
|
179
206
|
} else if (seg.includes(",")) {
|
|
180
207
|
const commaIdx = seg.indexOf(",");
|
|
181
|
-
email = seg.slice(0, commaIdx)
|
|
182
|
-
password = seg.slice(commaIdx + 1)
|
|
208
|
+
email = clean(seg.slice(0, commaIdx));
|
|
209
|
+
password = clean(seg.slice(commaIdx + 1));
|
|
183
210
|
} else {
|
|
184
211
|
invalid.push(seg);
|
|
185
212
|
continue;
|
|
@@ -289,9 +316,23 @@ export function addAccount(
|
|
|
289
316
|
return newAccount;
|
|
290
317
|
}
|
|
291
318
|
|
|
319
|
+
function wipeAccountSessionFiles(id: string): void {
|
|
320
|
+
const profilePath = getAccountProfilePath(id);
|
|
321
|
+
try {
|
|
322
|
+
fs.rmSync(profilePath, { recursive: true, force: true });
|
|
323
|
+
} catch {}
|
|
324
|
+
const siblingState = path.join(getProfilesDir(), `${id}_state.json`);
|
|
325
|
+
try {
|
|
326
|
+
fs.rmSync(siblingState, { force: true });
|
|
327
|
+
} catch {}
|
|
328
|
+
}
|
|
329
|
+
|
|
292
330
|
export function removeAccount(id: string): boolean {
|
|
293
331
|
const db = getDatabase();
|
|
294
332
|
const result = db.prepare("DELETE FROM accounts WHERE id = ?").run(id);
|
|
333
|
+
if (result.changes > 0) {
|
|
334
|
+
wipeAccountSessionFiles(id);
|
|
335
|
+
}
|
|
295
336
|
invalidateAccountsCache();
|
|
296
337
|
return result.changes > 0;
|
|
297
338
|
}
|
package/src/core/config.ts
CHANGED
|
@@ -10,7 +10,7 @@ const envSchema = z
|
|
|
10
10
|
return port >= 1 && port <= 65535;
|
|
11
11
|
}, "PORT must be between 1 and 65535")
|
|
12
12
|
.default("7936"),
|
|
13
|
-
HOST: z.string().default("
|
|
13
|
+
HOST: z.string().default("127.0.0.1"),
|
|
14
14
|
INTERNAL_HOST: z.string().default("127.0.0.1"),
|
|
15
15
|
USER_AGENT: z
|
|
16
16
|
.string()
|
package/src/core/crypto-utils.ts
CHANGED
|
@@ -67,7 +67,7 @@ export function decrypt(ciphertext: string): string {
|
|
|
67
67
|
decipher.setAuthTag(authTag);
|
|
68
68
|
return decipher.update(encrypted).toString("utf-8") + decipher.final("utf-8");
|
|
69
69
|
} catch {
|
|
70
|
-
|
|
70
|
+
throw new Error("Failed to decrypt stored secret");
|
|
71
71
|
}
|
|
72
72
|
}
|
|
73
73
|
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import { isRunningUnderNodeTest, getEnvFilePath } from "./paths.ts";
|
|
4
|
+
|
|
5
|
+
export const PLACEHOLDER_API_KEY = "sk-qwenproxy-local";
|
|
6
|
+
|
|
7
|
+
export function isPlaceholderApiKey(key?: string | null): boolean {
|
|
8
|
+
const value = (key || "").trim();
|
|
9
|
+
return value.length === 0 || value === PLACEHOLDER_API_KEY;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function isLoopbackHost(host: string): boolean {
|
|
13
|
+
const normalized = host.trim().toLowerCase().replace(/^\[|\]$/g, "");
|
|
14
|
+
return (
|
|
15
|
+
normalized === "127.0.0.1" ||
|
|
16
|
+
normalized === "localhost" ||
|
|
17
|
+
normalized === "::1" ||
|
|
18
|
+
normalized === "0:0:0:0:0:0:0:1"
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function isWildcardBind(host: string): boolean {
|
|
23
|
+
const normalized = host.trim().toLowerCase();
|
|
24
|
+
return normalized === "0.0.0.0" || normalized === "::" || normalized === "[::]";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function getRuntimeApiKey(): string {
|
|
28
|
+
return (process.env.API_KEY || "").trim();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function localApiAuthHeaders(): Record<string, string> {
|
|
32
|
+
const apiKey = getRuntimeApiKey();
|
|
33
|
+
if (isPlaceholderApiKey(apiKey)) return {};
|
|
34
|
+
return { Authorization: `Bearer ${apiKey}` };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function assertBindAllowed(host: string, apiKey: string): void {
|
|
38
|
+
if (isLoopbackHost(host)) return;
|
|
39
|
+
if (!isPlaceholderApiKey(apiKey)) return;
|
|
40
|
+
throw new Error(
|
|
41
|
+
`❌ [Server] HOST=${host} is reachable beyond loopback and requires a strong API_KEY.` +
|
|
42
|
+
`\n Bind 127.0.0.1 for local-only use, or set API_KEY before exposing ${host}.`,
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function persistApiKey(
|
|
47
|
+
apiKey: string,
|
|
48
|
+
envPath = getEnvFilePath(),
|
|
49
|
+
): void {
|
|
50
|
+
try {
|
|
51
|
+
const existing = fs.existsSync(envPath)
|
|
52
|
+
? fs.readFileSync(envPath, "utf-8")
|
|
53
|
+
: "";
|
|
54
|
+
const assignment = /^([ \t]*)API_KEY[ \t]*=[ \t]*(.*)$/m.exec(existing);
|
|
55
|
+
if (assignment?.[2].trim()) return;
|
|
56
|
+
|
|
57
|
+
let updated: string;
|
|
58
|
+
if (assignment) {
|
|
59
|
+
const start = assignment.index;
|
|
60
|
+
const end = start + assignment[0].length;
|
|
61
|
+
updated =
|
|
62
|
+
existing.slice(0, start) +
|
|
63
|
+
`${assignment[1]}API_KEY=${apiKey}` +
|
|
64
|
+
existing.slice(end);
|
|
65
|
+
} else {
|
|
66
|
+
const lineEnding = existing.includes("\r\n") ? "\r\n" : "\n";
|
|
67
|
+
const prefix =
|
|
68
|
+
existing.length === 0 || existing.endsWith("\n") ? "" : lineEnding;
|
|
69
|
+
updated = `${existing}${prefix}API_KEY=${apiKey}${lineEnding}`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
fs.writeFileSync(
|
|
73
|
+
envPath,
|
|
74
|
+
updated,
|
|
75
|
+
{ encoding: "utf-8", mode: 0o600 },
|
|
76
|
+
);
|
|
77
|
+
} catch (err) {
|
|
78
|
+
console.warn(
|
|
79
|
+
"⚠️ [Server] Generated API_KEY but could not persist it to .env:",
|
|
80
|
+
err instanceof Error ? err.message : String(err),
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function ensureRuntimeApiKey(host = "127.0.0.1"): string {
|
|
86
|
+
const existing = getRuntimeApiKey();
|
|
87
|
+
if (!isPlaceholderApiKey(existing)) return existing;
|
|
88
|
+
if (isLoopbackHost(host)) return existing;
|
|
89
|
+
if (isRunningUnderNodeTest()) return existing;
|
|
90
|
+
|
|
91
|
+
const generated = `sk-qpx-${crypto.randomBytes(24).toString("base64url")}`;
|
|
92
|
+
process.env.API_KEY = generated;
|
|
93
|
+
persistApiKey(generated);
|
|
94
|
+
console.warn(
|
|
95
|
+
`🔐 [Server] Generated API_KEY and saved it to ${getEnvFilePath()}. Clients must send this Bearer token.`,
|
|
96
|
+
);
|
|
97
|
+
return generated;
|
|
98
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import dns from "node:dns/promises";
|
|
2
|
+
import net from "node:net";
|
|
3
|
+
|
|
4
|
+
export class UnsafeRemoteUrlError extends Error {
|
|
5
|
+
constructor(message: string) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.name = "UnsafeRemoteUrlError";
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function isPrivateOrLocalIp(address: string): boolean {
|
|
12
|
+
const normalized = address.trim().toLowerCase().replace(/^\[|\]$/g, "");
|
|
13
|
+
if (net.isIP(normalized) === 4) {
|
|
14
|
+
const parts = normalized.split(".").map((part) => Number(part));
|
|
15
|
+
if (parts.length !== 4 || parts.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) {
|
|
16
|
+
return true;
|
|
17
|
+
}
|
|
18
|
+
const [a, b] = parts;
|
|
19
|
+
if (a === 0 || a === 10 || a === 127) return true;
|
|
20
|
+
if (a === 169 && b === 254) return true;
|
|
21
|
+
if (a === 172 && b >= 16 && b <= 31) return true;
|
|
22
|
+
if (a === 192 && b === 168) return true;
|
|
23
|
+
if (a === 100 && b >= 64 && b <= 127) return true;
|
|
24
|
+
if (a >= 224) return true;
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
if (net.isIP(normalized) === 6) {
|
|
28
|
+
if (normalized === "::" || normalized === "::1") return true;
|
|
29
|
+
if (normalized.startsWith("fe80:") || normalized.startsWith("ff")) return true;
|
|
30
|
+
if (normalized.startsWith("fc") || normalized.startsWith("fd")) return true;
|
|
31
|
+
if (normalized.startsWith("::ffff:")) {
|
|
32
|
+
return isPrivateOrLocalIp(normalized.slice("::ffff:".length));
|
|
33
|
+
}
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const BLOCKED_HOSTS = new Set([
|
|
40
|
+
"localhost",
|
|
41
|
+
"localhost.localdomain",
|
|
42
|
+
"metadata.google.internal",
|
|
43
|
+
"metadata.goog",
|
|
44
|
+
]);
|
|
45
|
+
|
|
46
|
+
function isBlockedHostname(hostname: string): boolean {
|
|
47
|
+
const host = hostname.trim().toLowerCase().replace(/\.$/, "");
|
|
48
|
+
if (!host) return true;
|
|
49
|
+
if (BLOCKED_HOSTS.has(host)) return true;
|
|
50
|
+
if (host.endsWith(".localhost") || host.endsWith(".local") || host.endsWith(".internal")) {
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
if (net.isIP(host) && isPrivateOrLocalIp(host)) return true;
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function assertSafeRemoteMediaUrl(raw: string): Promise<URL> {
|
|
58
|
+
let parsed: URL;
|
|
59
|
+
try {
|
|
60
|
+
parsed = new URL(raw);
|
|
61
|
+
} catch {
|
|
62
|
+
throw new UnsafeRemoteUrlError("Remote media URL is not valid");
|
|
63
|
+
}
|
|
64
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
65
|
+
throw new UnsafeRemoteUrlError(`Blocked media URL protocol: ${parsed.protocol}`);
|
|
66
|
+
}
|
|
67
|
+
if (parsed.username || parsed.password) {
|
|
68
|
+
throw new UnsafeRemoteUrlError("Remote media URL must not include credentials");
|
|
69
|
+
}
|
|
70
|
+
const hostname = parsed.hostname.replace(/^\[|\]$/g, "");
|
|
71
|
+
if (isBlockedHostname(hostname)) {
|
|
72
|
+
throw new UnsafeRemoteUrlError(`Blocked media URL host: ${hostname}`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const lookupTarget = net.isIP(hostname) ? hostname : parsed.hostname;
|
|
76
|
+
let records: Array<{ address: string }>;
|
|
77
|
+
try {
|
|
78
|
+
records = await dns.lookup(lookupTarget, { all: true });
|
|
79
|
+
} catch {
|
|
80
|
+
throw new UnsafeRemoteUrlError(`Could not resolve media URL host: ${hostname}`);
|
|
81
|
+
}
|
|
82
|
+
if (records.length === 0) {
|
|
83
|
+
throw new UnsafeRemoteUrlError(`Could not resolve media URL host: ${hostname}`);
|
|
84
|
+
}
|
|
85
|
+
for (const record of records) {
|
|
86
|
+
if (isPrivateOrLocalIp(record.address)) {
|
|
87
|
+
throw new UnsafeRemoteUrlError(
|
|
88
|
+
`Blocked media URL resolved to a private address (${record.address})`,
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return parsed;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export async function readResponseWithByteCap(
|
|
96
|
+
response: Response,
|
|
97
|
+
maxBytes: number,
|
|
98
|
+
): Promise<Buffer> {
|
|
99
|
+
const contentLength = Number(response.headers.get("content-length") || "0");
|
|
100
|
+
if (Number.isFinite(contentLength) && contentLength > maxBytes) {
|
|
101
|
+
throw new Error(`Remote media too large: ${contentLength}`);
|
|
102
|
+
}
|
|
103
|
+
if (!response.body) {
|
|
104
|
+
const buffer = Buffer.from(await response.arrayBuffer());
|
|
105
|
+
if (buffer.length > maxBytes) {
|
|
106
|
+
throw new Error(`Remote media too large: ${buffer.length}`);
|
|
107
|
+
}
|
|
108
|
+
return buffer;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const reader = response.body.getReader();
|
|
112
|
+
const chunks: Buffer[] = [];
|
|
113
|
+
let received = 0;
|
|
114
|
+
while (true) {
|
|
115
|
+
const { done, value } = await reader.read();
|
|
116
|
+
if (done) break;
|
|
117
|
+
if (!value) continue;
|
|
118
|
+
received += value.byteLength;
|
|
119
|
+
if (received > maxBytes) {
|
|
120
|
+
try {
|
|
121
|
+
await reader.cancel();
|
|
122
|
+
} catch {}
|
|
123
|
+
throw new Error(`Remote media too large: ${received}`);
|
|
124
|
+
}
|
|
125
|
+
chunks.push(Buffer.from(value));
|
|
126
|
+
}
|
|
127
|
+
return Buffer.concat(chunks);
|
|
128
|
+
}
|
|
@@ -80,9 +80,11 @@ export function recordServerLog(level: "INFO" | "WARN" | "ERROR", text: string):
|
|
|
80
80
|
.replace(/([\u{1F300}-\u{1FAFF}\u{2600}-\u{27BF}\u{2300}-\u{23FF}]\uFE0F?)\s{2,}/gu, "$1 ");
|
|
81
81
|
if (!line) continue;
|
|
82
82
|
|
|
83
|
-
// Prevent identical
|
|
84
|
-
const
|
|
85
|
-
|
|
83
|
+
// Prevent identical duplicate logs within the same second window
|
|
84
|
+
const isDuplicate = logHistory
|
|
85
|
+
.slice(-10)
|
|
86
|
+
.some((entry) => entry.time === time && entry.level === level && entry.message === line);
|
|
87
|
+
if (isDuplicate) {
|
|
86
88
|
continue;
|
|
87
89
|
}
|
|
88
90
|
|
package/src/routes/upload.ts
CHANGED
|
@@ -10,6 +10,11 @@ import { sendOpenAIError } from "../api/error-helpers.js";
|
|
|
10
10
|
import { buildQwenRequestHeaders } from "../services/qwen-headers.ts";
|
|
11
11
|
import { qwenUrl } from "../services/qwen-url.ts";
|
|
12
12
|
import { config } from "../core/config.ts";
|
|
13
|
+
import {
|
|
14
|
+
UnsafeRemoteUrlError,
|
|
15
|
+
assertSafeRemoteMediaUrl,
|
|
16
|
+
readResponseWithByteCap,
|
|
17
|
+
} from "../core/safe-remote-url.ts";
|
|
13
18
|
|
|
14
19
|
// Cache the heavy ali-oss module so we import it once, not on every upload.
|
|
15
20
|
let cachedOSSModule: any = null;
|
|
@@ -288,11 +293,15 @@ async function downloadRemoteMedia(url: string): Promise<{
|
|
|
288
293
|
filename: string;
|
|
289
294
|
mime: string;
|
|
290
295
|
}> {
|
|
296
|
+
await assertSafeRemoteMediaUrl(url);
|
|
297
|
+
const headMimeGuess = detectFileType(getFilenameFromUrl(url)).mime;
|
|
298
|
+
const maxSize = getMaxUploadSize(headMimeGuess);
|
|
291
299
|
const response = await fetch(url, {
|
|
292
300
|
headers: {
|
|
293
301
|
"User-Agent": config.auth.userAgent,
|
|
294
302
|
Accept: "image/*,*/*;q=0.8",
|
|
295
303
|
},
|
|
304
|
+
redirect: "error",
|
|
296
305
|
});
|
|
297
306
|
if (!response.ok) {
|
|
298
307
|
throw new Error(`Remote media download failed: ${response.status}`);
|
|
@@ -312,11 +321,10 @@ async function downloadRemoteMedia(url: string): Promise<{
|
|
|
312
321
|
);
|
|
313
322
|
}
|
|
314
323
|
|
|
315
|
-
const buffer =
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
}
|
|
324
|
+
const buffer = await readResponseWithByteCap(
|
|
325
|
+
response,
|
|
326
|
+
getMaxUploadSize(detectedMime) || maxSize,
|
|
327
|
+
);
|
|
320
328
|
|
|
321
329
|
return {
|
|
322
330
|
buffer,
|
|
@@ -650,12 +658,14 @@ export async function processImagesForQwen(
|
|
|
650
658
|
fileUrl = await uploadToOSS(remoteMedia.buffer, stsData, filename);
|
|
651
659
|
fileId = stsData.file_id;
|
|
652
660
|
} catch (err: any) {
|
|
661
|
+
if (err instanceof UnsafeRemoteUrlError) {
|
|
662
|
+
console.warn(`[Upload] Blocked unsafe remote media URL: ${err.message}`);
|
|
663
|
+
continue;
|
|
664
|
+
}
|
|
653
665
|
console.warn(
|
|
654
|
-
`[Upload] Failed to re-upload remote media,
|
|
666
|
+
`[Upload] Failed to re-upload remote media, skipping attachment: ${err.message}`,
|
|
655
667
|
);
|
|
656
|
-
|
|
657
|
-
filename = getFilenameFromUrl(mediaUrl);
|
|
658
|
-
fileId = uuidv4();
|
|
668
|
+
continue;
|
|
659
669
|
}
|
|
660
670
|
} else if (mediaUrl.startsWith("data:")) {
|
|
661
671
|
try {
|
|
@@ -352,10 +352,7 @@ export async function isPageLoggedIn(
|
|
|
352
352
|
const res = await fetch("/api/v1/auths/", { method: "GET" });
|
|
353
353
|
return res.status === 200;
|
|
354
354
|
} catch {
|
|
355
|
-
|
|
356
|
-
".header-right-auth-button, button.header-right-auth-button, a[href*='/auth'], a[href*='/login']",
|
|
357
|
-
);
|
|
358
|
-
return !btn || (btn as HTMLElement).offsetWidth === 0;
|
|
355
|
+
return false;
|
|
359
356
|
}
|
|
360
357
|
})
|
|
361
358
|
.catch(() => false);
|
|
@@ -1466,7 +1463,7 @@ export async function initPlaywrightForAccount(
|
|
|
1466
1463
|
`⚠️ [Playwright] Session expired for ${maskEmail(account.email)}, re-authenticating...`,
|
|
1467
1464
|
);
|
|
1468
1465
|
const ok = await loginToQwen(account.id, account.email, account.password);
|
|
1469
|
-
if (!ok) {
|
|
1466
|
+
if (!ok || !(await isPageLoggedIn(acctPage))) {
|
|
1470
1467
|
validationError = new Error(
|
|
1471
1468
|
`Session expired for ${maskEmail(account.email)} and re-authentication failed`,
|
|
1472
1469
|
);
|
|
@@ -1844,37 +1841,46 @@ async function loginViaApi(
|
|
|
1844
1841
|
.digest("hex");
|
|
1845
1842
|
const signinUrl = qwenUrl("/api/v2/auths/signin");
|
|
1846
1843
|
|
|
1844
|
+
const requestId = crypto.randomUUID();
|
|
1847
1845
|
const result = await page.evaluate(
|
|
1848
|
-
async ({ email, password, signinUrl }) => {
|
|
1846
|
+
async ({ email, password, signinUrl, requestId }) => {
|
|
1849
1847
|
try {
|
|
1850
1848
|
const response = await fetch(signinUrl, {
|
|
1851
1849
|
method: "POST",
|
|
1852
1850
|
signal: AbortSignal.timeout(10_000),
|
|
1851
|
+
credentials: "include",
|
|
1853
1852
|
headers: {
|
|
1854
1853
|
accept: "application/json, text/plain, */*",
|
|
1855
1854
|
"content-type": "application/json",
|
|
1856
1855
|
source: "web",
|
|
1857
1856
|
timezone: new Date().toString().split(" (")[0],
|
|
1858
|
-
"x-request-id":
|
|
1857
|
+
"x-request-id": requestId,
|
|
1859
1858
|
},
|
|
1860
1859
|
body: JSON.stringify({ email, password, login_type: "email" }),
|
|
1861
1860
|
});
|
|
1862
1861
|
const data = await response.json().catch(() => null);
|
|
1863
|
-
|
|
1862
|
+
const token = data?.data?.token || data?.token;
|
|
1863
|
+
if (token) {
|
|
1864
|
+
try {
|
|
1865
|
+
localStorage.setItem("token", token);
|
|
1866
|
+
document.cookie = `token=${encodeURIComponent(token)}; path=/; domain=.qwen.ai; max-age=31536000`;
|
|
1867
|
+
} catch {}
|
|
1868
|
+
}
|
|
1869
|
+
return { ok: response.ok, status: response.status, data, token };
|
|
1864
1870
|
} catch (e: any) {
|
|
1865
1871
|
return { ok: false, error: e.message };
|
|
1866
1872
|
}
|
|
1867
1873
|
},
|
|
1868
|
-
{ email, password: hashedPassword, signinUrl },
|
|
1874
|
+
{ email, password: hashedPassword, signinUrl, requestId },
|
|
1869
1875
|
);
|
|
1870
1876
|
|
|
1871
1877
|
if (result.data) {
|
|
1872
|
-
if (result.data.success === true) {
|
|
1878
|
+
if (result.data.success === true || result.token) {
|
|
1873
1879
|
await page.goto(qwenUrl("/"), {
|
|
1874
1880
|
waitUntil: "domcontentloaded",
|
|
1875
1881
|
timeout: config.timeouts.navigation,
|
|
1876
1882
|
});
|
|
1877
|
-
const loggedIn =
|
|
1883
|
+
const loggedIn = await isPageLoggedIn(page);
|
|
1878
1884
|
if (loggedIn) {
|
|
1879
1885
|
return { success: true };
|
|
1880
1886
|
}
|
|
@@ -1896,13 +1902,12 @@ async function loginViaApi(
|
|
|
1896
1902
|
waitUntil: "domcontentloaded",
|
|
1897
1903
|
timeout: config.timeouts.navigation,
|
|
1898
1904
|
});
|
|
1899
|
-
const loggedIn =
|
|
1905
|
+
const loggedIn = await isPageLoggedIn(page);
|
|
1900
1906
|
return {
|
|
1901
1907
|
success: loggedIn,
|
|
1902
|
-
reason: loggedIn ? undefined : "
|
|
1908
|
+
reason: loggedIn ? undefined : "Sessão não autenticada após signin",
|
|
1903
1909
|
};
|
|
1904
1910
|
}
|
|
1905
|
-
|
|
1906
1911
|
return {
|
|
1907
1912
|
success: false,
|
|
1908
1913
|
reason: result.error || `HTTP ${result.status || "desconhecido"} sem corpo JSON válido`,
|
|
@@ -1997,6 +2002,8 @@ async function loginViaUi(
|
|
|
1997
2002
|
}).catch(() => {});
|
|
1998
2003
|
// Check for UI error elements in DOM (Ant Design errors, alerts, toasts)
|
|
1999
2004
|
const errorSelector = [
|
|
2005
|
+
".qwen-chat-v2-toast-text",
|
|
2006
|
+
".qwen-chat-v2-toast-content",
|
|
2000
2007
|
".ant-form-item-explain-error",
|
|
2001
2008
|
".ant-message-error",
|
|
2002
2009
|
".ant-message-notice",
|
|
@@ -2018,8 +2025,7 @@ async function loginViaUi(
|
|
|
2018
2025
|
}
|
|
2019
2026
|
|
|
2020
2027
|
// Check if login was successful
|
|
2021
|
-
const isLoggedIn =
|
|
2022
|
-
!page.url().includes("auth") && !page.url().includes("login");
|
|
2028
|
+
const isLoggedIn = await isPageLoggedIn(page);
|
|
2023
2029
|
|
|
2024
2030
|
if (isLoggedIn) {
|
|
2025
2031
|
await page.goto(qwenUrl("/"), {
|
|
@@ -2291,7 +2297,7 @@ export async function captureQwenHeaders(
|
|
|
2291
2297
|
`⚠️ [Playwright] Session expired during header capture for ${accountId}; re-authenticating...`,
|
|
2292
2298
|
);
|
|
2293
2299
|
const ok = await loginToQwen(accountId, creds.email, creds.password);
|
|
2294
|
-
if (!ok) {
|
|
2300
|
+
if (!ok || !(await isPageLoggedIn(page))) {
|
|
2295
2301
|
settle(
|
|
2296
2302
|
new Error(
|
|
2297
2303
|
`Header capture failed for ${accountId}: re-login after session expiry did not succeed`,
|
|
@@ -2616,8 +2622,10 @@ async function refreshHeadersInternal(
|
|
|
2616
2622
|
const creds = getAccountCredentials(accountId);
|
|
2617
2623
|
if (creds && creds.email && creds.password) {
|
|
2618
2624
|
await loginToQwen(accountId, creds.email, creds.password);
|
|
2619
|
-
// Invalidate cookie cache after re-login
|
|
2620
2625
|
cookieCaches.delete(accountId);
|
|
2626
|
+
if (!(await isPageLoggedIn(page, 5_000))) {
|
|
2627
|
+
throw new Error(`Re-login for ${accountId} did not restore an authenticated session`);
|
|
2628
|
+
}
|
|
2621
2629
|
} else {
|
|
2622
2630
|
console.warn(
|
|
2623
2631
|
`[Playwright] No credentials available for re-login of ${accountId}`,
|
package/src/services/qwen.ts
CHANGED
|
@@ -1793,8 +1793,9 @@ export async function disableNativeTools(accountId?: string): Promise<void> {
|
|
|
1793
1793
|
const result = await withAccountPage(
|
|
1794
1794
|
accountId,
|
|
1795
1795
|
async (page) => {
|
|
1796
|
+
const requestId = crypto.randomUUID();
|
|
1796
1797
|
const response = await page.evaluate(
|
|
1797
|
-
async ({ payload, timeoutMs }: { payload: any; timeoutMs: number }) => {
|
|
1798
|
+
async ({ payload, timeoutMs, requestId }: { payload: any; timeoutMs: number; requestId: string }) => {
|
|
1798
1799
|
const controller = new AbortController();
|
|
1799
1800
|
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
|
1800
1801
|
try {
|
|
@@ -1805,7 +1806,7 @@ export async function disableNativeTools(accountId?: string): Promise<void> {
|
|
|
1805
1806
|
headers: {
|
|
1806
1807
|
accept: "application/json, text/plain, */*",
|
|
1807
1808
|
"content-type": "application/json",
|
|
1808
|
-
"x-request-id":
|
|
1809
|
+
"x-request-id": requestId,
|
|
1809
1810
|
timezone: new Date().toString().split(" (")[0],
|
|
1810
1811
|
source: "web",
|
|
1811
1812
|
},
|
|
@@ -1818,7 +1819,7 @@ export async function disableNativeTools(accountId?: string): Promise<void> {
|
|
|
1818
1819
|
clearTimeout(timeoutId);
|
|
1819
1820
|
}
|
|
1820
1821
|
},
|
|
1821
|
-
{ payload, timeoutMs: config.timeouts.http },
|
|
1822
|
+
{ payload, timeoutMs: config.timeouts.http, requestId },
|
|
1822
1823
|
);
|
|
1823
1824
|
return response;
|
|
1824
1825
|
},
|
|
@@ -2152,25 +2153,29 @@ export async function fetchQwenModels(
|
|
|
2152
2153
|
const result = await withAccountPage(
|
|
2153
2154
|
accountId,
|
|
2154
2155
|
async (page) => {
|
|
2155
|
-
const
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
const
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2156
|
+
const requestId = crypto.randomUUID();
|
|
2157
|
+
const response = await page.evaluate(
|
|
2158
|
+
async ({ timeoutMs, requestId }: { timeoutMs: number; requestId: string }) => {
|
|
2159
|
+
const controller = new AbortController();
|
|
2160
|
+
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
|
2161
|
+
try {
|
|
2162
|
+
const resp = await fetch("https://chat.qwen.ai/api/models", {
|
|
2163
|
+
method: "GET",
|
|
2164
|
+
headers: {
|
|
2165
|
+
accept: "application/json, text/plain, */*",
|
|
2166
|
+
"x-request-id": requestId,
|
|
2167
|
+
timezone: new Date().toString().split(" (")[0],
|
|
2168
|
+
source: "web",
|
|
2169
|
+
},
|
|
2170
|
+
signal: controller.signal,
|
|
2171
|
+
});
|
|
2172
|
+
return { status: resp.status, body: await resp.text() };
|
|
2173
|
+
} finally {
|
|
2174
|
+
clearTimeout(timeoutId);
|
|
2175
|
+
}
|
|
2176
|
+
},
|
|
2177
|
+
{ timeoutMs: config.timeouts.http, requestId },
|
|
2178
|
+
);
|
|
2174
2179
|
return response;
|
|
2175
2180
|
},
|
|
2176
2181
|
);
|
package/src/sync/index.ts
CHANGED
|
@@ -6,6 +6,11 @@ import Database from "better-sqlite3";
|
|
|
6
6
|
|
|
7
7
|
import { config } from "../core/config.ts";
|
|
8
8
|
import { getSyncStatePath } from "../core/paths.ts";
|
|
9
|
+
import {
|
|
10
|
+
PLACEHOLDER_API_KEY,
|
|
11
|
+
isLoopbackHost,
|
|
12
|
+
isPlaceholderApiKey,
|
|
13
|
+
} from "../core/local-auth.ts";
|
|
9
14
|
import type {
|
|
10
15
|
ClientSyncResult,
|
|
11
16
|
SyncAllOptions,
|
|
@@ -46,15 +51,27 @@ export {
|
|
|
46
51
|
syncAider,
|
|
47
52
|
restoreAider,
|
|
48
53
|
};
|
|
49
|
-
export function resolveApiKey(
|
|
54
|
+
export function resolveApiKey(
|
|
55
|
+
overrideKey?: string,
|
|
56
|
+
configKey?: string,
|
|
57
|
+
host = "127.0.0.1",
|
|
58
|
+
): string {
|
|
50
59
|
if (overrideKey && overrideKey.trim().length > 0) {
|
|
60
|
+
if (isPlaceholderApiKey(overrideKey) && !isLoopbackHost(host)) {
|
|
61
|
+
throw new Error(
|
|
62
|
+
`Refusing to sync clients with placeholder API key ${PLACEHOLDER_API_KEY}. Set API_KEY first.`,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
51
65
|
return overrideKey.trim();
|
|
52
66
|
}
|
|
53
67
|
const envKey = process.env.API_KEY || process.env.ADMIN_PASSWORD || configKey;
|
|
54
|
-
if (envKey && envKey
|
|
68
|
+
if (envKey && !isPlaceholderApiKey(envKey)) {
|
|
55
69
|
return envKey.trim();
|
|
56
70
|
}
|
|
57
|
-
return
|
|
71
|
+
if (isLoopbackHost(host)) return PLACEHOLDER_API_KEY;
|
|
72
|
+
throw new Error(
|
|
73
|
+
`Refusing to sync clients with placeholder API key ${PLACEHOLDER_API_KEY}. Set API_KEY or start the proxy once to generate one.`,
|
|
74
|
+
);
|
|
58
75
|
}
|
|
59
76
|
|
|
60
77
|
export function normalizeClientName(name: string): SyncClientName | null {
|
|
@@ -459,7 +476,8 @@ export function syncAllClients(options: SyncAllOptions = {}): SyncAllResult {
|
|
|
459
476
|
const port = options.port ?? (config.server?.port || 7936);
|
|
460
477
|
const configuredHost = config.server?.host;
|
|
461
478
|
const host = options.host ?? (configuredHost && configuredHost !== "0.0.0.0" ? configuredHost : "127.0.0.1");
|
|
462
|
-
const
|
|
479
|
+
const authHost = options.host ?? configuredHost ?? host;
|
|
480
|
+
const apiKey = resolveApiKey(options.apiKey, config.apiKey, authHost);
|
|
463
481
|
const { anthropicBaseUrl, openaiBaseUrl } = resolveBaseUrls(port, host);
|
|
464
482
|
const stateFilePath = options.stateFilePath || getDefaultStateFilePath();
|
|
465
483
|
const selectedModel = options.model || "qwen3.8-max";
|
|
@@ -529,6 +547,7 @@ export function syncAllClients(options: SyncAllOptions = {}): SyncAllResult {
|
|
|
529
547
|
baseUrl: openaiBaseUrl,
|
|
530
548
|
model: selectedModel,
|
|
531
549
|
models: syncModels,
|
|
550
|
+
setActive: options.setActive ?? true,
|
|
532
551
|
});
|
|
533
552
|
results.clients.openCode = openCodeRes;
|
|
534
553
|
if (openCodeRes.success && openCodeRes.backupPath) {
|
package/src/sync/opencode.ts
CHANGED
|
@@ -121,8 +121,32 @@ function findKeyObjectSpan(content: string, key: string): { start: number; end:
|
|
|
121
121
|
return null;
|
|
122
122
|
}
|
|
123
123
|
|
|
124
|
+
function openCodeModelId(model: string): string {
|
|
125
|
+
return `qwenproxy/${model}`;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function applyOpenCodeActiveModel(
|
|
129
|
+
content: string,
|
|
130
|
+
model: string,
|
|
131
|
+
setActive: boolean,
|
|
132
|
+
): string {
|
|
133
|
+
if (!setActive) return content;
|
|
134
|
+
const id = openCodeModelId(model);
|
|
135
|
+
if (/"model"\s*:/.test(content)) {
|
|
136
|
+
return content.replace(/"model"\s*:\s*("[^"]*"|'[^']*')/, `"model": "${id}"`);
|
|
137
|
+
}
|
|
138
|
+
return content.replace(/\{/, `{\n "model": "${id}",`);
|
|
139
|
+
}
|
|
140
|
+
|
|
124
141
|
export function syncOpenCode(options: SyncOptions): ClientSyncResult {
|
|
125
|
-
const {
|
|
142
|
+
const {
|
|
143
|
+
filePath,
|
|
144
|
+
apiKey,
|
|
145
|
+
baseUrl,
|
|
146
|
+
model = "qwen3.8-max",
|
|
147
|
+
models,
|
|
148
|
+
setActive = true,
|
|
149
|
+
} = options;
|
|
126
150
|
try {
|
|
127
151
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
128
152
|
let backupPath: string | undefined;
|
|
@@ -142,11 +166,14 @@ export function syncOpenCode(options: SyncOptions): ClientSyncResult {
|
|
|
142
166
|
const qwenEntry = ` "qwenproxy": ${providerJson}`;
|
|
143
167
|
|
|
144
168
|
if (!content.trim()) {
|
|
145
|
-
const initial = {
|
|
169
|
+
const initial: Record<string, unknown> = {
|
|
146
170
|
$schema: "https://opencode.ai/config.json",
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
171
|
+
};
|
|
172
|
+
if (setActive) {
|
|
173
|
+
initial.model = openCodeModelId(model);
|
|
174
|
+
}
|
|
175
|
+
initial.provider = {
|
|
176
|
+
qwenproxy: providerObj,
|
|
150
177
|
};
|
|
151
178
|
fs.writeFileSync(filePath, JSON.stringify(initial, null, 2) + "\n", "utf-8");
|
|
152
179
|
} else {
|
|
@@ -179,7 +206,11 @@ export function syncOpenCode(options: SyncOptions): ClientSyncResult {
|
|
|
179
206
|
}
|
|
180
207
|
}
|
|
181
208
|
}
|
|
182
|
-
fs.writeFileSync(
|
|
209
|
+
fs.writeFileSync(
|
|
210
|
+
filePath,
|
|
211
|
+
applyOpenCodeActiveModel(content, model, setActive),
|
|
212
|
+
"utf-8",
|
|
213
|
+
);
|
|
183
214
|
}
|
|
184
215
|
|
|
185
216
|
return {
|
package/src/sync-clients.ts
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
|
|
1
|
+
process.env.DOTENV_CONFIG_QUIET = "true";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import dotenv from "dotenv";
|
|
4
|
+
import { ensureDataDirs, getEnvFilePath } from "./core/paths.ts";
|
|
2
5
|
import {
|
|
3
6
|
syncAllClients,
|
|
4
7
|
restoreAllClients,
|
|
@@ -8,6 +11,14 @@ import {
|
|
|
8
11
|
} from "./sync/index.ts";
|
|
9
12
|
import type { SyncClientName } from "./sync/types.ts";
|
|
10
13
|
|
|
14
|
+
ensureDataDirs();
|
|
15
|
+
const envPath = getEnvFilePath();
|
|
16
|
+
if (fs.existsSync(envPath)) {
|
|
17
|
+
dotenv.config({ path: envPath, quiet: true });
|
|
18
|
+
} else {
|
|
19
|
+
dotenv.config({ quiet: true });
|
|
20
|
+
}
|
|
21
|
+
|
|
11
22
|
function parseArgs() {
|
|
12
23
|
const args = process.argv.slice(2);
|
|
13
24
|
const options: {
|
|
@@ -82,7 +93,7 @@ Exemplos:
|
|
|
82
93
|
Opções:
|
|
83
94
|
--client <nome> Nome do cliente (hermes, opencode, claude, openclaw, kilo, cline, omp, codex, zed, aider)
|
|
84
95
|
--model <modelo> Modelo padrão a configurar (padrão: qwen3.8-max)
|
|
85
|
-
--api-key <chave> Sobrescrever chave de API (
|
|
96
|
+
--api-key <chave> Sobrescrever chave de API (obrigatória; não usa placeholder)
|
|
86
97
|
--port <porta> Sobrescrever porta do servidor (padrão: lê do .env ou usa 7936)
|
|
87
98
|
--host <host> Sobrescrever host do servidor (padrão: 127.0.0.1)
|
|
88
99
|
--no-active Não definir o modelo ativo como padrão (apenas adiciona o provider)
|
package/src/tui/proxy-client.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import { config, type ChatMode } from "../core/config.ts";
|
|
6
|
+
import { localApiAuthHeaders } from "../core/local-auth.ts";
|
|
6
7
|
import { loadAccounts, type QwenAccount } from "../core/accounts.ts";
|
|
7
8
|
import {
|
|
8
9
|
getAccountCooldownInfo,
|
|
@@ -68,7 +69,10 @@ export async function fetchProxyStatus(): Promise<ProxyStatusSnapshot> {
|
|
|
68
69
|
isHealthCheckPending = true;
|
|
69
70
|
const controller = new AbortController();
|
|
70
71
|
const timeout = setTimeout(() => controller.abort(), 350);
|
|
71
|
-
fetch(`http://${host}:${port}/health`, {
|
|
72
|
+
fetch(`http://${host}:${port}/health`, {
|
|
73
|
+
signal: controller.signal,
|
|
74
|
+
headers: localApiAuthHeaders(),
|
|
75
|
+
})
|
|
72
76
|
.then(async (resp) => {
|
|
73
77
|
clearTimeout(timeout);
|
|
74
78
|
if (resp.ok) {
|
|
@@ -246,7 +250,7 @@ export async function streamChatCompletions(
|
|
|
246
250
|
const port = config.server?.port || 7936;
|
|
247
251
|
const configuredHost = config.server?.host;
|
|
248
252
|
const host = configuredHost && configuredHost !== "0.0.0.0" ? configuredHost : "127.0.0.1";
|
|
249
|
-
const
|
|
253
|
+
const apiKeyHeaders = localApiAuthHeaders();
|
|
250
254
|
|
|
251
255
|
const startTime = Date.now();
|
|
252
256
|
let ttfbMs = 0;
|
|
@@ -258,7 +262,7 @@ export async function streamChatCompletions(
|
|
|
258
262
|
method: "POST",
|
|
259
263
|
headers: {
|
|
260
264
|
"Content-Type": "application/json",
|
|
261
|
-
|
|
265
|
+
...apiKeyHeaders,
|
|
262
266
|
"x-qwenproxy-chat-mode": chatMode,
|
|
263
267
|
},
|
|
264
268
|
body: JSON.stringify({
|
|
@@ -360,7 +364,6 @@ export async function fetchLiveModels(forceRefresh = false): Promise<string[]> {
|
|
|
360
364
|
const port = config.server?.port || 7936;
|
|
361
365
|
const configuredHost = config.server?.host;
|
|
362
366
|
const host = configuredHost && configuredHost !== "0.0.0.0" ? configuredHost : "127.0.0.1";
|
|
363
|
-
const apiKey = config.apiKey || "sk-qwenproxy-local";
|
|
364
367
|
|
|
365
368
|
liveModelsPromise = (async () => {
|
|
366
369
|
const controller = new AbortController();
|
|
@@ -369,7 +372,7 @@ export async function fetchLiveModels(forceRefresh = false): Promise<string[]> {
|
|
|
369
372
|
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
370
373
|
try {
|
|
371
374
|
const resp = await fetch(`http://${host}:${port}/v1/models`, {
|
|
372
|
-
headers: {
|
|
375
|
+
headers: { ...localApiAuthHeaders() },
|
|
373
376
|
signal: controller.signal,
|
|
374
377
|
});
|
|
375
378
|
if (resp.ok) {
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { config } from "../core/config.ts";
|
|
7
|
+
import { localApiAuthHeaders } from "../core/local-auth.ts";
|
|
7
8
|
import { startServer, stopServer } from "../api/server.ts";
|
|
8
9
|
import { stripAnsi } from "./theme.ts";
|
|
9
10
|
|
|
@@ -230,6 +231,7 @@ export class ServerManager {
|
|
|
230
231
|
const timeout = setTimeout(() => controller.abort(), 600);
|
|
231
232
|
const resp = await fetch(`http://${cleanHost}:${port}/health`, {
|
|
232
233
|
signal: controller.signal,
|
|
234
|
+
headers: localApiAuthHeaders(),
|
|
233
235
|
});
|
|
234
236
|
clearTimeout(timeout);
|
|
235
237
|
if (resp.ok) {
|
|
@@ -295,7 +297,7 @@ export class ServerManager {
|
|
|
295
297
|
try {
|
|
296
298
|
const resp = await fetch(`http://${host}:${port}/logs/live`, {
|
|
297
299
|
signal: abort.signal,
|
|
298
|
-
headers: { Accept: "text/event-stream" },
|
|
300
|
+
headers: { Accept: "text/event-stream", ...localApiAuthHeaders() },
|
|
299
301
|
});
|
|
300
302
|
if (!resp.ok || !resp.body) return;
|
|
301
303
|
const reader = resp.body.getReader();
|