mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-03 05:12:10 +03:00
Compare commits
6 Commits
fix/v3850-
...
fix/v3850-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ab7fa0989 | ||
|
|
d130ef6e9e | ||
|
|
892545a1e3 | ||
|
|
5900c47644 | ||
|
|
3e6e21683b | ||
|
|
cd701c1eed |
11
.env.example
11
.env.example
@@ -1256,17 +1256,6 @@ GITHUB_OAUTH_CLIENT_ID=Iv1.b507a08c87ecfe98
|
||||
# VISION_BRIDGE_BASE_URL=
|
||||
# VISION_BRIDGE_API_KEY=
|
||||
|
||||
# ── Raycast Pro (local auto-import) ──
|
||||
# Raycast Pro AI is a reverse-engineered, unofficial API — local/personal use
|
||||
# only (no OAuth client_id/secret; token is captured via macOS Auto-Import
|
||||
# from the Keychain + local Raycast SQLite DB, or pasted manually). These
|
||||
# vars are optional manual overrides used by open-sse/services/raycast.ts
|
||||
# and the direct-probe benchmark script scripts/raycast/usage-benchmark.mjs.
|
||||
# RAYCAST_BEARER_TOKEN=
|
||||
# RAYCAST_DEVICE_ID=
|
||||
# RAYCAST_AID=
|
||||
# RAYCAST_SIG_SECRET=
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# ⚠️ GOOGLE OAUTH (Antigravity) & OTHER PROVIDERS — REMOTE SERVERS
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -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 | ✅ | ✅ | ❌ | ❌ |
|
||||
|
||||
@@ -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 | ✅ | ✅ | ❌ | ❌ |
|
||||
|
||||
@@ -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` | 服务商模型 | 服务商模型元数据,支撑自定义和管理可用模型 |
|
||||
|
||||
## 旁路处理器
|
||||
|
||||
|
||||
@@ -322,7 +322,7 @@ flowchart LR
|
||||
OAuth 提供者模組(`src/lib/oauth/providers/` 下的 22 個個別檔案):
|
||||
|
||||
- 註冊表索引:`src/lib/oauth/providers/index.ts`
|
||||
- 個別提供者:`agy.ts`, `antigravity.ts`, `claude.ts`, `cline.ts`, `codebuddy-cn.ts`, `codex.ts`, `cursor.ts`, `devin-desktop.ts`, `ghe-copilot.ts`, `github.ts`, `gitlab-duo.ts`, `grok-cli-oauth.ts`, `grok-cli.ts`, `kilocode.ts`, `kimi-coding.ts`, `kiro.ts`, `qoder.ts`, `raycast.ts`, `trae.ts`, `xai-oauth.ts`, `zed-hosted.ts`, `zed.ts`
|
||||
- 個別提供者:`agy.ts`, `antigravity.ts`, `claude.ts`, `cline.ts`, `codebuddy-cn.ts`, `codex.ts`, `cursor.ts`, `devin-desktop.ts`, `ghe-copilot.ts`, `github.ts`, `gitlab-duo.ts`, `grok-cli-oauth.ts`, `grok-cli.ts`, `kilocode.ts`, `kimi-coding.ts`, `kiro.ts`, `openference.ts`, `qoder.ts`, `trae.ts`, `xai-oauth.ts`, `zed-hosted.ts`, `zed.ts`
|
||||
- 薄包裝層:`src/lib/oauth/providers.ts` — 從個別模組重新匯出
|
||||
|
||||
## 5) 嵌入式服務(v3.8.4)
|
||||
@@ -901,7 +901,7 @@ flowchart LR
|
||||
| `PollinationsExecutor` | Pollinations AI | 無需 API 金鑰、速率限制請求 |
|
||||
| `QoderExecutor` | Qoder AI | PAT 與 OAuth 支援、多模型免費方案 |
|
||||
| `VertexExecutor` | Google Vertex AI | 服務帳戶驗證、基於區域的端點 |
|
||||
| `DevinDesktopExecutor` | Devin Desktop | 匯入的 API 金鑰 + Connect-protobuf 聊天串流 |
|
||||
| `DevinDesktopExecutor` | Devin Desktop | 匯入的 API 金鑰 + Connect-protobuf 聊天串流 |
|
||||
|
||||
所有其他提供者(包括自訂相容節點)使用 `DefaultExecutor`。
|
||||
|
||||
@@ -953,9 +953,9 @@ flowchart LR
|
||||
| GLMT(預設) | claude | API 金鑰 | ✅ | ✅ | ❌ | ⚠️ 每次請求 |
|
||||
| Kimi Coding | openai | OAuth / API 金鑰 | ✅ | ✅ | ✅ | ❌ |
|
||||
| KIE | openai | API 金鑰 | ✅ | ✅ | ❌ | ❌ |
|
||||
| Devin Desktop | openai | 匯入的 API 金鑰 | ✅ (Connect→SSE) | ✅ | ❌ | ⚠️ 每次請求 |
|
||||
| Devin Desktop | openai | 匯入的 API 金鑰 | ✅ (Connect→SSE) | ✅ | ❌ | ⚠️ 每次請求 |
|
||||
| GitLab Duo | openai | OAuth(GitLab) | ✅ | ✅ | ✅ | ❌ |
|
||||
| Devin CLI | openai | 本機 CLI 登入 | ✅ | ✅ | ❌ | ✅ 任務 API |
|
||||
| Devin CLI | openai | 本機 CLI 登入 | ✅ | ✅ | ❌ | ✅ 任務 API |
|
||||
| Codex Cloud | openai-responses | OAuth | ✅ | ❌ | ✅ | ✅ 速率限制 |
|
||||
| Jules | openai | OAuth | ✅ | ✅ | ✅ | ✅ 任務 API |
|
||||
| AgentRouter | openai | API 金鑰 | ✅ | ✅ | ❌ | ❌ |
|
||||
|
||||
@@ -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]
|
||||
>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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" },
|
||||
],
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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()),
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -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", {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
32
src/lib/db/migrations/166_retire_gpl_derived_providers.sql
Normal file
32
src/lib/db/migrations/166_retire_gpl_derived_providers.sql
Normal 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');
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
}),
|
||||
};
|
||||
@@ -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.",
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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"]);
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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": []
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
});
|
||||
48
tests/unit/gpl-derived-provider-removals.test.ts
Normal file
48
tests/unit/gpl-derived-provider-removals.test.ts
Normal 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`
|
||||
);
|
||||
}
|
||||
});
|
||||
130
tests/unit/migration-166-retire-gpl-derived-providers.test.ts
Normal file
130
tests/unit/migration-166-retire-gpl-derived-providers.test.ts
Normal 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",
|
||||
]);
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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" },
|
||||
];
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user