Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 130 additions & 0 deletions open-sse/config/zedConstants.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
/**
* Zed Hosted AI constants — model→upstream-provider routing and client defaults.
* Used by ZedExecutor (open-sse/executors/zed.js) and zedAuth (open-sse/shared/zedAuth.js).
*
* Zed's /completions envelope expects snake_case provider tags on the HTTP API
* (anthropic / open_ai / google / x_ai). PascalCase values are accepted by the
* JSON parser but fail at runtime with opaque 500s.
*/

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

/** Default x-zed-version header value when registry omits appVersion. */
export const ZED_CLIENT_VERSION = "1.6.3";

/** Header names shared by cloud.zed.dev LLM + account APIs. */
export const ZED_HEADER_NAMES = {
version: "x-zed-version",
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",
};

/** Alias for existing imports (zedAuth / executor). */
export const ZED_HEADERS = ZED_HEADER_NAMES;

/** LLM bearer token lifetime used by the in-process cache (ms). */
export const ZED_LLM_TOKEN_TTL_MS = 50 * 60 * 1000;

/** Live /models catalog cache TTL (ms). */
export const ZED_MODEL_CACHE_TTL_MS = 60 * 60 * 1000;

/** Opaque verifier prefix for RSA private keys flowing through OAuth codeVerifier. */
export const ZED_PRIVATE_KEY_PREFIX = "zed-rsa-pkcs1:";

/** Plan ids that do not include Zed-hosted model access. */
export const ZED_FREE_PLAN_IDS = new Set(["zed_free", "free"]);

/** Default local native-app callback port (Zed sign-in redirect target). */
export const ZED_DEFAULT_NATIVE_APP_PORT = 58443;

/** How long the local Zed OAuth callback proxy stays up (ms). */
export const ZED_OAUTH_TIMEOUT_MS = 600_000;

/** Account page path (joined with webBaseUrl) for upgrade / trial. */
export const ZED_ACCOUNT_PATH = "/account";

/** Wire-protocol provider tags for CompletionBody.provider (HTTP API snake_case). */
export const ZED_PROVIDER = {
anthropic: "anthropic",
openai: "open_ai",
google: "google",
xai: "x_ai",
};

export const ZED_DEFAULT_PROVIDER = ZED_PROVIDER.openai;

/** Normalize catalog / legacy provider strings to ZED_PROVIDER wire values. */
function normalizeZedProviderTag(value) {
const raw = String(value || "").toLowerCase().replace(/-/g, "_");
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;
return null;
}

/**
* Resolve which nested Zed upstream provider a catalog value or model id should use.
* @param {string|null|undefined} catalogProvider - raw `provider` from Zed's /models
* @param {string|null|undefined} model
* @returns {string} one of ZED_PROVIDER.*
*/
export function resolveZedProvider(catalogProvider, model) {
const fromCatalog = normalizeZedProviderTag(catalogProvider);
if (fromCatalog) return fromCatalog;

const m = String(model || "").toLowerCase();
if (/(claude|anthropic)/i.test(m)) return ZED_PROVIDER.anthropic;
if (/(gemini|google)/i.test(m)) return ZED_PROVIDER.google;
if (/(grok|x[_-]?ai)/i.test(m)) return ZED_PROVIDER.xai;
return ZED_DEFAULT_PROVIDER;
}

/**
* Human-readable warning when /models is empty due to plan/quota gates.
* @param {string} webBaseUrl
*/
export function buildZedHostedModelsBlockedMessage(webBaseUrl) {
const accountUrl = `${String(webBaseUrl || ZED_WEB_BASE_URL).replace(/\/+$/, "")}${ZED_ACCOUNT_PATH}`;
return (
"Zed Hosted AI models require Zed Pro (or an active Pro trial). " +
"This account is on the free plan with no hosted-model quota, so the live catalog is empty. " +
`Start a trial or upgrade at ${accountUrl} — then refresh this page.`
);
}

export function buildZedEmptyCatalogMessage() {
return "Zed returned an empty model catalog for this account.";
}

