Compare commits

...

6 Commits

Author SHA1 Message Date
Markus Hartung
7ab7fa0989 chore(quality): rebaseline file-size cap for combined retirement guard growth 2026-08-28 05:16:27 -03:00
Markus Hartung
d130ef6e9e fix(test): recompute RESERVED_PREFIX_COUNT after #11691 GPL-derived removal 2026-08-28 05:13:56 -03:00
Markus Hartung
892545a1e3 merge #11691 2026-08-28 05:12:35 -03:00
Markus Hartung
5900c47644 fix(providers): renumber GPL-derived retirement migration 163→166 and sync error-source label for v3.8.51
- src/lib/db/migrations: 163 collided with radar_feed_cache_generated_at
  and the two other provenance-sweep retirement migrations already in
  this batch (164 Designer, 165 Felo); renumbered to 166.
- Updated the migration's own 'migration:163' error-source literal and
  the 'retired in v3.8.50' text to match (166 / v3.8.51).
- docs/reference/PROVIDER_REFERENCE.md: regenerated via
  npm run gen:provider-reference.
2026-08-28 01:40:30 -03:00
Xiangzhe
3e6e21683b docs(changelog): bind provider retirement fragment to PR 11691 2026-08-28 01:37:57 -03:00
Xiangzhe
cd701c1eed chore(providers): retire GPL-derived Raycast and Hailuo ports 2026-08-28 01:37:56 -03:00
62 changed files with 399 additions and 2952 deletions

View File

@@ -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
# ─────────────────────────────────────────────────────────────────────────────

View File

@@ -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.

View File

@@ -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

View File

@@ -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
},

View File

@@ -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."

View File

@@ -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)

View File

@@ -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` |

View File

@@ -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` |

View File

@@ -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 | ✅ | ✅ | ❌ | ❌ |

View File

@@ -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 | ✅ | ✅ | ❌ | ❌ |

View File

