qwenproxy-cli 1.2.8 → 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 +26 -4
- 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/routes/upload.ts +19 -9
- package/src/services/playwright.ts +21 -11
- 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;
|
|
@@ -121,10 +124,15 @@ function getCachedAccounts(): QwenAccount[] {
|
|
|
121
124
|
)
|
|
122
125
|
.all() as QwenAccount[];
|
|
123
126
|
|
|
124
|
-
accountsCache = rows.map((row) =>
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
+
});
|
|
128
136
|
accountsCacheTime = now;
|
|
129
137
|
return accountsCache;
|
|
130
138
|
}
|
|
@@ -308,9 +316,23 @@ export function addAccount(
|
|
|
308
316
|
return newAccount;
|
|
309
317
|
}
|
|
310
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
|
+
|
|
311
330
|
export function removeAccount(id: string): boolean {
|
|
312
331
|
const db = getDatabase();
|
|
313
332
|
const result = db.prepare("DELETE FROM accounts WHERE id = ?").run(id);
|
|
333
|
+
if (result.changes > 0) {
|
|
334
|
+
wipeAccountSessionFiles(id);
|
|
335
|
+
}
|
|
314
336
|
invalidateAccountsCache();
|
|
315
337
|
return result.changes > 0;
|
|
316
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
|
+
}
|
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 {
|
|
@@ -1463,7 +1463,7 @@ export async function initPlaywrightForAccount(
|
|
|
1463
1463
|
`⚠️ [Playwright] Session expired for ${maskEmail(account.email)}, re-authenticating...`,
|
|
1464
1464
|
);
|
|
1465
1465
|
const ok = await loginToQwen(account.id, account.email, account.password);
|
|
1466
|
-
if (!ok) {
|
|
1466
|
+
if (!ok || !(await isPageLoggedIn(acctPage))) {
|
|
1467
1467
|
validationError = new Error(
|
|
1468
1468
|
`Session expired for ${maskEmail(account.email)} and re-authentication failed`,
|
|
1469
1469
|
);
|
|
@@ -1848,6 +1848,7 @@ async function loginViaApi(
|
|
|
1848
1848
|
const response = await fetch(signinUrl, {
|
|
1849
1849
|
method: "POST",
|
|
1850
1850
|
signal: AbortSignal.timeout(10_000),
|
|
1851
|
+
credentials: "include",
|
|
1851
1852
|
headers: {
|
|
1852
1853
|
accept: "application/json, text/plain, */*",
|
|
1853
1854
|
"content-type": "application/json",
|
|
@@ -1858,7 +1859,14 @@ async function loginViaApi(
|
|
|
1858
1859
|
body: JSON.stringify({ email, password, login_type: "email" }),
|
|
1859
1860
|
});
|
|
1860
1861
|
const data = await response.json().catch(() => null);
|
|
1861
|
-
|
|
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 };
|
|
1862
1870
|
} catch (e: any) {
|
|
1863
1871
|
return { ok: false, error: e.message };
|
|
1864
1872
|
}
|
|
@@ -1867,12 +1875,12 @@ async function loginViaApi(
|
|
|
1867
1875
|
);
|
|
1868
1876
|
|
|
1869
1877
|
if (result.data) {
|
|
1870
|
-
if (result.data.success === true) {
|
|
1878
|
+
if (result.data.success === true || result.token) {
|
|
1871
1879
|
await page.goto(qwenUrl("/"), {
|
|
1872
1880
|
waitUntil: "domcontentloaded",
|
|
1873
1881
|
timeout: config.timeouts.navigation,
|
|
1874
1882
|
});
|
|
1875
|
-
const loggedIn =
|
|
1883
|
+
const loggedIn = await isPageLoggedIn(page);
|
|
1876
1884
|
if (loggedIn) {
|
|
1877
1885
|
return { success: true };
|
|
1878
1886
|
}
|
|
@@ -1894,13 +1902,12 @@ async function loginViaApi(
|
|
|
1894
1902
|
waitUntil: "domcontentloaded",
|
|
1895
1903
|
timeout: config.timeouts.navigation,
|
|
1896
1904
|
});
|
|
1897
|
-
const loggedIn =
|
|
1905
|
+
const loggedIn = await isPageLoggedIn(page);
|
|
1898
1906
|
return {
|
|
1899
1907
|
success: loggedIn,
|
|
1900
|
-
reason: loggedIn ? undefined : "
|
|
1908
|
+
reason: loggedIn ? undefined : "Sessão não autenticada após signin",
|
|
1901
1909
|
};
|
|
1902
1910
|
}
|
|
1903
|
-
|
|
1904
1911
|
return {
|
|
1905
1912
|
success: false,
|
|
1906
1913
|
reason: result.error || `HTTP ${result.status || "desconhecido"} sem corpo JSON válido`,
|
|
@@ -1995,6 +2002,8 @@ async function loginViaUi(
|
|
|
1995
2002
|
}).catch(() => {});
|
|
1996
2003
|
// Check for UI error elements in DOM (Ant Design errors, alerts, toasts)
|
|
1997
2004
|
const errorSelector = [
|
|
2005
|
+
".qwen-chat-v2-toast-text",
|
|
2006
|
+
".qwen-chat-v2-toast-content",
|
|
1998
2007
|
".ant-form-item-explain-error",
|
|
1999
2008
|
".ant-message-error",
|
|
2000
2009
|
".ant-message-notice",
|
|
@@ -2016,8 +2025,7 @@ async function loginViaUi(
|
|
|
2016
2025
|
}
|
|
2017
2026
|
|
|
2018
2027
|
// Check if login was successful
|
|
2019
|
-
const isLoggedIn =
|
|
2020
|
-
!page.url().includes("auth") && !page.url().includes("login");
|
|
2028
|
+
const isLoggedIn = await isPageLoggedIn(page);
|
|
2021
2029
|
|
|
2022
2030
|
if (isLoggedIn) {
|
|
2023
2031
|
await page.goto(qwenUrl("/"), {
|
|
@@ -2289,7 +2297,7 @@ export async function captureQwenHeaders(
|
|
|
2289
2297
|
`⚠️ [Playwright] Session expired during header capture for ${accountId}; re-authenticating...`,
|
|
2290
2298
|
);
|
|
2291
2299
|
const ok = await loginToQwen(accountId, creds.email, creds.password);
|
|
2292
|
-
if (!ok) {
|
|
2300
|
+
if (!ok || !(await isPageLoggedIn(page))) {
|
|
2293
2301
|
settle(
|
|
2294
2302
|
new Error(
|
|
2295
2303
|
`Header capture failed for ${accountId}: re-login after session expiry did not succeed`,
|
|
@@ -2614,8 +2622,10 @@ async function refreshHeadersInternal(
|
|
|
2614
2622
|
const creds = getAccountCredentials(accountId);
|
|
2615
2623
|
if (creds && creds.email && creds.password) {
|
|
2616
2624
|
await loginToQwen(accountId, creds.email, creds.password);
|
|
2617
|
-
// Invalidate cookie cache after re-login
|
|
2618
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
|
+
}
|
|
2619
2629
|
} else {
|
|
2620
2630
|
console.warn(
|
|
2621
2631
|
`[Playwright] No credentials available for re-login of ${accountId}`,
|
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();
|