/**
* Zed often returns a bare `{"message":"An internal server error occurred."}` for
* billing/quota failures instead of a typed error code. Pair with plan info when known.
* @param {object} [planInfo] - from summarizeZedPlan
* @param {string} [webBaseUrl]
*/
export function buildZedOpaqueCompletionErrorMessage(planInfo, webBaseUrl) {
const accountUrl = `${String(webBaseUrl || ZED_WEB_BASE_URL).replace(/\/+$/, "")}${ZED_ACCOUNT_PATH}`;
const planId = planInfo?.planId || "unknown";
const limit = planInfo?.modelRequestLimit;
const bits = [`Upstream plan reported by cloud.zed.dev: ${planId}.`];
if (limit === 0) {
bits.push("Hosted model_requests limit is 0 (token-based plans sometimes still show this).");
}
bits.push(
`If you upgraded to Pro, confirm the same GitHub user is connected in 9router and refresh credentials at ${accountUrl}. ` +
"Otherwise contact billing-support@zed.dev with your GitHub username.",
);
return (
"Zed /completions returned an opaque 500 (request JSON accepted; failure is upstream). " +
bits.join(" ")
);
}
68 changes: 45 additions & 23 deletions open-sse/executors/zed.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,31 +23,25 @@ import { openaiToOpenAIResponsesRequest } from "../translator/request/openai-res
import { claudeToOpenAIResponse } from "../translator/response/claude-to-openai.js";
import { geminiToOpenAIResponse } from "../translator/response/gemini-to-openai.js";
import { openaiResponsesToOpenAIResponse } from "../translator/response/openai-responses.js";
import {
ZED_CLIENT_VERSION,
ZED_PROVIDER,
ZED_WEB_BASE_URL,
resolveZedProvider,
buildZedOpaqueCompletionErrorMessage,
} from "../config/zedConstants.js";
import {
ZED_HEADERS,
ZED_LLM_BASE_URL,
fetchZedAuthenticatedUser,
resolveZedModels,
summarizeZedPlan,
zedLlmFetch,
} from "../shared/zedAuth.js";

const ZED_PROVIDER = {
anthropic: "Anthropic",
openai: "OpenAi",
google: "Google",
xai: "XAi",
};
import crypto from "node:crypto";

function normalizeZedProvider(value, model) {
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;
return resolveZedProvider(value, model);
}

function buildProviderRequest(provider, model, body, stream, credentials) {
Expand Down Expand Up @@ -234,8 +228,8 @@ class ZedExecutor extends BaseExecutor {
const providerRequest = buildProviderRequest(provider, model, body, stream, credentials);
const bodyRecord = body || {};
const payload = {
thread_id: bodyRecord.thread_id || credentials?._clientSessionId,
prompt_id: bodyRecord.prompt_id,
thread_id: bodyRecord.thread_id || credentials?._clientSessionId || crypto.randomUUID(),
prompt_id: bodyRecord.prompt_id || crypto.randomUUID(),
provider,
model,
provider_request: providerRequest,
Expand All @@ -248,20 +242,36 @@ class ZedExecutor extends BaseExecutor {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/x-ndjson, text/event-stream, */*",
Accept: "application/json, text/plain, */*",
"User-Agent": "9router/zed",
"x-zed-version": this.config?.appVersion?.toString() || "0.200.0",
[ZED_HEADERS.version]: this.config?.appVersion?.toString() || ZED_CLIENT_VERSION,
[ZED_HEADERS.clientSupportsStatus]: "true",
[ZED_HEADERS.clientSupportsStreamEnded]: "true",
[ZED_HEADERS.clientSupportsXai]: "true",
},
body: JSON.stringify(payload),
},
});

// Cache plan summary for opaque 500 messages (Zed often hides billing failures).
if (!response.ok && response.status >= 500) {
try {
const userInfo = await fetchZedAuthenticatedUser(credentials, {
config: this.config,
signal,
});
this._lastPlanInfo = summarizeZedPlan(userInfo, {
webBaseUrl: this.config?.webBaseUrl || ZED_WEB_BASE_URL,
});
} catch {
this._lastPlanInfo = null;
}
}

