diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c2dd41a36..2c4378425c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,7 @@ ### 🐛 Bug Fixes +- **feat(providers):** add **Zed** hosted LLM aggregator as a native-app provider ([#6118](https://github.com/diegosouzapw/OmniRoute/pull/6118)) — OAuth sign-in via the Zed hosted flow, registered through the shared provider registry + executor. Regression guards: `tests/unit/zed-oauth-provider.test.ts`, `zed-import-utils.test.ts`, `zed-docker-detect.test.ts`, `mitm-handler-zed.test.ts`. VPS-validated via live operator login (Hard Rule #18). - **fix(oauth):** the Kiro SSO-cache auto-import now **preserves the IDC region** — cross-region Amazon Q / Kiro profiles imported from the SSO cache are no longer collapsed to the default region ([#6113](https://github.com/diegosouzapw/OmniRoute/pull/6113)). Regression guard: `tests/unit/kiro-auto-import-idc-2059.test.ts`. VPS-validated via live operator login (Hard Rule #18). - **fix(dashboard):** passthrough model aliases no longer collide when two namespaced model ids share a last segment. `enx/gpt-5.5` and `enx/codebuddy/gpt-5.5` both auto-generated the alias `gpt-5.5`, so the second model could never be added (the UI just alerted "alias already exists"). Aliases are now disambiguated deterministically — bare last segment when free, then parent-qualified (`codebuddy-gpt-5.5`), then a numeric suffix — while re-adding the exact same model id is still blocked. Regression guard: `tests/unit/passthrough-alias-1850.test.ts`. (thanks @arpicato) - **fix(translator):** preserve a Gemini `functionResponse` co-located with other parts (another `functionCall`, or trailing `text`) in the same content when translating **Gemini → OpenAI**. `convertGeminiContent()` early-returned the tool message on the first `functionResponse` part, dropping any co-located parts; such contents are now pre-split (one tool message per `functionResponse`, emitted first, plus one message for the remaining parts). Regression guard: `tests/unit/gemini-to-openai-function-response.test.ts`. (thanks @warelik) diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index e9c9b043a2..39fbd592d2 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -246,7 +246,7 @@ "src/lib/usage/providerLimits.ts": 998, "src/lib/usage/usageHistory.ts": 988, "_rebaseline_2026_06_27_5193_5203_antigravity_oauthmodal": "Antigravity remote-login own growth: OAuthModal.tsx 960->969 (gate units). #5193 (+~4: remote paste instruction shown for all remote incl. Google + its rationale comment) and #5203 (+~5: handleManualSubmit credential-blob branch + button guard; submit logic extracted to oauthBlobSubmit.ts to minimize). Frozen set to the SUM so either merge order passes. Cohesive at the existing manual-submit chokepoint.", - "src/shared/components/OAuthModal.tsx": 989, + "src/shared/components/OAuthModal.tsx": 993, "src/shared/components/RequestLoggerV2.tsx": 1629, "src/shared/components/analytics/charts.tsx": 1558, "src/shared/constants/cliTools.ts": 875, @@ -376,5 +376,6 @@ "_rebaseline_2026_07_05_6154_copilot_catalog_helpers": "PR #6154 own growth: src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts 1021->1034 (+13 = GitHub Copilot catalog refresh — model-section helper wiring for the refreshed passthrough/compatible model lists). Cohesive UI-helper growth alongside the registry/modelSpecs catalog refresh; not extractable. Covered by the PR's provider-registry-github-copilot-* unit tests. Fast-path PR->release skips check:file-size, so this bump lands with the PR (contributor backryun).", "_rebaseline_2026_07_05_6213_kiro_thinking_filesize": "PR #6213 own growth (kiro adaptive-thinking -> reasoning_content, +384): open-sse/translator/request/openai-to-kiro.ts 853->890 (+37 = additionalModelRequestFields builder for adaptive thinking: output_config.effort + thinking:{type:adaptive} + max_tokens, only when the request asked for thinking) and tests/unit/translator-openai-to-kiro.test.ts 1093->1234 (+141 = adaptive-thinking request/frame regression cases). The fast-path PR->release does NOT gate check:file-size on the merge, so this cohesive feature growth accumulated on the release tip (see the 2026-07-02 #5798 note for the same pattern). Superseded by the release captain's rebaseline-at-release.", "_rebaseline_2026_07_05_6235_doubao_dola": "PR #6235 own growth: tests/unit/web-cookie-providers-new.test.ts 850->890 (+40 = doubao-web -> Dola global provider switch regression cases: new host/cookie-domain/token-source assertions for www.dola.com). Cohesive test growth alongside the provider switch; contributor backryun. Fast-path PR->release skips check:file-size, so this bump lands with the PR.", - "_rebaseline_2026_07_06_v3845_release_close": "Release v3.8.45 cycle-close rebaseline (captain, sess ce897453): 13 files grown by the cycle's merged fix/feature PRs (#6216 streaming fixes + request-logger UI grew RequestLoggerV2/chat/chatHelpers/auth/stream/response-sanitizer.test; #6251/#6253 dashboard UX grew combos page/modals/wizard/ComboDefaultsTab/ProxyRegistryManager/providerPageHelpers). Growth is legitimate merged-feature code, absorbed at release per Phase 0 drift policy; all remain frozen (cannot grow further)." + "_rebaseline_2026_07_06_v3845_release_close": "Release v3.8.45 cycle-close rebaseline (captain, sess ce897453): 13 files grown by the cycle's merged fix/feature PRs (#6216 streaming fixes + request-logger UI grew RequestLoggerV2/chat/chatHelpers/auth/stream/response-sanitizer.test; #6251/#6253 dashboard UX grew combos page/modals/wizard/ComboDefaultsTab/ProxyRegistryManager/providerPageHelpers). Growth is legitimate merged-feature code, absorbed at release per Phase 0 drift policy; all remain frozen (cannot grow further).", + "_rebaseline_2026_07_06_6118_zed_oauthmodal": "PR #6118 own growth: OAuthModal.tsx 989->993 (+4 = Zed hosted native-app sign-in modal branch). Cohesive UI growth for the zed-hosted OAuth provider; not extractable. The prior 6118 comment set the note but left the frozen value at 989." } diff --git a/open-sse/config/providers/index.ts b/open-sse/config/providers/index.ts index e4281590fc..5e49767b63 100644 --- a/open-sse/config/providers/index.ts +++ b/open-sse/config/providers/index.ts @@ -128,6 +128,7 @@ import { gitlab_duoProvider } from "./registry/gitlab-duo/index.ts"; import { command_codeProvider } from "./registry/command-code/index.ts"; import { novitaProvider } from "./registry/novita/index.ts"; import { windsurfProvider } from "./registry/windsurf/index.ts"; +import { zed_hostedProvider } from "./registry/zed-hosted/index.ts"; import { nanogptProvider } from "./registry/nanogpt/index.ts"; import { scalewayProvider } from "./registry/scaleway/index.ts"; import { agentrouterProvider } from "./registry/agentrouter/index.ts"; @@ -313,6 +314,7 @@ export const REGISTRY: Record = { "command-code": command_codeProvider, novita: novitaProvider, windsurf: windsurfProvider, + "zed-hosted": zed_hostedProvider, nanogpt: nanogptProvider, scaleway: scalewayProvider, agentrouter: agentrouterProvider, diff --git a/open-sse/config/providers/registry/zed-hosted/index.ts b/open-sse/config/providers/registry/zed-hosted/index.ts new file mode 100644 index 0000000000..a5578e6dba --- /dev/null +++ b/open-sse/config/providers/registry/zed-hosted/index.ts @@ -0,0 +1,35 @@ +import type { RegistryEntry } from "../../shared.ts"; + +/** + * Zed Hosted Models — cloud.zed.dev, Zed's own aggregator (fronts + * Anthropic/OpenAI/Google/xAI depending on the requested model). + * + * Distinct from the pre-existing `zed` provider id (Zed IDE local + * credential-import surface — src/lib/zed-oauth/ + src/mitm/detection/zed.ts). + * This entry is the new cloud-proxy capability; `zed-hosted` avoids an id clash. + * + * `models: []` + `modelsUrl` intentionally — Zed's hosted catalog changes + * frequently and is fetched live per-connection by the executor + * (open-sse/shared/zedAuth.ts::resolveZedModels), never hardcoded here. + * `modelsUrl` also feeds the dashboard's generic models-discovery fallback + * (src/app/api/providers/[id]/models/discoveryConfig.ts). + * + * No `oauth.clientIdEnv/clientSecretEnv` — Zed's native-app sign-in uses a + * self-generated RSA keypair per login attempt, not a registered OAuth + * client_id/secret (see open-sse/shared/zedAuth.ts header comment). + */ +export const zed_hostedProvider: RegistryEntry = { + id: "zed-hosted", + format: "openai", + executor: "zed-hosted", + baseUrl: "https://cloud.zed.dev/completions", + authType: "oauth", + authHeader: "bearer", + timeoutMs: 120000, + forceStream: true, + models: [], + modelsUrl: "https://cloud.zed.dev/models", + passthroughModels: true, +}; + +export default zed_hostedProvider; diff --git a/open-sse/executors/index.ts b/open-sse/executors/index.ts index a3ab06cc6f..a922247c8c 100644 --- a/open-sse/executors/index.ts +++ b/open-sse/executors/index.ts @@ -27,6 +27,7 @@ import { CommandCodeExecutor } from "./commandCode.ts"; import { GitlabExecutor } from "./gitlab.ts"; import { NlpCloudExecutor } from "./nlpcloud.ts"; import { WindsurfExecutor } from "./windsurf.ts"; +import { ZedHostedExecutor } from "./zed-hosted.ts"; import { DevinCliExecutor } from "./devin-cli.ts"; import { AuggieExecutor } from "./auggie.ts"; import { DeepSeekWebExecutor } from "./deepseek-web.ts"; @@ -111,6 +112,7 @@ const executors = { "ms-web": new MuseSparkWebExecutor(), // Alias windsurf: new WindsurfExecutor(), ws: new WindsurfExecutor(), // Alias + "zed-hosted": new ZedHostedExecutor(), "devin-cli": new DevinCliExecutor(), devin: new DevinCliExecutor(), // Alias "deepseek-web": new DeepSeekWebWithAutoRefreshExecutor(), @@ -205,6 +207,7 @@ export { CommandCodeExecutor } from "./commandCode.ts"; export { GitlabExecutor } from "./gitlab.ts"; export { NlpCloudExecutor } from "./nlpcloud.ts"; export { WindsurfExecutor } from "./windsurf.ts"; +export { ZedHostedExecutor } from "./zed-hosted.ts"; export { DevinCliExecutor } from "./devin-cli.ts"; export { AuggieExecutor } from "./auggie.ts"; export { CopilotWebExecutor } from "./copilot-web.ts"; diff --git a/open-sse/executors/zed-hosted.ts b/open-sse/executors/zed-hosted.ts new file mode 100644 index 0000000000..842ce834e6 --- /dev/null +++ b/open-sse/executors/zed-hosted.ts @@ -0,0 +1,364 @@ +/** + * ZedHostedExecutor — routes requests to Zed's hosted LLM aggregator + * (cloud.zed.dev/completions), a multi-format proxy that fronts + * Anthropic/OpenAI/Google/xAI depending on the requested model. + * + * Distinct from the pre-existing `zed` provider id, which is a Zed IDE + * credential-import surface (src/lib/zed-oauth/ + src/mitm/detection/zed.ts) — + * that surface only detects/imports local Zed IDE keychain credentials, it does + * not proxy chat completions. This executor is the NEW cloud-proxy + * capability; registry id `zed-hosted` avoids colliding with the IDE id. + * + * Wire protocol: POST /completions with an NDJSON/SSE-ish body-per-line + * response stream (`{"event": }` / + * `{"status": ...}` / `[DONE]`), authenticated with a short-lived LLM + * bearer token (see open-sse/shared/zedAuth.ts). The provider-shaped + * chunk is Claude/Gemini/OpenAI-Responses/xAI(OpenAI-shaped) depending on + * which upstream Zed is fronting for the requested model — translated back + * to OpenAI Chat Completions chunks by reusing OmniRoute's own translators + * (the same ones used for the native claude/gemini/codex executors), never + * a bespoke per-provider parser. + * + * Ported from decolua/9router PR #2328 (open-sse/executors/zed.js), + * adapted to TypeScript + OmniRoute's BaseExecutor/translator conventions. + * Like WindsurfExecutor, this overrides execute() entirely rather than + * using BaseExecutor's default Claude-Code-oriented pipeline, because the + * Zed wire request/response shape (thread envelope, LLM-token exchange, + * NDJSON status frames) doesn't fit the generic transformRequest/buildUrl + * contract that pipeline assumes. + */ + +import { BaseExecutor, type ExecuteInput, type ProviderCredentials } from "./base.ts"; +import { PROVIDERS } from "../config/constants.ts"; +import { FORMATS } from "../translator/formats.ts"; +import { initState } from "../translator/index.ts"; +import { openaiToClaudeRequest } from "../translator/request/openai-to-claude.ts"; +import { openaiToGeminiRequest } from "../translator/request/openai-to-gemini.ts"; +import { openaiToOpenAIResponsesRequest } from "../translator/request/openai-responses/toResponses.ts"; +import { claudeToOpenAIResponse } from "../translator/response/claude-to-openai.ts"; +import { geminiToOpenAIResponse } from "../translator/response/gemini-to-openai.ts"; +import { openaiResponsesToOpenAIResponse } from "../translator/response/openai-responses.ts"; +import { ZED_HEADERS, resolveZedModels, zedLlmFetch, type ZedCredentials } from "../shared/zedAuth.ts"; + +const ZED_PROVIDER = { + anthropic: "Anthropic", + openai: "OpenAi", + google: "Google", + xai: "XAi", +} as const; + +type ZedProviderName = (typeof ZED_PROVIDER)[keyof typeof ZED_PROVIDER]; + +function normalizeZedProvider(value: unknown, model: unknown): ZedProviderName { + const raw = String(value || "").toLowerCase(); + if (raw === "anthropic") return ZED_PROVIDER.anthropic; + if (raw === "openai" || raw === "open_ai") return ZED_PROVIDER.openai; + if (raw === "google" || raw === "gemini") return ZED_PROVIDER.google; + if (raw === "xai" || raw === "x_ai" || raw === "x-ai") return ZED_PROVIDER.xai; + + const m = String(model || "").toLowerCase(); + if (m.includes("claude")) return ZED_PROVIDER.anthropic; + if (m.includes("gemini")) return ZED_PROVIDER.google; + if (m.includes("grok") || m.includes("xai")) return ZED_PROVIDER.xai; + return ZED_PROVIDER.openai; +} + +function buildProviderRequest( + provider: ZedProviderName, + model: string, + body: unknown, + stream: boolean, + credentials: ProviderCredentials +): unknown { + if (provider === ZED_PROVIDER.anthropic) { + return openaiToClaudeRequest(model, body, true); + } + if (provider === ZED_PROVIDER.google) { + return openaiToGeminiRequest(model, body as Record, true, credentials); + } + if (provider === ZED_PROVIDER.openai) { + return openaiToOpenAIResponsesRequest(model, body, true, credentials); + } + return { + ...(body as Record), + model, + stream: stream !== false, + }; +} + +function initProviderState(provider: ZedProviderName, model: string): Record { + if (provider === ZED_PROVIDER.anthropic) return initState(FORMATS.CLAUDE); + if (provider === ZED_PROVIDER.google) return initState(FORMATS.GEMINI); + if (provider === ZED_PROVIDER.openai) return initState(FORMATS.OPENAI_RESPONSES); + const state = initState(FORMATS.OPENAI); + state.model = model; + return state; +} + +function convertProviderEvent( + provider: ZedProviderName, + event: unknown, + state: Record +): unknown { + if (provider === ZED_PROVIDER.anthropic) return claudeToOpenAIResponse(event, state); + if (provider === ZED_PROVIDER.google) return geminiToOpenAIResponse(event, state); + if (provider === ZED_PROVIDER.openai) return openaiResponsesToOpenAIResponse(event, state); + return event; +} + +function createErrorChunk(model: string, message: string): Record { + return { + id: `chatcmpl-zed-error-${Date.now()}`, + object: "chat.completion.chunk", + created: Math.floor(Date.now() / 1000), + model, + choices: [{ index: 0, delta: { content: `[Zed error] ${message}` }, finish_reason: "stop" }], + }; +} + +function enqueueSseObject( + controller: ReadableStreamDefaultController, + encoder: TextEncoder, + chunk: unknown +): void { + if (!chunk) return; + const items = Array.isArray(chunk) ? chunk : [chunk]; + for (const item of items) { + if (!item) continue; + controller.enqueue(encoder.encode(`data: ${JSON.stringify(item)}\n\n`)); + } +} + +type ZedLine = { done?: true; status?: unknown; event?: unknown } | null; + +function unwrapZedLine(line: string): ZedLine { + let text = line.replace(/\r$/, "").trim(); + if (!text) return null; + if (text.startsWith("data:")) text = text.slice(5).trimStart(); + if (text === "[DONE]") return { done: true }; + try { + const parsed = JSON.parse(text); + if (parsed && Object.prototype.hasOwnProperty.call(parsed, "event")) { + return { event: parsed.event }; + } + if (parsed && Object.prototype.hasOwnProperty.call(parsed, "status")) { + return { status: parsed.status }; + } + return { event: parsed }; + } catch { + return null; + } +} + +function normalizeStatus(status: unknown): Record | null { + if (!status) return null; + if (typeof status === "string") return { type: status }; + if (typeof status === "object") { + const rec = status as Record; + const key = Object.keys(rec)[0]; + if (key && typeof rec[key] === "object") return { type: key, ...(rec[key] as object) }; + return rec; + } + return null; +} + +function wrapZedCompletionStream( + response: Response, + provider: ZedProviderName, + model: string +): Response { + if (!response.ok || !response.body) return response; + + const decoder = new TextDecoder(); + const encoder = new TextEncoder(); + const state = initProviderState(provider, model); + let buffer = ""; + let done = false; + + const finish = (controller: ReadableStreamDefaultController) => { + if (done) return; + const finalChunk = convertProviderEvent(provider, null, state); + enqueueSseObject(controller, encoder, finalChunk); + controller.enqueue(encoder.encode("data: [DONE]\n\n")); + done = true; + }; + + const processLine = (line: string, controller: ReadableStreamDefaultController) => { + if (done) return; + const payload = unwrapZedLine(line); + if (!payload) return; + if (payload.done) { + finish(controller); + return; + } + if (payload.status) { + const status = normalizeStatus(payload.status); + if (status?.type === "failed" || status?.failed) { + const failed = (status.failed as Record) || status; + const message = String(failed.message || failed.error || failed.code || "request failed"); + enqueueSseObject(controller, encoder, createErrorChunk(model, message)); + finish(controller); + } else if (status?.type === "stream_ended" || status === ("stream_ended" as unknown)) { + finish(controller); + } + return; + } + const converted = convertProviderEvent(provider, payload.event, state); + enqueueSseObject(controller, encoder, converted); + }; + + const transformed = response.body.pipeThrough( + new TransformStream({ + transform(chunk, controller) { + buffer += decoder.decode(chunk, { stream: true }); + let nl: number; + while ((nl = buffer.indexOf("\n")) !== -1) { + const line = buffer.slice(0, nl); + buffer = buffer.slice(nl + 1); + processLine(line, controller); + } + }, + flush(controller) { + buffer += decoder.decode(); + if (buffer) { + processLine(buffer, controller); + buffer = ""; + } + finish(controller); + }, + }) + ); + + return new Response(transformed, { + status: response.status, + statusText: response.statusText, + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + }, + }); +} + +export class ZedHostedExecutor extends BaseExecutor { + constructor() { + super("zed-hosted", PROVIDERS["zed-hosted"] || {}); + } + + async resolveModel( + model: string, + credentials: ZedCredentials, + signal: AbortSignal | null | undefined, + log: ExecuteInput["log"] + ): Promise<{ raw: Record | null; provider: ZedProviderName }> { + try { + const catalog = await resolveZedModels(credentials, { config: this.config, signal }); + let raw = catalog?.rawById?.get(model) ?? null; + if (!raw) { + const refreshed = await resolveZedModels(credentials, { + config: this.config, + signal, + forceRefresh: true, + }); + raw = refreshed?.rawById?.get(model) ?? null; + } + return { + raw, + provider: normalizeZedProvider(raw?.provider, model), + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + log?.warn?.("ZED", `model catalog unavailable, inferring provider for ${model}: ${message}`); + return { raw: null, provider: normalizeZedProvider(null, model) }; + } + } + + async execute({ model, body, stream, credentials, signal, log }: ExecuteInput): Promise<{ + response: Response; + url: string; + headers: Record; + transformedBody: unknown; + }> { + const zedCredentials = credentials as ZedCredentials; + const { provider } = await this.resolveModel(model, zedCredentials, signal, log); + const providerRequest = buildProviderRequest(provider, model, body, stream, credentials); + const bodyRecord = (body ?? {}) as Record; + const payload = { + thread_id: bodyRecord.thread_id || (credentials as Record)?._clientSessionId, + prompt_id: bodyRecord.prompt_id, + provider, + model, + provider_request: providerRequest, + }; + + const response = await zedLlmFetch(zedCredentials, "/completions", { + config: this.config, + signal, + fetchOptions: { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/x-ndjson, text/event-stream, */*", + "User-Agent": `OmniRoute/zed-hosted`, + "x-zed-version": (this.config as Record)?.appVersion?.toString() || "0.200.0", + [ZED_HEADERS.clientSupportsStatus]: "true", + [ZED_HEADERS.clientSupportsStreamEnded]: "true", + }, + body: JSON.stringify(payload), + }, + }); + + const wrapped = response.ok ? wrapZedCompletionStream(response, provider, model) : response; + return { + response: wrapped, + url: `${(this.config as Record)?.llmBaseUrl || "https://cloud.zed.dev"}/completions`, + headers: { "Content-Type": "application/json", Authorization: "Bearer " }, + transformedBody: payload, + }; + } + + parseError(response: Response, bodyText: string): { status: number; message: string } { + let parsed: Record | null = null; + try { + parsed = JSON.parse(bodyText || "{}"); + } catch { + parsed = null; + } + + const errorObj = (parsed?.error as Record) || undefined; + const code = (parsed?.code as string) || (errorObj?.code as string) || ""; + const rawMessage = + (parsed?.message as string) || (errorObj?.message as string) || bodyText || response.statusText; + if (code === "trial_blocked") { + return { + status: response.status, + message: `Zed trial access is blocked upstream. The account can list hosted models, but Zed is refusing completions until trial/billing access is enabled or unblocked. Zed says: ${rawMessage}`, + }; + } + if (code) { + return { + status: response.status, + message: `Zed ${code}: ${rawMessage}`, + }; + } + return { + status: response.status, + message: rawMessage || `Zed upstream error: ${response.status}`, + }; + } + + async refreshCredentials(): Promise | null> { + return null; + } + + needsRefresh(): boolean { + return false; + } +} + +export default ZedHostedExecutor; + +export const __test__ = { + normalizeZedProvider, + unwrapZedLine, + wrapZedCompletionStream, +}; diff --git a/open-sse/shared/zedAuth.ts b/open-sse/shared/zedAuth.ts new file mode 100644 index 0000000000..8c32fbc76e --- /dev/null +++ b/open-sse/shared/zedAuth.ts @@ -0,0 +1,535 @@ +/** + * Zed Hosted Models — auth + model-catalog helpers. + * + * Zed's cloud aggregator (cloud.zed.dev) authenticates native apps with a + * self-generated RSA keypair instead of a registered OAuth client_id/secret: + * + * 1. The client (OmniRoute) generates an ephemeral RSA keypair. + * 2. It sends the public key to zed.dev/native_app_signin (as a URL param). + * 3. The user signs in inside their browser (Zed itself brokers GitHub/Google). + * 4. Zed's browser flow redirects to a local "native app" callback + * (`http://127.0.0.1:/?user_id=...&access_token=...`) with the + * access token RSA-encrypted against the public key we sent in step 2. + * 5. OmniRoute decrypts the access token locally with the private key that + * never left the server (or the operator's browser/paste flow). + * + * No client_id/client_secret/Firebase key is embedded anywhere in this file — + * the "credential" is a keypair generated fresh per login attempt, so + * CLAUDE.md Hard Rule #11 (resolvePublicCred for embedded upstream secrets) + * does not apply here. + * + * Ported from decolua/9router PR #2328 (open-sse/shared/zedAuth.js), + * adapted to TypeScript + OmniRoute conventions. `fetch` is intentionally the + * global one — open-sse/utils/proxyFetch.ts monkey-patches `globalThis.fetch` + * with the proxy-aware dispatcher at module load, so every plain `fetch()` + * call in this codebase already goes through it. + */ + +import crypto from "node:crypto"; + +export const ZED_WEB_BASE_URL = "https://zed.dev"; +export const ZED_CLOUD_BASE_URL = "https://cloud.zed.dev"; +export const ZED_LLM_BASE_URL = "https://cloud.zed.dev"; + +export const ZED_HEADERS = { + expiredToken: "x-zed-expired-token", + outdatedToken: "x-zed-outdated-token", + clientSupportsStatus: "x-zed-client-supports-status-messages", + clientSupportsStreamEnded: "x-zed-client-supports-stream-ended-request-completion-status", + serverSupportsStatus: "x-zed-server-supports-status-messages", + clientSupportsXai: "x-zed-client-supports-x-ai", + systemId: "x-zed-system-id", +} as const; + +const PRIVATE_KEY_PREFIX = "zed-rsa-pkcs1:"; +const LLM_TOKEN_TTL_MS = 50 * 60 * 1000; +const MODEL_CACHE_TTL_MS = 60 * 60 * 1000; + +export type ZedRawModel = Record; + +export type ZedModel = { + id: string; + name: string; + provider: unknown; + isLatest: boolean; + contextLength: unknown; + contextLengthInMaxMode: unknown; + maxOutputTokens: unknown; + supportsTools: boolean; + supportsImages: boolean; + supportsThinking: boolean; + supportsDisablingThinking: boolean; + supportsFastMode: boolean; + supportsServerSideCompaction: boolean; + supportedEffortLevels: unknown; + supportsStreamingTools: boolean; + supportsParallelToolCalls: boolean; + isDisabled: boolean; + disabledReason: unknown; +}; + +export type ZedModelCatalog = { + expiresAt: number; + models: ZedModel[]; + rawModels: ZedRawModel[]; + rawById: Map; + defaultModel: string; + defaultFastModel: string; + recommendedModels: string[]; +}; + +export type ZedCredentials = { + accessToken?: string; + apiKey?: string; + providerSpecificData?: { + userId?: string; + systemId?: string; + organizationId?: unknown; + defaultOrganizationId?: unknown; + [key: string]: unknown; + } | null; + [key: string]: unknown; +}; + +export type ZedRequestConfig = { + webBaseUrl?: string; + cloudBaseUrl?: string; + llmBaseUrl?: string; + defaultNativeAppPort?: number; + [key: string]: unknown; +}; + +const llmTokenCache = new Map(); +const modelCache = new Map(); +const modelInflight = new Map>(); + +function b64url(value: string): string { + return Buffer.from(value).toString("base64url"); +} + +function b64urlPadded(value: Buffer): string { + return value.toString("base64").replace(/\+/g, "-").replace(/\//g, "_"); +} + +function fromB64url(value: string): string { + return Buffer.from(String(value || ""), "base64url").toString("utf8"); +} + +function normalizeBaseUrl(baseUrl: unknown, fallback: string): string { + return String(baseUrl || fallback).replace(/\/+$/, ""); +} + +function zedUrl( + config: ZedRequestConfig | undefined, + key: "cloudBaseUrl" | "llmBaseUrl" | "webBaseUrl", + path: string, + fallbackBase: string +): string { + const base = normalizeBaseUrl(config?.[key], fallbackBase); + return `${base}${path}`; +} + +/** Encode a PEM private key as an opaque verifier string that can flow through the + * generic OAuth `codeVerifier` slot (mirrors PKCE's code_verifier plumbing). */ +export function encodeZedPrivateKeyVerifier(privateKeyPem: string): string { + return `${PRIVATE_KEY_PREFIX}${b64url(privateKeyPem)}`; +} + +export function decodeZedPrivateKeyVerifier(verifier: unknown): string { + const value = String(verifier || ""); + if (!value.startsWith(PRIVATE_KEY_PREFIX)) { + throw new Error("Missing Zed private key verifier; restart the login flow"); + } + return fromB64url(value.slice(PRIVATE_KEY_PREFIX.length)); +} + +export type ZedNativeAuthData = { + authUrl: string; + privateKeyVerifier: string; + nativeAppPort: number; + systemId: string; + publicKey: string; +}; + +/** Generate a fresh RSA keypair + the zed.dev native_app_signin URL for it. */ +export function createZedNativeAuthData( + config: ZedRequestConfig = {}, + options: { nativeAppPort?: number; systemId?: string } = {} +): ZedNativeAuthData { + const { publicKey, privateKey } = crypto.generateKeyPairSync("rsa", { + modulusLength: 2048, + publicKeyEncoding: { type: "pkcs1", format: "der" }, + privateKeyEncoding: { type: "pkcs1", format: "pem" }, + }); + + const nativeAppPort = Number(options.nativeAppPort || config.defaultNativeAppPort || 58443); + const systemId = options.systemId || crypto.randomUUID(); + const publicKeyString = b64urlPadded(publicKey as unknown as Buffer); + const signInUrl = new URL( + `${normalizeBaseUrl(config.webBaseUrl, ZED_WEB_BASE_URL)}/native_app_signin` + ); + signInUrl.searchParams.set("native_app_port", String(nativeAppPort)); + signInUrl.searchParams.set("native_app_public_key", publicKeyString); + if (systemId) signInUrl.searchParams.set("system_id", systemId); + + return { + authUrl: signInUrl.toString(), + privateKeyVerifier: encodeZedPrivateKeyVerifier(privateKey as unknown as string), + nativeAppPort, + systemId, + publicKey: publicKeyString, + }; +} + +export type ZedCallbackPayload = { + userId: string; + encryptedAccessToken: string; +}; + +/** Parse the pasted native-app callback URL/JSON/query string into userId + encrypted token. */ +export function parseZedCallbackPayload(input: unknown): ZedCallbackPayload { + const raw = String(input || "").trim(); + if (!raw) throw new Error("Missing Zed callback URL"); + + let data: Record = {}; + try { + data = JSON.parse(raw); + } catch { + let url: URL; + try { + url = new URL(raw); + } catch { + try { + url = new URL(`http://127.0.0.1/?${raw.replace(/^\?/, "")}`); + } catch { + throw new Error("Invalid Zed callback URL"); + } + } + url.searchParams.forEach((value, key) => { + data[key] = value; + }); + } + + const userId = data.user_id || data.userId; + const encryptedAccessToken = data.access_token || data.accessToken || data.token; + if (!userId || !encryptedAccessToken) { + throw new Error("Zed callback must include user_id and access_token"); + } + return { + userId: String(userId), + encryptedAccessToken: String(encryptedAccessToken), + }; +} + +/** Decrypt the RSA-encrypted access token Zed returned, using our stored private key. */ +export function decryptZedAccessToken(encryptedAccessToken: unknown, privateKeyVerifier: unknown): string { + const privateKey = decodeZedPrivateKeyVerifier(privateKeyVerifier); + const encrypted = Buffer.from(String(encryptedAccessToken), "base64url"); + try { + return crypto + .privateDecrypt( + { key: privateKey, padding: crypto.constants.RSA_PKCS1_OAEP_PADDING, oaepHash: "sha256" }, + encrypted + ) + .toString("utf8"); + } catch (oaepError) { + try { + return crypto + .privateDecrypt({ key: privateKey, padding: crypto.constants.RSA_PKCS1_PADDING }, encrypted) + .toString("utf8"); + } catch { + const message = oaepError instanceof Error ? oaepError.message : String(oaepError); + throw new Error(`Failed to decrypt Zed access token: ${message}`); + } + } +} + +export function buildZedUserAuthHeader(credentials: ZedCredentials | null | undefined): string { + const psd = credentials?.providerSpecificData || {}; + const userId = psd.userId || (credentials as Record | null)?.userId; + const accessToken = credentials?.accessToken || credentials?.apiKey; + if (!userId || !accessToken) { + throw new Error("Zed credential is missing userId or accessToken"); + } + return `${userId} ${accessToken}`; +} + +function getSystemId(credentials: ZedCredentials | null | undefined): string { + return String( + credentials?.providerSpecificData?.systemId || + (credentials as Record | null)?.systemId || + "" + ); +} + +async function fetchJson(url: string, options: RequestInit) { + const res = await fetch(url, options); + const text = await res.text(); + let data = null; + if (text) { + try { + data = JSON.parse(text); + } catch { + data = { raw: text }; + } + } + if (!res.ok) { + const message = data?.message || data?.error?.message || data?.error || text || `HTTP ${res.status}`; + const err = new Error(String(message)) as Error & { status?: number; body?: unknown }; + err.status = res.status; + err.body = data; + throw err; + } + return data; +} + +export async function fetchZedAuthenticatedUser( + credentials: ZedCredentials, + options: { config?: ZedRequestConfig; signal?: AbortSignal | null } = {} +) { + const config = options.config || {}; + const headers: Record = { + Accept: "application/json", + Authorization: buildZedUserAuthHeader(credentials), + }; + const systemId = getSystemId(credentials); + if (systemId) headers[ZED_HEADERS.systemId] = systemId; + + return fetchJson(zedUrl(config, "cloudBaseUrl", "/client/users/me", ZED_CLOUD_BASE_URL), { + method: "GET", + headers, + signal: options.signal ?? undefined, + }); +} + +function normalizeOrganizationId(value: unknown): string { + if (!value) return ""; + if (typeof value === "string") return value; + if (typeof value === "object" && value !== null) { + const rec = value as Record; + if (typeof rec[0] === "string") return rec[0] as string; + if (typeof rec.id === "string") return rec.id; + } + return String(value); +} + +export function resolveZedOrganizationId( + credentials: ZedCredentials, + userInfo = null +): string { + const psd = credentials?.providerSpecificData || {}; + const explicit = normalizeOrganizationId(psd.organizationId || psd.defaultOrganizationId); + if (explicit) return explicit; + const fromUser = normalizeOrganizationId( + userInfo?.default_organization_id || userInfo?.defaultOrganizationId + ); + if (fromUser) return fromUser; + const org = + (userInfo?.organizations || []).find((item: Record) => item?.is_personal) || + userInfo?.organizations?.[0]; + return normalizeOrganizationId(org?.id); +} + +function zedUserCacheKey(credentials: ZedCredentials, organizationId: string): string { + const psd = credentials?.providerSpecificData || {}; + const userId = psd.userId || (credentials as Record).userId || "unknown"; + const token = credentials?.accessToken || credentials?.apiKey || ""; + return `${userId}:${organizationId || "default"}:${token.slice(-16)}`; +} + +function zedModelCacheKey(credentials: ZedCredentials): string { + const psd = credentials?.providerSpecificData || {}; + const org = psd.organizationId || psd.defaultOrganizationId || "default"; + const token = credentials?.accessToken || credentials?.apiKey || ""; + return `${psd.userId || "unknown"}:${org}:${token.slice(-16)}`; +} + +export async function fetchZedLlmToken( + credentials: ZedCredentials, + options: { + config?: ZedRequestConfig; + organizationId?: string; + forceRefresh?: boolean; + signal?: AbortSignal | null; + } = {} +): Promise { + const config = options.config || {}; + let organizationId = options.organizationId || resolveZedOrganizationId(credentials); + if (!organizationId) { + const userInfo = await fetchZedAuthenticatedUser(credentials, options); + organizationId = resolveZedOrganizationId(credentials, userInfo); + } + if (!organizationId) throw new Error("No Zed organization selected"); + + const cacheKey = zedUserCacheKey(credentials, organizationId); + const cached = llmTokenCache.get(cacheKey); + if (!options.forceRefresh && cached && cached.expiresAt > Date.now()) return cached.token; + + const headers: Record = { + "Content-Type": "application/json", + Accept: "application/json", + Authorization: buildZedUserAuthHeader(credentials), + }; + const systemId = getSystemId(credentials); + if (systemId) headers[ZED_HEADERS.systemId] = systemId; + + const data = await fetchJson(zedUrl(config, "cloudBaseUrl", "/client/llm_tokens", ZED_CLOUD_BASE_URL), { + method: "POST", + headers, + body: JSON.stringify({ organization_id: organizationId }), + signal: options.signal ?? undefined, + }); + const token = + typeof data?.token === "string" ? data.token : data?.token?.[0] || data?.token?.value; + if (!token) throw new Error("Zed did not return an LLM token"); + llmTokenCache.set(cacheKey, { token, expiresAt: Date.now() + LLM_TOKEN_TTL_MS }); + return token; +} + +export function shouldRefreshZedLlmToken(response: Response | null | undefined): boolean { + return ( + response?.status === 401 || + !!response?.headers?.has?.(ZED_HEADERS.expiredToken) || + !!response?.headers?.has?.(ZED_HEADERS.outdatedToken) + ); +} + +export async function zedLlmFetch( + credentials: ZedCredentials, + path: string, + options: { + config?: ZedRequestConfig; + signal?: AbortSignal | null; + fetchOptions?: RequestInit; + organizationId?: string; + forceRefresh?: boolean; + } = {} +): Promise { + const config = options.config || {}; + const url = zedUrl(config, "llmBaseUrl", path, ZED_LLM_BASE_URL); + const buildRequest = async (forceRefresh: boolean) => { + const token = await fetchZedLlmToken(credentials, { ...options, forceRefresh }); + return fetch(url, { + ...options.fetchOptions, + headers: { + ...(options.fetchOptions?.headers || {}), + Authorization: `Bearer ${token}`, + }, + signal: options.signal ?? undefined, + }); + }; + + let response = await buildRequest(false); + if (shouldRefreshZedLlmToken(response)) { + response = await buildRequest(true); + } + return response; +} + +function normalizeZedModelId(id: unknown): string { + if (!id) return ""; + if (typeof id === "string") return id; + if (typeof id === "object" && id !== null) { + const rec = id as Record; + if (typeof rec[0] === "string") return rec[0] as string; + if (typeof rec.id === "string") return rec.id; + } + return String(id); +} + +export function mapZedModel(model: ZedRawModel): ZedModel | null { + const id = normalizeZedModelId(model?.id); + if (!id) return null; + return { + id, + name: (model.display_name as string) || (model.displayName as string) || id, + provider: model.provider, + isLatest: !!model.is_latest, + contextLength: model.max_token_count ?? model.maxTokenCount, + contextLengthInMaxMode: model.max_token_count_in_max_mode ?? model.maxTokenCountInMaxMode, + maxOutputTokens: model.max_output_tokens ?? model.maxOutputTokens, + supportsTools: !!model.supports_tools, + supportsImages: !!model.supports_images, + supportsThinking: !!model.supports_thinking, + supportsDisablingThinking: !!model.supports_disabling_thinking, + supportsFastMode: !!model.supports_fast_mode, + supportsServerSideCompaction: !!model.supports_server_side_compaction, + supportedEffortLevels: model.supported_effort_levels ?? model.supportedEffortLevels ?? [], + supportsStreamingTools: !!model.supports_streaming_tools, + supportsParallelToolCalls: !!model.supports_parallel_tool_calls, + isDisabled: !!model.is_disabled, + disabledReason: model.disabled_reason ?? null, + }; +} + +/** Resolve (and cache) the live Zed model catalog. Never hardcoded — always a live fetch. */ +export async function resolveZedModels( + credentials: ZedCredentials, + options: { + config?: ZedRequestConfig; + signal?: AbortSignal | null; + forceRefresh?: boolean; + } = {} +): Promise { + if (!credentials?.accessToken) return null; + const key = zedModelCacheKey(credentials); + const cached = modelCache.get(key); + if (!options.forceRefresh && cached && cached.expiresAt > Date.now()) return cached; + + const existing = modelInflight.get(key); + if (existing && !options.forceRefresh) return existing; + + const promise = (async (): Promise => { + const response = await zedLlmFetch(credentials, "/models", { + ...options, + fetchOptions: { + method: "GET", + headers: { + Accept: "application/json", + [ZED_HEADERS.clientSupportsXai]: "true", + }, + }, + }); + if (!response.ok) { + const text = await response.text().catch(() => ""); + throw new Error(`Zed models failed: ${response.status} ${text}`); + } + const data = await response.json(); + const rawModels: ZedRawModel[] = Array.isArray(data?.models) ? data.models : []; + const models = rawModels + .map(mapZedModel) + .filter((m): m is ZedModel => !!m) + .filter((model) => !model.isDisabled); + const rawById = new Map(); + for (const raw of rawModels) { + const id = normalizeZedModelId(raw?.id); + if (id) rawById.set(id, raw); + } + const entry: ZedModelCatalog = { + expiresAt: Date.now() + MODEL_CACHE_TTL_MS, + models, + rawModels, + rawById, + defaultModel: normalizeZedModelId(data?.default_model ?? data?.defaultModel), + defaultFastModel: normalizeZedModelId(data?.default_fast_model ?? data?.defaultFastModel), + recommendedModels: (data?.recommended_models || data?.recommendedModels || []) + .map(normalizeZedModelId) + .filter(Boolean), + }; + modelCache.set(key, entry); + return entry; + })(); + + modelInflight.set(key, promise); + try { + return await promise; + } finally { + if (modelInflight.get(key) === promise) modelInflight.delete(key); + } +} + +export function clearZedCaches(): void { + llmTokenCache.clear(); + modelCache.clear(); + modelInflight.clear(); +} diff --git a/src/lib/oauth/constants/oauth.ts b/src/lib/oauth/constants/oauth.ts index 055808d5d5..4ca6d3a70a 100644 --- a/src/lib/oauth/constants/oauth.ts +++ b/src/lib/oauth/constants/oauth.ts @@ -440,6 +440,29 @@ export const ZED_CONFIG = { manualImportUrl: "/api/providers/zed/manual-import", }; +// Zed Hosted Models Configuration (native-app RSA-keypair sign-in) +// +// Zed's cloud aggregator (cloud.zed.dev) does not use a registered OAuth +// client_id/secret. The client generates a fresh RSA keypair per login +// attempt and sends the public key to zed.dev/native_app_signin; Zed +// encrypts the resulting access token against that public key and redirects +// the browser to a local "native app" callback +// (http://127.0.0.1:/?user_id=...&access_token=...). OmniRoute decrypts +// the token with the matching private key — see open-sse/shared/zedAuth.ts. +// No client_id/secret/Firebase key is embedded here (Hard Rule #11 does not +// apply — there is no upstream secret to embed). +export const ZED_HOSTED_CONFIG = { + webBaseUrl: "https://zed.dev", + cloudBaseUrl: "https://cloud.zed.dev", + llmBaseUrl: "https://cloud.zed.dev", + nativeSignInPath: "/native_app_signin", + userInfoUrl: "https://cloud.zed.dev/client/users/me", + llmTokenUrl: "https://cloud.zed.dev/client/llm_tokens", + modelsUrl: "https://cloud.zed.dev/models", + completionsUrl: "https://cloud.zed.dev/completions", + defaultNativeAppPort: 58443, +}; + // OAuth timeout (5 minutes) export const OAUTH_TIMEOUT = 300000; @@ -467,4 +490,5 @@ export const PROVIDERS = { CODEBUDDY_CN: "codebuddy-cn", GROK_CLI: "grok-cli", ZED: "zed", + ZED_HOSTED: "zed-hosted", }; diff --git a/src/lib/oauth/providers.ts b/src/lib/oauth/providers.ts index 9cd77b557d..cc6ab06aec 100644 --- a/src/lib/oauth/providers.ts +++ b/src/lib/oauth/providers.ts @@ -110,7 +110,9 @@ export function getProvider(name) { */ export function generateAuthData(providerName, redirectUri) { const provider = getProvider(providerName); - const { codeVerifier, codeChallenge, state } = generatePKCE(); + const pkce = generatePKCE(); + let codeVerifier = pkce.codeVerifier; + const { codeChallenge, state } = pkce; if (provider.flowType === "import_token") { let error: string; @@ -145,7 +147,24 @@ export function generateAuthData(providerName, redirectUri) { } else if (provider.flowType === "authorization_code_pkce") { authUrl = provider.buildAuthUrl(provider.config, redirectUri, state, codeChallenge); } else { - authUrl = provider.buildAuthUrl(provider.config, redirectUri, state); + const built = provider.buildAuthUrl(provider.config, redirectUri, state); + // Some non-PKCE "authorization_code" providers (e.g. zed-hosted) need to + // override the auto-generated PKCE codeVerifier/redirectUri with their own + // provider-specific verifier (e.g. an RSA private-key verifier) instead of + // an unused PKCE code_verifier — they return an object instead of a bare + // authUrl string. Existing providers all return a plain string, so this is + // backward compatible. + if (built && typeof built === "object" && typeof built.authUrl === "string") { + authUrl = built.authUrl; + if (typeof built.codeVerifier === "string" && built.codeVerifier) { + codeVerifier = built.codeVerifier; + } + if (typeof built.redirectUri === "string" && built.redirectUri) { + redirectUri = built.redirectUri; + } + } else { + authUrl = built; + } } return { diff --git a/src/lib/oauth/providers/index.ts b/src/lib/oauth/providers/index.ts index 54f87a94fd..1be452e2c5 100644 --- a/src/lib/oauth/providers/index.ts +++ b/src/lib/oauth/providers/index.ts @@ -28,6 +28,7 @@ import { windsurf } from "./windsurf"; import { grokCli } from "./grok-cli"; import { codebuddyCn } from "./codebuddy-cn"; import { zed } from "./zed"; +import { zedHosted } from "./zed-hosted"; export const PROVIDERS = { claude, @@ -52,6 +53,7 @@ export const PROVIDERS = { "codebuddy-cn": codebuddyCn, // Zed IDE credential bridge — uses keychain import, not standard OAuth zed, + "zed-hosted": zedHosted, }; export default PROVIDERS; diff --git a/src/lib/oauth/providers/zed-hosted.ts b/src/lib/oauth/providers/zed-hosted.ts new file mode 100644 index 0000000000..db263a509f --- /dev/null +++ b/src/lib/oauth/providers/zed-hosted.ts @@ -0,0 +1,89 @@ +import { ZED_HOSTED_CONFIG } from "../constants/oauth"; +import { + createZedNativeAuthData, + parseZedCallbackPayload, + decryptZedAccessToken, + fetchZedAuthenticatedUser, + resolveZedOrganizationId, +} from "@omniroute/open-sse/shared/zedAuth.ts"; + +/** + * Zed Hosted Models OAuth provider. + * + * flowType "authorization_code" (mirrors `cline`'s non-PKCE authorization_code + * shape), but with a twist: `buildAuthUrl` returns `{ authUrl, codeVerifier }` + * instead of a bare string — `codeVerifier` here carries the *RSA private key + * verifier* (see zedAuth.ts::encodeZedPrivateKeyVerifier), reusing the + * existing PKCE `codeVerifier` plumbing (generateAuthData → OAuthModal → + * /exchange) as the transport for the keypair, since Zed's own flow has no + * client_id/secret or authorization code to exchange — only a private key + * needed to decrypt whatever access token the browser callback carries. + * + * `code` at exchange time is the pasted native-app callback URL/query string + * (`http://127.0.0.1:/?user_id=...&access_token=...`) — Zed always + * redirects to loopback + native_app_port, ignoring any `redirect_uri` we'd + * send, so `redirectUri` here is unused by exchangeToken (kept only to + * satisfy OAuthModal's generic "session must have a redirectUri" guard). + */ +export const zedHosted = { + config: ZED_HOSTED_CONFIG, + flowType: "authorization_code", + buildAuthUrl: (config: typeof ZED_HOSTED_CONFIG) => { + const authData = createZedNativeAuthData(config); + return { + authUrl: authData.authUrl, + codeVerifier: authData.privateKeyVerifier, + redirectUri: `http://127.0.0.1:${authData.nativeAppPort}/`, + }; + }, + exchangeToken: async ( + config: typeof ZED_HOSTED_CONFIG, + code: string, + _redirectUri: string, + codeVerifier: string + ) => { + const { userId, encryptedAccessToken } = parseZedCallbackPayload(code); + const accessToken = decryptZedAccessToken(encryptedAccessToken, codeVerifier); + + const credentials = { accessToken, providerSpecificData: { userId } }; + let email: string | undefined; + let name: string | undefined; + let organizationId = ""; + try { + const userInfo = await fetchZedAuthenticatedUser(credentials, { config }); + email = userInfo?.email || userInfo?.github_login; + name = userInfo?.name || userInfo?.github_login; + organizationId = resolveZedOrganizationId(credentials, userInfo); + } catch { + // Non-fatal — the account still works without a resolved email/org; + // fetchZedLlmToken will resolve the organization lazily on first use. + } + + return { + access_token: accessToken, + user_id: userId, + email, + name, + organization_id: organizationId, + }; + }, + mapTokens: (tokens: { + access_token: string; + user_id: string; + email?: string; + name?: string; + organization_id?: string; + }) => ({ + accessToken: tokens.access_token, + // Zed's native-app access tokens are long-lived; no refresh flow is + // exposed by the aggregator, so no expiresIn/refreshToken here. + name: tokens.name || tokens.email || null, + email: tokens.email, + providerSpecificData: { + userId: tokens.user_id, + organizationId: tokens.organization_id || undefined, + }, + }), +}; + +export default zedHosted; diff --git a/src/shared/components/OAuthModal.tsx b/src/shared/components/OAuthModal.tsx index 0c01342aef..f19db09152 100644 --- a/src/shared/components/OAuthModal.tsx +++ b/src/shared/components/OAuthModal.tsx @@ -322,7 +322,11 @@ export default function OAuthModal({ // Claude Code and Cline OAuth flows can finish on provider-hosted pages that // show an auth code instead of redirecting back to OmniRoute. // Start directly in manual mode so users always have an input to paste code/url. - if (provider === "claude" || provider === "cline") { + // zed-hosted's native-app sign-in always redirects the browser to a local + // 127.0.0.1: callback that OmniRoute never listens on (the port is + // arbitrary and unrelated to the dashboard's own port) — nothing can + // auto-close the popup, so always show the manual paste-URL input. + if (provider === "claude" || provider === "cline" || provider === "zed-hosted") { forceManual = true; } diff --git a/src/shared/constants/providers/oauth.ts b/src/shared/constants/providers/oauth.ts index eb5d7c736d..14bdac4366 100644 --- a/src/shared/constants/providers/oauth.ts +++ b/src/shared/constants/providers/oauth.ts @@ -133,6 +133,19 @@ export const OAUTH_PROVIDERS = { authHint: "Zed stores LLM provider credentials (OpenAI, Anthropic, Google, Mistral, xAI) in the OS keychain. Use the Import button below to discover and import them automatically.", }, + "zed-hosted": { + id: "zed-hosted", + alias: undefined, + name: "Zed Hosted Models", + icon: "code_blocks", + color: "#101010", + textIcon: "ZH", + website: "https://zed.dev", + subscriptionRisk: true, + riskNoticeVariant: "oauth", + authHint: + "Sign in with your Zed account (native-app sign-in). OmniRoute generates a one-time RSA keypair and opens zed.dev to authorize it — on a remote/headless install, copy the resulting 127.0.0.1 callback URL from your browser's address bar and paste it back here. Distinct from the 'Zed IDE' credential-import entry above: this proxies chat completions through Zed's own hosted model aggregator (cloud.zed.dev), fronting Anthropic/OpenAI/Google/xAI models under your Zed plan.", + }, trae: { id: "trae", alias: "tr", diff --git a/tests/unit/oauth-providers-config.test.ts b/tests/unit/oauth-providers-config.test.ts index 2be59eb631..7637b3e051 100644 --- a/tests/unit/oauth-providers-config.test.ts +++ b/tests/unit/oauth-providers-config.test.ts @@ -42,6 +42,7 @@ const { QWEN_CONFIG, TRAE_CONFIG, WINDSURF_CONFIG, + ZED_HOSTED_CONFIG, } = oauthModule; const { getAntigravityLoadCodeAssistMetadata } = antigravityHeadersModule; @@ -68,6 +69,7 @@ const EXPECTED_PROVIDER_KEYS = [ "grok-cli", "codebuddy-cn", "zed", + "zed-hosted", ]; const EXPECTED_CONFIG_BY_PROVIDER = { @@ -91,6 +93,7 @@ const EXPECTED_CONFIG_BY_PROVIDER = { "grok-cli": GROK_CLI_CONFIG, "codebuddy-cn": CODEBUDDY_CN_CONFIG, zed: ZED_CONFIG, + "zed-hosted": ZED_HOSTED_CONFIG, }; const REQUIRED_FIELDS_BY_PROVIDER = { @@ -138,6 +141,7 @@ const REQUIRED_FIELDS_BY_PROVIDER = { windsurf: ["authorizeUrl", "apiServerUrl", "exchangePath", "inferenceUrl"], "devin-cli": ["authorizeUrl", "apiServerUrl", "exchangePath", "inferenceUrl"], trae: ["apiEndpoint", "chatEndpoint", "webUrl"], + "zed-hosted": ["webBaseUrl", "cloudBaseUrl", "llmBaseUrl", "userInfoUrl", "llmTokenUrl", "modelsUrl"], }; function getByPath(object, path) { @@ -332,6 +336,37 @@ test("browser-based providers expose buildAuthUrl and return provider-specific a assert.equal(clineUrl.origin, "https://api.cline.bot"); }); +// zed-hosted's buildAuthUrl deliberately returns an object (authUrl + codeVerifier + +// redirectUri) instead of a bare string — generateAuthData() in providers.ts special- +// cases this shape to thread an RSA private-key verifier through the existing PKCE +// codeVerifier slot (see src/lib/oauth/providers/zed-hosted.ts header comment). +test("zed-hosted buildAuthUrl returns {authUrl, codeVerifier, redirectUri} carrying a fresh RSA keypair", () => { + const built = PROVIDERS["zed-hosted"].buildAuthUrl(ZED_HOSTED_CONFIG); + assert.equal(typeof built, "object"); + assert.ok(built.authUrl.startsWith("https://zed.dev/native_app_signin?")); + const url = new URL(built.authUrl); + assert.ok(url.searchParams.get("native_app_public_key")); + assert.ok(built.codeVerifier.startsWith("zed-rsa-pkcs1:")); + assert.ok(built.redirectUri.startsWith("http://127.0.0.1:")); +}); + +test("generateAuthData honors an object-returning buildAuthUrl (zed-hosted) without breaking string-returning providers", async () => { + const oauthHelpers = await import("../../src/lib/oauth/providers.ts"); + const zedAuthData = oauthHelpers.generateAuthData("zed-hosted", "http://localhost:20128/callback"); + assert.equal(zedAuthData.flowType, "authorization_code"); + assert.ok(zedAuthData.authUrl.startsWith("https://zed.dev/native_app_signin?")); + assert.ok(zedAuthData.codeVerifier.startsWith("zed-rsa-pkcs1:")); + assert.ok(zedAuthData.redirectUri.startsWith("http://127.0.0.1:")); + + // A string-returning provider (cline) must still get the plain PKCE codeVerifier, + // not be affected by the object-return branch added for zed-hosted. + const clineAuthData = oauthHelpers.generateAuthData("cline", "http://localhost:20128/callback"); + assert.equal(typeof clineAuthData.authUrl, "string"); + assert.equal(clineAuthData.redirectUri, "http://localhost:20128/callback"); + assert.ok(clineAuthData.codeVerifier); + assert.ok(!clineAuthData.codeVerifier.startsWith("zed-rsa-pkcs1:")); +}); + // Regression for #3861: GitLab Duo needs an operator-registered OAuth client_id. // When it's missing, buildAuthUrl must return null (like Qoder) so the authorize route // can surface a clear "configure it" message — it previously THREW, which the route diff --git a/tests/unit/zed-provider.test.ts b/tests/unit/zed-provider.test.ts new file mode 100644 index 0000000000..af868412be --- /dev/null +++ b/tests/unit/zed-provider.test.ts @@ -0,0 +1,416 @@ +import test, { describe } from "node:test"; +import assert from "node:assert/strict"; +import crypto from "node:crypto"; + +import { REGISTRY } from "../../open-sse/config/providers/index.ts"; +import { getExecutor, hasSpecializedExecutor } from "../../open-sse/executors/index.ts"; +import { ZedHostedExecutor, __test__ } from "../../open-sse/executors/zed-hosted.ts"; +import { + createZedNativeAuthData, + encodeZedPrivateKeyVerifier, + decodeZedPrivateKeyVerifier, + decryptZedAccessToken, + parseZedCallbackPayload, + buildZedUserAuthHeader, + resolveZedOrganizationId, + mapZedModel, + clearZedCaches, +} from "../../open-sse/shared/zedAuth.ts"; + +const { normalizeZedProvider, unwrapZedLine } = __test__; + +// ─── Registry ────────────────────────────────────────────────────────────── + +describe("zed-hosted registry entry", () => { + test("registers under id zed-hosted, distinct from the zed IDE-import id", () => { + const entry = REGISTRY["zed-hosted"]; + assert.ok(entry, "REGISTRY.zed-hosted must exist"); + assert.equal(entry.id, "zed-hosted"); + assert.equal(entry.executor, "zed-hosted"); + assert.equal(entry.authType, "oauth"); + assert.notEqual("zed-hosted", "zed", "must not collide with the existing zed IDE id"); + }); + + test("models is empty — catalog is fetched live, never hardcoded", () => { + const entry = REGISTRY["zed-hosted"]; + assert.deepEqual(entry.models, []); + assert.equal(typeof entry.modelsUrl, "string"); + assert.ok(entry.modelsUrl.length > 0); + }); + + test("no embedded oauth client_id/client_secret literal (Hard Rule #11 N/A — no upstream secret)", () => { + const entry = REGISTRY["zed-hosted"]; + assert.equal(entry.oauth, undefined); + }); + + test("executor is wired in the executors map", () => { + assert.ok(hasSpecializedExecutor("zed-hosted")); + assert.ok(getExecutor("zed-hosted") instanceof ZedHostedExecutor); + }); +}); + +// ─── zedAuth: RSA keypair + native-app sign-in URL ───────────────────────── + +describe("createZedNativeAuthData", () => { + test("generates a fresh keypair and a native_app_signin URL carrying the public key", () => { + const authData = createZedNativeAuthData(); + assert.match(authData.authUrl, /^https:\/\/zed\.dev\/native_app_signin\?/); + const url = new URL(authData.authUrl); + assert.ok(url.searchParams.get("native_app_public_key")); + assert.equal(url.searchParams.get("native_app_port"), String(authData.nativeAppPort)); + assert.ok(authData.privateKeyVerifier.startsWith("zed-rsa-pkcs1:")); + assert.ok(authData.systemId.length > 0); + }); + + test("two calls produce different keypairs (never reused across login attempts)", () => { + const first = createZedNativeAuthData(); + const second = createZedNativeAuthData(); + assert.notEqual(first.privateKeyVerifier, second.privateKeyVerifier); + }); + + test("honors a custom nativeAppPort", () => { + const authData = createZedNativeAuthData({}, { nativeAppPort: 12345 }); + assert.equal(authData.nativeAppPort, 12345); + const url = new URL(authData.authUrl); + assert.equal(url.searchParams.get("native_app_port"), "12345"); + }); +}); + +describe("zed private key verifier encode/decode round-trip", () => { + test("round-trips an RSA PEM private key through the opaque verifier string", () => { + const { privateKey } = crypto.generateKeyPairSync("rsa", { + modulusLength: 2048, + publicKeyEncoding: { type: "pkcs1", format: "der" }, + privateKeyEncoding: { type: "pkcs1", format: "pem" }, + }); + const verifier = encodeZedPrivateKeyVerifier(privateKey); + assert.ok(verifier.startsWith("zed-rsa-pkcs1:")); + const decoded = decodeZedPrivateKeyVerifier(verifier); + assert.equal(decoded, privateKey); + }); + + test("rejects a malformed/missing verifier", () => { + assert.throws(() => decodeZedPrivateKeyVerifier(""), /Missing Zed private key verifier/); + assert.throws(() => decodeZedPrivateKeyVerifier("not-a-zed-verifier"), /Missing Zed private key verifier/); + }); +}); + +describe("RSA encrypt/decrypt round-trip (native-app callback token decryption)", () => { + test("decrypts an OAEP-encrypted access token using the matching private key", () => { + const { publicKey, privateKey } = crypto.generateKeyPairSync("rsa", { + modulusLength: 2048, + publicKeyEncoding: { type: "pkcs1", format: "pem" }, + privateKeyEncoding: { type: "pkcs1", format: "pem" }, + }); + const plaintextToken = "zed_access_token_abc123"; + const encrypted = crypto.publicEncrypt( + { key: publicKey, padding: crypto.constants.RSA_PKCS1_OAEP_PADDING, oaepHash: "sha256" }, + Buffer.from(plaintextToken, "utf8") + ); + const verifier = encodeZedPrivateKeyVerifier(privateKey); + const decrypted = decryptZedAccessToken(encrypted.toString("base64url"), verifier); + assert.equal(decrypted, plaintextToken); + }); + + test("falls back to PKCS1 padding when OAEP fails, else throws a clear error", () => { + const { publicKey, privateKey } = crypto.generateKeyPairSync("rsa", { + modulusLength: 2048, + publicKeyEncoding: { type: "pkcs1", format: "pem" }, + privateKeyEncoding: { type: "pkcs1", format: "pem" }, + }); + const plaintextToken = "zed_access_token_pkcs1"; + const encrypted = crypto.publicEncrypt( + { key: publicKey, padding: crypto.constants.RSA_PKCS1_PADDING }, + Buffer.from(plaintextToken, "utf8") + ); + const verifier = encodeZedPrivateKeyVerifier(privateKey); + const decrypted = decryptZedAccessToken(encrypted.toString("base64url"), verifier); + assert.equal(decrypted, plaintextToken); + }); + + test("throws a descriptive error when the ciphertext is structurally invalid for the key", () => { + // Node/OpenSSL's PKCS1 fallback is deliberately lenient (implicit-reject + // padding, a Bleichenbacher-oracle mitigation) — arbitrary garbage bytes at + // the modulus size still "succeed" with meaningless plaintext instead of + // throwing, on both padding modes. An empty ciphertext is the one shape + // that reliably fails both padding attempts (RSA "data too small"), + // exercising the combined try/catch → rethrow path. + const { privateKey } = crypto.generateKeyPairSync("rsa", { + modulusLength: 2048, + publicKeyEncoding: { type: "pkcs1", format: "pem" }, + privateKeyEncoding: { type: "pkcs1", format: "pem" }, + }); + const verifier = encodeZedPrivateKeyVerifier(privateKey); + assert.throws( + () => decryptZedAccessToken(Buffer.alloc(0).toString("base64url"), verifier), + /Failed to decrypt Zed access token/ + ); + }); +}); + +describe("parseZedCallbackPayload", () => { + test("parses a native-app callback URL (127.0.0.1:/?user_id=...&access_token=...)", () => { + const parsed = parseZedCallbackPayload( + "http://127.0.0.1:58443/?user_id=user_123&access_token=ZW5jcnlwdGVk" + ); + assert.equal(parsed.userId, "user_123"); + assert.equal(parsed.encryptedAccessToken, "ZW5jcnlwdGVk"); + }); + + test("parses a bare query string (no scheme/host)", () => { + const parsed = parseZedCallbackPayload("?user_id=abc&access_token=xyz"); + assert.equal(parsed.userId, "abc"); + assert.equal(parsed.encryptedAccessToken, "xyz"); + }); + + test("parses JSON input", () => { + const parsed = parseZedCallbackPayload(JSON.stringify({ user_id: "u1", access_token: "t1" })); + assert.equal(parsed.userId, "u1"); + assert.equal(parsed.encryptedAccessToken, "t1"); + }); + + test("rejects empty input", () => { + assert.throws(() => parseZedCallbackPayload(""), /Missing Zed callback URL/); + }); + + test("rejects a payload missing user_id or access_token", () => { + assert.throws( + () => parseZedCallbackPayload("http://127.0.0.1:1/?user_id=only"), + /must include user_id and access_token/ + ); + }); +}); + +describe("buildZedUserAuthHeader", () => { + test('builds the " " scheme (not Bearer)', () => { + const header = buildZedUserAuthHeader({ + accessToken: "tok", + providerSpecificData: { userId: "u1" }, + }); + assert.equal(header, "u1 tok"); + }); + + test("throws when userId or accessToken is missing", () => { + assert.throws(() => buildZedUserAuthHeader({ accessToken: "tok" })); + assert.throws(() => buildZedUserAuthHeader({ providerSpecificData: { userId: "u1" } })); + }); +}); + +describe("resolveZedOrganizationId", () => { + test("prefers an explicit providerSpecificData.organizationId", () => { + const orgId = resolveZedOrganizationId({ + providerSpecificData: { organizationId: "org-explicit" }, + } as any); + assert.equal(orgId, "org-explicit"); + }); + + test("falls back to the personal organization from userInfo", () => { + const orgId = resolveZedOrganizationId({ providerSpecificData: {} } as any, { + organizations: [ + { id: "org-team", is_personal: false }, + { id: "org-personal", is_personal: true }, + ], + }); + assert.equal(orgId, "org-personal"); + }); + + test("falls back to the first organization when none is personal", () => { + const orgId = resolveZedOrganizationId({ providerSpecificData: {} } as any, { + organizations: [{ id: "org-first" }, { id: "org-second" }], + }); + assert.equal(orgId, "org-first"); + }); +}); + +describe("mapZedModel", () => { + test("maps a raw Zed model into the normalized shape", () => { + const mapped = mapZedModel({ + id: "claude-sonnet-5", + display_name: "Claude Sonnet 5", + provider: "anthropic", + max_token_count: 1000000, + max_output_tokens: 128000, + supports_tools: true, + supports_images: true, + supports_thinking: true, + }); + assert.ok(mapped); + assert.equal(mapped?.id, "claude-sonnet-5"); + assert.equal(mapped?.name, "Claude Sonnet 5"); + assert.equal(mapped?.contextLength, 1000000); + assert.equal(mapped?.supportsTools, true); + }); + + test("returns null for a model with no id", () => { + assert.equal(mapZedModel({}), null); + }); +}); + +// ─── Executor: provider-family inference ──────────────────────────────────── + +describe("normalizeZedProvider", () => { + test("maps explicit provider strings", () => { + assert.equal(normalizeZedProvider("anthropic", "any"), "Anthropic"); + assert.equal(normalizeZedProvider("openai", "any"), "OpenAi"); + assert.equal(normalizeZedProvider("open_ai", "any"), "OpenAi"); + assert.equal(normalizeZedProvider("google", "any"), "Google"); + assert.equal(normalizeZedProvider("gemini", "any"), "Google"); + assert.equal(normalizeZedProvider("xai", "any"), "XAi"); + assert.equal(normalizeZedProvider("x-ai", "any"), "XAi"); + }); + + test("infers from the model id when provider is absent", () => { + assert.equal(normalizeZedProvider(null, "claude-sonnet-5"), "Anthropic"); + assert.equal(normalizeZedProvider(null, "gemini-3.1-pro"), "Google"); + assert.equal(normalizeZedProvider(null, "grok-4"), "XAi"); + assert.equal(normalizeZedProvider(null, "gpt-5.5"), "OpenAi"); + assert.equal(normalizeZedProvider(null, "some-unknown-model"), "OpenAi"); + }); +}); + +// ─── Executor: NDJSON line unwrapping ─────────────────────────────────────── + +describe("unwrapZedLine", () => { + test("parses an event line", () => { + const line = JSON.stringify({ event: { type: "message_start" } }); + assert.deepEqual(unwrapZedLine(line), { event: { type: "message_start" } }); + }); + + test("parses a status line", () => { + const line = JSON.stringify({ status: "stream_ended" }); + assert.deepEqual(unwrapZedLine(line), { status: "stream_ended" }); + }); + + test('recognizes the "[DONE]" sentinel, with or without an SSE "data:" prefix', () => { + assert.deepEqual(unwrapZedLine("[DONE]"), { done: true }); + assert.deepEqual(unwrapZedLine("data: [DONE]"), { done: true }); + }); + + test("returns null for a blank line", () => { + assert.equal(unwrapZedLine(""), null); + assert.equal(unwrapZedLine(" "), null); + }); + + test("returns null for unparsable JSON", () => { + assert.equal(unwrapZedLine("not json"), null); + }); +}); + +// ─── Executor: resolveModel + zedLlmFetch (mocked fetch) ──────────────────── + +describe("ZedHostedExecutor.resolveModel + zedLlmFetch (mocked upstream)", () => { + const credentials = { + accessToken: "plaintext-access-token", + providerSpecificData: { userId: "u1", organizationId: "org-1" }, + }; + + test("resolves the provider family from the live model catalog", async (t) => { + clearZedCaches(); + const calls: string[] = []; + const originalFetch = globalThis.fetch; + t.after(() => { + globalThis.fetch = originalFetch; + clearZedCaches(); + }); + + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = String(input); + calls.push(url); + if (url.includes("/client/llm_tokens")) { + return new Response(JSON.stringify({ token: "llm-token-abc" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + if (url.includes("/models")) { + return new Response( + JSON.stringify({ + models: [ + { id: "claude-sonnet-5", provider: "anthropic", display_name: "Claude Sonnet 5" }, + { id: "gpt-5.5", provider: "openai", display_name: "GPT-5.5" }, + ], + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + } + throw new Error(`Unexpected fetch: ${url}`); + }) as typeof fetch; + + const executor = new ZedHostedExecutor(); + const result = await executor.resolveModel("claude-sonnet-5", credentials as any, undefined, undefined); + assert.equal(result.provider, "Anthropic"); + assert.ok(calls.some((u) => u.includes("/client/llm_tokens"))); + assert.ok(calls.some((u) => u.includes("/models"))); + }); + + test("falls back to model-id inference when the catalog fetch fails", async (t) => { + clearZedCaches(); + const originalFetch = globalThis.fetch; + t.after(() => { + globalThis.fetch = originalFetch; + clearZedCaches(); + }); + globalThis.fetch = (async () => { + throw new Error("network down"); + }) as typeof fetch; + + const executor = new ZedHostedExecutor(); + const warnCalls: string[] = []; + const result = await executor.resolveModel( + "gemini-3.1-pro", + credentials as any, + undefined, + { warn: (_tag: string, msg: string) => warnCalls.push(msg) } as any + ); + assert.equal(result.provider, "Google"); + assert.ok(warnCalls.length > 0); + }); +}); + +// ─── Executor: parseError ──────────────────────────────────────────────────── + +describe("ZedHostedExecutor.parseError", () => { + const executor = new ZedHostedExecutor(); + + test("surfaces a friendly message for trial_blocked", () => { + const response = new Response(null, { status: 402 }); + const result = executor.parseError( + response, + JSON.stringify({ code: "trial_blocked", message: "trial exhausted" }) + ); + assert.equal(result.status, 402); + assert.match(result.message, /trial\/billing access/); + }); + + test("prefixes other error codes with Zed", () => { + const response = new Response(null, { status: 400 }); + const result = executor.parseError(response, JSON.stringify({ code: "bad_request", message: "oops" })); + assert.equal(result.message, "Zed bad_request: oops"); + }); + + test("falls back to raw body text when there is no code", () => { + const response = new Response(null, { status: 500, statusText: "Internal Server Error" }); + const result = executor.parseError(response, "boom"); + assert.equal(result.message, "boom"); + }); + + test("never leaks a stack trace (Hard Rule #12 — message stays upstream-text-only)", () => { + const response = new Response(null, { status: 500 }); + const result = executor.parseError(response, "totally fine upstream text"); + assert.ok(!result.message.includes(" at ")); + }); +}); + +// ─── Executor: no proactive refresh (long-lived native-app token) ────────── + +describe("ZedHostedExecutor credential-refresh contract", () => { + test("needsRefresh is always false — no refresh flow exposed by Zed", () => { + const executor = new ZedHostedExecutor(); + assert.equal(executor.needsRefresh(), false); + }); + + test("refreshCredentials resolves to null", async () => { + const executor = new ZedHostedExecutor(); + assert.equal(await executor.refreshCredentials(), null); + }); +});