@@ -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` | 服务商模型 | 服务商模型元数据,支撑自定义和管理可用模型 |
## 旁路处理器

View File

@@ -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 | OAuthGitLab | ✅ | ✅ | ✅ | ❌ |
| Devin CLI | openai | 本機 CLI 登入 | ✅ | ✅ | ❌ | ✅ 任務 API |
| Devin CLI | openai | 本機 CLI 登入 | ✅ | ✅ | ❌ | ✅ 任務 API |
| Codex Cloud | openai-responses | OAuth | ✅ | ❌ | ✅ | ✅ 速率限制 |
| Jules | openai | OAuth | ✅ | ✅ | ✅ | ✅ 任務 API |
| AgentRouter | openai | API 金鑰 | ✅ | ✅ | ❌ | ❌ |

View File

@@ -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]
>

View File

@@ -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<string, RegistryEntry> = {
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<string, RegistryEntry> = {
"perplexity-web": perplexity_webProvider,
minimax: minimaxProvider,
"minimax-cn": minimax_cnProvider,
"hailuo-web": hailuo_webProvider,
haiper: haiperProvider,
bytez: bytezProvider,
blackbox: blackboxProvider,

View File

@@ -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,
};

View File

@@ -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" },
],
};

View File

@@ -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?<fingerprint query>
* 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<string, unknown>;
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<string, unknown>;
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<string, string> {
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<string, string>; 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<string, string>,
form: FormData,
signal: AbortSignal | null | undefined,
body: unknown,
bodyObj: JsonRecord
): Promise<{ upstream: Response } | { errorResult: ReturnType<typeof makeErrorResult> }> {
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<string, string>,
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);
}
}

View File

@@ -172,9 +172,6 @@ const lazyExecutors: Record<string, () => Promise<BaseExecutor>> = {
"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()),

View File

@@ -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<string, unknown>;
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<string, string> {
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<string, string> | 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,
};
}
}

View File

@@ -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, unknown>): 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<string, string> {
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<RaycastModelEntry[]> {
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;
});
}

View File

@@ -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;

View File

@@ -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));

View File

@@ -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);
});

View File

@@ -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" ? (
<RaycastAuthModal
isOpen={showOAuthModal}
reauthConnection={reauthConnection}
onSuccess={handleOAuthSuccess}
onClose={() => setShowOAuthModal(false)}
/>
) : (
<OAuthModal
isOpen={showOAuthModal}

View File

@@ -1,124 +0,0 @@
/**
* @file route.ts
* @description Auto-import Raycast Pro credentials from local macOS Keychain + DB.
*
* @changes
* - [2026-07-27] [Composer] - One-click local Raycast credential extraction
*/
import { NextResponse } from "next/server";
import { createProviderConnection } from "@/models";
import { replaceSyncedAvailableModelsForConnection } from "@/lib/db/models";
import { RaycastService } from "@/lib/oauth/services/raycast";
import {
extractLocalRaycastCredentials,
isRaycastLocalExtractAvailable,
} from "@/lib/oauth/services/raycastLocal";
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 GET(request: Request) {
const authResponse = await requireOAuthImportAuth(request);
if (authResponse) return authResponse;
return NextResponse.json({
available: isRaycastLocalExtractAvailable(),
platform: process.platform,
requires: ["macOS", "Raycast.app installed", "sqlcipher CLI (brew install sqlcipher)"],
sources: {
bearerToken: "Keychain → Raycast / raycast-store_credentials → oauth.access_token",
deviceId:
"Raycast encrypted DB user.analyticsId (same as posthog.distinctId on disk)",
},
});
}
export async function POST(request: Request) {
const authResponse = await requireOAuthImportAuth(request);
if (authResponse) return authResponse;
if (!isRaycastLocalExtractAvailable()) {
return NextResponse.json(
{
error:
"Raycast auto-import unavailable — need macOS, Raycast installed, and sqlcipher (`brew install sqlcipher`)",
},
{ status: 400 }
);
}
try {
const local = extractLocalRaycastCredentials();
const raycastService = new RaycastService();
const resolved = raycastService.validateCredentials({
accessToken: local.accessToken,
deviceId: local.deviceId,
aid: local.aid,
});
const proxy = await resolveProxyForProvider("raycast");
const models = await runWithProxyContext(proxy, () =>
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 });
}
}

View File

@@ -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",
},
],
});
}

View File

@@ -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", {

View File

@@ -205,14 +205,6 @@ export const OAUTH_TEST_CONFIG: Record<string, OAuthTestConfigEntry> = {
// 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

View File

@@ -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

View File

@@ -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');

View File

@@ -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

View File

@@ -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",

View File

@@ -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

View File

@@ -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",
},
}),
};

View File

@@ -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<RaycastModelEntry[]> {
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.",
];
}
}

View File

@@ -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<string, unknown> | null {
try {
const raw = execFileSync(
"security",
["find-generic-password", "-s", "Raycast", "-a", account, "-w"],
{ encoding: "utf-8" }
).trim();
return JSON.parse(raw) as Record<string, unknown>;
} 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",
};
}

View File

@@ -65,7 +65,6 @@ export const LOCAL_ONLY_API_PREFIXES: ReadonlyArray<string> = [
"/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).

View File

@@ -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<string | null>(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<string, string> = {
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 (
<Modal isOpen={isOpen} title="Connect Raycast Pro AI" onClose={onClose}>
<div className="flex flex-col gap-4">
<div className="bg-emerald-50 dark:bg-emerald-900/20 p-4 rounded-lg border border-emerald-200 dark:border-emerald-800">
<p className="text-sm text-emerald-900 dark:text-emerald-200 mb-3">
<strong>Auto-import (recommended on macOS):</strong> reads your local Raycast login from
Keychain + analytics device ID. No proxy needed.
</p>
<Button onClick={handleAutoImport} fullWidth disabled={importing || !autoAvailable}>
{importing
? "Importing…"
: autoAvailable
? "Auto-Import from Local Raycast"
: "Auto-Import unavailable (need macOS + sqlcipher)"}
</Button>
{!autoAvailable && (
<p className="text-xs text-emerald-800 dark:text-emerald-300 mt-2">
Install sqlcipher: <span className="font-mono">brew install sqlcipher</span>
</p>
)}
</div>
<div className="bg-amber-50 dark:bg-amber-900/20 p-4 rounded-lg border border-amber-200 dark:border-amber-800">
<p className="text-sm text-amber-900 dark:text-amber-200">
<strong>Local dev only.</strong> Uses your Raycast Pro subscription via reverse-engineered
API. Not official may break on Raycast updates.
</p>
<button
type="button"
className="text-xs text-amber-800 dark:text-amber-300 underline mt-2"
onClick={() => setShowManual((v) => !v)}
>
{showManual ? "Hide manual import" : "Manual import (proxy capture)"}
</button>
</div>
{showManual && (
<>
<div>
<label className="block text-sm font-medium mb-2">
Bearer Token <span className="text-red-500">*</span>
</label>
<textarea
value={accessToken}
onChange={(e) => setAccessToken(e.target.value)}
placeholder="rca_..."
rows={3}
className="w-full px-3 py-2 text-sm font-mono border border-border rounded-lg bg-background focus:outline-none focus:border-primary resize-none"
/>
</div>
<div>
<label className="block text-sm font-medium mb-2">
Device ID <span className="text-red-500">*</span>
</label>
<Input
value={deviceId}
onChange={(e) => setDeviceId(e.target.value)}
placeholder="analyticsId / X-Raycast-DeviceId"
className="font-mono text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium mb-2">
Signature JWT <span className="text-text-muted text-xs">optional</span>
</label>
<textarea
value={signatureJwt}
onChange={(e) => setSignatureJwt(e.target.value)}
placeholder="X-Raycast-Signature (optional on current builds)"
rows={2}
className="w-full px-3 py-2 text-sm font-mono border border-border rounded-lg bg-background focus:outline-none focus:border-primary resize-none"
/>
</div>
<div>
<label className="block text-sm font-medium mb-2">
SIG_SECRET <span className="text-text-muted text-xs">optional</span>
</label>
<Input
value={sigSecret}
onChange={(e) => setSigSecret(e.target.value)}
placeholder="Override if Raycast rotated signing key"
className="font-mono text-sm"
/>
</div>
<Button
onClick={handleImportToken}
fullWidth
disabled={importing || !accessToken.trim() || !deviceId.trim()}
>
{importing ? "Importing…" : "Import Manually"}
</Button>
</>
)}
{error && (
<div className="bg-red-50 dark:bg-red-900/20 p-3 rounded-lg border border-red-200 dark:border-red-800">
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
</div>
)}
<Button onClick={onClose} variant="ghost" fullWidth>
Cancel
</Button>
</div>
</Modal>
);
}

View File

@@ -32,7 +32,6 @@ export { default as KiroOAuthWrapper } from "./KiroOAuthWrapper";
export { default as KiroSocialOAuthModal } from "./KiroSocialOAuthModal";
export { default as CursorAuthModal } from "./CursorAuthModal";
export { default as TraeAuthModal } from "./TraeAuthModal";
export { default as RaycastAuthModal } from "./RaycastAuthModal";
export { default as SegmentedControl } from "./SegmentedControl";
export { default as Breadcrumbs } from "./Breadcrumbs";
export { default as EmptyState } from "./EmptyState";

View File

@@ -75,7 +75,6 @@ export function getProviderConnectionFamilyIds(providerId: unknown): readonly st
// Web / Cookie Providers
// API Key Providers
// Sub-categories within APIKEY_PROVIDERS (used by dashboard and catalog views).
@@ -145,7 +144,6 @@ export const AGGREGATOR_PROVIDER_IDS = new Set([
"helixmind",
"tabitoken",
"logfare",
]);
export const ENTERPRISE_CLOUD_PROVIDER_IDS = new Set([
@@ -182,7 +180,7 @@ export const VIDEO_PROVIDER_IDS = new Set([
// IDE Providers: editors with built-in AI subscription (separate section in UI).
// These providers live in OAUTH_PROVIDERS but render under "IDE Providers"
// instead of "OAuth Providers" to avoid visual duplication.
export const IDE_PROVIDER_IDS = new Set(["cursor", "zed", "trae", "raycast"]);
export const IDE_PROVIDER_IDS = new Set(["cursor", "zed", "trae"]);
export const EMBEDDING_RERANK_PROVIDER_IDS = new Set(["voyage-ai", "jina-ai"]);

View File

@@ -187,19 +187,6 @@ export const OAUTH_PROVIDERS = {
authHint:
"Trae is an AI-native IDE by ByteDance (SOLO remote agent). Authorize via trae.ai in the popup, or sign in at solo.trae.ai and paste the Cloud-IDE-JWT (sent as 'Authorization: Cloud-IDE-JWT <token>', ~14-day lifetime) as the access token; web_id/biz_user_id/user_unique_id/scope/tenant/region propagate via providerSpecificData. No headless refresh for pasted tokens — re-paste on expiry.",
},
raycast: {
id: "raycast",
alias: "rc",
name: "Raycast Pro AI",
icon: "terminal",
color: "#FF6363",
textIcon: "RC",
website: "https://raycast.com/ai",
subscriptionRisk: true,
riskNoticeVariant: "oauth",
authHint:
"Unofficial integration — uses your Raycast Pro subscription via credentials from the macOS app (Auto-Import or manual capture). May break on Raycast updates. Not for redistribution; personal use only.",
},
"kimi-coding": {
id: "kimi-coding",
alias: "kmc",

View File

@@ -330,23 +330,6 @@ export const WEB_COOKIE_PROVIDERS = {
subscriptionRisk: true,
riskNoticeVariant: "webCookie",
},
"hailuo-web": {
id: "hailuo-web",
// Distinct alias: avoid colliding with the existing API-key "minimax"/
// "minimax-cn" providers (src/shared/constants/providers/apikey/regional.ts).
alias: "hailuo-web",
name: "Hailuo Web (MiniMax)",
icon: "auto_awesome",
color: "#5B21B6",
textIcon: "HL",
website: "https://chat.minimax.io",
authHint:
"Open hailuo.ai, log in, then open DevTools → Application → Local Storage → copy the " +
'"_token" value. device_id/uuid fingerprint fields are derived automatically; if ' +
"requests fail, re-capture _token (sessions can expire).",
subscriptionRisk: true,
riskNoticeVariant: "webCookie",
},
"qwen-web": {
id: "qwen-web",
// The web variant uses its own id; the retired `qw` alias is not reassigned.

View File

@@ -102,15 +102,14 @@ function isPublicCloudApiRoute(pathname: string, method: string): boolean {
);
}
// OAuth "auto-import" routes read host-local credential files (Cursor / Kiro /
// Raycast tokens). The broad `/api/oauth/` PUBLIC prefix would classify them
// OAuth "auto-import" routes read host-local credential files (Cursor / Kiro
// tokens). The broad `/api/oauth/` PUBLIC prefix would classify them
// PUBLIC, which skips the LOCAL_ONLY tier entirely (GHSA-wgwc-crjm-pmwv) and
// exposes the host credential to a remote caller (GHSA-gxv4-955v-v6cm). Exclude
// them so they fall through to MANAGEMENT and reach the loopback-only gate.
const LOCAL_ONLY_OAUTH_IMPORT_ROUTES = [
"/api/oauth/cursor/auto-import",
"/api/oauth/kiro/auto-import",
"/api/oauth/raycast/auto-import",
];
/**

View File

@@ -135,13 +135,6 @@ export const WEB_SESSION_CREDENTIAL_REQUIREMENTS = {
acceptsFullCookieHeader: true,
storageKeys: ["cookie", "ecto_1_sess", "abra_sess"],
},
"hailuo-web": {
kind: "token",
credentialName: "_token",
placeholder: '_token=... (hailuo.ai → DevTools → Local Storage → "_token")',
acceptsFullCookieHeader: false,
storageKeys: ["token", "_token"],
},
"claude-web": {
kind: "cookie",
credentialName: "sessionKey",

View File

@@ -185,14 +185,6 @@ export const traeImportSchema = z.object({
region: z.string().trim().optional(),
});
export const raycastImportSchema = z.object({
accessToken: z.string().trim().min(1, "Raycast bearer token is required"),
deviceId: z.string().trim().min(1, "Raycast device ID is required"),
aid: z.string().trim().optional(),
signatureJwt: z.string().trim().optional(),
sigSecret: z.string().trim().optional(),
});
export const kiroImportSchema = z.object({
refreshToken: z.string().trim().min(1, "Refresh token is required"),
region: z.string().trim().default("us-east-1"),

View File

@@ -370,11 +370,6 @@
"configSource": "<custom-config>",
"provider": "hyperagent"
},
"hailuo-web": {
"className": "HailuoWebExecutor",
"configSource": "<custom-config>",
"provider": "hailuo-web"
},
"hc": {
"className": "HuggingChatExecutor",
"configSource": "<custom-config>",
@@ -545,16 +540,6 @@
"configSource": "<custom-config>",
"provider": "qwen-web"
},
"raycast": {
"className": "RaycastExecutor",
"configSource": "raycast",
"provider": "raycast"
},
"rc": {
"className": "RaycastExecutor",
"configSource": "raycast",
"provider": "raycast"
},
"t3-web": {
"className": "T3ChatWebExecutor",
"configSource": "<custom-config>",
@@ -701,6 +686,6 @@
"provider": "zai-web"
}
},
"keyCount": 142,
"keyCount": 137,
"sharedInstances": []
}

View File

@@ -2790,29 +2790,6 @@
"stream": "https://api.groq.com/openai/v1/chat/completions"
}
},
"hailuo-web": {
"format": "openai",
"headers": {
"apiKey": {
"Accept": "text/event-stream",
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json"
},
"nonStream": {
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json"
},
"oauth": {
"Accept": "text/event-stream",
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json"
}
},
"url": {
"nonStream": "https://chat.minimax.io",
"stream": "https://chat.minimax.io"
}
},
"haiper": {
"format": "openai",
"headers": {
@@ -5078,29 +5055,6 @@
"stream": "https://chat.qwen.ai/api/v2/chat/completions"
}
},
"raycast": {
"format": "openai",
"headers": {
"apiKey": {
"Accept": "text/event-stream",
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json"
},
"nonStream": {
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json"
},
"oauth": {
"Accept": "text/event-stream",
"Authorization": "Bearer <TOK>",
"Content-Type": "application/json"
}
},
"url": {
"nonStream": "https://backend.raycast.com/api/v1/ai",
"stream": "https://backend.raycast.com/api/v1/ai"
}
},
"regolo": {
"format": "openai",
"headers": {

View File

@@ -8,11 +8,7 @@ import { isLocalOnlyPath } from "../../../src/server/authz/routeGuard.ts";
// host-local credential files. They must NOT be PUBLIC (which skips the LOCAL_ONLY
// tier); they must classify MANAGEMENT and be loopback-gated.
const AUTO_IMPORT = [
"/api/oauth/cursor/auto-import",
"/api/oauth/kiro/auto-import",
"/api/oauth/raycast/auto-import",
];
const AUTO_IMPORT = ["/api/oauth/cursor/auto-import", "/api/oauth/kiro/auto-import"];
test("OAuth auto-import routes are excluded from PUBLIC classification", () => {
for (const p of AUTO_IMPORT) {

View File

@@ -102,11 +102,7 @@ test("CORS relaxation reason set is unchanged", () => {
});
test("LOCAL_ONLY oauth auto-import exclusions still win over the /api/oauth/ subtree", () => {
for (const route of [
"/api/oauth/cursor/auto-import",
"/api/oauth/kiro/auto-import",
"/api/oauth/raycast/auto-import",
]) {
for (const route of ["/api/oauth/cursor/auto-import", "/api/oauth/kiro/auto-import"]) {
assert.equal(isPublicApiRoute(route, "POST"), false, route);
assert.equal(classifyRoute(route, "POST").routeClass, "MANAGEMENT", route);
}

View File

@@ -1,248 +0,0 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
const mod = await import("../../open-sse/executors/hailuo-web.ts");
describe("HailuoWebExecutor", () => {
it("can be instantiated", () => {
const executor = new mod.HailuoWebExecutor();
assert.ok(executor);
});
// Test vectors derived independently via Python's hashlib.md5 + urllib.parse.quote,
// reproducing generate_yy_header()/get_body_to_yy() from g4f's crypt.py bit-for-bit:
//
// import hashlib
// from urllib.parse import quote
// def hash_function(s): return hashlib.md5(s.encode()).hexdigest()
// def get_body_to_yy(characterID, msgContent, chatID):
// L = msgContent.replace("\r\n","").replace("\n","").replace("\r","")
// return hash_function(characterID) + hash_function(L) + hash_function(chatID) + hash_function("")
// def generate_yy_header(path, body_to_yy, t):
// encoded_path = quote(path, "")
// combined = f"{encoded_path}_{body_to_yy}{hash_function(str(t))}ooui"
// return hash_function(combined)
it("pyQuote percent-encodes exactly like Python's quote(s, safe='')", () => {
const path = "/v4/api/chat/msg?device_platform=web&biz_id=2&app_id=3001";
assert.equal(
mod.pyQuote(path),
"%2Fv4%2Fapi%2Fchat%2Fmsg%3Fdevice_platform%3Dweb%26biz_id%3D2%26app_id%3D3001"
);
// Unreserved chars (letters, digits, _.-~) pass through untouched.
assert.equal(mod.pyQuote("abcXYZ019_.-~"), "abcXYZ019_.-~");
});
it("getBodyToYy matches the independently-computed MD5 chain", () => {
const bodyToYy = mod.getBodyToYy("1", "hello world", "0");
assert.equal(
bodyToYy,
"c4ca4238a0b923820dcc509a6f75849b" +
"5eb63bbbe01eeed093cb22bb8f5acdc3" +
"cfcd208495d565ef66e7dff9f98764da" +
"d41d8cd98f00b204e9800998ecf8427e"
);
});
it("getBodyToYy normalizes CRLF/CR/LF in msgContent before hashing", () => {
const withCrlf = mod.getBodyToYy("1", "hello\r\nworld", "0");
const withLf = mod.getBodyToYy("1", "helloworld", "0");
assert.equal(withCrlf, withLf);
});
it("generateYyHeader matches the independently-computed signature", () => {
const path = "/v4/api/chat/msg?device_platform=web&biz_id=2&app_id=3001";
const bodyToYy = mod.getBodyToYy("1", "hello world", "0");
const yy = mod.generateYyHeader(path, bodyToYy, 1700000000000);
assert.equal(yy, "6893d64988ecf45b1de1808b91ae855b");
});
it("builds a stable path_and_query with derived device_id/uuid when none is supplied", () => {
const a = mod.buildHailuoPathAndQuery("token-abc", undefined, 1700000000000);
const b = mod.buildHailuoPathAndQuery("token-abc", undefined, 1700000000000);
assert.equal(a, b, "same token must derive the same fingerprint every time");
const params = new URL(`https://x${a}`).searchParams;
assert.equal(params.get("device_platform"), "web");
assert.equal(params.get("uuid")?.length, 32);
assert.equal(params.get("device_id")?.length, 32);
});
it("honors user-supplied device_id/uuid over the derived fallback", () => {
const path = mod.buildHailuoPathAndQuery(
"token-abc",
{ device_id: "real-device", uuid: "real-uuid" },
1700000000000
);
const params = new URL(`https://x${path}`).searchParams;
assert.equal(params.get("device_id"), "real-device");
assert.equal(params.get("uuid"), "real-uuid");
});
it("folds text-only OpenAI history into a single msgContent block", () => {
const folded = mod.foldHailuoMessages([
{ role: "system", content: "Be nice." },
{ role: "user", content: "hi" },
{ role: "assistant", content: "hello!" },
{ role: "user", content: "how are you?" },
]);
assert.equal(
folded,
"System: Be nice.\n\nUser: hi\n\nAssistant: hello!\n\nUser: how are you?"
);
});
it("throws on tool-call content it cannot faithfully forward", () => {
assert.throws(() =>
mod.foldHailuoMessages([{ role: "assistant", content: "", tool_calls: [{}] }])
);
assert.throws(() => mod.foldHailuoMessages([{ role: "tool", content: "result" }]));
});
it("diffs cumulative message_result content into deltas", () => {
const state = { emittedLen: 0 };
assert.equal(mod.extractHailuoMessageDelta("Hel", state), "Hel");
assert.equal(mod.extractHailuoMessageDelta("Hello", state), "lo");
assert.equal(mod.extractHailuoMessageDelta("Hello", state), "", "no growth => no delta");
});
it("parses event:/data: SSE lines and swallows malformed data without throwing", () => {
const eventLine = mod.parseHailuoLine("event: message_result");
assert.deepEqual(eventLine, { type: "event", value: "message_result" });
const dataLine = mod.parseHailuoLine('data: {"data":{"messageResult":{"content":"hi"}}}');
assert.deepEqual(dataLine, { type: "data", value: { data: { messageResult: { content: "hi" } } } });
// Truncated/malformed JSON must not throw — the stream must keep going.
assert.equal(mod.parseHailuoLine("data: {not json"), null);
assert.equal(mod.parseHailuoLine("not a recognized line"), null);
});
it("extracts message_result.content from a send_result/message_result event payload", () => {
const content = mod.extractHailuoMessageResultContent({
data: { messageResult: { content: "partial answer" } },
});
assert.equal(content, "partial answer");
assert.equal(mod.extractHailuoMessageResultContent({ data: { sendResult: { chatID: "1" } } }), null);
});
it("returns a 401 credential error when the token is missing", async () => {
const executor = new mod.HailuoWebExecutor();
const result = await executor.execute({
model: "hailuo",
body: { messages: [{ role: "user", content: "hi" }] },
stream: false,
credentials: { apiKey: "" },
signal: null,
});
const text = await result.response.text();
assert.equal(result.response.status, 401);
assert.match(text, /token/i);
});
it("maps an upstream 401 (invalid/expired token) as a terminal, non-cooldown error", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = (async () =>
new Response(JSON.stringify({ error: "invalid token" }), { status: 401 })) as typeof fetch;
try {
const executor = new mod.HailuoWebExecutor();
const result = await executor.execute({
model: "hailuo",
body: { messages: [{ role: "user", content: "hi" }] },
stream: false,
credentials: { apiKey: "expired-token" },
signal: null,
});
assert.equal(result.response.status, 401);
} finally {
globalThis.fetch = originalFetch;
}
});
it("maps an upstream 429 as a transient (retryable) error", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = (async () =>
new Response(JSON.stringify({ error: "rate limited" }), { status: 429 })) as typeof fetch;
try {
const executor = new mod.HailuoWebExecutor();
const result = await executor.execute({
model: "hailuo",
body: { messages: [{ role: "user", content: "hi" }] },
stream: false,
credentials: { apiKey: "some-token" },
signal: null,
});
assert.equal(result.response.status, 429);
} finally {
globalThis.fetch = originalFetch;
}
});
it("collects a non-streaming completion from send_result/message_result/close_chunk SSE events", async () => {
const sse = [
"event: send_result",
'data: {"data":{"sendResult":{"chatID":"c1","chatTitle":"hi"}}}',
"event: message_result",
'data: {"data":{"messageResult":{"content":"Hel"}}}',
"event: message_result",
'data: {"data":{"messageResult":{"content":"Hello"}}}',
"event: close_chunk",
"",
].join("\n");
const originalFetch = globalThis.fetch;
globalThis.fetch = (async () => new Response(sse, { status: 200 })) as typeof fetch;
try {
const executor = new mod.HailuoWebExecutor();
const result = await executor.execute({
model: "hailuo",
body: { messages: [{ role: "user", content: "hi" }] },
stream: false,
credentials: { apiKey: "some-token" },
signal: null,
});
const json = await result.response.json();
assert.equal(result.response.status, 200);
assert.equal(json.choices[0].message.content, "Hello");
assert.equal(new URL(result.url).pathname, "/v4/api/chat/msg");
} finally {
globalThis.fetch = originalFetch;
}
});
it("streams incremental deltas for a streaming request", async () => {
const sse = [
"event: message_result",
'data: {"data":{"messageResult":{"content":"Hi"}}}',
"event: message_result",
'data: {"data":{"messageResult":{"content":"Hi there"}}}',
"event: close_chunk",
"",
].join("\n");
const originalFetch = globalThis.fetch;
globalThis.fetch = (async () => new Response(sse, { status: 200 })) as typeof fetch;
try {
const executor = new mod.HailuoWebExecutor();
const result = await executor.execute({
model: "hailuo",
body: { messages: [{ role: "user", content: "hi" }] },
stream: true,
credentials: { apiKey: "some-token" },
signal: null,
});
const text = await result.response.text();
assert.match(text, /"content":"Hi"/);
assert.match(text, /"content":" there"/);
assert.match(text, /data: \[DONE\]/);
} finally {
globalThis.fetch = originalFetch;
}
});
});