const wrapped = response.ok ? wrapZedCompletionStream(response, provider, model) : response;
return {
response: wrapped,
url: `${this.config?.llmBaseUrl || "https://cloud.zed.dev"}/completions`,
url: `${this.config?.llmBaseUrl || ZED_LLM_BASE_URL}/completions`,
headers: { "Content-Type": "application/json", Authorization: "Bearer <zed-llm-token>" },
transformedBody: payload,
};
Expand All @@ -288,6 +298,18 @@ class ZedExecutor extends BaseExecutor {
if (code) {
return { status: response.status, message: `Zed ${code}: ${rawMessage}` };
}
const isOpaque =
response.status >= 500 &&
(!rawMessage || /internal server error/i.test(String(rawMessage)));
if (isOpaque) {
return {
status: response.status,
message: buildZedOpaqueCompletionErrorMessage(
this._lastPlanInfo,
this.config?.webBaseUrl || ZED_WEB_BASE_URL,
),
};
}
return { status: response.status, message: rawMessage || `Zed upstream error: ${response.status}` };
}

Expand Down
3 changes: 2 additions & 1 deletion open-sse/providers/registry/zed.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ export default {
priority: 10,
alias: "zd",
uiAlias: "zd",
hidden: true,
// Visible: RSA native-app OAuth via OAuthModal + optional CLI credential import.
hidden: false,
display: {
name: "Zed",
icon: "code",
Expand Down
2 changes: 1 addition & 1 deletion open-sse/services/tokenRefresh.js
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ const REFRESH_HANDLERS = {
"codebuddy-cn": (c, log) => refreshCodebuddyToken(c.refreshToken, log),
"codebuddy-intl": (c, log) => refreshCodebuddyIntlToken(c.refreshToken, log),
trae: (c, log) => refreshTraeToken(c.refreshToken, c, log),
zed: () => refreshZedToken(),
zed: (c, log) => refreshZedToken(c, log),
windsurf: (c, log) => refreshWindsurfToken(c, log),
// Kimi Code OAuth (merged into id `kimi`); legacy id still routes here
kimi: (c, log) => refreshKimiToken(c.refreshToken, c, log),
Expand Down
12 changes: 8 additions & 4 deletions open-sse/services/tokenRefresh/providers.js
Original file line number Diff line number Diff line change
Expand Up @@ -640,10 +640,14 @@ export async function refreshTraeToken(refreshToken, credentials, log) {
}, log);
}

// Zed access_token is long-lived; auth flow returns no refresh_token.
// No refresh possible — re-login required when token expires/revoked.
// Mirrors cursor/kilocode null-refresh pattern.
export function refreshZedToken() {
// Zed user access_token is long-lived (RSA decrypt or CLI import).
// Short-lived LLM bearer tokens are minted/cached inside open-sse/shared/zedAuth.js
// (fetchZedLlmToken / zedLlmFetch) — no OAuth refresh_token grant exists.
export async function refreshZedToken(credentials, log) {
log?.info?.(
"TOKEN_REFRESH",
"zed: user access_token is long-lived; LLM tokens refresh via zedAuth — skipping",
);
return null;
}

Expand Down
2 changes: 2 additions & 0 deletions open-sse/services/usage.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { getCodeBuddyCnUsage, getCodeBuddyIntlUsage } from "./usage/codebuddy-cn
import { getGrokCliUsage } from "./usage/grok-cli.js";
import { getKimiUsage } from "./usage/kimi.js";
import { getDeepseekUsage } from "./usage/deepseek.js";
import { getZedUsage } from "./usage/zed.js";
import { resolveQoderCredentials } from "./qoderModels.js";
import {
getIflowUsage,
Expand Down Expand Up @@ -54,6 +55,7 @@ const USAGE_HANDLERS = {
"grok-cli": (c) => getGrokCliUsage(c.accessToken, c.providerSpecificData, c.proxyOptions),
kimi: (c) => getKimiUsage(c.accessToken, c.apiKey, c.proxyOptions, c.providerSpecificData),
deepseek: (c) => getDeepseekUsage(c.apiKey, c.proxyOptions),
zed: (c) => getZedUsage(c.accessToken, c.providerSpecificData, c.proxyOptions),
};

export async function getUsageForProvider(connection, proxyOptions = null, options = {}) {
Expand Down
Loading