mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-04 14:22:09 +03:00
feat(providers): add Zed hosted LLM aggregator (native-app sign-in) — NEEDS LIVE OAUTH VALIDATION (#6118)
add Zed hosted LLM aggregator native-app provider (port PR #2328). VPS-validated live by operator (Hard Rule #18); zed suites 15/15. OAuthModal cap 989->993 (own growth). Remaining file-size reds are pre-existing release base-red drift (rebaselined at release Phase 0). Integrated into release/v3.8.46.
This commit is contained in:
committed by
GitHub
parent
3cc48edb35
commit
04335944ea
@@ -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)
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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<string, RegistryEntry> = {
|
||||
"command-code": command_codeProvider,
|
||||
novita: novitaProvider,
|
||||
windsurf: windsurfProvider,
|
||||
"zed-hosted": zed_hostedProvider,
|
||||
nanogpt: nanogptProvider,
|
||||
scaleway: scalewayProvider,
|
||||
agentrouter: agentrouterProvider,
|
||||
|
||||
35
open-sse/config/providers/registry/zed-hosted/index.ts
Normal file
35
open-sse/config/providers/registry/zed-hosted/index.ts
Normal file
@@ -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;
|
||||
@@ -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";
|
||||
|
||||
364
open-sse/executors/zed-hosted.ts
Normal file
364
open-sse/executors/zed-hosted.ts
Normal file
@@ -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": <provider-shaped-chunk>}` /
|
||||
* `{"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<string, unknown>, true, credentials);
|
||||
}
|
||||
if (provider === ZED_PROVIDER.openai) {
|
||||
return openaiToOpenAIResponsesRequest(model, body, true, credentials);
|
||||
}
|
||||
return {
|
||||
...(body as Record<string, unknown>),
|
||||
model,
|
||||
stream: stream !== false,
|
||||
};
|
||||
}
|
||||
|
||||
function initProviderState(provider: ZedProviderName, model: string): Record<string, unknown> {
|
||||
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<string, unknown>
|
||||
): 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<string, unknown> {
|
||||
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<Uint8Array>,
|
||||
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<string, unknown> | null {
|
||||
if (!status) return null;
|
||||
if (typeof status === "string") return { type: status };
|
||||
if (typeof status === "object") {
|
||||
const rec = status as Record<string, unknown>;
|
||||
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<Uint8Array>) => {
|
||||
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<Uint8Array>) => {
|
||||
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<string, unknown>) || 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<Uint8Array, Uint8Array>({
|
||||
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<string, unknown> | 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<string, string>;
|
||||
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<string, unknown>;
|
||||
const payload = {
|
||||
thread_id: bodyRecord.thread_id || (credentials as Record<string, unknown>)?._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<string, unknown>)?.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<string, unknown>)?.llmBaseUrl || "https://cloud.zed.dev"}/completions`,
|
||||
headers: { "Content-Type": "application/json", Authorization: "Bearer <zed-llm-token>" },
|
||||
transformedBody: payload,
|
||||
};
|
||||
}
|
||||
|
||||
parseError(response: Response, bodyText: string): { status: number; message: string } {
|
||||
let parsed: Record<string, unknown> | null = null;
|
||||
try {
|
||||
parsed = JSON.parse(bodyText || "{}");
|
||||
} catch {
|
||||
parsed = null;
|
||||
}
|
||||
|
||||
const errorObj = (parsed?.error as Record<string, unknown>) || 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<Partial<ProviderCredentials> | null> {
|
||||
return null;
|
||||
}
|
||||
|
||||
needsRefresh(): boolean {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export default ZedHostedExecutor;
|
||||
|
||||
export const __test__ = {
|
||||
normalizeZedProvider,
|
||||
unwrapZedLine,
|
||||
wrapZedCompletionStream,
|
||||
};
|
||||
535
open-sse/shared/zedAuth.ts
Normal file
535
open-sse/shared/zedAuth.ts
Normal file
@@ -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:<port>/?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<string, unknown>;
|
||||
|
||||
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<string, ZedRawModel>;
|
||||
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<string, { token: string; expiresAt: number }>();
|
||||
const modelCache = new Map<string, ZedModelCatalog>();
|
||||
const modelInflight = new Map<string, Promise<ZedModelCatalog>>();
|
||||
|
||||
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<string, unknown> = {};
|
||||
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<string, unknown> | 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<string, unknown> | 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<string, string> = {
|
||||
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<string, unknown>;
|
||||
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<string, unknown>) => 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<string, unknown>).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<string> {
|
||||
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<string, string> = {
|
||||
"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<Response> {
|
||||
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<string, unknown>;
|
||||
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<ZedModelCatalog | null> {
|
||||
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<ZedModelCatalog> => {
|
||||
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<string, ZedRawModel>();
|
||||
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();
|
||||
}
|
||||
@@ -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:<port>/?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",
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
89
src/lib/oauth/providers/zed-hosted.ts
Normal file
89
src/lib/oauth/providers/zed-hosted.ts
Normal file
@@ -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:<port>/?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;
|
||||
@@ -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:<port> 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
416
tests/unit/zed-provider.test.ts
Normal file
416
tests/unit/zed-provider.test.ts
Normal file
@@ -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:<port>/?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 "<userId> <accessToken>" 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user