View File

@@ -0,0 +1,48 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import test from "node:test";
import { REGISTRY } from "../../open-sse/config/providerRegistry.ts";
import { hasSpecializedExecutor } from "../../open-sse/executors/index.ts";
test("Raycast Relay GPL-derived integration is absent from runtime dispatch", () => {
assert.equal(REGISTRY.raycast, undefined);
assert.equal(REGISTRY.rc, undefined);
assert.equal(hasSpecializedExecutor("raycast"), false);
assert.equal(hasSpecializedExecutor("rc"), false);
assert.ok(REGISTRY.github, "the independent official GitHub provider must remain");
});
test("Hailuo Web GPL-derived integration is absent without removing official MiniMax", () => {
assert.equal(REGISTRY["hailuo-web"], undefined);
assert.equal(hasSpecializedExecutor("hailuo-web"), false);
assert.ok(REGISTRY.minimax, "the official MiniMax provider must remain registered");
assert.ok(REGISTRY["minimax-cn"], "the official MiniMax China provider must remain registered");
});
test("GPL-derived implementation and credential source files are absent from the shipped tree", () => {
const removedPaths = [
"open-sse/config/providers/registry/minimax/web/index.ts",
"open-sse/config/providers/registry/raycast/index.ts",
"open-sse/executors/hailuo-web.ts",
"open-sse/executors/raycast.ts",
"open-sse/services/raycast.ts",
"scripts/raycast/extract-credentials.mjs",
"scripts/raycast/usage-benchmark.mjs",
"src/app/api/oauth/raycast/auto-import/route.ts",
"src/app/api/oauth/raycast/import/route.ts",
"src/lib/oauth/providers/raycast.ts",
"src/lib/oauth/services/raycast.ts",
"src/lib/oauth/services/raycastLocal.ts",
"src/shared/components/RaycastAuthModal.tsx",
];
for (const relativePath of removedPaths) {
assert.equal(
fs.existsSync(path.join(process.cwd(), relativePath)),
false,
`${relativePath} must not ship`
);
}
});

