diff --git a/.env.example b/.env.example index ef5b4eab5f..715650390e 100644 --- a/.env.example +++ b/.env.example @@ -1256,17 +1256,6 @@ GITHUB_OAUTH_CLIENT_ID=Iv1.b507a08c87ecfe98 # VISION_BRIDGE_BASE_URL= # VISION_BRIDGE_API_KEY= -# ── Raycast Pro (local auto-import) ── -# Raycast Pro AI is a reverse-engineered, unofficial API — local/personal use -# only (no OAuth client_id/secret; token is captured via macOS Auto-Import -# from the Keychain + local Raycast SQLite DB, or pasted manually). These -# vars are optional manual overrides used by open-sse/services/raycast.ts -# and the direct-probe benchmark script scripts/raycast/usage-benchmark.mjs. -# RAYCAST_BEARER_TOKEN= -# RAYCAST_DEVICE_ID= -# RAYCAST_AID= -# RAYCAST_SIG_SECRET= - # ───────────────────────────────────────────────────────────────────────────── # ⚠️ GOOGLE OAUTH (Antigravity) & OTHER PROVIDERS — REMOTE SERVERS # ───────────────────────────────────────────────────────────────────────────── diff --git a/changelog.d/maintenance/11691-retire-gpl-derived-integrations.md b/changelog.d/maintenance/11691-retire-gpl-derived-integrations.md new file mode 100644 index 0000000000..fca4cef4bc --- /dev/null +++ b/changelog.d/maintenance/11691-retire-gpl-derived-integrations.md @@ -0,0 +1 @@ +- **chore(providers):** retire the distributed Raycast Relay and Hailuo Web integrations after a provenance audit confirmed substantial derivation from GPL-3.0 sources; the official GitHub and MiniMax API providers remain available. Migration 163 disables legacy connections and invalidates their active leases while preserving connection IDs, audit/history rows, quota snapshots, usage records, and fail-closed API-key connection policies. diff --git a/config/quality/eslint-suppressions.json b/config/quality/eslint-suppressions.json index 9e7aca2f54..131a569da3 100644 --- a/config/quality/eslint-suppressions.json +++ b/config/quality/eslint-suppressions.json @@ -194,11 +194,6 @@ "count": 7 } }, - "open-sse/executors/raycast.ts": { - "@typescript-eslint/no-unused-vars": { - "count": 1 - } - }, "open-sse/executors/t3-chat-web.ts": { "@typescript-eslint/no-explicit-any": { "count": 11 diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index 33adce912a..e2fa297ace 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -436,7 +436,8 @@ "src/shared/components/analytics/charts.tsx": 1346, "src/shared/services/cliRuntime.ts": 1459, "src/sse/handlers/chat.ts": 2493, - "src/sse/services/auth.ts": 3432, + "src/sse/services/auth.ts": 3443, + "_rebaseline_2026_08_28_mergebatch_v3851_provenance_sweep_batch6": "/merge-batch 2026-08-27/28 (v3.8.51) provider/asset provenance & legal compliance sweep — combining the Designer Web + Felo Web + Runtime + GPL-derived (Raycast/Hailuo Web, #11691) retirement guards at their shared chokepoints: src/sse/services/auth.ts 3432->3443 (+11, getProviderCredentials()'s two sequential retirement-check if-blocks plus getModelInfoOrRetirementResponse() catch-branch wiring), src/sse/handlers/chatHelpers.ts 1019->1037 (+18, the combined retirement-error catch branches in the executor dispatch path), src/shared/constants/providers/apikey/gateways.ts 1330->1347 (+17, catalog drift from the same PR chain since the prior 2026-08-11 rebaseline), open-sse/services/autoCombo/virtualFactory.ts 1130->1132 (+2, retirement guard import wiring at the virtual-instance factory chokepoint). Each guard call is irreducible per-mechanism wiring at pre-existing chokepoints (getExecutor, resolveExecutorWithProxy, chat.ts/chatHelpers.ts catch branches, providers.ts write paths) — combining them is additive, not a new branch. Covered by the focused test suites of each boarded PR (chatcore-executor-proxy.test.ts, provider-node-reserved-prefix.test.ts, gpl-derived-provider-removals.test.ts, migration-166-retire-gpl-derived-providers.test.ts, among others).", "_rebaseline_2026_08_24_lasterror_provider_error_detail": "PR (ntdat812) own growth: src/sse/services/auth.ts 3344->3346 (+2). One line is the import of describeUpstreamFailure from @/shared/utils/upstreamError, which replaces the string-only collapse `typeof errorText === \"string\" ? errorText.slice(0, 100) : \"Provider error\"` at the single markAccountUnavailable chokepoint (net 0 lines there) — the logic itself lives in upstreamError.ts, next to the extractErrorMessage it reuses, so nothing else moved into this file. The second line is the repo's own lint-staged prettier pass splitting a pre-existing two-statements-on-one-line at getProviderCredentials (`invalidateManagedLease(...); log.warn(...)`); it re-applies on any commit that touches this file, so it is not separable from the change. Covered by tests/unit/provider-error-detail-lastError.test.ts.", "_rebaseline_2026_08_23_11186_synced_inventory_routing": "PR #11186 (pacocartones) own growth: src/sse/services/auth.ts 3260->3337 (+77, loadAdvertisedModelsForSelfHostedConnections + the modelNotAdvertised candidate-filter predicate — pins chat routing to the connection whose synced inventory actually advertises the model, fixing spurious model-not-found on multi-host self-hosted setups; at the existing credential-selection chokepoint, not extractable without splitting the selection flow). Covered by tests/unit/chat-routing-synced-inventory-11089.test.ts. Owner pre-authorized baseline bumps 2026-08-22.", "tests/unit/account-fallback-service.test.ts": 2044, @@ -460,14 +461,14 @@ "_rebaseline_2026_08_22_11156_enter_check_disabled": "PR #11156 (rqzbeh) own growth: AddApiKeyModal.tsx 1080->1082 (+2, Enter keydown handler now mirrors the isCheckDisabled condition — owner-requested post-merge polish from #11056; the rest of the diff is Prettier reflow). Covered by tests/unit/ui/add-api-key-modal-enter-key.test.tsx (jsdom render test, Enter dispatch assertions).", "src/app/(dashboard)/dashboard/providers/[id]/hooks/useProviderConnections.ts": 1051, "src/shared/components/ModelSelectModal.tsx": 1138, - "src/shared/constants/providers/apikey/gateways.ts": 1330, + "src/shared/constants/providers/apikey/gateways.ts": 1347, "open-sse/vendor/codex-chatgpt-web/bridge.ts": 1387, "_rebaseline_2026_08_11_v3850_merge_storm_provider_registry": "DRIFT do merge-storm 2026-08-11 (99 PRs mergeados no release/v3.8.50). AddApiKeyModal.tsx (PR #8949 ChatGPT Web provider) e useProviderConnections.ts/ModelSelectModal.tsx (PRs #9011 combo test-all, #9499 image combos) = UI nova legitima acima do cap; gateways.ts = god-file de catalogo de providers que cresceu com PRs #9009/#9421/#9468/#9594 (qualquer split arriscaria corromper o merge de novo — o proprio PR #9421 quebrou o arquivo); bridge.ts (PR #8949) = ponte Chromium vendored; proxyFetch.ts 1207->1220 = drift herdado de merges. Owner autorizou rebaseline com anotacao (2026-08-11).", "src/lib/modelCapabilities.ts": 1072, "_rebaseline_2026_08_21_11034_effort_variants": "DRIFT do tip (base-red #9985): modelCapabilities.ts 1016->1072 (+56) acumulado por PRs ja mergeadas no release/v3.8.50 — principalmente #11034 (resolve effort-variant capabilities a partir do modelo base), alem de #10963/#11040/#10987 growth dos catalogos. Tip puro ficou vermelho neste gate; rebaseline no tip por push direto (owner pre-autorizou crescimento legitimo). Nao tocou no arquivo da #11038.", "src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts": 1014, "open-sse/config/imageRegistry.ts": 1034, - "src/sse/handlers/chatHelpers.ts": 1019, + "src/sse/handlers/chatHelpers.ts": 1037, "src/shared/middleware/chatBodyAdmission.ts": 1118, "_rebaseline_2026_08_22_11020_sigterm_drain": "PR #11020 (RaviTharuma) own growth: chatBodyAdmission.ts 1005->1009 (+4, heavyweight admission leases now increment the SIGTERM drain counter and releaseChatAdmissionWhenDone holds it for the SSE lifetime — closes #11015; +4 are the lease/drain wiring lines at the existing admission chokepoint). Covered by tests/unit/chat-body-admission.test.ts heavyweight-lease cases. Owner pre-authorized baseline bumps 2026-08-22.", "_rebaseline_2026_08_20_10668_tabitoken_gateway": "#10668 (yawar-aquil) own catalog growth: src/shared/constants/providers/apikey/gateways.ts 1268->1283 (+15, entirely this PR diff -- one new tabitoken gateway entry, data lines only; base moved from 1255 to 1268 via other merges since the PR forked). Not combination drift: reproducible on the PR branch alone, so the WS5.5 release-captain rule does not apply. Extraction is not available -- the file is pure data (own header: \"Pure data; merged by apikey/index.ts via spread\") and already split into 6 family files under apikey/. Same precedent as _rebaseline_2026_08_14_imagetotext_servicekinds (#10275/#10291, gateways.ts 1250->1255, data lines only) and _rebaseline_2026_08_11_v3850_merge_storm_provider_registry (owner-authorized for this same file).", @@ -480,7 +481,7 @@ "_rebaseline_2026_08_24_11355_cooldown_recovery_guards": "PR #11355 own growth: test/route.ts 1215->1237, +22 (startup crash-recovery guard: clearStaleCrashCooldowns() now parses the persisted rate_limited_until deadline and skips clearing rows still genuinely in the future, instead of clearing every non-terminal cooldown unconditionally). Cohesive fix at the existing test-route dispatch chokepoint alongside the #11141 probe builder. Covered by tests/unit/startup-stale-cooldown-recovery.test.ts + tests/unit/repro-zai-cooldown-cleared-by-connection-test.test.ts.", "src/lib/guardrails/videoBridgeRuntime.ts": 1009, "_rebaseline_2026_08_24_video_bridge_fu02_fu07_sampler": "PRs #11344 (FU-02 one-frame scene-aware determinism) + #11381 (FU-07 opt-in segment_aware structural sampling) own growth: videoBridgeRuntime.ts <1000->1009, +9 (sum of both boarded together in the same merge-batch). #11344 adds the deterministic one-frame midpoint fallback + policyEffective=uniform report at the existing scene_aware seam; #11381 adds the bounded local-only FFmpeg structural pre-analysis pass (scene/freeze/blur/exposure/SI-TI) and its budget-reallocation logic. Covered by tests/unit/guardrails/videoBridgeSampler.test.ts, tests/unit/guardrails/videoBridgeFu07StructuralSampling.test.ts, tests/integration/video-bridge-sampler-ffmpeg.test.ts. Owner pre-authorized rebaseline for legitimate PR growth (2026-08-19 directive).", - "open-sse/services/autoCombo/virtualFactory.ts": 1130, + "open-sse/services/autoCombo/virtualFactory.ts": 1132, "src/lib/cloudflaredTunnel.ts": 1078, "src/shared/components/RequestLoggerDetail.tsx": 1018 }, diff --git a/config/quality/test-masking-allowlist.json b/config/quality/test-masking-allowlist.json index e3d35f8a6e..0554c19bf8 100644 --- a/config/quality/test-masking-allowlist.json +++ b/config/quality/test-masking-allowlist.json @@ -37,6 +37,36 @@ "replacement": "tests/unit/felo-web-retirement.test.ts", "reason": "v3.8.50 #11698: the Felo Web integration remains on a GPL provenance/licensing HOLD, so its executor and registry no longer ship. The replacement proves runtime/catalog/source absence; generic no-auth and strict-zero-cost behavior remains covered with independent fixtures." }, + "tests/unit/executor-hailuo-web.test.ts": { + "replacement": "tests/unit/gpl-derived-provider-removals.test.ts", + "sourceRemoved": [ + "open-sse/executors/hailuo-web.ts", + "open-sse/config/providers/registry/minimax/web/index.ts" + ], + "reason": "v3.8.50 provenance remediation: the GPL-derived Hailuo Web executor and registry were removed. The replacement proves runtime/source absence while preserving both official MiniMax API providers." + }, + "tests/unit/raycast-auth.test.ts": { + "replacement": "tests/unit/gpl-derived-provider-removals.test.ts", + "sourceRemoved": [ + "open-sse/services/raycast.ts", + "src/lib/oauth/providers/raycast.ts", + "src/lib/oauth/services/raycast.ts" + ], + "reason": "v3.8.50 provenance remediation: the GPL-derived Raycast protocol and OAuth service were removed. The replacement proves raycast/rc runtime and source absence while preserving the independent GitHub provider." + }, + "tests/unit/raycast-local-extract.test.ts": { + "replacement": "tests/unit/gpl-derived-provider-removals.test.ts", + "sourceRemoved": ["src/lib/oauth/services/raycastLocal.ts"], + "reason": "v3.8.50 provenance remediation: the retired Raycast integration no longer ships local credential extraction. The replacement checks exact source closure and the negative runtime contract." + }, + "tests/unit/web-cookie-hailuo-web-11000.test.ts": { + "replacement": "tests/unit/gpl-derived-provider-removals.test.ts", + "sourceRemoved": [ + "open-sse/executors/hailuo-web.ts", + "open-sse/config/providers/registry/minimax/web/index.ts" + ], + "reason": "v3.8.50 provenance remediation: the Hailuo Web cookie transport was removed with its GPL-derived executor. The replacement proves absence while preserving MiniMax and MiniMax China." + }, "open-sse/services/combo/__tests__/targetExhaustion.test.ts": { "replacement": "tests/unit/combo/combo-target-exhaustion.test.ts", "reason": "v3.8.44 #5976: os testes de exaustão eram flake-prone (delays Math.random, timeouts 30s, >3min no CI) e foram REESCRITOS como unit determinístico com MAIS cobertura (21 casos/52 asserts vs 13 casos/37 asserts). Documentado no commit 5fe225850. Revisão humana: apresentado ao operador no STOP #1 do release v3.8.44." diff --git a/docs/architecture/ARCHITECTURE.md b/docs/architecture/ARCHITECTURE.md index 461d64fe3a..8aa7b54461 100644 --- a/docs/architecture/ARCHITECTURE.md +++ b/docs/architecture/ARCHITECTURE.md @@ -324,7 +324,7 @@ Domain layer modules: OAuth provider modules (22 individual files under `src/lib/oauth/providers/`): - Registry index: `src/lib/oauth/providers/index.ts` -- Individual providers: `agy.ts`, `antigravity.ts`, `claude.ts`, `cline.ts`, `codebuddy-cn.ts`, `codex.ts`, `cursor.ts`, `devin-desktop.ts`, `ghe-copilot.ts`, `github.ts`, `gitlab-duo.ts`, `grok-cli-oauth.ts`, `grok-cli.ts`, `kilocode.ts`, `kimi-coding.ts`, `kiro.ts`, `qoder.ts`, `raycast.ts`, `trae.ts`, `xai-oauth.ts`, `zed-hosted.ts`, `zed.ts` +- Individual providers: `agy.ts`, `antigravity.ts`, `claude.ts`, `cline.ts`, `codebuddy-cn.ts`, `codex.ts`, `cursor.ts`, `devin-desktop.ts`, `ghe-copilot.ts`, `github.ts`, `gitlab-duo.ts`, `grok-cli-oauth.ts`, `grok-cli.ts`, `kilocode.ts`, `kimi-coding.ts`, `kiro.ts`, `openference.ts`, `qoder.ts`, `trae.ts`, `xai-oauth.ts`, `zed-hosted.ts`, `zed.ts` - Thin wrapper: `src/lib/oauth/providers.ts` — re-exports from individual modules ## 5) Embedded Services (v3.8.4) diff --git a/docs/architecture/CODEBASE_DOCUMENTATION.md b/docs/architecture/CODEBASE_DOCUMENTATION.md index a0d9ff603e..d6437857a3 100644 --- a/docs/architecture/CODEBASE_DOCUMENTATION.md +++ b/docs/architecture/CODEBASE_DOCUMENTATION.md @@ -292,7 +292,7 @@ table groups the actual directories and notable top-level files. | `jobs/` | Background jobs (`autoUpdate.ts`, …) | | `memory/` | Persistent memory: `store.ts`, `cache.ts`, `retrieval.ts`, `summarization.ts`, `extraction.ts`, `injection.ts`, `qdrant.ts`, `settings.ts`, `verify.ts`, `schemas.ts`, `types.ts` | | `monitoring/` | `observability.ts` | -| `oauth/` | OAuth/import provider modules (22): `agy`, `antigravity`, `claude`, `cline`, `codebuddy-cn`, `codex`, `cursor`, `devin-desktop`, `ghe-copilot`, `github`, `gitlab-duo`, `grok-cli-oauth`, `grok-cli`, `kilocode`, `kimi-coding`, `kiro`, `qoder`, `raycast`, `trae`, `xai-oauth`, `zed-hosted`, `zed`, plus `services/`, `utils/`, and `constants/oauth.ts` | +| `oauth/` | OAuth/import provider modules (22): `agy`, `antigravity`, `claude`, `cline`, `codebuddy-cn`, `codex`, `cursor`, `devin-desktop`, `ghe-copilot`, `github`, `gitlab-duo`, `grok-cli-oauth`, `grok-cli`, `kilocode`, `kimi-coding`, `kiro`, `openference`, `qoder`, `trae`, `xai-oauth`, `zed-hosted`, `zed`, plus `services/`, `utils/`, and `constants/oauth.ts` | | `plugins/` | Plugin loader (`index.ts`) | | `promptCache/` | `prefixAnalyzer.ts`, `index.ts` | | `providerModels/` | Managed model lifecycle: `modelDiscovery.ts`, `managedModelImport.ts`, `managedAvailableModels.ts`, `cursorAgent.ts` | diff --git a/docs/architecture/REPOSITORY_MAP.md b/docs/architecture/REPOSITORY_MAP.md index 4d59418054..84bf149a67 100644 --- a/docs/architecture/REPOSITORY_MAP.md +++ b/docs/architecture/REPOSITORY_MAP.md @@ -195,7 +195,7 @@ src/ | `memory/vectorStore.ts` | sqlite-vec v0.1.9 wrapper — KNN brute-force + hybrid RRF (FTS5 + vector, k=60). Lazy-init, degrades gracefully when sqlite-vec unavailable. (plan 21) | | `memory/reindex.ts` | `runReindexBatch()` — processes memories with `needs_reindex=1` in background; called by `POST /api/memory/reindex` and lazy-backfill path. (plan 21) | | `monitoring/` | Health checks, metrics emission | -| `oauth/` | OAuth/import flows for 22 provider modules (agy, antigravity, claude, cline, codebuddy-cn, codex, cursor, devin-desktop, ghe-copilot, github, gitlab-duo, grok-cli-oauth, grok-cli, kilocode, kimi-coding, kiro, qoder, raycast, trae, xai-oauth, zed-hosted, zed) | +| `oauth/` | OAuth/import flows for 22 provider modules (agy, antigravity, claude, cline, codebuddy-cn, codex, cursor, devin-desktop, ghe-copilot, github, gitlab-duo, grok-cli-oauth, grok-cli, kilocode, kimi-coding, kiro, openference, qoder, trae, xai-oauth, zed-hosted, zed) | | `plugins/` | Plugin registry | | `promptCache/` | Anthropic-style prompt cache breakpoints | | `skills/` | Skills framework (built-in + marketplace + SkillsSH) — see `docs/frameworks/SKILLS.md` | diff --git a/docs/i18n/pl/docs/architecture/ARCHITECTURE.md b/docs/i18n/pl/docs/architecture/ARCHITECTURE.md index 6997bd716a..45e6c33cb1 100644 --- a/docs/i18n/pl/docs/architecture/ARCHITECTURE.md +++ b/docs/i18n/pl/docs/architecture/ARCHITECTURE.md @@ -324,7 +324,7 @@ Moduły warstwy domenowej: Moduły dostawców OAuth (22 osobne pliki w `src/lib/oauth/providers/`): - Indeks rejestru: `src/lib/oauth/providers/index.ts` -- Poszczególni dostawcy: `agy.ts`, `antigravity.ts`, `claude.ts`, `cline.ts`, `codebuddy-cn.ts`, `codex.ts`, `cursor.ts`, `devin-desktop.ts`, `ghe-copilot.ts`, `github.ts`, `gitlab-duo.ts`, `grok-cli-oauth.ts`, `grok-cli.ts`, `kilocode.ts`, `kimi-coding.ts`, `kiro.ts`, `qoder.ts`, `raycast.ts`, `trae.ts`, `xai-oauth.ts`, `zed-hosted.ts`, `zed.ts` +- Poszczególni dostawcy: `agy.ts`, `antigravity.ts`, `claude.ts`, `cline.ts`, `codebuddy-cn.ts`, `codex.ts`, `cursor.ts`, `devin-desktop.ts`, `ghe-copilot.ts`, `github.ts`, `gitlab-duo.ts`, `grok-cli-oauth.ts`, `grok-cli.ts`, `kilocode.ts`, `kimi-coding.ts`, `kiro.ts`, `openference.ts`, `qoder.ts`, `trae.ts`, `xai-oauth.ts`, `zed-hosted.ts`, `zed.ts` - Cienki wrapper: `src/lib/oauth/providers.ts` — re-eksport z poszczególnych modułów ## 5) Osadzone usługi (v3.8.4) @@ -926,7 +926,7 @@ Każdy dostawca ma wyspecjalizowany executor rozszerzający `BaseExecutor` (w `o | `PollinationsExecutor` | Pollinations AI | Klucz API niewymagany, żądania z rate limitem | | `QoderExecutor` | Qoder AI | Wsparcie PAT i OAuth, darmowy tier multi-model | | `VertexExecutor` | Google Vertex AI | Auth service account, endpointy oparte na regionie | -| `DevinDesktopExecutor` | Devin Desktop | Importowany klucz API + strumieniowanie czatu Connect-protobuf | +| `DevinDesktopExecutor` | Devin Desktop | Importowany klucz API + strumieniowanie czatu Connect-protobuf | Wszystkie pozostałe dostawcy (w tym niestandardowe węzły kompatybilne) używają `DefaultExecutor`. @@ -978,9 +978,9 @@ Wszystkie pozostałe dostawcy (w tym niestandardowe węzły kompatybilne) używa | GLMT (preset) | claude | API Key | ✅ | ✅ | ❌ | ⚠️ Per żądanie | | Kimi Coding | openai | OAuth / API Key | ✅ | ✅ | ✅ | ❌ | | KIE | openai | API Key | ✅ | ✅ | ❌ | ❌ | -| Devin Desktop | openai | Importowany klucz API | ✅ (Connect→SSE) | ✅ | ❌ | ⚠️ Na żądanie | +| Devin Desktop | openai | Importowany klucz API | ✅ (Connect→SSE) | ✅ | ❌ | ⚠️ Na żądanie | | GitLab Duo | openai | OAuth (GitLab) | ✅ | ✅ | ✅ | ❌ | -| Devin CLI | openai | Lokalne logowanie CLI | ✅ | ✅ | ❌ | ✅ Task API | +| Devin CLI | openai | Lokalne logowanie CLI | ✅ | ✅ | ❌ | ✅ Task API | | Codex Cloud | openai-responses | OAuth | ✅ | ❌ | ✅ | ✅ Rate limity | | Jules | openai | OAuth | ✅ | ✅ | ✅ | ✅ Task API | | AgentRouter | openai | API Key | ✅ | ✅ | ❌ | ❌ | diff --git a/docs/i18n/pt-BR/docs/architecture/ARCHITECTURE.md b/docs/i18n/pt-BR/docs/architecture/ARCHITECTURE.md index ae89dd97ca..f6d6534a09 100644 --- a/docs/i18n/pt-BR/docs/architecture/ARCHITECTURE.md +++ b/docs/i18n/pt-BR/docs/architecture/ARCHITECTURE.md @@ -332,7 +332,7 @@ Módulos da camada de domínio: Módulos do provedor OAuth (22 arquivos individuais em `src/lib/oauth/providers/`): - Índice do registro: `src/lib/oauth/providers/index.ts` -- Provedores individuais: `agy.ts`, `antigravity.ts`, `claude.ts`, `cline.ts`, `codebuddy-cn.ts`, `codex.ts`, `cursor.ts`, `devin-desktop.ts`, `ghe-copilot.ts`, `github.ts`, `gitlab-duo.ts`, `grok-cli-oauth.ts`, `grok-cli.ts`, `kilocode.ts`, `kimi-coding.ts`, `kiro.ts`, `qoder.ts`, `raycast.ts`, `trae.ts`, `xai-oauth.ts`, `zed-hosted.ts`, `zed.ts` +- Provedores individuais: `agy.ts`, `antigravity.ts`, `claude.ts`, `cline.ts`, `codebuddy-cn.ts`, `codex.ts`, `cursor.ts`, `devin-desktop.ts`, `ghe-copilot.ts`, `github.ts`, `gitlab-duo.ts`, `grok-cli-oauth.ts`, `grok-cli.ts`, `kilocode.ts`, `kimi-coding.ts`, `kiro.ts`, `openference.ts`, `qoder.ts`, `trae.ts`, `xai-oauth.ts`, `zed-hosted.ts`, `zed.ts` - Wrapper fino: `src/lib/oauth/providers.ts` — re-exportações de módulos individuais ## Subsistemas Principais (v3.8.0) @@ -907,7 +907,7 @@ Cada provedor tem um executor especializado que estende `BaseExecutor` (em `open | `PollinationsExecutor` | Pollinations AI | Nenhuma chave de API necessária, requisições limitadas por taxa | | `QoderExecutor` | Qoder AI | Suporte a PAT e OAuth, nível gratuito multi-modelo | | `VertexExecutor` | Google Vertex AI | Autenticação de conta de serviço, endpoints baseados em região | -| `DevinDesktopExecutor` | Devin Desktop | Chave de API importada + streaming de chat Connect-protobuf | +| `DevinDesktopExecutor` | Devin Desktop | Chave de API importada + streaming de chat Connect-protobuf | Todos os outros provedores (incluindo nós compatíveis personalizados) usam o `DefaultExecutor`. @@ -960,9 +960,9 @@ Todos os outros provedores (incluindo nós compatíveis personalizados) usam o ` | GLMT (preset) | claude | Chave de API | ✅ | ✅ | ❌ | ⚠️ Por solicitação | | Kimi Coding | openai | OAuth / Chave de API | ✅ | ✅ | ✅ | ❌ | | KIE | openai | Chave de API | ✅ | ✅ | ❌ | ❌ | -| Devin Desktop | openai | Chave de API importada | ✅ (Connect→SSE) | ✅ | ❌ | ⚠️ Por solicitação | +| Devin Desktop | openai | Chave de API importada | ✅ (Connect→SSE) | ✅ | ❌ | ⚠️ Por solicitação | | GitLab Duo | openai | OAuth (GitLab) | ✅ | ✅ | ✅ | ❌ | -| Devin CLI | openai | Login local da CLI | ✅ | ✅ | ❌ | ✅ API de Tarefas | +| Devin CLI | openai | Login local da CLI | ✅ | ✅ | ❌ | ✅ API de Tarefas | | Codex Cloud | openai-responses | OAuth | ✅ | ❌ | ✅ | ✅ Limites de taxa | | Jules | openai | OAuth | ✅ | ✅ | ✅ | ✅ API de Tarefas | | AgentRouter | openai | Chave de API | ✅ | ✅ | ❌ | ❌ | diff --git a/docs/i18n/zh-CN/docs/architecture/ARCHITECTURE.md b/docs/i18n/zh-CN/docs/architecture/ARCHITECTURE.md index 691bfae8c1..e79474458d 100644 --- a/docs/i18n/zh-CN/docs/architecture/ARCHITECTURE.md +++ b/docs/i18n/zh-CN/docs/architecture/ARCHITECTURE.md @@ -326,7 +326,7 @@ flowchart LR OAuth 服务商模块(`src/lib/oauth/providers/` 下 22 个独立文件): - 注册表索引:`src/lib/oauth/providers/index.ts` -- 独立服务商:`agy.ts`, `antigravity.ts`, `claude.ts`, `cline.ts`, `codebuddy-cn.ts`, `codex.ts`, `cursor.ts`, `devin-desktop.ts`, `ghe-copilot.ts`, `github.ts`, `gitlab-duo.ts`, `grok-cli-oauth.ts`, `grok-cli.ts`, `kilocode.ts`, `kimi-coding.ts`, `kiro.ts`, `qoder.ts`, `raycast.ts`, `trae.ts`, `xai-oauth.ts`, `zed-hosted.ts`, `zed.ts` +- 独立服务商:`agy.ts`, `antigravity.ts`, `claude.ts`, `cline.ts`, `codebuddy-cn.ts`, `codex.ts`, `cursor.ts`, `devin-desktop.ts`, `ghe-copilot.ts`, `github.ts`, `gitlab-duo.ts`, `grok-cli-oauth.ts`, `grok-cli.ts`, `kilocode.ts`, `kimi-coding.ts`, `kiro.ts`, `openference.ts`, `qoder.ts`, `trae.ts`, `xai-oauth.ts`, `zed-hosted.ts`, `zed.ts` - 薄封装层:`src/lib/oauth/providers.ts` — 从独立模块重新导出 ## 5) 嵌入式服务(v3.8.4) @@ -896,35 +896,35 @@ flowchart LR 每个服务商都有一个继承 `BaseExecutor`(在 `open-sse/executors/base.ts` 中)的专用执行器,该基类提供了 URL 构建、Header 构造、带指数退避的重试、凭据刷新钩子以及 `execute()` 编排方法。 -| 执行器 | 服务商 | 特殊处理 | -| ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------- | -| `DefaultExecutor` | OpenAI, Claude, Gemini, Qwen, OpenRouter, GLM, Kimi, MiniMax, DeepSeek, Groq, xAI, Mistral, Perplexity, Together, Fireworks, Cerebras, Cohere, NVIDIA 等 | 每服务商动态 URL/Header 配置 | -| `AntigravityExecutor` | Google Antigravity | 自定义项目/会话 ID、Retry-After 解析、429 混淆 | -| `AzureOpenAIExecutor` | Azure OpenAI | 基于部署的路由、api-version 查询参数强制执行 | -| `BlackboxWebExecutor` | Blackbox AI (web-mode) | Web 会话反向 + TLS 指纹模拟 | -| `ChatGPTWebExecutor` | ChatGPT web | TLS 客户端 + 会话 Cookie 管理(`chatgptTlsClient.ts`)| -| `ClaudeIdentityExecutor` | Claude.ai (CCH 通道) | 约束 + Tool 重映射管线、指纹塑造 | -| `CliProxyApiExecutor` | CLIProxyAPI 兼容服务商 | 自定义认证和协议处理 | -| `CloudflareAiExecutor` | Cloudflare Workers AI | 帐户 ID 注入、基于 Neurons 的用量追踪 | -| `CodexExecutor` | OpenAI Codex | 注入系统指令、强制推理力度 | -| `CommandCodeExecutor` | Command Code | OAuth + 每会话 Header 轮换 | -| `CursorExecutor` | Cursor IDE | ConnectRPC 协议、Protobuf 编码、基于校验和的请求签名| -| `DevinCliExecutor` | Devin CLI | Devin 任务生命周期桥接(通过云代理模块) | -| `GithubExecutor` | GitHub Copilot | Copilot Token 刷新、VSCode 模仿 Header | -| `GitlabExecutor` | GitLab Duo | GitLab OAuth + 项目级路由 | -| `GlmExecutor` | Z.AI GLM(含 `glmt` 预设) | Thinking Budget 感知、GLMT 预设常量 | -| `GrokWebExecutor` | xAI Grok web | Web 会话反向、模式选择(think/standard) | -| `KieExecutor` | KIE | 自定义 Token 签发 + 轮换会话锚点 | -| `KiroExecutor` | AWS CodeWhisperer/Kiro | AWS EventStream 二进制格式 → SSE 转换 | -| `MuseSparkWebExecutor` | Muse Spark (web) | Web 会话反向 + 图片消息桥接 | -| `NlpCloudExecutor` | NLP Cloud | 服务商专属请求体形状 | -| `OpenCodeExecutor` | OpenCode | AI SDK 兼容服务商初始化 | -| `PerplexityWebExecutor` | Perplexity web | Web 会话反向,用于聊延续 | -| `PetalsExecutor` | Petals distributed inference | 去中心化集群路由 | -| `PollinationsExecutor` | Pollinations AI | 无需 API Key、带速率限制的请求 | -| `QoderExecutor` | Qoder AI | PAT 和 OAuth 支持、多模型免费层 | -| `VertexExecutor` | Google Vertex AI | 服务帐户认证、基于区域的端点 | -| `DevinDesktopExecutor` | Devin Desktop | 导入的 API 密钥 + Connect-protobuf 聊天流 | +| 执行器 | 服务商 | 特殊处理 | +| ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------ | +| `DefaultExecutor` | OpenAI, Claude, Gemini, Qwen, OpenRouter, GLM, Kimi, MiniMax, DeepSeek, Groq, xAI, Mistral, Perplexity, Together, Fireworks, Cerebras, Cohere, NVIDIA 等 | 每服务商动态 URL/Header 配置 | +| `AntigravityExecutor` | Google Antigravity | 自定义项目/会话 ID、Retry-After 解析、429 混淆 | +| `AzureOpenAIExecutor` | Azure OpenAI | 基于部署的路由、api-version 查询参数强制执行 | +| `BlackboxWebExecutor` | Blackbox AI (web-mode) | Web 会话反向 + TLS 指纹模拟 | +| `ChatGPTWebExecutor` | ChatGPT web | TLS 客户端 + 会话 Cookie 管理(`chatgptTlsClient.ts`) | +| `ClaudeIdentityExecutor` | Claude.ai (CCH 通道) | 约束 + Tool 重映射管线、指纹塑造 | +| `CliProxyApiExecutor` | CLIProxyAPI 兼容服务商 | 自定义认证和协议处理 | +| `CloudflareAiExecutor` | Cloudflare Workers AI | 帐户 ID 注入、基于 Neurons 的用量追踪 | +| `CodexExecutor` | OpenAI Codex | 注入系统指令、强制推理力度 | +| `CommandCodeExecutor` | Command Code | OAuth + 每会话 Header 轮换 | +| `CursorExecutor` | Cursor IDE | ConnectRPC 协议、Protobuf 编码、基于校验和的请求签名 | +| `DevinCliExecutor` | Devin CLI | Devin 任务生命周期桥接(通过云代理模块) | +| `GithubExecutor` | GitHub Copilot | Copilot Token 刷新、VSCode 模仿 Header | +| `GitlabExecutor` | GitLab Duo | GitLab OAuth + 项目级路由 | +| `GlmExecutor` | Z.AI GLM(含 `glmt` 预设) | Thinking Budget 感知、GLMT 预设常量 | +| `GrokWebExecutor` | xAI Grok web | Web 会话反向、模式选择(think/standard) | +| `KieExecutor` | KIE | 自定义 Token 签发 + 轮换会话锚点 | +| `KiroExecutor` | AWS CodeWhisperer/Kiro | AWS EventStream 二进制格式 → SSE 转换 | +| `MuseSparkWebExecutor` | Muse Spark (web) | Web 会话反向 + 图片消息桥接 | +| `NlpCloudExecutor` | NLP Cloud | 服务商专属请求体形状 | +| `OpenCodeExecutor` | OpenCode | AI SDK 兼容服务商初始化 | +| `PerplexityWebExecutor` | Perplexity web | Web 会话反向,用于聊延续 | +| `PetalsExecutor` | Petals distributed inference | 去中心化集群路由 | +| `PollinationsExecutor` | Pollinations AI | 无需 API Key、带速率限制的请求 | +| `QoderExecutor` | Qoder AI | PAT 和 OAuth 支持、多模型免费层 | +| `VertexExecutor` | Google Vertex AI | 服务帐户认证、基于区域的端点 | +| `DevinDesktopExecutor` | Devin Desktop | 导入的 API 密钥 + Connect-protobuf 聊天流 | 其余所有服务商(含自定义兼容节点)使用 `DefaultExecutor`。 @@ -935,65 +935,65 @@ flowchart LR > [`docs/reference/PROVIDER_REFERENCE.md`](../reference/PROVIDER_REFERENCE.md)(自动生成)或数据源头 > `src/shared/constants/providers.ts`(加载时通过 Zod 校验)。 -| 服务商 | 格式 | 认证 | 流式 | 非流式 | Token 刷新 | 用量 API | -| ----------------- | ---------------- | --------------------- | -------------- | ---------- | ----------- | ----------------- | -| Claude | claude | API Key / OAuth | ✅ | ✅ | ✅ | ⚠️ 仅管理员 | -| Gemini | gemini | API Key / OAuth | ✅ | ✅ | ✅ | ⚠️ Cloud Console | -| Antigravity | antigravity | OAuth | ✅ | ✅ | ✅ | ✅ 完整配额 API | -| OpenAI | openai | API Key | ✅ | ✅ | ❌ | ❌ | -| Codex | openai-responses | OAuth | ✅ 强制 | ❌ | ✅ | ✅ 速率限制 | -| GitHub Copilot | openai | OAuth + Copilot Token | ✅ | ✅ | ✅ | ✅ 配额快照 | -| Cursor | cursor | 自定义校验和 | ✅ | ✅ | ❌ | ❌ | -| Kiro | kiro | AWS SSO OIDC | ✅ (EventStream)| ❌ | ✅ | ✅ 用量限制 | -| Qwen | openai | OAuth | ✅ | ✅ | ✅ | ⚠️ 每请求 | -| Qoder | openai | OAuth / PAT | ✅ | ✅ | ✅ | ⚠️ 每请求 | -| Kilo Code | openai | OAuth | ✅ | ✅ | ✅ | ❌ | -| Cline | openai | OAuth | ✅ | ✅ | ✅ | ❌ | -| Kimi Coding | openai | OAuth | ✅ | ✅ | ✅ | ❌ | -| OpenRouter | openai | API Key | ✅ | ✅ | ❌ | ❌ | -| GLM/Kimi/MiniMax | claude | API Key | ✅ | ✅ | ❌ | ❌ | -| DeepSeek | openai | API Key | ✅ | ✅ | ❌ | ❌ | -| Groq | openai | API Key | ✅ | ✅ | ❌ | ❌ | -| xAI (Grok) | openai | API Key | ✅ | ✅ | ❌ | ❌ | -| Mistral | openai | API Key | ✅ | ✅ | ❌ | ❌ | -| Perplexity | openai | API Key | ✅ | ✅ | ❌ | ❌ | -| Together AI | openai | API Key | ✅ | ✅ | ❌ | ❌ | -| Fireworks AI | openai | API Key | ✅ | ✅ | ❌ | ❌ | -| Cerebras | openai | API Key | ✅ | ✅ | ❌ | ❌ | -| Cohere | openai | API Key | ✅ | ✅ | ❌ | ❌ | -| NVIDIA NIM | openai | API Key | ✅ | ✅ | ❌ | ❌ | -| Cloudflare AI | openai | API Token + 帐户 ID | ✅ | ✅ | ❌ | ❌ | -| Pollinations | openai | 无需密钥 | ✅ | ✅ | ❌ | ❌ | -| Scaleway AI | openai | API Key | ✅ | ✅ | ❌ | ❌ | -| LongCat | openai | API Key | ✅ | ✅ | ❌ | ❌ | -| Ollama Cloud | openai | API Key(可选) | ✅ | ✅ | ❌ | ❌ | -| HuggingFace | openai | API Key | ✅ | ✅ | ❌ | ❌ | -| Nebius | openai | API Key | ✅ | ✅ | ❌ | ❌ | -| SiliconFlow | openai | API Key | ✅ | ✅ | ❌ | ❌ | -| Hyperbolic | openai | API Key | ✅ | ✅ | ❌ | ❌ | -| Vertex AI | gemini | 服务帐户 | ✅ | ✅ | ✅ | ⚠️ Cloud Console | -| Command Code | openai | OAuth | ✅ | ✅ | ✅ | ⚠️ 每请求 | -| Z.AI / GLM | openai | API Key / OAuth | ✅ | ✅ | ❌ | ❌ | -| GLMT (preset) | claude | API Key | ✅ | ✅ | ❌ | ⚠️ 每请求 | -| Kimi Coding | openai | OAuth / API Key | ✅ | ✅ | ✅ | ❌ | -| KIE | openai | API Key | ✅ | ✅ | ❌ | ❌ | -| Devin Desktop | openai | 导入的 API 密钥 | ✅ (Connect→SSE) | ✅ | ❌ | ⚠️ 每请求 | -| GitLab Duo | openai | OAuth (GitLab) | ✅ | ✅ | ✅ | ❌ | -| Devin CLI | openai | 本地 CLI 登录 | ✅ | ✅ | ❌ | ✅ 任务 API | -| Codex Cloud | openai-responses | OAuth | ✅ | ❌ | ✅ | ✅ 速率限制 | -| Jules | openai | OAuth | ✅ | ✅ | ✅ | ✅ 任务 API | -| AgentRouter | openai | API Key | ✅ | ✅ | ❌ | ❌ | -| ChatGPT-Web | openai | 会话 Cookie + TLS | ✅ | ✅ | ❌ | ❌ | -| Grok-Web | openai | 会话 Cookie | ✅ | ✅ | ❌ | ❌ | -| Perplexity-Web | openai | 会话 Cookie | ✅ | ✅ | ❌ | ❌ | -| BlackBox-Web | openai | 会话 Cookie + TLS | ✅ | ✅ | ❌ | ❌ | -| Muse-Spark-Web | openai | 会话 Cookie | ✅ | ✅ | ❌ | ❌ | -| ModelScope | openai | API Key | ✅ | ✅ | ❌ | ⚠️ 配额策略 | -| BazaarLink | openai | API Key | ✅ | ✅ | ❌ | ❌ | -| Petals | openai | 无需密钥 | ✅ | ✅ | ❌ | ❌ | -| Qoder | openai | OAuth / PAT | ✅ | ✅ | ✅ | ⚠️ 每请求 | -| OpenCode (Go/Zen) | openai | OAuth | ✅ | ✅ | ✅ | ❌ | -| CLIProxyAPI | openai | 自定义 | ✅ | ✅ | ❌ | ❌ | +| 服务商 | 格式 | 认证 | 流式 | 非流式 | Token 刷新 | 用量 API | +| ----------------- | ---------------- | --------------------- | ---------------- | ------ | ---------- | ---------------- | +| Claude | claude | API Key / OAuth | ✅ | ✅ | ✅ | ⚠️ 仅管理员 | +| Gemini | gemini | API Key / OAuth | ✅ | ✅ | ✅ | ⚠️ Cloud Console | +| Antigravity | antigravity | OAuth | ✅ | ✅ | ✅ | ✅ 完整配额 API | +| OpenAI | openai | API Key | ✅ | ✅ | ❌ | ❌ | +| Codex | openai-responses | OAuth | ✅ 强制 | ❌ | ✅ | ✅ 速率限制 | +| GitHub Copilot | openai | OAuth + Copilot Token | ✅ | ✅ | ✅ | ✅ 配额快照 | +| Cursor | cursor | 自定义校验和 | ✅ | ✅ | ❌ | ❌ | +| Kiro | kiro | AWS SSO OIDC | ✅ (EventStream) | ❌ | ✅ | ✅ 用量限制 | +| Qwen | openai | OAuth | ✅ | ✅ | ✅ | ⚠️ 每请求 | +| Qoder | openai | OAuth / PAT | ✅ | ✅ | ✅ | ⚠️ 每请求 | +| Kilo Code | openai | OAuth | ✅ | ✅ | ✅ | ❌ | +| Cline | openai | OAuth | ✅ | ✅ | ✅ | ❌ | +| Kimi Coding | openai | OAuth | ✅ | ✅ | ✅ | ❌ | +| OpenRouter | openai | API Key | ✅ | ✅ | ❌ | ❌ | +| GLM/Kimi/MiniMax | claude | API Key | ✅ | ✅ | ❌ | ❌ | +| DeepSeek | openai | API Key | ✅ | ✅ | ❌ | ❌ | +| Groq | openai | API Key | ✅ | ✅ | ❌ | ❌ | +| xAI (Grok) | openai | API Key | ✅ | ✅ | ❌ | ❌ | +| Mistral | openai | API Key | ✅ | ✅ | ❌ | ❌ | +| Perplexity | openai | API Key | ✅ | ✅ | ❌ | ❌ | +| Together AI | openai | API Key | ✅ | ✅ | ❌ | ❌ | +| Fireworks AI | openai | API Key | ✅ | ✅ | ❌ | ❌ | +| Cerebras | openai | API Key | ✅ | ✅ | ❌ | ❌ | +| Cohere | openai | API Key | ✅ | ✅ | ❌ | ❌ | +| NVIDIA NIM | openai | API Key | ✅ | ✅ | ❌ | ❌ | +| Cloudflare AI | openai | API Token + 帐户 ID | ✅ | ✅ | ❌ | ❌ | +| Pollinations | openai | 无需密钥 | ✅ | ✅ | ❌ | ❌ | +| Scaleway AI | openai | API Key | ✅ | ✅ | ❌ | ❌ | +| LongCat | openai | API Key | ✅ | ✅ | ❌ | ❌ | +| Ollama Cloud | openai | API Key(可选) | ✅ | ✅ | ❌ | ❌ | +| HuggingFace | openai | API Key | ✅ | ✅ | ❌ | ❌ | +| Nebius | openai | API Key | ✅ | ✅ | ❌ | ❌ | +| SiliconFlow | openai | API Key | ✅ | ✅ | ❌ | ❌ | +| Hyperbolic | openai | API Key | ✅ | ✅ | ❌ | ❌ | +| Vertex AI | gemini | 服务帐户 | ✅ | ✅ | ✅ | ⚠️ Cloud Console | +| Command Code | openai | OAuth | ✅ | ✅ | ✅ | ⚠️ 每请求 | +| Z.AI / GLM | openai | API Key / OAuth | ✅ | ✅ | ❌ | ❌ | +| GLMT (preset) | claude | API Key | ✅ | ✅ | ❌ | ⚠️ 每请求 | +| Kimi Coding | openai | OAuth / API Key | ✅ | ✅ | ✅ | ❌ | +| KIE | openai | API Key | ✅ | ✅ | ❌ | ❌ | +| Devin Desktop | openai | 导入的 API 密钥 | ✅ (Connect→SSE) | ✅ | ❌ | ⚠️ 每请求 | +| GitLab Duo | openai | OAuth (GitLab) | ✅ | ✅ | ✅ | ❌ | +| Devin CLI | openai | 本地 CLI 登录 | ✅ | ✅ | ❌ | ✅ 任务 API | +| Codex Cloud | openai-responses | OAuth | ✅ | ❌ | ✅ | ✅ 速率限制 | +| Jules | openai | OAuth | ✅ | ✅ | ✅ | ✅ 任务 API | +| AgentRouter | openai | API Key | ✅ | ✅ | ❌ | ❌ | +| ChatGPT-Web | openai | 会话 Cookie + TLS | ✅ | ✅ | ❌ | ❌ | +| Grok-Web | openai | 会话 Cookie | ✅ | ✅ | ❌ | ❌ | +| Perplexity-Web | openai | 会话 Cookie | ✅ | ✅ | ❌ | ❌ | +| BlackBox-Web | openai | 会话 Cookie + TLS | ✅ | ✅ | ❌ | ❌ | +| Muse-Spark-Web | openai | 会话 Cookie | ✅ | ✅ | ❌ | ❌ | +| ModelScope | openai | API Key | ✅ | ✅ | ❌ | ⚠️ 配额策略 | +| BazaarLink | openai | API Key | ✅ | ✅ | ❌ | ❌ | +| Petals | openai | 无需密钥 | ✅ | ✅ | ❌ | ❌ | +| Qoder | openai | OAuth / PAT | ✅ | ✅ | ✅ | ⚠️ 每请求 | +| OpenCode (Go/Zen) | openai | OAuth | ✅ | ✅ | ✅ | ❌ | +| CLIProxyAPI | openai | 自定义 | ✅ | ✅ | ❌ | ❌ | ## 格式转换覆盖 @@ -1029,25 +1029,25 @@ flowchart LR ## 支持的 API 端点 -| 端点 | 格式 | 处理器 | -| --------------------------------------------------- | ------------------ | ----------------------------------------------------------------- | -| `POST /v1/chat/completions` | OpenAI Chat | `src/sse/handlers/chat.ts` | -| `POST /v1/messages` | Claude Messages | 同一处理器(自动检测) | -| `POST /v1/responses` | OpenAI Responses | `open-sse/handlers/responsesHandler.ts` | -| `POST /v1/embeddings` | OpenAI Embeddings | `open-sse/handlers/embeddings.ts` | -| `GET /v1/embeddings` | 模型列表 | API 路由 | -| `POST /v1/images/generations` | OpenAI Images | `open-sse/handlers/imageGeneration.ts` | -| `GET /v1/images/generations` | 模型列表 | API 路由 | -| `POST /v1/providers/{provider}/chat/completions` | OpenAI Chat | 带模型校验的专用每服务商路由 | -| `POST /v1/providers/{provider}/embeddings` | OpenAI Embeddings | 带模型校验的专用每服务商路由 | -| `POST /v1/providers/{provider}/images/generations` | OpenAI Images | 带模型校验的专用每服务商路由 | -| `POST /v1/messages/count_tokens` | Claude Token Count | API 路由 | -| `GET /v1/models` | OpenAI Models 列表 | API 路由(聊 + 向量嵌入 + 图片 + 自定义模型) | -| `GET /api/models/catalog` | 目录 | 按服务商 + 类型分组的所有模型 | -| `POST /v1beta/models/*:streamGenerateContent` | Gemini native | API 路由 | -| `GET/PUT/DELETE /api/settings/proxy` | 代理配置 | 网络代理配置 | -| `POST /api/settings/proxy/test` | 代理连通性 | 代理健康/连通性测试端点 | -| `GET/POST/DELETE /api/provider-models` | 服务商模型 | 服务商模型元数据,支撑自定义和管理可用模型 | +| 端点 | 格式 | 处理器 | +| -------------------------------------------------- | ------------------ | --------------------------------------------- | +| `POST /v1/chat/completions` | OpenAI Chat | `src/sse/handlers/chat.ts` | +| `POST /v1/messages` | Claude Messages | 同一处理器(自动检测) | +| `POST /v1/responses` | OpenAI Responses | `open-sse/handlers/responsesHandler.ts` | +| `POST /v1/embeddings` | OpenAI Embeddings | `open-sse/handlers/embeddings.ts` | +| `GET /v1/embeddings` | 模型列表 | API 路由 | +| `POST /v1/images/generations` | OpenAI Images | `open-sse/handlers/imageGeneration.ts` | +| `GET /v1/images/generations` | 模型列表 | API 路由 | +| `POST /v1/providers/{provider}/chat/completions` | OpenAI Chat | 带模型校验的专用每服务商路由 | +| `POST /v1/providers/{provider}/embeddings` | OpenAI Embeddings | 带模型校验的专用每服务商路由 | +| `POST /v1/providers/{provider}/images/generations` | OpenAI Images | 带模型校验的专用每服务商路由 | +| `POST /v1/messages/count_tokens` | Claude Token Count | API 路由 | +| `GET /v1/models` | OpenAI Models 列表 | API 路由(聊 + 向量嵌入 + 图片 + 自定义模型) | +| `GET /api/models/catalog` | 目录 | 按服务商 + 类型分组的所有模型 | +| `POST /v1beta/models/*:streamGenerateContent` | Gemini native | API 路由 | +| `GET/PUT/DELETE /api/settings/proxy` | 代理配置 | 网络代理配置 | +| `POST /api/settings/proxy/test` | 代理连通性 | 代理健康/连通性测试端点 | +| `GET/POST/DELETE /api/provider-models` | 服务商模型 | 服务商模型元数据,支撑自定义和管理可用模型 | ## 旁路处理器 diff --git a/docs/i18n/zh-TW/docs/architecture/ARCHITECTURE.md b/docs/i18n/zh-TW/docs/architecture/ARCHITECTURE.md index 756e62c76b..5c3fd5a85b 100644 --- a/docs/i18n/zh-TW/docs/architecture/ARCHITECTURE.md +++ b/docs/i18n/zh-TW/docs/architecture/ARCHITECTURE.md @@ -322,7 +322,7 @@ flowchart LR OAuth 提供者模組(`src/lib/oauth/providers/` 下的 22 個個別檔案): - 註冊表索引:`src/lib/oauth/providers/index.ts` -- 個別提供者:`agy.ts`, `antigravity.ts`, `claude.ts`, `cline.ts`, `codebuddy-cn.ts`, `codex.ts`, `cursor.ts`, `devin-desktop.ts`, `ghe-copilot.ts`, `github.ts`, `gitlab-duo.ts`, `grok-cli-oauth.ts`, `grok-cli.ts`, `kilocode.ts`, `kimi-coding.ts`, `kiro.ts`, `qoder.ts`, `raycast.ts`, `trae.ts`, `xai-oauth.ts`, `zed-hosted.ts`, `zed.ts` +- 個別提供者:`agy.ts`, `antigravity.ts`, `claude.ts`, `cline.ts`, `codebuddy-cn.ts`, `codex.ts`, `cursor.ts`, `devin-desktop.ts`, `ghe-copilot.ts`, `github.ts`, `gitlab-duo.ts`, `grok-cli-oauth.ts`, `grok-cli.ts`, `kilocode.ts`, `kimi-coding.ts`, `kiro.ts`, `openference.ts`, `qoder.ts`, `trae.ts`, `xai-oauth.ts`, `zed-hosted.ts`, `zed.ts` - 薄包裝層:`src/lib/oauth/providers.ts` — 從個別模組重新匯出 ## 5) 嵌入式服務(v3.8.4) @@ -901,7 +901,7 @@ flowchart LR | `PollinationsExecutor` | Pollinations AI | 無需 API 金鑰、速率限制請求 | | `QoderExecutor` | Qoder AI | PAT 與 OAuth 支援、多模型免費方案 | | `VertexExecutor` | Google Vertex AI | 服務帳戶驗證、基於區域的端點 | -| `DevinDesktopExecutor` | Devin Desktop | 匯入的 API 金鑰 + Connect-protobuf 聊天串流 | +| `DevinDesktopExecutor` | Devin Desktop | 匯入的 API 金鑰 + Connect-protobuf 聊天串流 | 所有其他提供者(包括自訂相容節點)使用 `DefaultExecutor`。 @@ -953,9 +953,9 @@ flowchart LR | GLMT(預設) | claude | API 金鑰 | ✅ | ✅ | ❌ | ⚠️ 每次請求 | | Kimi Coding | openai | OAuth / API 金鑰 | ✅ | ✅ | ✅ | ❌ | | KIE | openai | API 金鑰 | ✅ | ✅ | ❌ | ❌ | -| Devin Desktop | openai | 匯入的 API 金鑰 | ✅ (Connect→SSE) | ✅ | ❌ | ⚠️ 每次請求 | +| Devin Desktop | openai | 匯入的 API 金鑰 | ✅ (Connect→SSE) | ✅ | ❌ | ⚠️ 每次請求 | | GitLab Duo | openai | OAuth(GitLab) | ✅ | ✅ | ✅ | ❌ | -| Devin CLI | openai | 本機 CLI 登入 | ✅ | ✅ | ❌ | ✅ 任務 API | +| Devin CLI | openai | 本機 CLI 登入 | ✅ | ✅ | ❌ | ✅ 任務 API | | Codex Cloud | openai-responses | OAuth | ✅ | ❌ | ✅ | ✅ 速率限制 | | Jules | openai | OAuth | ✅ | ✅ | ✅ | ✅ 任務 API | | AgentRouter | openai | API 金鑰 | ✅ | ✅ | ❌ | ❌ | diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index cd9eab11b3..fa416fe0e3 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -598,10 +598,6 @@ Built-in credentials for **localhost development**. For remote deployments, regi | `BLACKBOX_WEB_VALIDATED_TOKEN` | Blackbox Web | Frontend `tk` token to send as `validated` on `/api/chat`. Required when Blackbox enforces token matching; otherwise OmniRoute falls back to a random UUID. See issue #2252. | | `VISION_BRIDGE_BASE_URL` | Vision Bridge guardrail | OpenAI-compatible base URL for non-Anthropic vision-bridge calls. Defaults to the legacy OpenAI URL env or api.openai.com. Point at OmniRoute's `/v1` self-loop or any OpenAI-compat endpoint (Gemini OpenAI-compat, OpenRouter). Issue #2232. When the URL is OmniRoute's own `/v1`, the describe sub-request sends `x-omniroute-admission-bypass: internal` and authenticates with the resolved self-loop credential (`sk_omniroute` sentinel in local mode, or `OMNIROUTE_API_KEY` / `ROUTER_API_KEY` — #1350) so `REQUIRE_API_KEY=true` deployments work. | | `VISION_BRIDGE_API_KEY` | Vision Bridge guardrail | API key for the URL above. Overrides per-provider OpenAI / Google env vars for non-Anthropic vision-bridge calls. Anthropic models keep their dedicated Anthropic key path. Issue #2232. | -| `RAYCAST_BEARER_TOKEN` | Raycast Pro | Optional manual override for the Raycast access token (normally captured via macOS Auto-Import). No OAuth client_id/secret — reverse-engineered, local/personal use only. | -| `RAYCAST_DEVICE_ID` | Raycast Pro | Optional manual override for the Raycast device ID used to sign requests. | -| `RAYCAST_AID` | Raycast Pro | Optional manual override for the Raycast account/app ID; falls back to the device ID when unset. | -| `RAYCAST_SIG_SECRET` | Raycast Pro | Optional override for the request-signing HMAC secret. Defaults to a community-extracted value in `open-sse/services/raycast.ts`. | > [!WARNING] > diff --git a/open-sse/config/providers/index.ts b/open-sse/config/providers/index.ts index 947e9071ac..387dcd66f9 100644 --- a/open-sse/config/providers/index.ts +++ b/open-sse/config/providers/index.ts @@ -24,7 +24,6 @@ import { glmProvider } from "./registry/glm/index.ts"; import { glmtProvider } from "./registry/glm/t/index.ts"; import { glm_cnProvider } from "./registry/glm/cn/index.ts"; import { traeProvider } from "./registry/trae/index.ts"; -import { raycastProvider } from "./registry/raycast/index.ts"; import { muse_spark_webProvider } from "./registry/muse-spark-web/index.ts"; import { lmarenaProvider } from "./registry/lmarena/index.ts"; import { kilocodeProvider } from "./registry/kilocode/index.ts"; @@ -116,7 +115,6 @@ import { perplexityProvider } from "./registry/perplexity/index.ts"; import { perplexity_webProvider } from "./registry/perplexity/web/index.ts"; import { minimaxProvider } from "./registry/minimax/index.ts"; import { minimax_cnProvider } from "./registry/minimax/cn/index.ts"; -import { hailuo_webProvider } from "./registry/minimax/web/index.ts"; import { haiperProvider } from "./registry/haiper/index.ts"; import { bytezProvider } from "./registry/bytez/index.ts"; import { blackboxProvider } from "./registry/blackbox/index.ts"; @@ -293,7 +291,6 @@ export const REGISTRY: Record = { glmt: glmtProvider, "glm-cn": glm_cnProvider, trae: traeProvider, - raycast: raycastProvider, "muse-spark-web": muse_spark_webProvider, lmarena: lmarenaProvider, kilocode: kilocodeProvider, @@ -385,7 +382,6 @@ export const REGISTRY: Record = { "perplexity-web": perplexity_webProvider, minimax: minimaxProvider, "minimax-cn": minimax_cnProvider, - "hailuo-web": hailuo_webProvider, haiper: haiperProvider, bytez: bytezProvider, blackbox: blackboxProvider, diff --git a/open-sse/config/providers/registry/minimax/web/index.ts b/open-sse/config/providers/registry/minimax/web/index.ts deleted file mode 100644 index 6c2addc043..0000000000 --- a/open-sse/config/providers/registry/minimax/web/index.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { RegistryEntry } from "../../../shared.ts"; - -export const HAILUO_WEB_STATIC_MODELS = [ - // The Hailuo web client does not expose a model selector in its chat API — - // one default assistant persona (characterID) handles every request. See - // open-sse/executors/hailuo-web.ts for the ported g4f protocol details. - { id: "hailuo", name: "Hailuo (MiniMax)" }, -]; - -export const hailuo_webProvider: RegistryEntry = { - id: "hailuo-web", - // Distinct alias: the paid API-key "minimax"/"minimax-cn" providers - // (../../minimax/index.ts) keep their own short alias; this free web/cookie - // variant is addressed by its own id, per the established kimi-web/qwen-web - // secondary-variant convention (tests/unit/provider-alias-uniqueness.test.ts). - alias: "hailuo-web", - format: "openai", - executor: "hailuo-web", - baseUrl: "https://chat.minimax.io", - authType: "apikey", - authHeader: "bearer", - models: HAILUO_WEB_STATIC_MODELS, -}; diff --git a/open-sse/config/providers/registry/raycast/index.ts b/open-sse/config/providers/registry/raycast/index.ts deleted file mode 100644 index aca286a6d1..0000000000 --- a/open-sse/config/providers/registry/raycast/index.ts +++ /dev/null @@ -1,61 +0,0 @@ -/** - * @file index.ts - * @description Raycast Pro AI provider registry entry (reverse-engineered, unofficial API). - * - * @changes - * - [2026-07-28] [Composer] - Initial Raycast provider registry module - */ - -import type { RegistryEntry } from "../../shared.ts"; - -/** Seed catalog — full list synced from Raycast /api/v1/ai/models on connect/import. */ -export const raycastProvider: RegistryEntry = { - id: "raycast", - alias: "rc", - format: "openai", - executor: "raycast", - baseUrl: "https://backend.raycast.com/api/v1/ai", - authType: "oauth", - authHeader: "bearer", - defaultContextLength: 128000, - models: [ - //GPT - { id: "openai-gpt-5.6-sol", name: "GPT-5.6 Sol" }, - { id: "openai-gpt-5.6-terra", name: "GPT-5.6 Terra" }, - { id: "openai-gpt-5.6-luna", name: "GPT-5.6 Luna" }, - //Claude - { id: "anthropic-claude-opus-5", name: "Claude Opus 5" }, - { id: "anthropic-claude-sonnet-5", name: "Claude Sonnet 5" }, - { id: "anthropic-claude-4-5-haiku-reasoning", name: "Claude 4.5 Haiku Reasoning" }, - { id: "anthropic-claude-4-5-haiku", name: "Claude 4.5 Haiku" }, - //Gemini - { id: "google-gemini-3.1-pro", name: "Gemini 3.1 Pro" }, - { id: "google-gemini-3.7-flash", name: "Gemini 3.7 Flash" }, - { id: "google-gemini-3.5-flash-lite", name: "Gemini 3.5 Flash Lite" }, - //Perplexity - { id: "perplexity-sonar-reasoning-pro", name: "Sonar Reasoning Pro" }, - { id: "perplexity-sonar-pro", name: "Sonar Pro" }, - { id: "perplexity-sonar", name: "Sonar" }, - //Mistral - { id: "mistral-mistral-large-latest", name: "Mistral Large" }, - { id: "mistral-mistral-medium-latest", name: "Mistral Medium" }, - { id: "mistral-mistral-small-latest", name: "Mistral Small" }, - { id: "mistral-codestral-latest", name: "Codestral" }, - { id: "mistral-open-mistral-nemo", name: "Mistral Nemo" }, - //Grok - { id: "xai-grok-4.6", name: "Grok 4.6" }, - //Opensource - { id: "gateway-alibaba/qwen3.8-max", name: "Qwen 3.8 Max" }, - { id: "gateway-moonshotai/kimi-k3", name: "Kimi K3" }, - { id: "baseten-deepseek-ai/DeepSeek-V4-Pro", name: "DeepSeek V4 Pro" }, - { id: "gateway-deepseek/deepseek-v4-flash", name: "DeepSeek V4 Flash" }, - { id: "baseten-zai-org/GLM-5.2", name: "GLM 5.2" }, - { id: "gateway-thinkingmachines/inkling-1.0", name: "Inkling 1.0" }, - { id: "gateway-google/gemma-4-31b-it", name: "Gemma 4 31B" }, - { id: "groq-openai/gpt-oss-120b", name: "GPT-OSS 120B" }, - { id: "groq-openai/gpt-oss-20b", name: "GPT-OSS 20B" }, - { id: "groq-qwen/qwen3-32b", name: "Qwen 3 32B" }, - { id: "groq-llama-3.3-70b-versatile", name: "LLaMA 3.3 70B" }, - { id: "groq-llama-3.1-8b-instant", name: "LLaMA 3.1 8B" }, - ], -}; diff --git a/open-sse/executors/hailuo-web.ts b/open-sse/executors/hailuo-web.ts deleted file mode 100644 index 7d1b839c26..0000000000 --- a/open-sse/executors/hailuo-web.ts +++ /dev/null @@ -1,546 +0,0 @@ -/** - * HailuoWebExecutor — Hailuo AI (MiniMax) web chat via chat.minimax.io. - * - * Distinct from the paid API-key `minimax`/`minimax-cn` providers - * (open-sse/config/providers/registry/minimax/) — this targets the free - * consumer chat product at chat.minimax.io. - * - * Endpoint: POST https://chat.minimax.io/v4/api/chat/msg? - * Auth: `token` header — value read from the site's `_token` localStorage - * entry, plus a per-request `yy` signature header. - * Body: multipart/form-data — characterID, msgContent, chatID, searchMode. - * Response: text/event-stream lines (`event:` / `data:`) carrying - * `send_result` (chat title + chatID, once) and `message_result` - * (cumulative — not delta — `content` field per event) until a - * `close_chunk` event ends the stream. - * - * Ported from the g4f reference implementation - * (g4f/Provider/needs_auth/mini_max/{HailuoAI,crypt}.py) — request signing - * (`generate_yy_header`/`get_body_to_yy`) and the SSE event shape are ported - * 1:1. The device-fingerprint fields (device_id, uuid, os/browser name, - * screen dims) are normally generated by the browser and stored in - * localStorage; when the user hasn't captured them, this executor derives - * stable per-connection values from the token via MD5 so the signature stays - * consistent across requests without server-side state. - * - * ⚠️ Not yet validated against a live hailuo.ai session — see PR description - * for the exact VPS live-check command that must be run before this is - * treated as fully verified. The host, API path, header shape, and signing - * scheme are ported directly from the (actively maintained) g4f source, but - * upstream reverse-engineered protocols can change without notice. - */ -import { createHash } from "node:crypto"; -import { BaseExecutor, type ExecuteInput } from "./base.ts"; -import { makeExecutorErrorResult as makeErrorResult, sanitizeErrorMessage } from "../utils/error.ts"; - -const BASE_URL = "https://chat.minimax.io"; -const API_PATH = "/v4/api/chat/msg"; -const USER_AGENT = - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36"; -const DEFAULT_CHARACTER_ID = "1"; -const DEFAULT_CHAT_ID = "0"; - -type JsonRecord = Record; - -function asRecord(value: unknown): JsonRecord { - return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {}; -} - -function toStringOrEmpty(value: unknown): string { - return typeof value === "string" ? value.trim() : ""; -} - -function md5(input: string): string { - return createHash("md5").update(input, "utf8").digest("hex"); -} - -/** - * Percent-encode matching Python's `urllib.parse.quote(s, safe="")` — encode - * every byte except the always-safe RFC 3986 unreserved set (letters, - * digits, `_.-~`). `encodeURIComponent` leaves a few extra characters - * (`!*'()`) unescaped, so it is not a drop-in replacement for the upstream - * signature to match byte-for-byte. - */ -export function pyQuote(input: string): string { - const bytes = new TextEncoder().encode(input); - let out = ""; - for (const byte of bytes) { - const ch = String.fromCharCode(byte); - if (/[A-Za-z0-9_.\-~]/.test(ch)) { - out += ch; - } else { - out += `%${byte.toString(16).toUpperCase().padStart(2, "0")}`; - } - } - return out; -} - -/** Port of `get_body_to_yy()` from crypt.py. */ -export function getBodyToYy(characterID: string, msgContent: string, chatID: string): string { - const normalized = msgContent.replace(/\r\n/g, "").replace(/\n/g, "").replace(/\r/g, ""); - return md5(characterID) + md5(normalized) + md5(chatID) + md5(""); -} - -/** Port of `generate_yy_header()` from crypt.py. */ -export function generateYyHeader( - pathAndQuery: string, - bodyToYy: string, - timestampMs: number -): string { - const encodedPath = pyQuote(pathAndQuery); - const timeHash = md5(String(timestampMs)); - const combined = `${encodedPath}_${bodyToYy}${timeHash}ooui`; - return md5(combined); -} - -/** - * Derive a stable per-connection fingerprint id from the token when the user - * hasn't captured the real browser-generated value from localStorage. Pure - * function of the token, so it stays identical across requests without - * needing to persist any new state. - */ -function deriveFingerprintId(token: string, salt: string): string { - return md5(`${token}:${salt}`); -} - -export function buildHailuoPathAndQuery( - token: string, - providerSpecificData: unknown, - unixMs: number -): string { - const data = asRecord(providerSpecificData); - const deviceId = - toStringOrEmpty(data.device_id) || - toStringOrEmpty(data.deviceId) || - deriveFingerprintId(token, "device_id"); - const uuid = toStringOrEmpty(data.uuid) || deriveFingerprintId(token, "uuid"); - - const params = new URLSearchParams({ - device_platform: "web", - biz_id: "2", - app_id: "3001", - version_code: "22201", - lang: "en", - uuid, - device_id: deviceId, - os_name: toStringOrEmpty(data.os_name) || "Windows", - browser_name: toStringOrEmpty(data.browser_name) || "chrome", - cpu_core_num: toStringOrEmpty(data.cpu_core_num) || "8", - browser_language: toStringOrEmpty(data.browser_language) || "en-US", - browser_platform: toStringOrEmpty(data.browser_platform) || "Win32", - screen_width: toStringOrEmpty(data.screen_width) || "1920", - screen_height: toStringOrEmpty(data.screen_height) || "1080", - unix: String(unixMs), - }); - return `${API_PATH}?${params.toString()}`; -} - -type HailuoInputMessage = { - role: string; - content: unknown; - tool_calls?: unknown; -}; - -function textFromContent(content: unknown): string { - if (typeof content === "string") return content; - if (!Array.isArray(content)) { - throw new Error("Hailuo Web only supports text message content"); - } - return content - .map((part) => { - if (!part || typeof part !== "object" || Array.isArray(part)) { - throw new Error("Hailuo Web only supports text message content"); - } - const record = part as Record; - if ( - (record.type === "text" || record.type === "input_text") && - typeof record.text === "string" - ) { - return record.text; - } - throw new Error("Hailuo Web does not support image, audio, file, or tool content"); - }) - .join(""); -} - -/** Fold text-only OpenAI history into the single msgContent field Hailuo accepts. */ -export function foldHailuoMessages(messages: HailuoInputMessage[]): string { - const parts: string[] = []; - for (const message of messages) { - if (message.role === "tool" || message.role === "function") { - throw new Error("Hailuo Web does not support tool result messages"); - } - if (message.tool_calls !== undefined) { - throw new Error("Hailuo Web does not support assistant tool calls"); - } - const text = textFromContent(message.content); - if (!text) continue; - if (message.role === "system" || message.role === "developer") { - parts.push(`System: ${text}`); - } else if (message.role === "user") { - parts.push(parts.length > 0 ? `User: ${text}` : text); - } else if (message.role === "assistant") { - parts.push(`Assistant: ${text}`); - } else { - throw new Error(`Hailuo Web does not support message role ${message.role}`); - } - } - return parts.join("\n\n").trim(); -} - -export interface HailuoStreamState { - emittedLen: number; -} - -/** `message_result.content` is a cumulative snapshot, not a delta — diff it. */ -export function extractHailuoMessageDelta(content: string, state: HailuoStreamState): string { - if (typeof content !== "string" || content.length <= state.emittedLen) return ""; - const delta = content.slice(state.emittedLen); - state.emittedLen = content.length; - return delta; -} - -export type HailuoSseLine = - | { type: "event"; value: string } - | { type: "data"; value: unknown } - | null; - -/** Parse a single raw SSE line. Malformed/truncated `data:` lines are swallowed, not thrown. */ -export function parseHailuoLine(line: string): HailuoSseLine { - if (line.startsWith("event:")) { - return { type: "event", value: line.slice(6).trim() }; - } - if (line.startsWith("data:")) { - const raw = line.slice(5).trim(); - try { - return { type: "data", value: JSON.parse(raw) }; - } catch { - return null; - } - } - return null; -} - -export function extractHailuoMessageResultContent(data: unknown): string | null { - const root = asRecord(data); - const payload = asRecord(root.data); - const messageResult = asRecord(payload.messageResult); - return typeof messageResult.content === "string" ? messageResult.content : null; -} - -function openAiChunk(id: string, created: number, modelId: string, content: string): JsonRecord { - return { - id, - object: "chat.completion.chunk", - created, - model: modelId, - choices: [{ index: 0, delta: { content }, finish_reason: null }], - }; -} - -function openAiCompletion(id: string, created: number, modelId: string, content: string): JsonRecord { - return { - id, - object: "chat.completion", - created, - model: modelId, - choices: [{ index: 0, message: { role: "assistant", content }, finish_reason: "stop" }], - }; -} - -export class HailuoWebExecutor extends BaseExecutor { - constructor() { - super("hailuo-web", { id: "hailuo-web", baseUrl: BASE_URL }); - } - - private buildStreamHeaders(token: string, yy: string): Record { - return { - Accept: "text/event-stream", - "User-Agent": USER_AGENT, - Origin: BASE_URL, - Referer: `${BASE_URL}/`, - token, - yy, - }; - } - - private async streamToText( - upstream: Response, - onDelta: (delta: string) => void - ): Promise<{ ok: boolean; errorMessage?: string }> { - const reader = upstream.body?.getReader(); - if (!reader) return { ok: true }; - - const decoder = new TextDecoder(); - const state: HailuoStreamState = { emittedLen: 0 }; - let currentEvent = ""; - let buffer = ""; - - const processLine = (line: string): "continue" | "close" => { - const parsed = parseHailuoLine(line); - if (!parsed) return "continue"; - if (parsed.type === "event") { - currentEvent = parsed.value; - if (currentEvent === "close_chunk") return "close"; - return "continue"; - } - if (currentEvent === "message_result") { - const content = extractHailuoMessageResultContent(parsed.value); - if (content !== null) { - const delta = extractHailuoMessageDelta(content, state); - if (delta) onDelta(delta); - } - } - return "continue"; - }; - - try { - while (true) { - const { done, value } = await reader.read(); - if (done) break; - buffer += decoder.decode(value, { stream: true }); - const lines = buffer.split(/\r?\n/); - buffer = lines.pop() || ""; - for (const line of lines) { - if (processLine(line) === "close") return { ok: true }; - } - } - if (buffer) processLine(buffer); - return { ok: true }; - } catch (error) { - return { - ok: false, - errorMessage: error instanceof Error ? error.message : "Hailuo stream read failed", - }; - } - } - - /** Validate tool/function-call fields and fold messages into a single msgContent string. */ - private prepareMsgContent(bodyObj: JsonRecord): { msgContent: string } | { error: string } { - const tools = bodyObj.tools; - const functions = bodyObj.functions; - if (tools != null && (!Array.isArray(tools) || tools.length > 0)) { - return { error: "Hailuo Web does not support OpenAI function tools" }; - } - if (functions != null && (!Array.isArray(functions) || functions.length > 0)) { - return { error: "Hailuo Web does not support legacy function tools" }; - } - try { - const messages = Array.isArray(bodyObj.messages) - ? (bodyObj.messages as HailuoInputMessage[]) - : []; - const msgContent = foldHailuoMessages(messages); - if (!msgContent) throw new Error("Hailuo Web requires a non-empty user message"); - return { msgContent }; - } catch (error) { - return { error: error instanceof Error ? error.message : "Invalid Hailuo Web request" }; - } - } - - /** Build the signed request: URL, headers, and the multipart form body. */ - private buildSignedRequest( - token: string, - providerSpecificData: unknown, - msgContent: string - ): { url: string; headers: Record; form: FormData } { - const now = Date.now(); - const pathAndQuery = buildHailuoPathAndQuery(token, providerSpecificData, now); - const psd = asRecord(providerSpecificData); - const characterID = toStringOrEmpty(psd.characterID) || DEFAULT_CHARACTER_ID; - const chatID = toStringOrEmpty(psd.chatID) || DEFAULT_CHAT_ID; - const bodyToYy = getBodyToYy(characterID, msgContent, chatID); - const yy = generateYyHeader(pathAndQuery, bodyToYy, now); - - const form = new FormData(); - form.set("characterID", characterID); - form.set("msgContent", msgContent); - form.set("chatID", chatID); - form.set("searchMode", "0"); - - return { url: `${BASE_URL}${pathAndQuery}`, headers: this.buildStreamHeaders(token, yy), form }; - } - - /** POST the signed multipart request and normalize both network + upstream-status errors. */ - private async dispatch( - url: string, - reqHeaders: Record, - form: FormData, - signal: AbortSignal | null | undefined, - body: unknown, - bodyObj: JsonRecord - ): Promise<{ upstream: Response } | { errorResult: ReturnType }> { - let upstream: Response; - try { - upstream = await fetch(url, { method: "POST", headers: reqHeaders, body: form, signal }); - } catch (err) { - return { - errorResult: { - ...makeErrorResult( - 502, - `Hailuo fetch failed: ${err instanceof Error ? err.message : "unknown"}`, - body, - url - ), - headers: reqHeaders, - transformedBody: bodyObj, - }, - }; - } - if (!upstream.ok) { - const errText = await upstream.text().catch(() => ""); - return { - errorResult: { - ...makeErrorResult( - upstream.status, - `Hailuo error: ${sanitizeErrorMessage(errText)}`, - body, - url - ), - headers: reqHeaders, - transformedBody: bodyObj, - }, - }; - } - return { upstream }; - } - - /** Buffer the SSE stream into a single OpenAI-shaped chat.completion response. */ - private async buildNonStreamingResponse( - upstream: Response, - id: string, - created: number, - modelId: string, - url: string, - reqHeaders: Record, - body: unknown, - bodyObj: JsonRecord - ) { - let answer = ""; - const result = await this.streamToText(upstream, (delta) => { - answer += delta; - }); - if (!result.ok) { - return { - ...makeErrorResult( - 502, - `Hailuo protocol error: ${sanitizeErrorMessage(result.errorMessage || "unknown")}`, - body, - url - ), - headers: reqHeaders, - transformedBody: bodyObj, - }; - } - return { - response: new Response(JSON.stringify(openAiCompletion(id, created, modelId, answer)), { - headers: { "Content-Type": "application/json" }, - }), - url, - headers: reqHeaders, - transformedBody: bodyObj, - }; - } - - private buildStreamingResponse( - upstream: Response, - id: string, - created: number, - modelId: string, - signal?: AbortSignal | null - ): ReadableStream { - const encoder = new TextEncoder(); - return new ReadableStream({ - start: async (controller) => { - let emittedRole = false; - const result = await this.streamToText(upstream, (delta) => { - if (!emittedRole) { - emittedRole = true; - controller.enqueue( - encoder.encode(`data: ${JSON.stringify(openAiChunk(id, created, modelId, ""))}\n\n`) - ); - } - controller.enqueue( - encoder.encode(`data: ${JSON.stringify(openAiChunk(id, created, modelId, delta))}\n\n`) - ); - }); - if (!result.ok) { - if (!signal?.aborted) { - controller.error(new Error(result.errorMessage || "Hailuo stream error")); - } else { - try { - controller.close(); - } catch { - /* already closed */ - } - } - return; - } - controller.enqueue( - encoder.encode( - `data: ${JSON.stringify({ - id, - object: "chat.completion.chunk", - created, - model: modelId, - choices: [{ index: 0, delta: {}, finish_reason: "stop" }], - })}\n\n` - ) - ); - controller.enqueue(encoder.encode("data: [DONE]\n\n")); - controller.close(); - }, - }); - } - - async execute(input: ExecuteInput) { - const { body, credentials, signal, stream: wantStream } = input; - const bodyObj = asRecord(body); - - const token = toStringOrEmpty(credentials?.apiKey) || toStringOrEmpty(credentials?.accessToken); - if (!token) { - return makeErrorResult( - 401, - "Missing Hailuo _token — log in at hailuo.ai and capture _token from localStorage.", - body, - `${BASE_URL}${API_PATH}` - ); - } - - const prepared = this.prepareMsgContent(bodyObj); - if ("error" in prepared) { - return makeErrorResult(400, prepared.error, body, BASE_URL); - } - - const { url, headers: reqHeaders, form } = this.buildSignedRequest( - token, - credentials?.providerSpecificData, - prepared.msgContent - ); - - const dispatched = await this.dispatch(url, reqHeaders, form, signal, body, bodyObj); - if ("errorResult" in dispatched) return dispatched.errorResult; - const { upstream } = dispatched; - - const id = `chatcmpl-hailuo-${Date.now()}`; - const created = Math.floor(Date.now() / 1000); - const modelId = input.model || "hailuo"; - - if (wantStream) { - const outStream = this.buildStreamingResponse(upstream, id, created, modelId, signal); - return { - response: new Response(outStream, { - headers: { - "Content-Type": "text/event-stream", - "Cache-Control": "no-cache", - Connection: "keep-alive", - }, - }), - url, - headers: reqHeaders, - transformedBody: bodyObj, - }; - } - - return this.buildNonStreamingResponse(upstream, id, created, modelId, url, reqHeaders, body, bodyObj); - } -} diff --git a/open-sse/executors/index.ts b/open-sse/executors/index.ts index 9e762e8a05..41d2cc3406 100644 --- a/open-sse/executors/index.ts +++ b/open-sse/executors/index.ts @@ -172,9 +172,6 @@ const lazyExecutors: Record Promise> = { "doubao-web": () => import("./doubao-web.ts").then((m) => new m.DoubaoWebExecutor()), db: () => import("./doubao-web.ts").then((m) => new m.DoubaoWebExecutor()), // Alias "qwen-web": () => import("./qwen-web.ts").then((m) => new m.QwenWebExecutor()), - raycast: () => import("./raycast.ts").then((m) => new m.RaycastExecutor()), - rc: () => import("./raycast.ts").then((m) => new m.RaycastExecutor()), // Alias - "hailuo-web": () => import("./hailuo-web.ts").then((m) => new m.HailuoWebExecutor()), "zai-web": () => import("./zai-web.ts").then((m) => new m.ZaiWebExecutor()), zw: () => import("./zai-web.ts").then((m) => new m.ZaiWebExecutor()), // Alias theoldllm: () => import("./theoldllm.ts").then((m) => new m.TheOldLlmExecutor()), diff --git a/open-sse/executors/raycast.ts b/open-sse/executors/raycast.ts deleted file mode 100644 index bfa8d28028..0000000000 --- a/open-sse/executors/raycast.ts +++ /dev/null @@ -1,235 +0,0 @@ -/** - * @file raycast.ts - * @description Executor for Raycast Pro AI (reverse-engineered backend.raycast.com API). - * - * @changes - * - [2026-07-27] [Composer] - Initial Raycast Pro local-dev executor - */ - -import { BaseExecutor, mergeUpstreamExtraHeaders, type ProviderCredentials } from "./base.ts"; -import { PROVIDERS } from "../config/constants.ts"; -import { sanitizeErrorMessage } from "../utils/error.ts"; -import { - RAYCAST_CHAT_URL, - buildRaycastChatBody, - buildRaycastHeaders, - parseRaycastSseText, -} from "../services/raycast.ts"; - -type JsonRecord = Record; -type ChatMessage = { role?: string; content?: unknown }; - -export class RaycastExecutor extends BaseExecutor { - constructor() { - super("raycast", PROVIDERS.raycast); - } - - buildUrl(): string { - return RAYCAST_CHAT_URL; - } - - // Not a BaseExecutor.buildHeaders override: Raycast signs headers over the exact - // request payload (2nd param is the body string, not the base's `stream` boolean), - // and execute() below is fully custom — keep it as a distinct helper so a - // polymorphic buildHeaders(credentials, true) call can never land here. - private buildRaycastRequestHeaders( - credentials: ProviderCredentials, - payload?: string - ): Record { - const body = payload || "{}"; - return buildRaycastHeaders(body, credentials as JsonRecord); - } - - async execute({ model, body, stream, credentials, signal, upstreamExtraHeaders }) { - const reqBody = body as { messages?: ChatMessage[]; temperature?: number }; - let payload: string; - - try { - payload = buildRaycastChatBody(model as string, reqBody.messages || [], reqBody.temperature); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - return { - response: new Response( - JSON.stringify({ - error: { - message: sanitizeErrorMessage(message), - type: "invalid_request_error", - code: "", - }, - }), - { status: 400, headers: { "Content-Type": "application/json" } } - ), - url: RAYCAST_CHAT_URL, - headers: {}, - transformedBody: body, - }; - } - - const headers = this.buildRaycastRequestHeaders(credentials as ProviderCredentials, payload); - mergeUpstreamExtraHeaders(headers, upstreamExtraHeaders as Record | null); - - let raycastResponse: Response; - try { - raycastResponse = await fetch(RAYCAST_CHAT_URL, { - method: "POST", - headers, - body: payload, - signal: signal || undefined, - }); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - return { - response: new Response( - JSON.stringify({ - error: { message: sanitizeErrorMessage(message), type: "api_error", code: "" }, - }), - { status: 502, headers: { "Content-Type": "application/json" } } - ), - url: RAYCAST_CHAT_URL, - headers, - transformedBody: payload, - }; - } - - if (!raycastResponse.ok) { - const errorText = await raycastResponse.text(); - return { - response: new Response( - JSON.stringify({ - error: { - message: sanitizeErrorMessage(`Raycast API error (${raycastResponse.status})`), - type: "api_error", - code: String(raycastResponse.status), - }, - }), - { status: raycastResponse.status, headers: { "Content-Type": "application/json" } } - ), - url: RAYCAST_CHAT_URL, - headers, - transformedBody: payload, - }; - } - - const responseId = `chatcmpl-raycast-${Date.now()}`; - const created = Math.floor(Date.now() / 1000); - const modelId = model as string; - - if (stream !== false) { - const raycastBody = raycastResponse.body; - if (!raycastBody) { - return { - response: new Response( - JSON.stringify({ - error: { message: "Raycast returned empty stream body", type: "api_error", code: "" }, - }), - { status: 502, headers: { "Content-Type": "application/json" } } - ), - url: RAYCAST_CHAT_URL, - headers, - transformedBody: payload, - }; - } - - const sseStream = new ReadableStream({ - async start(controller) { - const encoder = new TextEncoder(); - const reader = raycastBody.getReader(); - const decoder = new TextDecoder(); - let buffer = ""; - - try { - while (true) { - const { done, value } = await reader.read(); - if (done) break; - - buffer += decoder.decode(value, { stream: true }); - let newlineIndex: number; - while ((newlineIndex = buffer.indexOf("\n")) >= 0) { - const line = buffer.slice(0, newlineIndex).trim(); - buffer = buffer.slice(newlineIndex + 1); - if (!line.startsWith("data:")) continue; - - try { - const data = JSON.parse(line.slice(5).trim()) as { - text?: string; - finish_reason?: string | null; - complete?: boolean; - }; - const hasContent = typeof data.text === "string" && data.text.length > 0; - const hasFinishReason = - data.finish_reason !== undefined && data.finish_reason !== null; - if (data.complete || (!hasContent && !hasFinishReason)) continue; - - const chunk = { - id: responseId, - object: "chat.completion.chunk", - created, - model: modelId, - choices: [ - { - index: 0, - delta: { content: data.text || "" }, - finish_reason: hasFinishReason ? data.finish_reason : null, - }, - ], - }; - controller.enqueue(encoder.encode(`data: ${JSON.stringify(chunk)}\n\n`)); - } catch { - // Ignore malformed SSE data. - } - } - } - controller.enqueue(encoder.encode("data: [DONE]\n\n")); - controller.close(); - } catch (err) { - controller.error(err); - } - }, - }); - - return { - response: new Response(sseStream, { - status: 200, - headers: { - "Content-Type": "text/event-stream", - "Cache-Control": "no-cache", - Connection: "keep-alive", - }, - }), - url: RAYCAST_CHAT_URL, - headers, - transformedBody: payload, - }; - } - - const responseText = await raycastResponse.text(); - const content = parseRaycastSseText(responseText); - - return { - response: new Response( - JSON.stringify({ - id: responseId, - object: "chat.completion", - created, - model: modelId, - choices: [ - { - index: 0, - message: { role: "assistant", content, refusal: null }, - finish_reason: "stop", - }, - ], - usage: { - prompt_tokens: 0, - completion_tokens: 0, - total_tokens: 0, - }, - }), - { status: 200, headers: { "Content-Type": "application/json" } } - ), - url: RAYCAST_CHAT_URL, - headers, - transformedBody: payload, - }; - } -} diff --git a/open-sse/services/raycast.ts b/open-sse/services/raycast.ts deleted file mode 100644 index 801f2bf77a..0000000000 --- a/open-sse/services/raycast.ts +++ /dev/null @@ -1,280 +0,0 @@ -/** - * @file raycast.ts - * @description Raycast Pro AI reverse-engineered protocol (backend.raycast.com). - * Ported from szcharlesji/raycast-relay (Node, 2026-06) — V2 HMAC + V1 JWT signatures. - * - * @changes - * - [2026-07-27] [Composer] - Initial Raycast Pro local-dev provider protocol - */ - -import { createHmac, createHash, randomUUID } from "node:crypto"; - -import { resolvePublicCred } from "../utils/publicCreds.ts"; - -export const RAYCAST_CHAT_URL = "https://backend.raycast.com/api/v1/ai/chat_completions"; -export const RAYCAST_MODELS_URL = "https://backend.raycast.com/api/v1/ai/models"; -export const RAYCAST_DEFAULT_USER_AGENT = "Raycast/1.104.20 (macOS Version 26.5.1 (Build 25F80))"; -export const RAYCAST_DEFAULT_EXPERIMENTAL = "chatBranching, mcpHTTPServer"; - -/** - * Community-extracted default; override via providerSpecificData.sigSecret or - * RAYCAST_SIG_SECRET. Embedded through resolvePublicCred() per Hard Rule #11 — - * a public upstream credential must never be a string literal in the source - * (see docs/security/PUBLIC_CREDS.md). - */ -export const RAYCAST_DEFAULT_SIG_SECRET = resolvePublicCred( - "raycast_sig_secret", - "RAYCAST_SIG_SECRET" -); - -export type RaycastCredentials = { - accessToken?: string; - providerSpecificData?: { - deviceId?: string; - aid?: string; - sigSecret?: string; - userAgent?: string; - experimental?: string; - }; -}; - -export type RaycastModelEntry = { - id: string; - model: string; - name: string; - provider: string; - requires_better_ai?: boolean; - availability?: string; -}; - -type ChatMessage = { role?: string; content?: unknown }; - -export function rot13rot5(input: string): string { - return input.replace(/[A-Za-z0-9]/g, (char) => { - const code = char.charCodeAt(0); - if (code >= 65 && code <= 90) return String.fromCharCode(((code - 65 + 13) % 26) + 65); - if (code >= 97 && code <= 122) return String.fromCharCode(((code - 97 + 13) % 26) + 97); - return String.fromCharCode(((code - 48 + 5) % 10) + 48); - }); -} - -export function signatureV2( - timestamp: string, - deviceId: string, - payload: string, - secret: string -): string { - const bodyHash = createHash("sha256").update(payload).digest("hex"); - const message = [timestamp, deviceId, bodyHash].map(rot13rot5).join("."); - return createHmac("sha256", secret).update(message).digest("hex"); -} - -function base64UrlJson(value: Record): string { - return Buffer.from(JSON.stringify(value)).toString("base64url"); -} - -export function raycastJwt(aid: string, secret: string): string { - const iat = Date.now() / 1000; - const header = base64UrlJson({ typ: "JWT", alg: "HS256" }); - const payload = base64UrlJson({ aid, exp: iat + 60, iat }); - const signature = createHmac("sha256", secret).update(`${header}.${payload}`).digest("base64url"); - return `${header}.${payload}.${signature}`; -} - -export function decodeAidFromRaycastJwt(jwt: string): string | null { - const parts = jwt.trim().split("."); - if (parts.length < 2) return null; - try { - const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8")) as { - aid?: string; - }; - return payload.aid || null; - } catch { - return null; - } -} - -export function resolveRaycastSecrets(credentials: RaycastCredentials): { - bearerToken: string; - deviceId: string; - aid: string; - sigSecret: string; -} { - const psd = credentials.providerSpecificData || {}; - const bearerToken = (credentials.accessToken || "").trim(); - const deviceId = (psd.deviceId || "").trim(); - const aid = (psd.aid || deviceId || "").trim(); - const sigSecret = ( - psd.sigSecret || - process.env.RAYCAST_SIG_SECRET || - RAYCAST_DEFAULT_SIG_SECRET - ).trim(); - - if (!bearerToken) throw new Error("Raycast bearer token is required"); - if (!deviceId) throw new Error("Raycast device ID is required"); - if (!sigSecret) throw new Error("Raycast signature secret is required"); - - return { bearerToken, deviceId, aid, sigSecret }; -} - -export function buildRaycastHeaders( - payload: string, - credentials: RaycastCredentials -): Record { - const { bearerToken, deviceId, aid, sigSecret } = resolveRaycastSecrets(credentials); - const psd = credentials.providerSpecificData || {}; - const timestamp = Math.floor(Date.now() / 1000).toString(); - - return { - Accept: "application/json", - Authorization: `Bearer ${bearerToken}`, - "X-Raycast-Timestamp": timestamp, - "Accept-Language": "en-US,en;q=0.9", - "X-Raycast-DeviceId": deviceId, - "Content-Type": "application/json", - "X-Raycast-Signature-v2": signatureV2(timestamp, deviceId, payload, sigSecret), - "X-Raycast-Experimental": psd.experimental || RAYCAST_DEFAULT_EXPERIMENTAL, - "X-Raycast-Signature": raycastJwt(aid, sigSecret), - "User-Agent": psd.userAgent || RAYCAST_DEFAULT_USER_AGENT, - }; -} - -export function contentToText(content: unknown): string { - if (typeof content === "string") return content; - if (!Array.isArray(content)) return JSON.stringify(content ?? ""); - - return content - .map((part) => { - if (typeof part === "string") return part; - if ( - part && - typeof part === "object" && - "type" in part && - (part as { type?: string }).type === "text" - ) { - return String((part as { text?: string }).text || ""); - } - return ""; - }) - .filter(Boolean) - .join("\n"); -} - -export function convertOpenAiMessages(messages: ChatMessage[]): { - raycastMessages: Array<{ author: string; content: { text: string } }>; - systemInstruction: string; -} { - let systemInstruction = "markdown"; - const raycastMessages: Array<{ author: string; content: { text: string } }> = []; - - for (const [index, message] of messages.entries()) { - if (message.role === "system" && index === 0) { - systemInstruction = contentToText(message.content); - continue; - } - - if (message.role === "user" || message.role === "assistant") { - raycastMessages.push({ - author: message.role, - content: { text: contentToText(message.content) }, - }); - } - } - - return { raycastMessages, systemInstruction }; -} - -export function inferProviderInfo(modelId: string): { provider: string; model: string } { - if (modelId.startsWith("openai_o1-")) { - return { provider: "openai", model: modelId.slice("openai_o1-".length) }; - } - - const providers = [ - "anthropic", - "baseten", - "google", - "groq", - "mistral", - "openai", - "perplexity", - "raycast", - "together", - "xai", - ]; - - for (const provider of providers) { - const prefix = `${provider}-`; - if (modelId.startsWith(prefix)) { - return { provider, model: modelId.slice(prefix.length) }; - } - } - - if (modelId.includes("/")) return { provider: "baseten", model: modelId }; - return { provider: "openai", model: modelId || "gpt-5-mini" }; -} - -export function buildRaycastChatBody( - modelId: string, - messages: ChatMessage[], - temperature?: number -): string { - const { provider, model } = inferProviderInfo(modelId); - const { raycastMessages, systemInstruction } = convertOpenAiMessages(messages); - - if (raycastMessages.length === 0) { - throw new Error("Raycast requires at least one user or assistant message"); - } - - return JSON.stringify({ - model, - provider, - messages: raycastMessages, - system_instruction: systemInstruction, - temperature: temperature ?? 0.5, - additional_system_instructions: "", - debug: false, - locale: "en-US", - source: "ai_chat", - thread_id: randomUUID(), - tools: [], - }); -} - -export function parseRaycastSseText(responseText: string): string { - let fullText = ""; - - for (const line of responseText.split("\n")) { - if (!line.startsWith("data:")) continue; - try { - const data = JSON.parse(line.slice(5).trim()) as { text?: string }; - if (data.text) fullText += data.text; - } catch { - // Ignore non-JSON SSE lines. - } - } - - return fullText; -} - -export async function fetchRaycastModels( - credentials: RaycastCredentials, - options?: { includePremium?: boolean; includeDeprecated?: boolean } -): Promise { - const payload = "{}"; - const headers = buildRaycastHeaders(payload, credentials); - const res = await fetch(RAYCAST_MODELS_URL, { method: "GET", headers }); - if (!res.ok) { - const text = await res.text(); - throw new Error(`Raycast models error [${res.status}]: ${text.slice(0, 300)}`); - } - - const data = (await res.json()) as { models?: RaycastModelEntry[] }; - const includePremium = options?.includePremium ?? true; - const includeDeprecated = options?.includeDeprecated ?? true; - - return (data.models || []).filter((model) => { - if (!includePremium && model.requires_better_ai) return false; - if (!includeDeprecated && model.availability === "deprecated") return false; - return true; - }); -} diff --git a/open-sse/utils/publicCreds.ts b/open-sse/utils/publicCreds.ts index 5257128992..af2de6c6c3 100644 --- a/open-sse/utils/publicCreds.ts +++ b/open-sse/utils/publicCreds.ts @@ -189,15 +189,6 @@ const EMBEDDED_DEFAULTS = { // Firefly credits balance endpoint public x-api-key (`SunbreakWebUI1`) from // GET firefly.adobe.io/v1/credits/balance browser traffic. adobe_firefly_balance_api_key: [60, 24, 0, 11, 0, 10, 20, 31, 50, 72, 18, 32, 43, 93], - // Raycast Pro V2 request-signature secret (#8895). Community-extracted from the - // public Raycast macOS client — the SAME value ships to every install, so it is - // public by design, not a per-user credential. Overridable via RAYCAST_SIG_SECRET - // or providerSpecificData.sigSecret. - raycast_sig_secret: [ - 89, 15, 13, 93, 71, 90, 65, 67, 86, 24, 71, 67, 1, 9, 91, 0, 73, 64, 87, 88, 93, 90, 91, 68, 12, - 20, 18, 3, 21, 70, 66, 3, 13, 11, 1, 72, 69, 87, 88, 95, 87, 88, 17, 94, 20, 67, 92, 27, 72, 68, - 3, 10, 92, 6, 21, 21, 84, 95, 14, 15, 88, 70, 95, 77, - ], } as const; export type EmbeddedDefaultKey = keyof typeof EMBEDDED_DEFAULTS; diff --git a/scripts/raycast/extract-credentials.mjs b/scripts/raycast/extract-credentials.mjs deleted file mode 100644 index 44d3866239..0000000000 --- a/scripts/raycast/extract-credentials.mjs +++ /dev/null @@ -1,100 +0,0 @@ -#!/usr/bin/env node -/** - * @file extract-credentials.mjs - * @description Print Raycast Pro credentials from local macOS install (redacted preview). - * - * Usage: node scripts/raycast/extract-credentials.mjs - * - * @changes - * - [2026-07-27] [Composer] - CLI credential extractor for local Raycast - */ - -import { execFileSync } from "node:child_process"; -import { createHash } from "node:crypto"; -import { copyFileSync, existsSync, mkdtempSync, readFileSync, rmdirSync, unlinkSync } from "node:fs"; -import { homedir, tmpdir } from "node:os"; -import { join } from "node:path"; - -const RAYCAST_SALT = "yvkwWXzxPPBAqY2tmaKrB*DvYjjMaeEf"; -const RAYCAST_SUPPORT = join(homedir(), "Library", "Application Support", "com.raycast.macos"); -const RAYCAST_DB = join(RAYCAST_SUPPORT, "raycast-enc.sqlite"); - -function redact(s, keep = 8) { - if (!s || s.length <= keep * 2) return "***"; - return `${s.slice(0, keep)}…${s.slice(-4)}`; -} - -function readKeychain(account) { - return JSON.parse( - execFileSync("security", ["find-generic-password", "-s", "Raycast", "-a", account, "-w"], { - encoding: "utf-8", - }).trim() - ); -} - -function dbPassphrase() { - const keyHex = execFileSync( - "security", - ["find-generic-password", "-s", "Raycast", "-a", "database_key", "-w"], - { encoding: "utf-8" } - ).trim(); - return createHash("sha256") - .update(keyHex + RAYCAST_SALT) - .digest("hex"); -} - -function queryDb(sql) { - const tmpDir = mkdtempSync(join(tmpdir(), "raycast-extract-")); - const tmpDb = join(tmpDir, "db.sqlite"); - copyFileSync(RAYCAST_DB, tmpDb); - for (const ext of ["-wal", "-shm"]) { - const src = RAYCAST_DB + ext; - if (existsSync(src)) copyFileSync(src, tmpDb + ext); - } - const passphrase = dbPassphrase(); - const input = `PRAGMA key = '${passphrase}';\n.mode json\n${sql}`; - const out = execFileSync("sqlcipher", [tmpDb], { input, encoding: "utf-8" }); - for (const ext of ["", "-wal", "-shm"]) { - try { - unlinkSync(tmpDb + ext); - } catch {} - } - try { - rmdirSync(tmpDir); - } catch {} - const jsonStr = out.startsWith("ok\n") ? out.slice(3) : out; - return JSON.parse(jsonStr.trim() || "[]"); -} - -if (process.platform !== "darwin") { - console.error("macOS only"); - process.exit(1); -} - -const store = readKeychain("raycast-store_credentials"); -const token = store?.oauth?.access_token; -if (!token) { - console.error("No Raycast bearer token in Keychain — open Raycast and sign in"); - process.exit(1); -} - -const users = queryDb("SELECT analyticsId, email, username, hasProFeatures, hasBetterAI FROM user LIMIT 1;"); -const user = users[0] || {}; -const deviceId = - user.analyticsId || - JSON.parse(readFileSync(join(RAYCAST_SUPPORT, "posthog.distinctId"), "utf-8"))["posthog.distinctId"]; - -console.log(JSON.stringify({ - accessTokenPreview: redact(token), - accessToken: token, - deviceId, - aid: deviceId, - email: user.email || store?.user?.email, - username: user.username || store?.user?.username, - hasProFeatures: !!user.hasProFeatures, - hasBetterAI: !!user.hasBetterAI, - sources: { - bearer: "Keychain Raycast / raycast-store_credentials", - deviceId: "raycast-enc.sqlite user.analyticsId", - }, -}, null, 2)); diff --git a/scripts/raycast/usage-benchmark.mjs b/scripts/raycast/usage-benchmark.mjs deleted file mode 100644 index 85c30232d6..0000000000 --- a/scripts/raycast/usage-benchmark.mjs +++ /dev/null @@ -1,165 +0,0 @@ -#!/usr/bin/env node -/** - * @file usage-benchmark.mjs - * @description Battle-test Raycast Pro usage via OmniRoute local endpoint. - * - * Env (required): - * OMNIROUTE_URL default http://127.0.0.1:20128/v1 - * OMNIROUTE_API_KEY OmniRoute API key (if REQUIRE_API_KEY) - * - * Env (optional — direct Raycast probe without OmniRoute): - * RAYCAST_BEARER_TOKEN - * RAYCAST_DEVICE_ID - * RAYCAST_AID - * RAYCAST_SIG_SECRET - * - * Usage: - * node scripts/raycast/usage-benchmark.mjs --models 5 --rounds 3 - * node scripts/raycast/usage-benchmark.mjs --model openai-gpt-5-mini --rounds 10 - * - * @changes - * - [2026-07-27] [Composer] - Initial Raycast Pro usage benchmark script - */ - -import { createHmac, createHash } from "node:crypto"; - -const args = process.argv.slice(2); -function arg(name, fallback) { - const i = args.indexOf(`--${name}`); - return i >= 0 && args[i + 1] ? args[i + 1] : fallback; -} - -const rounds = Number(arg("rounds", "3")); -const model = arg("model", ""); -const modelCount = Number(arg("models", "5")); -const omnirouteUrl = (process.env.OMNIROUTE_URL || "http://127.0.0.1:20128/v1").replace(/\/$/, ""); -const apiKey = process.env.OMNIROUTE_API_KEY || ""; - -const RAYCAST_CHAT_URL = "https://backend.raycast.com/api/v1/ai/chat_completions"; -const RAYCAST_MODELS_URL = "https://backend.raycast.com/api/v1/ai/models"; -const SIG_SECRET = - process.env.RAYCAST_SIG_SECRET || - "6bc455473576ce2cd6f70426caff867aabbe3f7291c1a79681af5e8ce0ca1408"; - -function rot13rot5(input) { - return input.replace(/[A-Za-z0-9]/g, (char) => { - const code = char.charCodeAt(0); - if (code >= 65 && code <= 90) return String.fromCharCode(((code - 65 + 13) % 26) + 65); - if (code >= 97 && code <= 122) return String.fromCharCode(((code - 97 + 13) % 26) + 97); - return String.fromCharCode(((code - 48 + 5) % 10) + 48); - }); -} - -function signatureV2(timestamp, deviceId, payload, secret) { - const bodyHash = createHash("sha256").update(payload).digest("hex"); - const message = [timestamp, deviceId, bodyHash].map(rot13rot5).join("."); - return createHmac("sha256", secret).update(message).digest("hex"); -} - -function raycastJwt(aid, secret) { - const iat = Date.now() / 1000; - const header = Buffer.from(JSON.stringify({ typ: "JWT", alg: "HS256" })).toString("base64url"); - const payload = Buffer.from(JSON.stringify({ aid, exp: iat + 60, iat })).toString("base64url"); - const signature = createHmac("sha256", secret) - .update(`${header}.${payload}`) - .digest("base64url"); - return `${header}.${payload}.${signature}`; -} - -function raycastHeaders(payload) { - const bearerToken = process.env.RAYCAST_BEARER_TOKEN; - const deviceId = process.env.RAYCAST_DEVICE_ID; - const aid = process.env.RAYCAST_AID; - if (!bearerToken || !deviceId || !aid) { - throw new Error("Set RAYCAST_BEARER_TOKEN, RAYCAST_DEVICE_ID, RAYCAST_AID for direct probe"); - } - const timestamp = Math.floor(Date.now() / 1000).toString(); - return { - Accept: "application/json", - Authorization: `Bearer ${bearerToken}`, - "X-Raycast-Timestamp": timestamp, - "X-Raycast-DeviceId": deviceId, - "Content-Type": "application/json", - "X-Raycast-Signature-v2": signatureV2(timestamp, deviceId, payload, SIG_SECRET), - "X-Raycast-Signature": raycastJwt(aid, SIG_SECRET), - "X-Raycast-Experimental": "chatBranching, mcpHTTPServer", - "User-Agent": "Raycast/1.104.20 (macOS Version 26.5.1 (Build 25F80))", - }; -} - -async function fetchRaycastModels() { - const payload = "{}"; - const res = await fetch(RAYCAST_MODELS_URL, { method: "GET", headers: raycastHeaders(payload) }); - const text = await res.text(); - if (!res.ok) throw new Error(`models [${res.status}]: ${text.slice(0, 200)}`); - const data = JSON.parse(text); - return (data.models || []).map((m) => m.id); -} - -async function chatOmniroute(modelId, prompt) { - const headers = { "Content-Type": "application/json" }; - if (apiKey) headers.Authorization = `Bearer ${apiKey}`; - const started = Date.now(); - const res = await fetch(`${omnirouteUrl}/chat/completions`, { - method: "POST", - headers, - body: JSON.stringify({ - model: `raycast/${modelId}`, - messages: [{ role: "user", content: prompt }], - stream: false, - max_tokens: 32, - }), - }); - const ms = Date.now() - started; - const body = await res.text(); - return { ok: res.ok, status: res.status, ms, body: body.slice(0, 300) }; -} - -async function main() { - console.log(`OmniRoute: ${omnirouteUrl}`); - console.log(`Rounds per model: ${rounds}`); - - let models = []; - if (model) { - models = [model]; - } else if (process.env.RAYCAST_BEARER_TOKEN) { - models = (await fetchRaycastModels()).slice(0, modelCount); - console.log(`Direct Raycast model probe — testing ${models.length} models via OmniRoute`); - } else { - models = ["openai-gpt-5-mini"]; - console.log("No RAYCAST_* env — using default model openai-gpt-5-mini via OmniRoute combo id"); - } - - const results = []; - for (const modelId of models) { - let ok = 0; - let fail = 0; - const latencies = []; - for (let i = 0; i < rounds; i++) { - const prompt = `Raycast benchmark round ${i + 1} — reply with exactly: pong`; - try { - const r = await chatOmniroute(modelId, prompt); - latencies.push(r.ms); - if (r.ok) ok++; - else { - fail++; - console.error(` FAIL ${modelId} #${i + 1} [${r.status}]: ${r.body}`); - } - } catch (err) { - fail++; - console.error(` ERR ${modelId} #${i + 1}:`, err.message); - } - } - const avg = latencies.length ? Math.round(latencies.reduce((a, b) => a + b, 0) / latencies.length) : 0; - results.push({ modelId, ok, fail, avgMs: avg }); - console.log(`${modelId}: ${ok}/${rounds} ok, avg ${avg}ms`); - } - - console.log("\nSummary:"); - console.table(results); -} - -main().catch((err) => { - console.error(err); - process.exit(1); -}); diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/ProviderModalsPanel.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/ProviderModalsPanel.tsx index 45aa63ec63..a4d994eac5 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/components/ProviderModalsPanel.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/ProviderModalsPanel.tsx @@ -8,7 +8,6 @@ import { KiroOAuthWrapper, CursorAuthModal, TraeAuthModal, - RaycastAuthModal, ProxyConfigModal, } from "@/shared/components"; import RiskNoticeModal from "../../components/RiskNoticeModal"; @@ -281,13 +280,6 @@ export default function ProviderModalsPanel({ onSuccess={handleOAuthSuccess} onClose={() => setShowOAuthModal(false)} /> - ) : providerId === "raycast" ? ( - setShowOAuthModal(false)} - /> ) : ( - raycastService.probeModels({ - accessToken: local.accessToken, - deviceId: local.deviceId, - aid: resolved.aid, - }) - ); - - const connection: any = await createProviderConnection({ - provider: "raycast", - authType: "oauth", - accessToken: local.accessToken, - refreshToken: null, - email: local.email || null, - expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(), - providerSpecificData: { - deviceId: local.deviceId, - aid: resolved.aid, - authMethod: "auto_imported", - username: local.username, - hasProFeatures: local.hasProFeatures, - hasBetterAI: local.hasBetterAI, - extractSource: local.source, - modelCount: models.length, - premiumModelCount: models.filter((m) => m.requires_better_ai).length, - }, - testStatus: "active", - }); - - await replaceSyncedAvailableModelsForConnection( - "raycast", - connection.id, - models.map((model) => ({ - id: model.id, - name: model.name || model.id, - })) - ); - - return NextResponse.json({ - success: true, - source: local.source, - connection: { - id: connection.id, - provider: connection.provider, - email: connection.email, - }, - models: { - total: models.length, - premium: models.filter((m) => m.requires_better_ai).length, - sample: models.slice(0, 12).map((m) => m.id), - }, - }); - } catch (error: unknown) { - const message = error instanceof Error ? error.message : String(error); - console.error("Raycast auto-import error:", message); - return NextResponse.json({ error: message }, { status: 400 }); - } -} diff --git a/src/app/api/oauth/raycast/import/route.ts b/src/app/api/oauth/raycast/import/route.ts deleted file mode 100644 index 266dd1aecc..0000000000 --- a/src/app/api/oauth/raycast/import/route.ts +++ /dev/null @@ -1,143 +0,0 @@ -/** - * @file route.ts - * @description Import Raycast Pro credentials captured from macOS app traffic. - * - * @changes - * - [2026-07-27] [Composer] - Initial Raycast token import route (local dev) - */ - -import { NextResponse } from "next/server"; -import { createProviderConnection } from "@/models"; -import { RaycastService } from "@/lib/oauth/services/raycast"; -import { raycastImportSchema } from "@/shared/validation/schemas"; -import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; -import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; -import { resolveProxyForProvider } from "@/models"; -import { runWithProxyContext } from "@omniroute/open-sse/utils/proxyFetch.ts"; - -async function requireOAuthImportAuth(request: Request) { - // GHSA-mg76: importing a provider connection is a state-mutating admin action; - // require management scope (or a dashboard session), not any valid client key. - return requireManagementAuth(request, { invalidApiKeyStatus: 401 }); -} - -export async function POST(request: Request) { - const authResponse = await requireOAuthImportAuth(request); - if (authResponse) return authResponse; - - let rawBody; - try { - rawBody = await request.json(); - } catch { - return NextResponse.json( - { - error: { - message: "Invalid request", - details: [{ field: "body", message: "Invalid JSON body" }], - }, - }, - { status: 400 } - ); - } - - try { - const validation = validateBody(raycastImportSchema, rawBody); - if (isValidationFailure(validation)) { - return NextResponse.json({ error: validation.error }, { status: 400 }); - } - - const { accessToken, deviceId, aid, signatureJwt, sigSecret } = validation.data; - const raycastService = new RaycastService(); - const resolved = raycastService.validateCredentials({ - accessToken, - deviceId, - aid, - signatureJwt, - sigSecret, - }); - - const proxy = await resolveProxyForProvider("raycast"); - const models = await runWithProxyContext(proxy, () => - raycastService.probeModels({ - accessToken: accessToken.trim(), - deviceId: deviceId.trim(), - aid: resolved.aid, - sigSecret: sigSecret?.trim(), - }) - ); - - const connection: any = await createProviderConnection({ - provider: "raycast", - authType: "oauth", - accessToken: accessToken.trim(), - refreshToken: null, - expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(), - providerSpecificData: { - deviceId: deviceId.trim(), - aid: resolved.aid, - sigSecret: sigSecret?.trim() || "", - authMethod: "imported", - modelCount: models.length, - premiumModelCount: models.filter((m) => m.requires_better_ai).length, - }, - testStatus: "active", - }); - - return NextResponse.json({ - success: true, - connection: { - id: connection.id, - provider: connection.provider, - }, - models: { - total: models.length, - premium: models.filter((m) => m.requires_better_ai).length, - sample: models.slice(0, 8).map((m) => m.id), - }, - }); - } catch (error: unknown) { - const message = error instanceof Error ? error.message : String(error); - console.error("Raycast import token error:", message); - return NextResponse.json({ error: message }, { status: 400 }); - } -} - -export async function GET(request: Request) { - const authResponse = await requireOAuthImportAuth(request); - if (authResponse) return authResponse; - - const raycastService = new RaycastService(); - - return NextResponse.json({ - provider: "raycast", - method: "import_token", - localDevOnly: true, - instructions: raycastService.getCaptureInstructions(), - requiredFields: [ - { - name: "accessToken", - label: "Bearer Token", - description: "From Authorization: Bearer header on backend.raycast.com requests", - type: "textarea", - }, - { - name: "deviceId", - label: "Device ID", - description: "From X-Raycast-DeviceId header", - type: "text", - }, - { - name: "signatureJwt", - label: "Signature JWT", - description: "From X-Raycast-Signature header (AID decoded automatically)", - type: "textarea", - }, - { - name: "sigSecret", - label: "Signature Secret", - description: "Optional override — defaults to community-extracted SIG_SECRET", - type: "text", - }, - ], - }); -} diff --git a/src/app/api/providers/[id]/models/route.ts b/src/app/api/providers/[id]/models/route.ts index 8ccbb3e311..ee2baeb393 100755 --- a/src/app/api/providers/[id]/models/route.ts +++ b/src/app/api/providers/[id]/models/route.ts @@ -91,8 +91,6 @@ import { isConnectionUnavailableToAuxiliaryActivity } from "@/lib/exclusiveLease import { fetchCursorAgentModels } from "@/lib/providerModels/cursorAgent"; import { fetchCursorAvailableModels } from "@/lib/providerModels/cursorAvailableModels"; import { ensureCursorAutoCatalogEntry } from "@/lib/providerModels/cursorAutoCatalog"; -import { fetchRaycastModels } from "@omniroute/open-sse/services/raycast.ts"; -import { runWithProxyContext } from "@omniroute/open-sse/utils/proxyFetch.ts"; import { type JsonRecord, asRecord, @@ -1298,58 +1296,6 @@ export async function GET( }); } - if (provider === "raycast") { - const cachedResponse = maybeReturnCachedDiscovery(); - if (cachedResponse) return cachedResponse; - - const autoFetchDisabledResponse = maybeReturnAutoFetchDisabled(); - if (autoFetchDisabledResponse) return autoFetchDisabledResponse; - - const psd = asRecord(connection.providerSpecificData); - const deviceId = toNonEmptyString(psd.deviceId); - const aid = toNonEmptyString(psd.aid) || deviceId; - if (!accessToken || !deviceId) { - const fallback = buildDiscoveryFallbackResponse({ - localWarning: "Raycast credentials incomplete — using local catalog", - }); - if (fallback) return fallback; - return NextResponse.json({ error: "Raycast credentials incomplete" }, { status: 400 }); - } - - try { - const raycastModels = await runWithProxyContext(proxy, () => - fetchRaycastModels({ - accessToken, - providerSpecificData: { - deviceId, - aid: aid || deviceId, - sigSecret: toNonEmptyString(psd.sigSecret) || undefined, - }, - }) - ); - const models = raycastModels.map((model) => ({ - id: model.id, - name: model.name || model.id, - owned_by: model.provider || provider, - ...(model.requires_better_ai ? { premium: true } : {}), - ...(model.availability ? { availability: model.availability } : {}), - })); - return buildApiDiscoveryResponse(models); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - console.log("[models] raycast fetch failed:", message); - const fallback = buildDiscoveryFallbackResponse({ - cacheWarning: `Raycast API unavailable (${message}) — using cached catalog`, - localWarning: `Raycast API unavailable (${message}) — using local catalog`, - }); - if (fallback) return fallback; - return NextResponse.json( - { error: `Failed to fetch Raycast models: ${message}` }, - { status: 502 } - ); - } - } - if (provider === "cursor") { const cachedResponse = maybeReturnCachedDiscovery(); if (cachedResponse) return cachedResponse; @@ -1906,8 +1852,7 @@ export async function GET( // ponytail: Anthropic partner models via Model Garden publisher endpoint (Bearer only) if (bearerToken) { const psd = asRecord(connection.providerSpecificData); - const region = - (typeof psd.region === "string" && psd.region.trim()) || "us-central1"; + const region = (typeof psd.region === "string" && psd.region.trim()) || "us-central1"; // Extract project_id from SA JSON for project-scoped listing (mirrors executor URL pattern). // Falls back to global publisher endpoint if no project available. @@ -1917,7 +1862,9 @@ export async function GET( try { const sa = JSON.parse(credential); if (sa?.project_id) projectId = sa.project_id; - } catch { /* not SA JSON, skip */ } + } catch { + /* not SA JSON, skip */ + } } if (projectId) { anthropicModelsUrl = `https://aiplatform.googleapis.com/v1/projects/${projectId}/locations/${region}/publishers/anthropic/models`; @@ -1938,9 +1885,8 @@ export async function GET( }); if (anthropicResponse.ok) { const anthropicData = await anthropicResponse.json(); - const { parseVertexAnthropicModels } = await import( - "@/lib/providerModels/vertexAnthropicModelsParser" - ); + const { parseVertexAnthropicModels } = + await import("@/lib/providerModels/vertexAnthropicModelsParser"); allModels.push(...parseVertexAnthropicModels(anthropicData)); } else { console.log("[models] Vertex Anthropic partner discovery failed", { diff --git a/src/app/api/providers/[id]/test/oauthTestConfig.ts b/src/app/api/providers/[id]/test/oauthTestConfig.ts index 754003e44b..354c4adef3 100644 --- a/src/app/api/providers/[id]/test/oauthTestConfig.ts +++ b/src/app/api/providers/[id]/test/oauthTestConfig.ts @@ -205,14 +205,6 @@ export const OAUTH_TEST_CONFIG: Record = { // Validate using token presence/expiry as a lightweight auth check. checkExpiry: true, }, - raycast: { - // #8895 — Raycast Pro is an `import_token` provider: the token is imported - // from the local Raycast install, `refreshToken` is always null and the - // stored `expiresIn` defaults to 30 days. There is nothing to refresh, so - // the test is the expiry check on the imported token; without an entry here - // Test Connection persists testStatus="error" on a healthy account (#8408). - checkExpiry: true, - }, "zed-hosted": { // Zed Hosted Models uses a long-lived native-app access token with no // expiry or refresh token. Validate presence here; real connectivity is diff --git a/src/app/api/providers/[id]/test/webSessionTestDispatch.ts b/src/app/api/providers/[id]/test/webSessionTestDispatch.ts index 5e472237f3..53b77eea32 100644 --- a/src/app/api/providers/[id]/test/webSessionTestDispatch.ts +++ b/src/app/api/providers/[id]/test/webSessionTestDispatch.ts @@ -8,7 +8,7 @@ import { getWebSessionCredentialRequirement } from "@/shared/providers/webSessio * `validateWebCookieProvider` probe (`src/lib/providers/validation/webCookie.ts`). * * `WEB_SESSION_CREDENTIAL_REQUIREMENTS` currently marks more providers as `kind: "token"` - * than have a matching validator (e.g. hailuo-web, t3-chat-web, promptql). Those fall + * than have a matching validator (e.g. t3-chat-web, promptql). Those fall * through to `validateWebCookieProvider`'s generic probe, which * sends the stored credential as a `Cookie` header and treats most non-401/403 responses * as valid — the wrong wire format for a token-authenticated provider, so an invalid diff --git a/src/lib/db/migrations/166_retire_gpl_derived_providers.sql b/src/lib/db/migrations/166_retire_gpl_derived_providers.sql new file mode 100644 index 0000000000..cbe167c8a8 --- /dev/null +++ b/src/lib/db/migrations/166_retire_gpl_derived_providers.sql @@ -0,0 +1,32 @@ +-- Retire the Raycast Relay and Hailuo Web integrations whose distributed +-- implementations were substantially derived from GPL-3.0 sources. +-- +-- Keep connection rows and historical records for auditability. Disabling the +-- connections is deliberately fail-closed: API-key allowed_connections entries +-- continue to reference the same connection ids instead of becoming an empty +-- allowlist, which would mean unrestricted access in the policy layer. + +UPDATE exclusive_connection_leases +SET state = 'INVALIDATED', + ended_at = COALESCE(ended_at, datetime('now')), + end_reason = COALESCE(end_reason, 'provider integration retired in v3.8.51') +WHERE state = 'ACTIVE' + AND ( + provider IN ('raycast', 'rc', 'hailuo-web') + OR connection_id IN ( + SELECT id + FROM provider_connections + WHERE provider IN ('raycast', 'rc', 'hailuo-web') + ) + ); + +UPDATE provider_connections +SET is_active = 0, + test_status = 'unavailable', + error_code = 'PROVIDER_REMOVED', + last_error = 'Provider integration retired from OmniRoute v3.8.51', + last_error_type = 'provider_removed', + last_error_source = 'migration:166', + last_error_at = COALESCE(last_error_at, datetime('now')), + updated_at = datetime('now') +WHERE provider IN ('raycast', 'rc', 'hailuo-web'); diff --git a/src/lib/db/webSessionDedup.ts b/src/lib/db/webSessionDedup.ts index b68ee00122..7983c2686f 100644 --- a/src/lib/db/webSessionDedup.ts +++ b/src/lib/db/webSessionDedup.ts @@ -78,7 +78,7 @@ function fieldMatch(incoming: string | null, existing: string | null): boolean | * and `incomingProfileArn` (#10815). * * Two independent disambiguators, either of which can prove "different - * account": `providerSpecificData.username` (Raycast-style IdP dedup) and + * account": `providerSpecificData.username` (generic username/IdP fallback) and * `providerSpecificData.profileArn` (Kiro/AWS profile dedup — Kiro never * sets `username`). A field only rules a match IN/OUT when both the * incoming and existing record carry it; when neither carries either field diff --git a/src/lib/oauth/constants/oauth.ts b/src/lib/oauth/constants/oauth.ts index 0318db40b3..0b8a65a75c 100644 --- a/src/lib/oauth/constants/oauth.ts +++ b/src/lib/oauth/constants/oauth.ts @@ -435,17 +435,6 @@ export const TRAE_CONFIG = { "Authorize via trae.ai in the popup, or sign in to solo.trae.ai and paste the Cloud-IDE-JWT from the Authorization header (~14-day lifetime).", }; -// Raycast Pro AI — reverse-engineered, unofficial API. LOCAL / PERSONAL USE ONLY. -// See docs/security/PUBLIC_CREDS.md pattern: no secrets in repo; credentials from user's Mac. -export const RAYCAST_CONFIG = { - apiEndpoint: "https://backend.raycast.com", - chatEndpoint: "/api/v1/ai/chat_completions", - modelsEndpoint: "/api/v1/ai/models", - clientType: "macos-app", - captureInstructions: - "macOS only: use Auto-Import (Keychain + Raycast DB) or capture Bearer, X-Raycast-DeviceId, and optional X-Raycast-Signature JWT from backend.raycast.com traffic.", -}; - // Devin Desktop / Devin CLI import-token configuration. // Public product identity is Devin. The upstream transport still identifies // the IDE as `windsurf`; authentication itself is import-only. @@ -509,10 +498,6 @@ export const PROVIDERS = { KIRO: "kiro", AMAZON_Q: "amazon-q", CURSOR: "cursor", - // #8895 — registered in src/lib/oauth/providers/index.ts but missing here, so - // every consumer reading PROVIDERS (onboarding wizard, test-connection routing) - // did not know Raycast Pro exists as an OAuth provider. - RAYCAST: "raycast", KILOCODE: "kilocode", CLINE: "cline", CLINEPASS: "clinepass", diff --git a/src/lib/oauth/providers/index.ts b/src/lib/oauth/providers/index.ts index 596890c6bd..cfd7ced721 100644 --- a/src/lib/oauth/providers/index.ts +++ b/src/lib/oauth/providers/index.ts @@ -22,7 +22,6 @@ import { gitlabDuo } from "./gitlab-duo"; import { kiro } from "./kiro"; import { cursor } from "./cursor"; import { trae } from "./trae"; -import { raycast } from "./raycast"; import { kilocode } from "./kilocode"; import { cline } from "./cline"; import { devinDesktop } from "./devin-desktop"; @@ -47,7 +46,6 @@ export const PROVIDERS = { "amazon-q": kiro, cursor, trae, - raycast, kilocode, cline, // clinepass reuses the Cline WorkOS OAuth flow 1:1 (same api.cline.bot host, same token diff --git a/src/lib/oauth/providers/raycast.ts b/src/lib/oauth/providers/raycast.ts deleted file mode 100644 index 85f15bd35c..0000000000 --- a/src/lib/oauth/providers/raycast.ts +++ /dev/null @@ -1,37 +0,0 @@ -/** - * @file raycast.ts - * @description Raycast Pro token-import OAuth provider (reverse-engineered, local dev only). - * - * @changes - * - [2026-07-27] [Composer] - Initial Raycast Pro import_token provider - */ - -import { RAYCAST_CONFIG } from "../constants/oauth"; - -type RaycastRawTokens = { - accessToken?: string; - access_token?: string; - deviceId?: string; - device_id?: string; - aid?: string; - sigSecret?: string; - signatureSecret?: string; - signatureJwt?: string; - expiresIn?: number; -}; - -export const raycast = { - config: RAYCAST_CONFIG, - flowType: "import_token", - mapTokens: (tokens: RaycastRawTokens) => ({ - accessToken: tokens.accessToken || tokens.access_token, - refreshToken: null, - expiresIn: tokens.expiresIn || 30 * 24 * 60 * 60, - providerSpecificData: { - deviceId: tokens.deviceId || tokens.device_id || "", - aid: tokens.aid || "", - sigSecret: tokens.sigSecret || tokens.signatureSecret || "", - authMethod: "imported", - }, - }), -}; diff --git a/src/lib/oauth/services/raycast.ts b/src/lib/oauth/services/raycast.ts deleted file mode 100644 index cadd9d82a8..0000000000 --- a/src/lib/oauth/services/raycast.ts +++ /dev/null @@ -1,65 +0,0 @@ -/** - * @file raycast.ts - * @description Raycast Pro credential validation via live models API probe. - * - * @changes - * - [2026-07-27] [Composer] - Initial Raycast import validation service - */ - -import { - decodeAidFromRaycastJwt, - fetchRaycastModels, - type RaycastModelEntry, -} from "@omniroute/open-sse/services/raycast.ts"; - -export class RaycastService { - validateCredentials(input: { - accessToken: string; - deviceId: string; - aid?: string; - signatureJwt?: string; - sigSecret?: string; - }): { aid: string } { - const accessToken = input.accessToken.trim(); - const deviceId = input.deviceId.trim(); - let aid = (input.aid || "").trim(); - - if (!aid && input.signatureJwt?.trim()) { - aid = decodeAidFromRaycastJwt(input.signatureJwt.trim()) || ""; - } - - if (!accessToken) throw new Error("Bearer token is required"); - if (!deviceId) throw new Error("Device ID is required"); - - // AID is optional for current Raycast API — fall back to deviceId when not captured manually. - if (!aid) aid = deviceId; - - return { aid }; - } - - async probeModels(credentials: { - accessToken: string; - deviceId: string; - aid: string; - sigSecret?: string; - }): Promise { - return fetchRaycastModels({ - accessToken: credentials.accessToken, - providerSpecificData: { - deviceId: credentials.deviceId, - aid: credentials.aid, - sigSecret: credentials.sigSecret, - }, - }); - } - - getCaptureInstructions(): string[] { - return [ - "Easiest: click Auto-Import (macOS) — reads Keychain + local Raycast DB.", - "Manual fallback: Proxyman/Charles SSL proxy on backend.raycast.com.", - "Bearer token lives in Keychain: Raycast / raycast-store_credentials.", - "Device ID = analyticsId in ~/Library/Application Support/com.raycast.macos/posthog.distinctId.", - "Signature JWT is optional with current Raycast builds.", - ]; - } -} diff --git a/src/lib/oauth/services/raycastLocal.ts b/src/lib/oauth/services/raycastLocal.ts deleted file mode 100644 index 976a023a39..0000000000 --- a/src/lib/oauth/services/raycastLocal.ts +++ /dev/null @@ -1,198 +0,0 @@ -/** - * @file raycastLocal.ts - * @description Extract Raycast Pro credentials from local macOS install (Keychain + encrypted DB). - * - * @changes - * - [2026-07-27] [Composer] - Auto-extract bearer token and device ID from local Raycast - */ - -import { execFileSync } from "node:child_process"; -import { createHash } from "node:crypto"; -import { copyFileSync, existsSync, mkdtempSync, readFileSync, rmdirSync, unlinkSync } from "node:fs"; -import { homedir, tmpdir } from "node:os"; -import { join } from "node:path"; - -const RAYCAST_SALT = "yvkwWXzxPPBAqY2tmaKrB*DvYjjMaeEf"; -const RAYCAST_SUPPORT = join(homedir(), "Library", "Application Support", "com.raycast.macos"); -const RAYCAST_DB = join(RAYCAST_SUPPORT, "raycast-enc.sqlite"); -const POSTHOG_DISTINCT = join(RAYCAST_SUPPORT, "posthog.distinctId"); - -export type RaycastLocalCredentials = { - accessToken: string; - deviceId: string; - aid: string; - email?: string; - username?: string; - hasProFeatures?: boolean; - hasBetterAI?: boolean; - source: "keychain+analyticsId" | "keychain+posthog"; -}; - -function readKeychainJson(account: string): Record | null { - try { - const raw = execFileSync( - "security", - ["find-generic-password", "-s", "Raycast", "-a", account, "-w"], - { encoding: "utf-8" } - ).trim(); - return JSON.parse(raw) as Record; - } catch { - return null; - } -} - -function getDatabasePassphrase(): string { - const keyHex = execFileSync( - "security", - ["find-generic-password", "-s", "Raycast", "-a", "database_key", "-w"], - { encoding: "utf-8" } - ).trim(); - return createHash("sha256") - .update(keyHex + RAYCAST_SALT) - .digest("hex"); -} - -function queryEncryptedDb(passphrase: string, sql: string): unknown[] { - if (!existsSync(RAYCAST_DB)) return []; - - const tmpDir = mkdtempSync(join(tmpdir(), "omniroute-raycast-")); - const tmpDb = join(tmpDir, "raycast-enc.sqlite"); - - const cleanup = () => { - for (const ext of ["", "-wal", "-shm"]) { - try { - unlinkSync(tmpDb + ext); - } catch { - // ignore - } - } - try { - rmdirSync(tmpDir); - } catch { - // ignore - } - }; - - try { - copyFileSync(RAYCAST_DB, tmpDb); - for (const ext of ["-wal", "-shm"]) { - const src = RAYCAST_DB + ext; - if (existsSync(src)) copyFileSync(src, tmpDb + ext); - } - - const input = `PRAGMA key = '${passphrase}';\n.mode json\n${sql}`; - const result = execFileSync("sqlcipher", [tmpDb], { input, encoding: "utf-8" }); - const jsonStr = result.startsWith("ok\n") ? result.slice(3) : result; - return JSON.parse(jsonStr.trim() || "[]") as unknown[]; - } catch { - return []; - } finally { - cleanup(); - } -} - -function readAnalyticsIdFromDb(): string | null { - try { - const passphrase = getDatabasePassphrase(); - const rows = queryEncryptedDb( - passphrase, - "SELECT analyticsId FROM user WHERE analyticsId IS NOT NULL LIMIT 1;" - ) as Array<{ analyticsId?: string }>; - const id = rows[0]?.analyticsId?.trim(); - return id || null; - } catch { - return null; - } -} - -function readAnalyticsIdFromPosthog(): string | null { - try { - if (!existsSync(POSTHOG_DISTINCT)) return null; - const parsed = JSON.parse(readFileSync(POSTHOG_DISTINCT, "utf-8")) as { - "posthog.distinctId"?: string; - }; - const id = parsed["posthog.distinctId"]?.trim(); - return id || null; - } catch { - return null; - } -} - -function readUserProfile(): { email?: string; username?: string; hasProFeatures?: boolean; hasBetterAI?: boolean } { - try { - const passphrase = getDatabasePassphrase(); - const rows = queryEncryptedDb( - passphrase, - "SELECT email, username, hasProFeatures, hasBetterAI FROM user LIMIT 1;" - ) as Array<{ - email?: string; - username?: string; - hasProFeatures?: number; - hasBetterAI?: number; - }>; - const row = rows[0]; - if (!row) return {}; - return { - email: row.email, - username: row.username, - hasProFeatures: !!row.hasProFeatures, - hasBetterAI: !!row.hasBetterAI, - }; - } catch { - return {}; - } -} - -export function isRaycastLocalExtractAvailable(): boolean { - if (process.platform !== "darwin") return false; - try { - execFileSync("which", ["sqlcipher"], { encoding: "utf-8" }); - } catch { - return false; - } - return existsSync(RAYCAST_DB) || existsSync(POSTHOG_DISTINCT); -} - -/** - * Pull Raycast Pro credentials from the local macOS install. - * Bearer token: Keychain entry `raycast-store_credentials` → oauth.access_token - * Device ID: user.analyticsId (same as posthog.distinctId) - */ -export function extractLocalRaycastCredentials(): RaycastLocalCredentials { - if (process.platform !== "darwin") { - throw new Error("Raycast auto-import is macOS-only"); - } - - const store = readKeychainJson("raycast-store_credentials"); - const oauth = (store?.oauth || {}) as { access_token?: string }; - const accessToken = oauth.access_token?.trim(); - if (!accessToken) { - throw new Error( - "Raycast bearer token not found in Keychain — open Raycast and sign in first" - ); - } - - const analyticsFromDb = readAnalyticsIdFromDb(); - const analyticsFromPosthog = readAnalyticsIdFromPosthog(); - const deviceId = analyticsFromDb || analyticsFromPosthog; - if (!deviceId) { - throw new Error( - "Raycast device/analytics ID not found — launch Raycast once so it writes local state" - ); - } - - const profile = readUserProfile(); - const user = (store?.user || {}) as { email?: string; username?: string }; - - return { - accessToken, - deviceId, - // V1 JWT aid — chat works without a captured signature JWT; deviceId is a stable fallback. - aid: deviceId, - email: profile.email || user.email, - username: profile.username || user.username, - hasProFeatures: profile.hasProFeatures, - hasBetterAI: profile.hasBetterAI, - source: analyticsFromDb ? "keychain+analyticsId" : "keychain+posthog", - }; -} diff --git a/src/server/authz/routeGuard.ts b/src/server/authz/routeGuard.ts index ee5b5232c5..60a8d8e15a 100644 --- a/src/server/authz/routeGuard.ts +++ b/src/server/authz/routeGuard.ts @@ -65,7 +65,6 @@ export const LOCAL_ONLY_API_PREFIXES: ReadonlyArray = [ "/api/jobs/", // sub-paths: /api/jobs/:id/{runs,enable,disable,run-now} (the bare `/api/jobs` above matches the list route; this matches children) "/api/oauth/cursor/auto-import", // spawns execFile("which", argv-array-of-one-arg "cursor") to verify a local Cursor install before importing creds — RCE-via-tunnel surface (Hard Rules #15 + #17, found by 6A.8 route-guard gate). Specific path only: the rest of /api/oauth/ (browser redirect/callback flows) must stay remote-reachable. Note: this comment intentionally avoids a literal closing square bracket character — check-openapi-security-tiers.mjs's naive regex parser for this array stops at the first one it finds, silently truncating its view of every entry after this one. "/api/oauth/kiro/auto-import", // reads host-local Kiro credential files (homedir kiro-cli data) — must reach the loopback-only gate, not the PUBLIC /api/oauth/ prefix (GHSA-wgwc-crjm-pmwv, GHSA-gxv4-955v-v6cm). Excluded from PUBLIC in publicApiRoutes.ts. - "/api/oauth/raycast/auto-import", // reads host-local Raycast credential files — same loopback-only rationale as the kiro and cursor auto-import routes above. "/api/skills/collect/", // Skill Collector CLI detection: GET .../detect probes getCliRuntimeStatus() per CLI_TOOL_IDS entry, which spawns a child process to check each tool — RCE-via-tunnel surface (Hard Rules #15 + #17, PR #6294 review). "/api/discovery/", // Discovery tool (opt-in provider scanner): the scan route makes outbound probes to provider endpoints (SSRF-adjacent) and the whole surface is an admin research tool — strict-loopback only, no manage-scope bypass (NOT in LOCAL_ONLY_MANAGE_SCOPE_BYPASS_PREFIXES). See _tasks/features-v3.8.42/gaps/DISCOVERY_TOOL_DESIGN.md. VNC_ROUTE_PREFIX, // #7892: /api/vnc-session/* spawns Docker containers via child_process.spawn (src/lib/vncSession/service.ts) — RCE-via-tunnel surface (Hard Rules #15 + #17), same CVE class (GHSA-fhh6-4qxv-rpqj). diff --git a/src/shared/components/RaycastAuthModal.tsx b/src/shared/components/RaycastAuthModal.tsx deleted file mode 100644 index 7d1461294f..0000000000 --- a/src/shared/components/RaycastAuthModal.tsx +++ /dev/null @@ -1,213 +0,0 @@ -"use client"; - -/** - * @file RaycastAuthModal.tsx - * @description Import Raycast Pro AI credentials (auto-detect from local macOS install). - * - * @changes - * - [2026-07-27] [Composer] - Add one-click auto-import from Keychain + Raycast DB - */ - -import { useEffect, useState } from "react"; -import Modal from "./Modal"; -import Button from "./Button"; -import Input from "./Input"; - -type RaycastAuthModalProps = { - isOpen: boolean; - reauthConnection?: unknown; - onSuccess?: () => void; - onClose: () => void; -}; - -export default function RaycastAuthModal({ - isOpen, - onSuccess, - onClose, -}: RaycastAuthModalProps) { - const [accessToken, setAccessToken] = useState(""); - const [deviceId, setDeviceId] = useState(""); - const [signatureJwt, setSignatureJwt] = useState(""); - const [sigSecret, setSigSecret] = useState(""); - const [importing, setImporting] = useState(false); - const [autoAvailable, setAutoAvailable] = useState(false); - const [showManual, setShowManual] = useState(false); - const [error, setError] = useState(null); - - useEffect(() => { - if (!isOpen) return; - fetch("/api/oauth/raycast/auto-import") - .then((r) => r.json()) - .then((d) => setAutoAvailable(!!d.available)) - .catch(() => setAutoAvailable(false)); - }, [isOpen]); - - const handleAutoImport = async () => { - setImporting(true); - setError(null); - try { - const res = await fetch("/api/oauth/raycast/auto-import", { method: "POST" }); - const data = await res.json(); - if (!res.ok) { - throw new Error( - typeof data.error === "string" ? data.error : data.error?.message || "Auto-import failed" - ); - } - onSuccess?.(); - onClose(); - } catch (err) { - setError(err instanceof Error ? err.message : String(err)); - } finally { - setImporting(false); - } - }; - - const handleImportToken = async () => { - if (!accessToken.trim() || !deviceId.trim()) { - setError("Bearer token and device ID are required."); - return; - } - - setImporting(true); - setError(null); - - try { - const body: Record = { - accessToken: accessToken.trim(), - deviceId: deviceId.trim(), - }; - if (signatureJwt.trim()) body.signatureJwt = signatureJwt.trim(); - if (sigSecret.trim()) body.sigSecret = sigSecret.trim(); - - const res = await fetch("/api/oauth/raycast/import", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), - }); - const data = await res.json(); - if (!res.ok) { - throw new Error( - typeof data.error === "string" ? data.error : data.error?.message || "Import failed" - ); - } - - onSuccess?.(); - onClose(); - } catch (err) { - setError(err instanceof Error ? err.message : String(err)); - } finally { - setImporting(false); - } - }; - - return ( - -
-
-

- Auto-import (recommended on macOS): reads your local Raycast login from - Keychain + analytics device ID. No proxy needed. -

- - {!autoAvailable && ( -

- Install sqlcipher: brew install sqlcipher -

- )} -
- -
-

- Local dev only. Uses your Raycast Pro subscription via reverse-engineered - API. Not official — may break on Raycast updates. -

- -
- - {showManual && ( - <> -
- -