mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-14 19:22:32 +03:00
Every zed-hosted completion failed with
500 {"error":{"message":"[500]: An internal server error occurred."}}
for every model id, including deliberately invalid ones.
Root cause: ZED_PROVIDER held display-cased names ("Anthropic", "OpenAi",
"Google", "XAi"), and normalizeZedProvider's return value is serialized
straight into the `provider` field of the POST /completions envelope. Zed
matches that field exactly and fails the request before looking at the model,
which is why the model id never mattered.
Verified live against cloud.zed.dev with an otherwise identical request:
{"provider":"anthropic",...} -> 200
{"provider":"Anthropic",...} -> 500 {"message":"An internal server error occurred."}
{"provider":"open_ai",...} -> reaches the OpenAI request parser
{"provider":"openai",...} -> 500 (same internal error)
The spellings now follow Zed's own GET /models catalog, which reports
`anthropic`, `open_ai` and `google`. That also makes normalizeZedProvider
identity on catalog values instead of corrupting a value Zed just supplied —
previously it accepted the correct lowercase input and re-cased it into the
form that 500s.
`x_ai` follows the same underscore convention; this account's catalog exposes
no xAI models, so that one spelling is by convention rather than observation.
The constant is module-local and every branch compares against it, so internal
dispatch (initProviderState / convertProviderEvent / buildProviderRequest) is
unaffected. Two existing tests asserted the display-cased values and one passed
"Anthropic" to wrapZedCompletionStream directly; all are updated to the wire
values the executor now produces.
Co-authored-by: root <root@srv1710948.hstgr.cloud>
438 lines
16 KiB
TypeScript
438 lines
16 KiB
TypeScript
/**
|
|
* 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 DevinDesktopExecutor, 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";
|
|
import { resolveSuppressThinkClose, THINKING_MARKER_HEADER } from "../utils/thinkCloseMarker.ts";
|
|
|
|
// Wire values for the `provider` field of POST /completions. These are NOT
|
|
// display names: cloud.zed.dev matches them exactly, and an unrecognized value
|
|
// fails the whole request with `500 {"message":"An internal server error
|
|
// occurred."}` before the model is ever looked at — which is why every model id,
|
|
// including invalid ones, produced an identical 500.
|
|
//
|
|
// The spellings come from Zed's own GET /models catalog, which reports
|
|
// `anthropic`, `open_ai` and `google` (note the underscore); `x_ai` follows the
|
|
// same convention. Feeding a catalog value back through normalizeZedProvider is
|
|
// therefore identity, as it must be.
|
|
const ZED_PROVIDER = {
|
|
anthropic: "anthropic",
|
|
openai: "open_ai",
|
|
google: "google",
|
|
xai: "x_ai",
|
|
} 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" }],
|
|
};
|
|
}
|
|
|
|
/**
|
|
* The single controller capability these SSE helpers use. They only ever enqueue —
|
|
* never `close()`, never read `desiredSize` — so typing them by that one method lets
|
|
* the same code serve both stream kinds. The wider
|
|
* `ReadableStreamDefaultController` annotation rejected every call site, because the
|
|
* helpers are driven from a TransformStream and `TransformStreamDefaultController`
|
|
* has no `close()`.
|
|
*/
|
|
type SseEnqueueTarget = Pick<ReadableStreamDefaultController<Uint8Array>, "enqueue">;
|
|
|
|
function enqueueSseObject(
|
|
controller: SseEnqueueTarget,
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* Resolves `</think>` close-marker suppression from the incoming client
|
|
* headers / response format, extracted from `ZedHostedExecutor.execute` to
|
|
* keep that method's cyclomatic complexity under the project cap.
|
|
*/
|
|
function resolveZedSuppressThinkClose(
|
|
clientHeaders: ExecuteInput["clientHeaders"],
|
|
clientResponseFormat: ExecuteInput["clientResponseFormat"]
|
|
): boolean {
|
|
return resolveSuppressThinkClose({
|
|
userAgent: clientHeaders?.["user-agent"] ?? clientHeaders?.["User-Agent"] ?? null,
|
|
thinkingMarkerHeader:
|
|
clientHeaders?.[THINKING_MARKER_HEADER] ??
|
|
clientHeaders?.["x-omniroute-thinking-marker"] ??
|
|
null,
|
|
clientResponseFormat: clientResponseFormat ?? null,
|
|
});
|
|
}
|
|
|
|
function wrapZedCompletionStream(
|
|
response: Response,
|
|
provider: ZedProviderName,
|
|
model: string,
|
|
options?: { suppressThinkClose?: boolean }
|
|
): Response {
|
|
if (!response.ok || !response.body) return response;
|
|
|
|
const decoder = new TextDecoder();
|
|
const encoder = new TextEncoder();
|
|
const state = initProviderState(provider, model);
|
|
if (options?.suppressThinkClose) {
|
|
// Responses API clients (and UA/header-opted-out clients) must not see the
|
|
// textual `</think>` close marker — same policy chatCore applies (#4633 /
|
|
// #5245 / kimi-coding stray marker on /v1/responses).
|
|
state.suppressThinkClose = true;
|
|
}
|
|
let buffer = "";
|
|
let done = false;
|
|
|
|
const finish = (controller: SseEnqueueTarget) => {
|
|
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: SseEnqueueTarget) => {
|
|
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,
|
|
clientHeaders,
|
|
clientResponseFormat,
|
|
}: 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),
|
|
},
|
|
});
|
|
|
|
// The Anthropic backend converts Claude events to OpenAI chunks inside
|
|
// wrapZedCompletionStream, bypassing chatCore's marker policy — resolve
|
|
// `</think>` close-marker suppression here from the client format /
|
|
// headers (same policy as chatCore / GLM, #5245 / kimi-coding leak).
|
|
const suppressThinkClose = resolveZedSuppressThinkClose(clientHeaders, clientResponseFormat);
|
|
|
|
const wrapped = response.ok
|
|
? wrapZedCompletionStream(response, provider, model, { suppressThinkClose })
|
|
: 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,
|
|
};
|