View File

@@ -0,0 +1,130 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import test from "node:test";
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-gpl-provider-retirement-"));
process.env.DATA_DIR = TEST_DATA_DIR;
const core = await import("../../src/lib/db/core.ts");
const apiKeysDb = await import("../../src/lib/db/apiKeys.ts");
const RETIRED_PROVIDER_IDS = ["raycast", "rc", "hailuo-web"] as const;
test.after(() => {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});
test("migration 166 disables GPL-derived connections fail-closed and preserves audit history", async () => {
const db = core.getDbInstance();
const applied = db
.prepare("SELECT version FROM _omniroute_migrations WHERE version = 166")
.get() as { version: number } | undefined;
assert.ok(applied, "migration 166 must be recorded as applied");
// api_keys policy columns are reconciled lazily by the domain module on fresh
// databases; production upgrades already carry them from normal API-key use.
await apiKeysDb.getApiKeys();
for (const provider of [...RETIRED_PROVIDER_IDS, "minimax", "minimax-cn"]) {
const connectionId = `${provider}-connection`;
db.prepare(
"INSERT INTO provider_connections " +
"(id, provider, auth_type, name, is_active, created_at, updated_at) " +
"VALUES (?, ?, 'apikey', ?, 1, datetime('now'), datetime('now'))"
).run(connectionId, provider, `${provider}-fixture`);
}
db.prepare(
"INSERT INTO api_keys " +
"(id, name, key, key_hash, key_prefix, allowed_connections, is_active, created_at) " +
"VALUES ('restricted-key', 'restricted-key', 'restricted-secret', " +
"'restricted-hash', 'restrict', ?, 1, datetime('now'))"
).run(JSON.stringify(["raycast-connection", "minimax-connection"]));
for (const provider of RETIRED_PROVIDER_IDS) {
const connectionId = `${provider}-connection`;
db.prepare(
"INSERT INTO exclusive_connection_leases " +
"(lease_owner_hash, api_key_id, provider, connection_id, generation, state, " +
"acquired_at, renewed_at, expires_at) VALUES (?, 'restricted-key', ?, ?, 1, " +
"'ACTIVE', datetime('now'), datetime('now'), datetime('now', '+1 hour'))"
).run(provider.padEnd(64, "0"), provider, connectionId);
db.prepare(
"INSERT INTO usage_history (provider, model, timestamp) " +
"VALUES (?, 'legacy-model', datetime('now'))"
).run(provider);
db.prepare(
"INSERT INTO call_logs (id, timestamp, provider, model, status) " +
"VALUES (?, datetime('now'), ?, 'legacy-model', 200)"
).run(`${provider}-call`, provider);
db.prepare(
"INSERT INTO quota_snapshots " +
"(provider, connection_id, window_key, remaining_percentage, is_exhausted, created_at) " +
"VALUES (?, ?, 'monthly', 50, 0, ?)"
).run(provider, connectionId, new Date().toISOString());
}
const sql = fs.readFileSync(
path.join(process.cwd(), "src/lib/db/migrations/166_retire_gpl_derived_providers.sql"),
"utf8"
);
db.exec(sql);
db.exec(sql);
for (const provider of RETIRED_PROVIDER_IDS) {
const connectionId = `${provider}-connection`;
const connection = db
.prepare(
"SELECT is_active, test_status, error_code, last_error_type, last_error_source " +
"FROM provider_connections WHERE id = ?"
)
.get(connectionId) as {
is_active: number;
test_status: string;
error_code: string;
last_error_type: string;
last_error_source: string;
};
assert.deepEqual(connection, {
is_active: 0,
test_status: "unavailable",
error_code: "PROVIDER_REMOVED",
last_error_type: "provider_removed",
last_error_source: "migration:166",
});
const lease = db
.prepare(
"SELECT state, ended_at, end_reason FROM exclusive_connection_leases " +
"WHERE connection_id = ?"
)
.get(connectionId) as { state: string; ended_at: string | null; end_reason: string | null };
assert.equal(lease.state, "INVALIDATED");
assert.ok(lease.ended_at);
assert.equal(lease.end_reason, "provider integration retired in v3.8.51");
assert.ok(db.prepare("SELECT id FROM usage_history WHERE provider = ?").get(provider));
assert.ok(db.prepare("SELECT id FROM call_logs WHERE provider = ?").get(provider));
assert.ok(db.prepare("SELECT id FROM quota_snapshots WHERE provider = ?").get(provider));
}
for (const provider of ["minimax", "minimax-cn"]) {
const row = db
.prepare("SELECT is_active FROM provider_connections WHERE id = ?")
.get(`${provider}-connection`) as { is_active: number };
assert.equal(row.is_active, 1, `${provider} must remain active`);
}
const apiKey = db
.prepare("SELECT is_active, allowed_connections FROM api_keys WHERE id = 'restricted-key'")
.get() as { is_active: number; allowed_connections: string };
assert.equal(apiKey.is_active, 1);
assert.deepEqual(JSON.parse(apiKey.allowed_connections), [
"raycast-connection",
"minimax-connection",
]);
});

View File

@@ -36,7 +36,7 @@ test("#9502: web-session credential spec for muse-spark-web mentions the ecto1:
assert.ok(startIdx >= 0, "muse-spark-web credential spec not found");
const museSection = webSessionCredentials.slice(
startIdx,
webSessionCredentials.indexOf('"hailuo-web"', startIdx)
webSessionCredentials.indexOf('"claude-web"', startIdx)
);
assert.match(museSection, /ecto1/, "credential spec must mention the ecto1: WS auth token");
});

View File

@@ -41,7 +41,6 @@ const {
OAUTH_TIMEOUT,
PROVIDERS: OAUTH_PROVIDER_IDS,
QODER_CONFIG,
RAYCAST_CONFIG,
TRAE_CONFIG,
XAI_OAUTH_CONFIG,
OPENFERENCE_CONFIG,
@@ -65,7 +64,6 @@ const EXPECTED_PROVIDER_KEYS = [
"amazon-q",
"cursor",
"trae",
"raycast",
"kilocode",
"cline",
"clinepass",
@@ -104,7 +102,6 @@ const EXPECTED_CONFIG_BY_PROVIDER = {
clinepass: CLINE_CONFIG, // reuses the Cline WorkOS flow (clinepass: cline in providers/index.ts)
"devin-desktop": DEVIN_DESKTOP_CONFIG,
"devin-cli": DEVIN_DESKTOP_CONFIG,
raycast: RAYCAST_CONFIG,
trae: TRAE_CONFIG,
"grok-cli": GROK_BUILD_OAUTH_CONFIG,
"xai-oauth": XAI_OAUTH_CONFIG,

View File

@@ -62,12 +62,10 @@ test("src/shared providers map resolves the same aliases unambiguously", () => {
assert.equal(getProviderAlias("kimi"), "kimi");
});
// #6673: hailuo-web must not collide with the paid API-key minimax/minimax-cn
// providers — it uses its own id as alias, per the secondary-variant convention.
test("hailuo-web resolves to its own id/alias and does not collide with minimax", () => {
assert.equal(PROVIDER_ID_TO_ALIAS["hailuo-web"], "hailuo-web");
assert.equal(resolveProviderId("hailuo-web"), "hailuo-web");
assert.equal(getProviderAlias("hailuo-web"), "hailuo-web");
test("retired Hailuo alias is absent while official MiniMax aliases remain distinct", () => {
assert.equal(PROVIDER_ID_TO_ALIAS["hailuo-web"], undefined);
assert.equal(PROVIDER_ID_TO_ALIAS.minimax, "minimax");
assert.equal(PROVIDER_ID_TO_ALIAS["minimax-cn"], "minimax-cn");
assert.equal(resolveProviderId("minimax"), "minimax");
assert.equal(resolveProviderId("minimax-cn"), "minimax-cn");
});

View File

@@ -117,8 +117,9 @@ test("shared set excludes manual aliases that never intercept nodes at runtime",
test("shared set size includes live REGISTRY and retired Designer + Felo prefixes", () => {
// Computed (not hand-derived) after combining Designer's 2 retired
// ids/aliases with Felo's 2 retired ids/aliases on top of the live
// REGISTRY walk.
assert.equal(RESERVED_PREFIX_COUNT, 400);
// REGISTRY walk, minus the 3 GPL-derived Raycast/Hailuo Web ids/aliases
// removed from REGISTRY by #11691's migration 166.
assert.equal(RESERVED_PREFIX_COUNT, 397);
});
test("isReservedProviderPrefix rejects non-string input", () => {

View File

@@ -21,17 +21,18 @@ test("cookie-kind web sessions do not use the API-key test path", () => {
});
test("token-kind web sessions WITHOUT a token-aware validator stay off the API-key test path", () => {
// hailuo-web and promptql are `kind: "token"` in WEB_SESSION_CREDENTIAL_REQUIREMENTS,
// but neither has an entry in validation.ts's SPECIALTY_VALIDATORS map (nor a
// token-aware branch like zai-web's in validateWebCookieProvider). Routing them through
// promptql and microsoft-designer-web are `kind: "token"` in
// WEB_SESSION_CREDENTIAL_REQUIREMENTS, but neither has an entry in validation.ts's
// SPECIALTY_VALIDATORS map (nor a token-aware branch like zai-web's). Routing them through
// the API-key test path would dispatch to the generic cookie probe, which sends the
// stored token as a `Cookie` header and treats most non-401/403 responses as valid —
// an invalid hailuo-web/promptql token could be reported as a healthy connection.
assert.equal(shouldUseApiKeyConnectionTest("cookie", "hailuo-web"), false);
// an invalid token could be reported as a healthy connection.
assert.equal(shouldUseApiKeyConnectionTest("cookie", "promptql"), false);
// Same reasoning applies to the remaining token-kind provider with no validator.
assert.equal(shouldUseApiKeyConnectionTest("cookie", "t3-chat-web"), false);
// Retired provider ids also stay off every credential-test path.
assert.equal(shouldUseApiKeyConnectionTest("cookie", "hailuo-web"), false);
});
test("every token-kind web session with a real token-aware validator uses the API-key test path", () => {

View File

@@ -17,8 +17,8 @@
// sarvam+plamo in regional) to 193, then #8170 (inception/typhoon — inception in frontier-labs,
// typhoon in regional) to 195, then Firecrawl dual search+fetch under SEARCH_PROVIDERS.firecrawl
// (removed specialty-media duplicate) to 194, #8861 (Xiaomi MiMo Token Plan, regional) to 195, and
// the Cheaper Inference gateway (OSS-sponsor reseller, gateways family) to 198 (UnoRouter #9009,
// Raycast Pro #8895), then later additions to 199; retiring GitHub Models brings it to 198.
// the Cheaper Inference gateway (OSS-sponsor reseller, gateways family) to 198 (UnoRouter #9009),
// then later additions to 199; retiring GitHub Models brings it to 198.
// The v3.8.50 free-tier gateway waves (#9631 registry cycle, waves 2-5, #9210 phase 3) grew the
// gateways family to 228 measured on the tip; Puter retired (#10210) and chatanywhere restored
// (base-reds round 3, #9985) are both included in that measurement; Cursor API (specialty-media,

View File

@@ -1,75 +0,0 @@
/**
* @file raycast-auth.test.ts
* @description Unit tests for Raycast V2 signature + message conversion.
*
* @changes
* - [2026-07-27] [Composer] - Initial Raycast protocol tests
*/
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
buildRaycastChatBody,
convertOpenAiMessages,
decodeAidFromRaycastJwt,
inferProviderInfo,
rot13rot5,
signatureV2,
} from "../../open-sse/services/raycast.ts";
describe("raycast auth protocol", () => {
it("rot13rot5 encodes alphanumerics", () => {
assert.equal(rot13rot5("ABCabc123"), "NOPnop678");
});
it("signatureV2 matches raycast-relay fixture shape", () => {
const secret = "6bc455473576ce2cd6f70426caff867aabbe3f7291c1a79681af5e8ce0ca1408";
const timestamp = "1720000000";
const deviceId = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee";
const payload = "{}";
const sig = signatureV2(timestamp, deviceId, payload, secret);
assert.match(sig, /^[a-f0-9]{64}$/);
assert.equal(sig, signatureV2(timestamp, deviceId, payload, secret));
});
it("decodes aid from JWT payload", () => {
const header = Buffer.from(JSON.stringify({ typ: "JWT", alg: "HS256" })).toString("base64url");
const payload = Buffer.from(JSON.stringify({ aid: "test-aid-123", exp: 9999999999, iat: 1 })).toString(
"base64url"
);
const jwt = `${header}.${payload}.fake-sig`;
assert.equal(decodeAidFromRaycastJwt(jwt), "test-aid-123");
});
it("converts OpenAI messages to Raycast shape", () => {
const { raycastMessages, systemInstruction } = convertOpenAiMessages([
{ role: "system", content: "Be concise" },
{ role: "user", content: "Hello" },
]);
assert.equal(systemInstruction, "Be concise");
assert.deepEqual(raycastMessages, [{ author: "user", content: { text: "Hello" } }]);
});
it("infers provider prefixes from model ids", () => {
assert.deepEqual(inferProviderInfo("openai-gpt-5-mini"), {
provider: "openai",
model: "gpt-5-mini",
});
assert.deepEqual(inferProviderInfo("anthropic-claude-sonnet-4-6"), {
provider: "anthropic",
model: "claude-sonnet-4-6",
});
assert.deepEqual(inferProviderInfo("raycast-ray1"), { provider: "raycast", model: "ray1" });
});
it("buildRaycastChatBody includes thread_id and provider split", () => {
const body = JSON.parse(
buildRaycastChatBody("openai-gpt-5-mini", [{ role: "user", content: "ping" }], 0.7)
);
assert.equal(body.model, "gpt-5-mini");
assert.equal(body.provider, "openai");
assert.equal(body.temperature, 0.7);
assert.equal(typeof body.thread_id, "string");
assert.equal(body.messages[0].author, "user");
});
});

View File

@@ -1,18 +0,0 @@
/**
* @file raycast-local-extract.test.ts
* @description Tests for Raycast local credential extraction (mocked where needed).
*
* @changes
* - [2026-07-27] [Composer] - Raycast local extract unit tests
*/
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { isRaycastLocalExtractAvailable } from "../../src/lib/oauth/services/raycastLocal.ts";
describe("raycast local extract", () => {
it("reports availability on darwin when Raycast paths exist", () => {
const result = isRaycastLocalExtractAvailable();
assert.equal(typeof result, "boolean");
});
});

View File

@@ -5,7 +5,6 @@
* has nothing to do with `BaseExecutor.buildHeaders(credentials, stream?, clientHeaders?,
* model?, health?)`:
*
* hailuo-web (token: string, yy: string)
* lmarena (_model: string, credentials: unknown, _body: unknown)
* qwen-web (token: string, cookieHeader: string, chatId?: string)
*
@@ -23,12 +22,10 @@ import test from "node:test";
import assert from "node:assert/strict";
import { BaseExecutor } from "../../open-sse/executors/base.ts";
import { HailuoWebExecutor } from "../../open-sse/executors/hailuo-web.ts";
import { LMArenaExecutor } from "../../open-sse/executors/lmarena.ts";
import { QwenWebExecutor } from "../../open-sse/executors/qwen-web.ts";
const CASES = [
{ name: "hailuo-web", make: () => new HailuoWebExecutor(), helper: "buildStreamHeaders" },
{ name: "lmarena", make: () => new LMArenaExecutor(), helper: "buildRequestHeaders" },
{ name: "qwen-web", make: () => new QwenWebExecutor(), helper: "buildApiHeaders" },
];

View File

@@ -1,16 +0,0 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { WEB_COOKIE_PROVIDERS } from "../../src/shared/constants/providers/web-cookie.ts";
import { hailuo_webProvider } from "../../open-sse/config/providers/registry/minimax/web/index.ts";
describe("hailuo-web domain updates (#11000)", () => {
it("hailuo-web website targets chat.minimax.io to avoid 301 redirect errors", () => {
const entry = WEB_COOKIE_PROVIDERS["hailuo-web"];
assert.ok(entry);
assert.equal(entry.website, "https://chat.minimax.io");
});
it("hailuo-web registry entry baseUrl targets chat.minimax.io", () => {
assert.equal(hailuo_webProvider.baseUrl, "https://chat.minimax.io");
});
});