diff --git a/open-sse/config/zedConstants.js b/open-sse/config/zedConstants.js new file mode 100644 index 0000000000..190c15c12d --- /dev/null +++ b/open-sse/config/zedConstants.js @@ -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(" ") + ); +} diff --git a/open-sse/executors/zed.js b/open-sse/executors/zed.js index e6233fcb33..412e90b060 100644 --- a/open-sse/executors/zed.js +++ b/open-sse/executors/zed.js @@ -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) { @@ -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, @@ -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 " }, transformedBody: payload, }; @@ -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}` }; } diff --git a/open-sse/providers/registry/zed.js b/open-sse/providers/registry/zed.js index 9224cf950e..f7f85e310f 100644 --- a/open-sse/providers/registry/zed.js +++ b/open-sse/providers/registry/zed.js @@ -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", diff --git a/open-sse/services/tokenRefresh.js b/open-sse/services/tokenRefresh.js index 3160f4a7df..c35c92f192 100644 --- a/open-sse/services/tokenRefresh.js +++ b/open-sse/services/tokenRefresh.js @@ -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), diff --git a/open-sse/services/tokenRefresh/providers.js b/open-sse/services/tokenRefresh/providers.js index 40f27f51a6..d50c06f6a5 100644 --- a/open-sse/services/tokenRefresh/providers.js +++ b/open-sse/services/tokenRefresh/providers.js @@ -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; } diff --git a/open-sse/services/usage.js b/open-sse/services/usage.js index 650624677a..9978e054ac 100644 --- a/open-sse/services/usage.js +++ b/open-sse/services/usage.js @@ -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, @@ -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 = {}) { diff --git a/open-sse/services/usage/zed.js b/open-sse/services/usage/zed.js new file mode 100644 index 0000000000..ded9c89291 --- /dev/null +++ b/open-sse/services/usage/zed.js @@ -0,0 +1,228 @@ +/** + * Zed usage — GET https://cloud.zed.dev/client/users/me + * Auth: Authorization: {user_id} {access_token} + * + * Quota rows are derived from plan.usage (edit_predictions, optional model_requests) + * and subscription_period.ended_at for billing-cycle reset. + */ + +import { + fetchZedAuthenticatedUser, + summarizeZedPlan, +} from "../../shared/zedAuth.js"; +import { parseResetTime, toFiniteNumber } from "./shared.js"; + +/** Map plan_v3 ids to dashboard labels (CodexBar-compatible). */ +export function formatZedPlanLabel(rawPlan) { + const raw = String(rawPlan || "").trim(); + if (!raw) return "Zed"; + switch (raw.toLowerCase()) { + case "zed_free": + return "Zed Free"; + case "zed_pro": + return "Zed Pro"; + case "zed_pro_trial": + return "Zed Pro Trial"; + case "zed_student": + return "Zed Student"; + case "zed_business": + return "Zed Business"; + default: + return raw + .replace(/_/g, " ") + .split(/\s+/) + .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) + .join(" "); + } +} + +/** + * Parse Zed UsageLimit JSON: "unlimited", a number, or { limited: N }. + */ +export function parseZedUsageLimit(limit) { + if (limit == null) return { unlimited: false, total: 0 }; + + if (limit === "unlimited" || limit?.unlimited === true) { + return { unlimited: true, total: 0 }; + } + + if (typeof limit === "number" && Number.isFinite(limit)) { + return { unlimited: false, total: Math.max(0, limit) }; + } + + if (typeof limit === "string") { + const trimmed = limit.trim(); + if (trimmed === "unlimited") return { unlimited: true, total: 0 }; + const parsed = Number(trimmed); + if (Number.isFinite(parsed)) return { unlimited: false, total: Math.max(0, parsed) }; + } + + const limited = limit.limited ?? limit.Limited; + if (typeof limited === "number" && Number.isFinite(limited)) { + return { unlimited: false, total: Math.max(0, limited) }; + } + + return { unlimited: false, total: 0 }; +} + +/** limit `{ limited: 0 }` on Pro/Student means token billing, not a 0-cap request quota. */ +export function isZedTokenBillingModelRequestsLimit(limitRaw) { + const info = parseZedUsageLimit(limitRaw); + return !info.unlimited && info.total === 0; +} + +function makeZedQuotaRow(name, usedRaw, limitRaw, resetAt = null) { + const used = Math.max(0, toFiniteNumber(usedRaw, 0)); + const limitInfo = parseZedUsageLimit(limitRaw); + + if (limitInfo.unlimited) { + return { + used, + total: 0, + remainingPercentage: 100, + resetAt: resetAt || null, + unlimited: true, + }; + } + + const total = limitInfo.total; + if (total <= 0) { + return { + used, + total: 0, + remainingPercentage: 0, + resetAt: resetAt || null, + unlimited: false, + }; + } + + const clampedUsed = Math.min(used, total); + const remaining = Math.max(0, total - clampedUsed); + return { + used: clampedUsed, + total, + remainingPercentage: (remaining / total) * 100, + resetAt: resetAt || null, + unlimited: false, + }; +} + +function usageBucketLimit(bucket) { + if (!bucket || typeof bucket !== "object") return null; + if (bucket.limit != null) return bucket.limit; + return bucket; +} + +/** + * Map /client/users/me JSON → { plan, quotas, message } for the dashboard. + */ +export function parseZedAuthenticatedUserUsage(userInfo) { + const plan = userInfo?.plan || {}; + const planId = + plan.plan_v3 || plan.plan_v2 || plan.plan || userInfo?.plan_v3 || null; + const planSummary = summarizeZedPlan(userInfo); + const resetAt = + parseResetTime(plan.subscription_period?.ended_at) || + parseResetTime(plan.subscriptionPeriod?.endedAt) || + null; + + const quotas = {}; + const usage = plan.usage || {}; + + const editPredictions = usage.edit_predictions || usage.editPredictions; + if (editPredictions) { + quotas["Edit Predictions"] = makeZedQuotaRow( + "Edit Predictions", + editPredictions.used, + editPredictions.limit, + resetAt, + ); + } + + const modelRequests = usage.model_requests || usage.modelRequests; + if (modelRequests) { + const limitRaw = + modelRequests.limit != null + ? modelRequests.limit + : usageBucketLimit(modelRequests)?.limit; + const limitInfo = parseZedUsageLimit(limitRaw); + // Token-billed plans report model_requests.limit=0 — not a request quota. + if (limitInfo.unlimited || limitInfo.total > 0) { + quotas["Hosted Model Requests"] = makeZedQuotaRow( + "Hosted Model Requests", + modelRequests.used, + limitRaw, + resetAt, + ); + } + } + + const tokenBillingNote = + modelRequests && + isZedTokenBillingModelRequestsLimit( + modelRequests.limit ?? usageBucketLimit(modelRequests)?.limit, + ) + ? "Hosted AI models are billed per token (not request count). Edit Predictions are tracked below. Token spend is on dashboard.zed.dev." + : null; + + let planLabel = formatZedPlanLabel(planId); + if (plan.trial_started_at || plan.trialStartedAt) { + if (!/trial/i.test(planLabel)) planLabel = `${planLabel} (Trial active)`; + } + + let message = tokenBillingNote; + if (plan.has_overdue_invoices || plan.hasOverdueInvoices) { + message = "This Zed account has overdue invoices. Usage may be blocked until billing is resolved."; + } else if (planSummary?.blocksHostedModels && Object.keys(quotas).length === 0) { + message = planSummary.message; + } + + return { + plan: planLabel, + quotas, + message, + hasOverdueInvoices: !!(plan.has_overdue_invoices || plan.hasOverdueInvoices), + trialStarted: !!(plan.trial_started_at || plan.trialStartedAt), + planId: planId || null, + resetAt, + }; +} + +/** + * @param {string|null|undefined} accessToken + * @param {object|null|undefined} providerSpecificData + * @param {object|null|undefined} proxyOptions + */ +export async function getZedUsage( + accessToken = null, + providerSpecificData = {}, + proxyOptions = null, +) { + const psd = providerSpecificData || {}; + const userId = psd.userId; + + if (!accessToken || typeof accessToken !== "string" || !accessToken.trim()) { + return { message: "Zed access token not available. Re-connect Zed to view quota." }; + } + if (!userId) { + return { message: "Zed credential is missing user id. Re-connect Zed to view quota." }; + } + + const credentials = { + accessToken: accessToken.trim(), + providerSpecificData: psd, + }; + + try { + const userInfo = await fetchZedAuthenticatedUser(credentials, { proxyOptions }); + return parseZedAuthenticatedUserUsage(userInfo); + } catch (error) { + const status = error?.status; + if (status === 401 || status === 403) { + return { + message: "Zed authentication failed. Sign in again from the dashboard or Zed editor.", + }; + } + return { message: `Zed error: ${error.message || "Failed to fetch quota"}` }; + } +} diff --git a/open-sse/shared/zedAuth.js b/open-sse/shared/zedAuth.js index aa3337d786..f8c57a1029 100644 --- a/open-sse/shared/zedAuth.js +++ b/open-sse/shared/zedAuth.js @@ -11,25 +11,30 @@ import crypto from "node:crypto"; import { proxyAwareFetch } from "../utils/proxyFetch.js"; - -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", +import { + ZED_WEB_BASE_URL, + ZED_CLOUD_BASE_URL, + ZED_LLM_BASE_URL, + ZED_CLIENT_VERSION, + ZED_HEADERS, + ZED_LLM_TOKEN_TTL_MS, + ZED_MODEL_CACHE_TTL_MS, + ZED_PRIVATE_KEY_PREFIX, + ZED_FREE_PLAN_IDS, + buildZedHostedModelsBlockedMessage, + buildZedEmptyCatalogMessage, +} from "../config/zedConstants.js"; + +export { + ZED_WEB_BASE_URL, + ZED_CLOUD_BASE_URL, + ZED_LLM_BASE_URL, + ZED_HEADERS, }; -const PRIVATE_KEY_PREFIX = "zed-rsa-pkcs1:"; -const LLM_TOKEN_TTL_MS = 50 * 60 * 1000; -const MODEL_CACHE_TTL_MS = 60 * 60 * 1000; +const PRIVATE_KEY_PREFIX = ZED_PRIVATE_KEY_PREFIX; +const LLM_TOKEN_TTL_MS = ZED_LLM_TOKEN_TTL_MS; +const MODEL_CACHE_TTL_MS = ZED_MODEL_CACHE_TTL_MS; const llmTokenCache = new Map(); const modelCache = new Map(); @@ -107,12 +112,26 @@ export function parseZedCallbackPayload(input) { try { data = JSON.parse(raw); } catch { - let url; + let url = null; + // Absolute URL try { url = new URL(raw); } catch { + /* continue */ + } + // Path + query from the local proxy: "/?user_id=…&access_token=…" or "/callback?…" + if (!url && raw.startsWith("/")) { + try { + url = new URL(raw, "http://127.0.0.1"); + } catch { + /* continue */ + } + } + // Bare query: "?user_id=…" or "user_id=…" + if (!url) { try { - url = new URL(`http://127.0.0.1/?${raw.replace(/^\?/, "")}`); + const q = raw.startsWith("?") ? raw : `?${raw}`; + url = new URL(`http://127.0.0.1/${q}`); } catch { throw new Error("Invalid Zed callback URL"); } @@ -172,8 +191,8 @@ function getSystemId(credentials) { ); } -async function fetchJson(url, options) { - const res = await proxyAwareFetch(url, options); +async function fetchJson(url, options, proxyOptions = null) { + const res = await proxyAwareFetch(url, options, proxyOptions); const text = await res.text(); let data = null; if (text) { @@ -203,11 +222,15 @@ export async function fetchZedAuthenticatedUser(credentials, options = {}) { 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, - }); + return fetchJson( + zedUrl(config, "cloudBaseUrl", "/client/users/me", ZED_CLOUD_BASE_URL), + { + method: "GET", + headers, + signal: options.signal ?? undefined, + }, + options.proxyOptions ?? null, + ); } function normalizeOrganizationId(value) { @@ -367,6 +390,7 @@ export async function resolveZedModels(credentials, options = {}) { headers: { Accept: "application/json", [ZED_HEADERS.clientSupportsXai]: "true", + [ZED_HEADERS.version]: ZED_CLIENT_VERSION, }, }, }); @@ -385,6 +409,23 @@ export async function resolveZedModels(credentials, options = {}) { const id = normalizeZedModelId(raw?.id); if (id) rawById.set(id, raw); } + + let planInfo = null; + let warning = null; + if (models.length === 0) { + // Empty catalog is usually a plan gate (Zed Free has no hosted models), not a parse bug. + try { + const userInfo = await fetchZedAuthenticatedUser(credentials, options); + const webBaseUrl = options.config?.webBaseUrl || ZED_WEB_BASE_URL; + planInfo = summarizeZedPlan(userInfo, { webBaseUrl }); + warning = planInfo?.blocksHostedModels + ? planInfo.message + : buildZedEmptyCatalogMessage(); + } catch { + warning = buildZedEmptyCatalogMessage(); + } + } + const entry = { expiresAt: Date.now() + MODEL_CACHE_TTL_MS, models, @@ -395,6 +436,8 @@ export async function resolveZedModels(credentials, options = {}) { recommendedModels: (data?.recommended_models || data?.recommendedModels || []) .map(normalizeZedModelId) .filter(Boolean), + planInfo, + warning, }; modelCache.set(key, entry); return entry; @@ -408,6 +451,39 @@ export async function resolveZedModels(credentials, options = {}) { } } +/** + * Explain why Zed's /models catalog is empty (plan/trial/quota). + * Token-based plans (student/pro) may report model_requests.limit=0 while still + * listing models — that alone is NOT "free plan blocked". + */ +export function summarizeZedPlan(userInfo, options = {}) { + const plan = userInfo?.plan || {}; + const planId = plan.plan_v3 || plan.plan_v2 || plan.plan || null; + const limit = plan.usage?.model_requests?.limit; + const modelLimit = + typeof limit?.limited === "number" + ? limit.limited + : typeof limit?.Limited === "number" + ? limit.Limited + : limit === "unlimited" || limit?.unlimited + ? Infinity + : null; + const trialStarted = !!plan.trial_started_at; + const isFree = !planId || ZED_FREE_PLAN_IDS.has(String(planId)); + // Only treat classic free plans as hard-blocked for catalog purposes. + const blocksHostedModels = isFree; + const webBaseUrl = options.webBaseUrl || ZED_WEB_BASE_URL; + + return { + planId, + modelRequestLimit: modelLimit, + trialStarted, + isFree, + blocksHostedModels, + message: blocksHostedModels ? buildZedHostedModelsBlockedMessage(webBaseUrl) : null, + }; +} + export function clearZedCaches() { llmTokenCache.clear(); modelCache.clear(); diff --git a/open-sse/translator/formats.js b/open-sse/translator/formats.js index 89367d0071..02e647323c 100644 --- a/open-sse/translator/formats.js +++ b/open-sse/translator/formats.js @@ -11,6 +11,7 @@ export const FORMATS = { ANTIGRAVITY: "antigravity", KIRO: "kiro", CURSOR: "cursor", + ZED: "zed", OLLAMA: "ollama", COMMANDCODE: "commandcode" }; diff --git a/open-sse/translator/index.js b/open-sse/translator/index.js index e2f45339cc..406a722c3c 100644 --- a/open-sse/translator/index.js +++ b/open-sse/translator/index.js @@ -284,6 +284,7 @@ import "./request/antigravity-to-openai.js"; import "./request/openai-responses.js"; import "./request/openai-to-kiro.js"; import "./request/openai-to-cursor.js"; +import "./request/openai-to-zed.js"; import "./request/openai-to-ollama.js"; import "./request/openai-to-commandcode.js"; import "./request/claude-to-kiro.js"; @@ -294,6 +295,7 @@ import "./response/openai-to-antigravity.js"; import "./response/openai-responses.js"; import "./response/kiro-to-openai.js"; import "./response/cursor-to-openai.js"; +import "./response/zed-to-openai.js"; import "./response/ollama-to-openai.js"; import "./response/commandcode-to-openai.js"; import "./response/kiro-to-claude.js"; diff --git a/open-sse/translator/request/openai-to-zed.js b/open-sse/translator/request/openai-to-zed.js new file mode 100644 index 0000000000..be5af12e74 --- /dev/null +++ b/open-sse/translator/request/openai-to-zed.js @@ -0,0 +1,60 @@ +/** + * OpenAI → Zed Hosted AI request translator + * Wraps an OpenAI chat body into Zed's CompletionBody envelope with the + * correct nested provider_request shape per upstream (Anthropic / Gemini / + * OpenAI Responses API). + */ +import { register } from "../index.js"; +import { FORMATS } from "../formats.js"; +import { openaiToClaudeRequest } from "./openai-to-claude.js"; +import { openaiToGeminiRequest } from "./openai-to-gemini.js"; +import { openaiToOpenAIResponsesRequest } from "./openai-responses.js"; +import { ZED_PROVIDER, resolveZedProvider } from "../../config/zedConstants.js"; +import crypto from "crypto"; + +export { resolveZedProvider }; + +function randomId() { + return crypto.randomUUID?.() || `${Date.now()}-${Math.random().toString(36).slice(2)}`; +} + +/** + * @param {string} model + * @param {object} body - OpenAI chat completion body + * @param {boolean} stream + * @param {object} [credentials] + */ +export function openaiToZedRequest(model, body, stream = true, credentials = null) { + // Already a CompletionBody + if (body?.provider_request && body?.provider) { + return { ...body, model: body.model || model }; + } + + const provider = resolveZedProvider(null, model); + const wantStream = stream !== false; + let providerRequest; + + if (provider === ZED_PROVIDER.anthropic) { + providerRequest = openaiToClaudeRequest(model, body, wantStream); + } else if (provider === ZED_PROVIDER.google) { + providerRequest = openaiToGeminiRequest(model, body, wantStream); + } else if (provider === ZED_PROVIDER.openai) { + // Zed Hosted OpenAI models speak Responses API (`input` + typed content) + providerRequest = openaiToOpenAIResponsesRequest(model, body, wantStream, credentials); + } else { + // XAi — OpenAI chat-compatible + providerRequest = { ...body, model, stream: wantStream }; + delete providerRequest.thread_id; + delete providerRequest.prompt_id; + } + + return { + thread_id: body.thread_id || randomId(), + prompt_id: body.prompt_id || randomId(), + provider, + model, + provider_request: providerRequest, + }; +} + +register(FORMATS.OPENAI, FORMATS.ZED, openaiToZedRequest, null); diff --git a/open-sse/translator/response/zed-to-openai.js b/open-sse/translator/response/zed-to-openai.js new file mode 100644 index 0000000000..da29c098d1 --- /dev/null +++ b/open-sse/translator/response/zed-to-openai.js @@ -0,0 +1,15 @@ +/** + * Zed → OpenAI response translator + * ZedExecutor already emits OpenAI chat.completion(.chunk) objects — passthrough. + */ +import { register } from "../index.js"; +import { FORMATS } from "../formats.js"; + +export function zedToOpenAIResponse(chunk) { + if (!chunk) return null; + if (chunk.object === "chat.completion.chunk" && chunk.choices) return chunk; + if (chunk.object === "chat.completion" && chunk.choices) return chunk; + return chunk; +} + +register(FORMATS.ZED, FORMATS.OPENAI, null, zedToOpenAIResponse); diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.js b/src/app/(dashboard)/dashboard/providers/[id]/page.js index 8ca07ae440..81c949af40 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/page.js +++ b/src/app/(dashboard)/dashboard/providers/[id]/page.js @@ -5,7 +5,7 @@ import { useParams, useRouter } from "next/navigation"; import Link from "next/link"; import Image from "next/image"; import { getProviderIconSrc, markProviderIconMissing } from "@/shared/utils/providerIcon"; -import { Card, Button, Badge, Input, Modal, CardSkeleton, OAuthModal, KiroOAuthWrapper, CursorAuthModal, IFlowCookieModal, GitLabAuthModal, Toggle, Select, EditConnectionModal, NoAuthProxyCard, ConfirmModal } from "@/shared/components"; +import { Card, Button, Badge, Input, Modal, CardSkeleton, OAuthModal, KiroOAuthWrapper, CursorAuthModal, IFlowCookieModal, GitLabAuthModal, Toggle, Select, EditConnectionModal, NoAuthProxyCard, ConfirmModal, ZedOAuthWrapper } from "@/shared/components"; import { OAUTH_PROVIDERS, APIKEY_PROVIDERS, FREE_PROVIDERS, FREE_TIER_PROVIDERS, WEB_COOKIE_PROVIDERS, getProviderAlias, isOpenAICompatibleProvider, isAnthropicCompatibleProvider, AI_PROVIDERS } from "@/shared/constants/providers"; import { getModelsByProviderId, getModelKind } from "@/shared/constants/models"; import { getThinkingLevels } from "open-sse/providers/thinkingLevels.js"; @@ -144,7 +144,9 @@ export default function ProviderDetailPage() { const supportsApiKeyAuth = !!APIKEY_PROVIDERS[providerId] || authModes.includes("apikey"); const isFreeNoAuth = !!FREE_PROVIDERS[providerId]?.noAuth; const staticModels = getModelsByProviderId(providerId); - const models = providerId === "cursor" && liveModels.length > 0 + // Cursor/Zed catalogs are account-specific and empty in the static registry — + // prefer the live /models fetch when available. + const models = (providerId === "cursor" || providerId === "zed") && liveModels.length > 0 ? liveModels : staticModels; const providerAlias = getProviderAlias(providerId); @@ -458,12 +460,13 @@ export default function ProviderDetailPage() { fetchDisabledModels(); }, [fetchConnections, fetchAliases, fetchCustomModels, fetchDisabledModels]); - // Cursor's model availability is account-specific and changes frequently. + // Cursor/Zed model availability is account-specific and changes frequently. // Load the active account's live catalog for the dashboard; the static // registry remains the fallback while the request is pending or unavailable. useEffect(() => { - if (providerId !== "cursor") { + if (providerId !== "cursor" && providerId !== "zed") { setLiveModels([]); + setModelsTestError(""); return; } @@ -474,14 +477,25 @@ export default function ProviderDetailPage() { } let cancelled = false; + setModelsTestError(""); fetch(`/api/providers/${connection.id}/models`, { cache: "no-store" }) .then(async (res) => ({ ok: res.ok, data: await res.json() })) .then(({ ok, data }) => { - if (!cancelled && ok && Array.isArray(data.models) && data.models.length > 0) { + if (cancelled) return; + if (ok && Array.isArray(data.models) && data.models.length > 0) { setLiveModels(data.models); + if (data.warning) setModelsTestError(data.warning); + } else { + setLiveModels([]); + setModelsTestError(data?.error || data?.warning || "No models returned"); } }) - .catch(() => {}); + .catch((err) => { + if (!cancelled) { + setLiveModels([]); + setModelsTestError(err.message || "Failed to fetch models"); + } + }); return () => { cancelled = true; }; }, [providerId, connections]); @@ -1699,6 +1713,13 @@ export default function ProviderDetailPage() { onSuccess={handleOAuthSuccess} onClose={() => setShowOAuthModal(false)} /> + ) : providerId === "zed" ? ( + setShowOAuthModal(false)} + /> ) : providerId === "gitlab" ? ( {currentPageRows.map((quota) => { + const isUnlimited = quota.unlimited === true; const colors = getColorClasses(quota.remaining); const countdown = formatResetTime(quota.resetAt); const resetDisplay = formatResetTimeDisplay(quota.resetAt); @@ -174,6 +175,7 @@ export default function QuotaTable({ {/* Progress + used/total */}
+ {!isUnlimited && (
@@ -182,16 +184,23 @@ export default function QuotaTable({ style={{ width: `${Math.min(quota.remaining, 100)}%` }} />
+ )}
0 ? quota.total.toLocaleString() : "∞"}`} + title={ + isUnlimited + ? `${quota.used.toLocaleString()} used · Unlimited` + : `${quota.used.toLocaleString()} / ${quota.total > 0 ? quota.total.toLocaleString() : "∞"}` + } > - {quota.used.toLocaleString()} / {quota.total > 0 ? quota.total.toLocaleString() : "∞"} + {isUnlimited + ? `${quota.used.toLocaleString()} used · Unlimited` + : `${quota.used.toLocaleString()} / ${quota.total > 0 ? quota.total.toLocaleString() : "∞"}`} - - {quota.remaining}% + + {isUnlimited ? "Unlimited" : `${quota.remaining}%`}
diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js index c55633c2c9..683ea53d85 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js @@ -1265,6 +1265,11 @@ export default function ProviderLimits() { onHideQuota={(quotaRow) => handleHideQuota(conn.provider, quotaRow)} /> )} + {quota?.message && !error && !isLoading && ( +

+ {quota.message} +

+ )} {hiddenQuotaRows.length > 0 && (
diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js index ffd861e025..75d4a8fcf7 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js @@ -549,6 +549,22 @@ export function parseQuotaData(provider, data) { } break; + case "zed": + // Edit predictions + optional hosted model_requests; unlimited uses remainingPercentage. + if (data.quotas) { + Object.entries(data.quotas).forEach(([name, quota]) => { + normalizedQuotas.push({ + name, + used: quota.used || 0, + total: quota.total || 0, + resetAt: quota.resetAt || null, + remainingPercentage: quota.remainingPercentage, + unlimited: quota.unlimited, + }); + }); + } + break; + default: // Generic fallback for unknown providers if (data.quotas) { diff --git a/src/app/api/oauth/[provider]/[action]/route.js b/src/app/api/oauth/[provider]/[action]/route.js index 824cad8b2e..31b9d5cdb8 100644 --- a/src/app/api/oauth/[provider]/[action]/route.js +++ b/src/app/api/oauth/[provider]/[action]/route.js @@ -33,6 +33,8 @@ import { registerZedSession, getZedSessionStatus, clearZedSession, + peekZedAuthReuse, + saveZedAuthDraft, } from "@/lib/oauth/utils/server"; import { detectIdeInstalled } from "@/lib/oauth/utils/ideDetect"; import { ZED_HOSTED_CONFIG } from "@/lib/oauth/constants/oauth"; @@ -96,10 +98,40 @@ export async function GET(request, { params }) { searchParams.forEach((value, key) => { if (!reservedParams.has(key)) meta[key] = value; }); // Zed: derive native_app_port from the local callback URL so the RSA keypair // is bound to the port the proxy is actually listening on. + // Reuse in-flight auth on StrictMode double-/authorize so the popup's public + // key still matches the session private key at callback time. if (provider === "zed") { try { const p = new URL(redirectUri).port; if (p) meta.nativeAppPort = p; } catch { /* ignore */ } + const reuse = peekZedAuthReuse(); + if (reuse) { + return NextResponse.json({ + authUrl: reuse.authUrl, + state: reuse.state, + codeVerifier: reuse.codeVerifier, + systemId: reuse.systemId, + redirectUri: reuse.redirectUri || redirectUri, + flowType: reuse.flowType || "authorization_code", + callbackPath: reuse.callbackPath || "/", + }); + } } const authData = await generateAuthData(provider, redirectUri, Object.keys(meta).length ? meta : undefined); + if (provider === "zed") { + if (!saveZedAuthDraft(authData)) { + const winner = peekZedAuthReuse(); + if (winner) { + return NextResponse.json({ + authUrl: winner.authUrl, + state: winner.state, + codeVerifier: winner.codeVerifier, + systemId: winner.systemId, + redirectUri: winner.redirectUri || redirectUri, + flowType: winner.flowType || "authorization_code", + callbackPath: winner.callbackPath || "/", + }); + } + } + } return NextResponse.json(authData); } @@ -252,7 +284,7 @@ export async function POST(request, { params }) { } if (action === "register-session") { - // Register proxy session out of URL query (state) + body (codeVerifier). + // Register proxy session from body (preferred) or URL query (state). // Zed's codeVerifier encodes the RSA private key — must stay out of URL/logs. const searchParams = new URL(request.url).searchParams; const state = searchParams.get("state") || body?.state; @@ -260,7 +292,14 @@ export async function POST(request, { params }) { let ok = false; if (provider === "trae") ok = registerTraeSession({ state }); else if (provider === "windsurf") ok = registerWindsurfSession({ state }); - else if (provider === "zed") ok = registerZedSession({ state, codeVerifier: body?.codeVerifier }); + else if (provider === "zed") { + ok = registerZedSession({ + state, + codeVerifier: body?.codeVerifier, + systemId: body?.systemId, + authUrl: body?.authUrl, + }); + } else return NextResponse.json({ error: "register-session only supported for trae/windsurf/zed" }, { status: 400 }); return NextResponse.json({ success: ok }); } @@ -300,6 +339,51 @@ export async function POST(request, { params }) { } } + // Zed: pasted callback URL (?user_id=&access_token=) decrypted with session private key. + if (provider === "zed") { + const token = typeof code === "string" ? code.trim() : ""; + if (!token) { + return NextResponse.json({ error: "Missing Zed callback URL" }, { status: 400 }); + } + const session = getZedSessionStatus(state) || getZedSessionStatus(); + const verifier = codeVerifier || session?.codeVerifier; + if (!verifier) { + return NextResponse.json( + { error: "Missing Zed session key; restart Sign in with browser, then paste the callback URL" }, + { status: 400 }, + ); + } + try { + const tokenData = await exchangeTokens( + "zed", + token, + null, + verifier, + state || session?.state, + { systemId: session?.systemId || body?.systemId }, + ); + const connection = await createProviderConnection({ + provider: "zed", + authType: "oauth", + ...tokenData, + testStatus: "active", + }); + if (session?.state) clearZedSession(session.state); + stopZedProxy(); + return NextResponse.json({ + success: true, + connection: { + id: connection.id, + provider: connection.provider, + email: connection.email, + displayName: connection.displayName, + }, + }); + } catch (err) { + return NextResponse.json({ error: err.message }, { status: 500 }); + } + } + // Detect if "code" is actually a raw JWT access token (starts with eyJ) if (code && code.startsWith("eyJ") && code.includes(".")) { const { extractCodexAccountInfo } = await import("@/lib/oauth/providers"); diff --git a/src/app/api/oauth/zed/auto-import/route.js b/src/app/api/oauth/zed/auto-import/route.js new file mode 100644 index 0000000000..8ba9534148 --- /dev/null +++ b/src/app/api/oauth/zed/auto-import/route.js @@ -0,0 +1,269 @@ +import { NextResponse } from "next/server"; +import { access, constants, readFile } from "fs/promises"; +import { homedir } from "os"; +import { join } from "path"; +import { execFile } from "child_process"; +import { promisify } from "util"; + +const execFileAsync = promisify(execFile); + +/** Zed stores cloud login under the credentials URL (usually https://zed.dev). */ +const ZED_CREDENTIAL_URLS = [ + "https://zed.dev", + "https://cloud.zed.dev", + "http://localhost:3000", +]; + +/** + * Candidate paths for Zed development credentials / config. + * Production credentials live in the OS keychain (libsecret / Keychain). + */ +function getCandidatePaths(platform) { + const home = homedir(); + const paths = [ + join(home, ".local/share/zed/credentials"), + join(home, ".local/share/zed/development_credentials"), + join(home, ".config/zed/credentials"), + join(home, ".config/zed/development_credentials"), + ]; + if (platform === "darwin") { + paths.push( + join(home, "Library/Application Support/Zed/credentials"), + join(home, "Library/Application Support/Zed/development_credentials"), + ); + } + if (platform === "win32") { + const appData = process.env.APPDATA || join(home, "AppData", "Roaming"); + paths.push( + join(appData, "Zed", "credentials"), + join(appData, "Zed", "development_credentials"), + ); + } + return paths; +} + +/** + * Parse Zed credential payloads. + * - Plain: "{user_id} {access_token}" + * - Keyring JSON v2: {"version":2,"id":"client_token_…","token":"…"} (+ username from attrs) + * - Legacy JSON: {user_id, access_token} + */ +function parseCredentialsPayload(raw, usernameHint = null) { + if (!raw || typeof raw !== "string") return null; + const trimmed = raw.trim(); + + const spaced = trimmed.match(/^(\d+)\s+(\S+)$/); + if (spaced) { + return { userId: spaced[1], accessToken: spaced[2] }; + } + + try { + const parsed = JSON.parse(trimmed); + // Zed keyring v2: the entire JSON blob IS the access_token used in + // Authorization: "{userId} {json}". Inner `.token` alone returns 401. + if (parsed?.version === 2 && typeof parsed.token === "string") { + const userId = usernameHint || parsed.user_id || parsed.userId; + if (userId != null && /^\d+$/.test(String(userId))) { + return { userId: String(userId), accessToken: trimmed }; + } + return null; + } + + const userId = + usernameHint || + parsed.user_id || + parsed.userId || + (typeof parsed.id === "number" || /^\d+$/.test(String(parsed.id || "")) + ? parsed.id + : null) || + parsed?.user?.id; + const accessToken = + parsed.access_token || parsed.accessToken || parsed.token; + if (userId != null && accessToken) { + return { userId: String(userId), accessToken: String(accessToken) }; + } + } catch { + /* not JSON — maybe raw token with username hint */ + if (usernameHint && /^\d+$/.test(String(usernameHint)) && trimmed.length >= 16) { + return { userId: String(usernameHint), accessToken: trimmed }; + } + } + return null; +} + +/** + * Parse `secret-tool search` multi-line output into {username, secret}. + */ +function parseSecretToolSearch(stdout) { + if (!stdout) return null; + let username = null; + let secret = null; + for (const line of stdout.split("\n")) { + const mUser = line.match(/^attribute\.username\s*=\s*(.+)\s*$/); + if (mUser) username = mUser[1].trim(); + const mSecret = line.match(/^secret\s*=\s*(.+)\s*$/); + if (mSecret) secret = mSecret[1].trim(); + } + if (!secret) return null; + return parseCredentialsPayload(secret, username); +} + +async function trySecretTool() { + // Prefer search (attributes on stderr, secret on stdout). + for (const url of ZED_CREDENTIAL_URLS) { + try { + const { stdout, stderr } = await execFileAsync( + "secret-tool", + ["search", "--all", "url", url], + { timeout: 8000 }, + ); + const parsed = parseSecretToolSearch(`${stdout || ""}\n${stderr || ""}`); + if (parsed?.userId && parsed?.accessToken) return parsed; + } catch { + /* try next */ + } + } + + for (const url of ZED_CREDENTIAL_URLS) { + try { + const { stdout } = await execFileAsync( + "secret-tool", + ["lookup", "url", url], + { timeout: 5000 }, + ); + // lookup has no username — cannot use JSON v2 alone + const parsed = parseCredentialsPayload(stdout); + if (parsed?.userId && parsed?.accessToken) return parsed; + } catch { + /* try next */ + } + } + + // Legacy attribute guesses + for (const [attr, value] of [ + ["application", "zed"], + ["service", "zed"], + ["service", "https://zed.dev"], + ]) { + try { + const { stdout, stderr } = await execFileAsync( + "secret-tool", + ["search", "--all", attr, value], + { timeout: 5000 }, + ); + const parsed = parseSecretToolSearch(`${stdout || ""}\n${stderr || ""}`); + if (parsed?.userId && parsed?.accessToken) return parsed; + } catch { + /* try next */ + } + } + return null; +} + +async function tryMacKeychain() { + // Zed stores under service = credentials URL (https://zed.dev) + for (const service of ZED_CREDENTIAL_URLS) { + try { + const { stdout: secret } = await execFileAsync( + "security", + ["find-generic-password", "-s", service, "-w"], + { timeout: 5000 }, + ); + let account = null; + try { + const { stdout: meta } = await execFileAsync( + "security", + ["find-generic-password", "-s", service, "-g"], + { timeout: 5000 }, + ); + const m = meta.match(/"acct"="([^"]+)"/) || meta.match(/acct[^"]*"([^"]+)"/); + if (m) account = m[1]; + } catch { + /* account optional for some formats */ + } + const parsed = parseCredentialsPayload(secret.trim(), account); + if (parsed?.userId && parsed?.accessToken) return parsed; + } catch { + /* try next service */ + } + } + + // Fallback label search + try { + const { stdout } = await execFileAsync( + "security", + ["find-generic-password", "-l", "zed-github-account", "-w"], + { timeout: 5000 }, + ); + return parseCredentialsPayload(stdout.trim()); + } catch { + return null; + } +} + +/** + * GET /api/oauth/zed/auto-import + * Best-effort auto-detect Zed credentials from local files / keyring. + */ +export async function GET() { + try { + const platform = process.platform; + const candidates = getCandidatePaths(platform); + + for (const candidate of candidates) { + try { + await access(candidate, constants.R_OK); + const raw = await readFile(candidate, "utf8"); + const parsed = parseCredentialsPayload(raw); + if (parsed?.userId && parsed?.accessToken) { + return NextResponse.json({ + found: true, + userId: parsed.userId, + accessToken: parsed.accessToken, + source: candidate, + }); + } + } catch { + /* try next */ + } + } + + if (platform === "linux") { + const fromSecret = await trySecretTool(); + if (fromSecret?.userId && fromSecret?.accessToken) { + return NextResponse.json({ + found: true, + userId: fromSecret.userId, + accessToken: fromSecret.accessToken, + source: "secret-tool:url=https://zed.dev", + }); + } + } + + if (platform === "darwin") { + const fromKeychain = await tryMacKeychain(); + if (fromKeychain?.userId && fromKeychain?.accessToken) { + return NextResponse.json({ + found: true, + userId: fromKeychain.userId, + accessToken: fromKeychain.accessToken, + source: "keychain", + }); + } + } + + return NextResponse.json({ + found: false, + windowsManual: true, + error: + "Zed credentials not found automatically. Paste your user ID and access token manually.", + checked: candidates, + }); + } catch (error) { + console.log("Zed auto-import error:", error); + return NextResponse.json( + { found: false, error: error.message }, + { status: 500 }, + ); + } +} diff --git a/src/app/api/oauth/zed/import/route.js b/src/app/api/oauth/zed/import/route.js new file mode 100644 index 0000000000..f9482ccb46 --- /dev/null +++ b/src/app/api/oauth/zed/import/route.js @@ -0,0 +1,86 @@ +import { NextResponse } from "next/server"; +import { ZedService } from "@/lib/oauth/services/zed"; +import { createProviderConnection } from "@/models"; + +/** + * POST /api/oauth/zed/import + * Import Zed Hosted AI credentials (user_id + access_token). + * Stores the long-lived user token the same way RSA OAuth does; LLM bearer + * tokens are minted on demand by open-sse/shared/zedAuth.js. + * + * Request body: + * - userId: string + * - accessToken: string — Zed user access token + */ +export async function POST(request) { + try { + const { userId, accessToken } = await request.json(); + + if (!userId || typeof userId !== "string") { + return NextResponse.json({ error: "User ID is required" }, { status: 400 }); + } + if (!accessToken || typeof accessToken !== "string") { + return NextResponse.json({ error: "Access token is required" }, { status: 400 }); + } + + const zedService = new ZedService(); + const tokenData = await zedService.validateImportToken(userId.trim(), accessToken.trim()); + + const connection = await createProviderConnection({ + provider: "zed", + authType: "oauth", + accessToken: tokenData.accessToken, + refreshToken: null, + expiresAt: null, + email: tokenData.email || null, + displayName: tokenData.name || undefined, + providerSpecificData: { + authMethod: "imported", + userId: tokenData.userId, + organizationId: tokenData.organizationId || "", + }, + testStatus: "active", + }); + + return NextResponse.json({ + success: true, + connection: { + id: connection.id, + provider: connection.provider, + email: connection.email, + }, + }); + } catch (error) { + console.log("Zed import token error:", error); + return NextResponse.json({ error: error.message }, { status: 500 }); + } +} + +/** + * GET /api/oauth/zed/import + * Instructions for importing Zed credentials + */ +export async function GET() { + const zedService = new ZedService(); + const instructions = zedService.getTokenStorageInstructions(); + + return NextResponse.json({ + provider: "zed", + method: "import_token", + instructions, + requiredFields: [ + { + name: "userId", + label: "User ID", + description: "Numeric Zed user id from credentials", + type: "text", + }, + { + name: "accessToken", + label: "Access Token", + description: "Zed user access token (paired with user id)", + type: "textarea", + }, + ], + }); +} diff --git a/src/app/api/providers/[id]/models/route.js b/src/app/api/providers/[id]/models/route.js index d73ec11dfa..d84a4b293d 100644 --- a/src/app/api/providers/[id]/models/route.js +++ b/src/app/api/providers/[id]/models/route.js @@ -11,6 +11,7 @@ import { resolveQoderModels } from "open-sse/services/qoderModels.js"; import { resolveGrokCliModels } from "open-sse/services/grokCliModels.js"; import { resolveConnectionProxyConfig } from "@/lib/network/connectionProxy"; import { resolveCursorModels } from "open-sse/services/cursorModels.js"; +import { resolveZedModels } from "open-sse/shared/zedAuth.js"; const GEMINI_CLI_MODELS_URL = "https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels"; @@ -286,6 +287,41 @@ const PROVIDER_MODELS_CONFIG = { }; }, }, + zed: { + customResolver: async (connection) => { + let warning; + try { + const result = await resolveZedModels({ + accessToken: connection.accessToken, + providerSpecificData: connection.providerSpecificData || {}, + }, { forceRefresh: true }); + if (result?.models?.length) { + return { + models: result.models.map((m) => ({ + id: m.id, + name: m.name || m.id, + contextLength: m.contextLength, + maxOutputTokens: m.maxOutputTokens, + capabilities: { + ...(m.supportsTools ? { tools: true } : {}), + ...(m.supportsImages ? { vision: true } : {}), + ...(m.supportsThinking ? { thinking: true } : {}), + }, + provider: m.provider, + supportsThinking: m.supportsThinking, + supportedEffortLevels: m.supportedEffortLevels, + })), + ...(result.defaultModel ? { defaultModel: result.defaultModel } : {}), + }; + } + warning = result?.warning || "Zed returned no models."; + } catch (error) { + warning = `Failed to fetch Zed models: ${error.message}`; + console.log("Failed to fetch Zed models:", error.message); + } + return { models: [], warning }; + }, + }, // Custom resolvers (non-OpenAI-shaped APIs / token-refresh flows) kiro: { diff --git a/src/app/api/providers/[id]/test/testUtils.js b/src/app/api/providers/[id]/test/testUtils.js index b9bd966a86..dd6deff017 100644 --- a/src/app/api/providers/[id]/test/testUtils.js +++ b/src/app/api/providers/[id]/test/testUtils.js @@ -16,6 +16,7 @@ import { CLINE_CONFIG, KILOCODE_CONFIG, KIMCHI_CONFIG, + ZED_HOSTED_CONFIG, } from "@/lib/oauth/constants/oauth"; import { buildClineHeaders } from "@/shared/utils/clineAuth"; @@ -125,6 +126,23 @@ const OAUTH_TEST_CONFIG = { 402: "Connected, but Grok Build credits are exhausted (spending limit). Add credits or upgrade SuperGrok.", }, }, + // Zed Hosted AI — probe /client/users/me with "userId accessToken" user auth + // (same scheme as open-sse/shared/zedAuth.buildZedUserAuthHeader). LLM bearer + // tokens are minted on demand and must not be stored as accessToken. + zed: { + url: `${(ZED_HOSTED_CONFIG.cloudBaseUrl || "https://cloud.zed.dev").replace(/\/$/, "")}/client/users/me`, + method: "GET", + authHeader: "Authorization", + authPrefix: "", + // buildAuth overrides the default `${prefix}${accessToken}` composition + buildAuth: (accessToken, connection) => { + const userId = connection?.providerSpecificData?.userId; + if (!userId || !accessToken) return null; + return `${userId} ${accessToken}`; + }, + extraHeaders: { Accept: "application/json" }, + refreshable: false, + }, }; /** @@ -393,9 +411,15 @@ async function testOAuthConnection(connection, effectiveProxy = null) { try { const testUrl = config.buildUrl ? config.buildUrl(accessToken) : config.url; + const authValue = config.buildAuth + ? config.buildAuth(accessToken, connection) + : `${config.authPrefix}${accessToken}`; + if (config.buildAuth && !authValue) { + return { valid: false, error: "Zed credential is missing userId or accessToken", refreshed }; + } const headers = config.noAuth ? { ...config.extraHeaders } - : { [config.authHeader]: `${config.authPrefix}${accessToken}`, ...config.extraHeaders }; + : { [config.authHeader]: authValue, ...config.extraHeaders }; const fetchOpts = { method: config.method, headers }; if (config.body) fetchOpts.body = config.body; const res = await fetchWithConnectionProxy(testUrl, fetchOpts, effectiveProxy); @@ -417,9 +441,12 @@ async function testOAuthConnection(connection, effectiveProxy = null) { const tokens = await refreshOAuthToken(connection); if (tokens) { const retryUrl = config.buildUrl ? config.buildUrl(tokens.accessToken) : testUrl; + const retryAuth = config.buildAuth + ? config.buildAuth(tokens.accessToken, { ...connection, ...tokens, providerSpecificData: { ...(connection.providerSpecificData || {}), ...(tokens.providerSpecificData || {}) } }) + : `${config.authPrefix}${tokens.accessToken}`; const retryHeaders = config.noAuth ? { ...config.extraHeaders } - : { [config.authHeader]: `${config.authPrefix}${tokens.accessToken}`, ...config.extraHeaders }; + : { [config.authHeader]: retryAuth, ...config.extraHeaders }; const retryOpts = { method: config.method, headers: retryHeaders }; if (config.body) retryOpts.body = config.body; const retryRes = await fetchWithConnectionProxy(retryUrl, retryOpts, effectiveProxy); diff --git a/src/lib/oauth/constants/oauth.js b/src/lib/oauth/constants/oauth.js index c9e3cae64d..21cc637a9c 100644 --- a/src/lib/oauth/constants/oauth.js +++ b/src/lib/oauth/constants/oauth.js @@ -183,16 +183,24 @@ export const WINDSURF_CONFIG = { oauthTimeoutMs: 600_000, }; +import { + ZED_WEB_BASE_URL, + ZED_CLOUD_BASE_URL, + ZED_LLM_BASE_URL, + ZED_DEFAULT_NATIVE_APP_PORT, + ZED_OAUTH_TIMEOUT_MS, +} from "open-sse/config/zedConstants.js"; + // Zed hosted LLM aggregator — RSA keypair native-app auth (NOT OAuth). // Client generates ephemeral RSA-2048 keypair; user signs in at zed.dev/native_app_signin; // Zed redirects to local callback with access_token RSA-encrypted against our public key. // See open-sse/shared/zedAuth.js for the keypair/decrypt helpers. export const ZED_HOSTED_CONFIG = { - webBaseUrl: "https://zed.dev", - cloudBaseUrl: "https://cloud.zed.dev", - llmBaseUrl: "https://cloud.zed.dev", - defaultNativeAppPort: 58443, - oauthTimeoutMs: 600_000, + webBaseUrl: ZED_WEB_BASE_URL, + cloudBaseUrl: ZED_CLOUD_BASE_URL, + llmBaseUrl: ZED_LLM_BASE_URL, + defaultNativeAppPort: ZED_DEFAULT_NATIVE_APP_PORT, + oauthTimeoutMs: ZED_OAUTH_TIMEOUT_MS, }; // OAuth timeout (5 minutes) diff --git a/src/lib/oauth/providers/index.js b/src/lib/oauth/providers/index.js index e505b01dbb..ea6afa963a 100644 --- a/src/lib/oauth/providers/index.js +++ b/src/lib/oauth/providers/index.js @@ -112,6 +112,8 @@ export async function generateAuthData(providerName, redirectUri, meta) { flowType: provider.flowType, fixedPort: provider.fixedPort, callbackPath: provider.callbackPath || "/callback", + // Zed: systemId was bound into native_app_signin; keep it for exchange/headers. + ...(config.systemId ? { systemId: config.systemId } : {}), }; } @@ -121,7 +123,8 @@ export async function generateAuthData(providerName, redirectUri, meta) { */ export async function exchangeTokens(providerName, code, redirectUri, codeVerifier, state, meta) { const provider = getProvider(providerName); - const config = provider.prepareConfig + // Zed (and similar): prepareConfig mints ephemeral secrets — skip on exchange. + const config = provider.prepareConfig && !provider.skipPrepareOnExchange ? await provider.prepareConfig(provider.config, meta || {}) : provider.config; diff --git a/src/lib/oauth/providers/zed.js b/src/lib/oauth/providers/zed.js index 3343976a8d..8f7e1dbc63 100644 --- a/src/lib/oauth/providers/zed.js +++ b/src/lib/oauth/providers/zed.js @@ -14,6 +14,8 @@ const zed = { config: ZED_HOSTED_CONFIG, flowType: "authorization_code", callbackPath: "/", + // prepareConfig mints a fresh RSA keypair — never re-run it during exchange. + skipPrepareOnExchange: true, prepareConfig: async (config, meta) => { // native_app_port is the local callback port (passed via meta from start-proxy). const nativeAppPort = Number(meta?.nativeAppPort) || ZED_HOSTED_CONFIG.defaultNativeAppPort; @@ -21,11 +23,12 @@ const zed = { return { ...config, ...auth }; }, buildAuthUrl: (config, redirectUri, state) => config.authUrl, - exchangeToken: async (config, code, redirectUri, codeVerifier, state) => { + exchangeToken: async (config, code, redirectUri, codeVerifier, state, meta = {}) => { // code = raw callback URL/query; codeVerifier = encoded private key verifier. const { userId, encryptedAccessToken } = parseZedCallbackPayload(code); const accessToken = decryptZedAccessToken(encryptedAccessToken, codeVerifier); - return { accessToken, userId, systemId: config.systemId }; + // systemId must match the one embedded in the native_app_signin URL (session meta). + return { accessToken, userId, systemId: meta.systemId || config.systemId || "" }; }, postExchange: async (tokens) => { const credentials = { diff --git a/src/lib/oauth/services/index.js b/src/lib/oauth/services/index.js index f8f0214af4..5c5399f012 100644 --- a/src/lib/oauth/services/index.js +++ b/src/lib/oauth/services/index.js @@ -13,4 +13,5 @@ export { OpenAIService } from "./openai.js"; export { GitHubService } from "./github.js"; export { KiroService } from "./kiro.js"; export { CursorService } from "./cursor.js"; +export { ZedService } from "./zed.js"; diff --git a/src/lib/oauth/services/zed.js b/src/lib/oauth/services/zed.js new file mode 100644 index 0000000000..5bbf025c58 --- /dev/null +++ b/src/lib/oauth/services/zed.js @@ -0,0 +1,90 @@ +/** + * Zed Hosted AI credential helpers for CLI/keyring import. + * RSA native-app OAuth lives in open-sse/shared/zedAuth.js + providers/zed.js; + * this service covers the alternate "paste/import user_id + access_token" path. + */ +import { + ZED_HOSTED_CONFIG, +} from "../constants/oauth.js"; +import { + fetchZedAuthenticatedUser, + fetchZedLlmToken, + resolveZedOrganizationId, +} from "open-sse/shared/zedAuth.js"; + +export class ZedService { + constructor() { + this.config = ZED_HOSTED_CONFIG; + } + + /** + * Validate import credentials against cloud.zed.dev. + * Returns the long-lived user access token shape expected by zedAuth / ZedExecutor + * (LLM tokens are minted on demand by zedLlmFetch — do NOT store them as accessToken). + * @param {string} userId + * @param {string} accessToken - Zed user access token (plain, or keyring JSON v2 blob) + */ + async validateImportToken(userId, accessToken) { + if (!userId || typeof userId !== "string") { + throw new Error("User ID is required"); + } + if (!accessToken || typeof accessToken !== "string") { + throw new Error("Access token is required"); + } + + const trimmedUserId = userId.trim(); + const trimmedToken = accessToken.trim(); + if (!/^\d+$/.test(trimmedUserId) && !/^[a-zA-Z0-9_-]+$/.test(trimmedUserId)) { + throw new Error("Invalid user ID format"); + } + if (trimmedToken.length < 16) { + throw new Error("Invalid access token format. Token appears too short."); + } + + const credentials = { + accessToken: trimmedToken, + providerSpecificData: { userId: trimmedUserId }, + }; + + let userMe; + try { + userMe = await fetchZedAuthenticatedUser(credentials, { config: this.config }); + } catch (firstErr) { + const msg = String(firstErr?.message || firstErr); + if ((msg.includes("401") || firstErr?.status === 401) && !trimmedToken.trimStart().startsWith("{")) { + throw new Error( + `${msg}. For Zed keyring v2 credentials, paste the full JSON secret ` + + `(starts with {"version":2,...}), not only the inner token field. ` + + `Click Retry to auto-detect again.`, + ); + } + throw firstErr; + } + + const organizationId = resolveZedOrganizationId(credentials, userMe); + // Probe LLM mint once so bad org/billing fails at import time, not mid-chat. + await fetchZedLlmToken( + { ...credentials, providerSpecificData: { ...credentials.providerSpecificData, organizationId } }, + { config: this.config, organizationId }, + ); + + return { + userId: trimmedUserId, + accessToken: trimmedToken, + organizationId, + email: userMe?.email || userMe?.user?.email || userMe?.github_login || userMe?.user?.github_login || null, + name: userMe?.name || userMe?.display_name || userMe?.user?.name || null, + userMe, + }; + } + + getTokenStorageInstructions() { + return { + linux: "Zed credentials are typically in the system keyring (libsecret) or ~/.local/share/zed/ development credentials when ZED_DEVELOPMENT_USE_KEYCHAIN is set.", + macos: "Zed credentials are stored in the macOS Keychain (search for zed).", + windows: "Zed credentials are stored via the Windows Credential Manager.", + manual: + "From a Zed session, copy your user_id and access_token (format used as Authorization: \"{user_id} {access_token}\"). Prefer the dashboard Connect flow (RSA native-app sign-in) when possible.", + }; + } +} diff --git a/src/lib/oauth/utils/server.js b/src/lib/oauth/utils/server.js index 56eb67b12e..6bee80026e 100644 --- a/src/lib/oauth/utils/server.js +++ b/src/lib/oauth/utils/server.js @@ -641,16 +641,71 @@ export function stopWindsurfProxy() { // Zed RSA native-app proxy. Singleton session. // Callback: GET http://127.0.0.1:/?user_id=...&access_token= // The proxy decrypts the access token using the private key stored in session.codeVerifier. +// +// React StrictMode (and double-clicks) call /authorize twice. A second keypair would +// orphan the popup's public key and make decrypt fail — reuse the in-flight draft/session. // ─────────────────────────────────────────────────────────────────────────── let zedProxyServer = null; let zedProxyTimeout = null; let zedProxyPort = null; let zedSession = null; +/** Auth material from /authorize before /register-session confirms it. */ +let zedAuthDraft = null; + +/** Return in-flight Zed auth so a second /authorize reuses the same RSA keypair. */ +export function peekZedAuthReuse() { + if (zedSession?.status === "pending" && zedSession.codeVerifier && zedSession.authUrl) { + return { + authUrl: zedSession.authUrl, + state: zedSession.state, + codeVerifier: zedSession.codeVerifier, + systemId: zedSession.systemId, + redirectUri: zedSession.redirectUri, + flowType: zedSession.flowType, + callbackPath: zedSession.callbackPath || "/", + }; + } + if (zedAuthDraft?.codeVerifier && zedAuthDraft?.authUrl) { + return { ...zedAuthDraft }; + } + return null; +} + +export function saveZedAuthDraft(authData) { + if (!authData?.codeVerifier || !authData?.authUrl || !authData?.state) return false; + // First writer wins — a parallel StrictMode /authorize must reuse, not replace. + if (zedAuthDraft?.codeVerifier || (zedSession?.status === "pending" && zedSession?.codeVerifier)) { + return false; + } + zedAuthDraft = { + authUrl: authData.authUrl, + state: authData.state, + codeVerifier: authData.codeVerifier, + systemId: authData.systemId || null, + redirectUri: authData.redirectUri, + flowType: authData.flowType, + callbackPath: authData.callbackPath || "/", + }; + return true; +} -export function registerZedSession({ state, codeVerifier }) { +export function registerZedSession({ state, codeVerifier, systemId, authUrl, redirectUri, flowType, callbackPath }) { if (!state || !codeVerifier) return false; - zedSession = { state, codeVerifier, status: "pending", createdAt: Date.now() }; + // Prefer explicit fields; fall back to draft so systemId/authUrl survive register. + const draft = zedAuthDraft; + zedSession = { + state, + codeVerifier, + systemId: systemId || draft?.systemId || null, + authUrl: authUrl || draft?.authUrl || null, + redirectUri: redirectUri || draft?.redirectUri || null, + flowType: flowType || draft?.flowType || null, + callbackPath: callbackPath || draft?.callbackPath || "/", + status: "pending", + createdAt: Date.now(), + }; + zedAuthDraft = null; return true; } export function getZedSessionStatus(state) { @@ -659,7 +714,10 @@ export function getZedSessionStatus(state) { return zedSession; } export function clearZedSession(state) { - if (!state || (zedSession && zedSession.state === state)) zedSession = null; + if (!state || (zedSession && zedSession.state === state)) { + zedSession = null; + zedAuthDraft = null; + } } export function startZedProxy(preferredPort = 0) { @@ -668,6 +726,9 @@ export function startZedProxy(preferredPort = 0) { resolve({ success: true, port: zedProxyPort, callbackUrl: `http://127.0.0.1:${zedProxyPort}/` }); return; } + // Fresh listen — drop any stale draft/session from a previous attempt. + zedSession = null; + zedAuthDraft = null; const server = http.createServer(async (req, res) => { const url = new URL(req.url, "http://localhost"); // Log path + redacted params (access_token is the RSA-encrypted credential). @@ -696,11 +757,19 @@ export function startZedProxy(preferredPort = 0) { } // Pass raw callback path+query to exchangeTokens → parseZedCallbackPayload. // codeVerifier carries the encoded RSA private key for decryption. - const rawCallback = url.search ? `${url.pathname}?${url.searchParams.toString()}` : url.pathname; + // Full absolute URL so parseZedCallbackPayload never sees a bare "/?…" path. + const rawCallback = `http://127.0.0.1:${zedProxyPort || "0"}${url.pathname}${url.search}`; try { const { exchangeTokens } = await import("../providers.js"); const { createProviderConnection } = await import("@/models"); - const tokenData = await exchangeTokens("zed", rawCallback, null, session.codeVerifier, session.state); + const tokenData = await exchangeTokens( + "zed", + rawCallback, + null, + session.codeVerifier, + session.state, + { systemId: session.systemId }, + ); const connection = await createProviderConnection({ provider: "zed", authType: "oauth", @@ -713,6 +782,7 @@ export function startZedProxy(preferredPort = 0) { res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); res.end(renderCodexResultPage(true, "You can close this window.")); } catch (err) { + console.log("[Zed proxy] exchange failed:", err?.message || err); session.status = "error"; session.error = err.message; res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); diff --git a/src/mitm/config.js b/src/mitm/config.js index 73653a3ce4..758706ffe9 100644 --- a/src/mitm/config.js +++ b/src/mitm/config.js @@ -21,6 +21,8 @@ const TARGET_HOSTS = [ "codewhisperer.us-east-1.amazonaws.com", "runtime.us-east-1.kiro.dev", "api2.cursor.sh", + "cloud.zed.dev", + "api.zed.dev", ]; const URL_PATTERNS = { @@ -30,6 +32,7 @@ const URL_PATTERNS = { // see isChatRequest() for the header-based match. kiro: ["/generateAssistantResponse"], cursor: ["/BidiAppend", "/RunSSE", "/RunPoll", "/Run"], + zed: ["/completions"], }; /** @@ -104,6 +107,7 @@ function getToolForHost(host) { if (h === "daily-cloudcode-pa.googleapis.com" || h === "cloudcode-pa.googleapis.com") return "antigravity"; if (h === "q.us-east-1.amazonaws.com" || h === "codewhisperer.us-east-1.amazonaws.com" || h === "runtime.us-east-1.kiro.dev") return "kiro"; if (h === "api2.cursor.sh") return "cursor"; + if (h === "cloud.zed.dev" || h === "api.zed.dev") return "zed"; return null; } diff --git a/src/mitm/handlers/zed.js b/src/mitm/handlers/zed.js new file mode 100644 index 0000000000..bb476b38ac --- /dev/null +++ b/src/mitm/handlers/zed.js @@ -0,0 +1,175 @@ +const { err } = require("../logger"); +const { fetchRouter, pipeSSE } = require("./base"); + +/** + * Map Zed cloud provider id → 9Router path. + * Anthropic-shaped provider_request → /v1/messages; + * everything else → /v1/chat/completions. + */ +function resolveRouterPath(provider) { + const p = String(provider || "").toLowerCase(); + if (p === "anthropic") return "/v1/messages"; + return "/v1/chat/completions"; +} + +/** + * Convert 9Router SSE/JSON into Zed JSONL Status/Event lines and write to res. + */ +async function pipeAsZedJsonl(routerRes, res) { + const status = routerRes.status || 200; + res.writeHead(status, { + "Content-Type": "application/x-ndjson", + "Cache-Control": "no-cache", + Connection: "keep-alive", + "x-zed-client-supports-status-messages": "true", + }); + + res.write(`${JSON.stringify({ Status: { Queued: { position: 0 } } })}\n`); + res.write(`${JSON.stringify({ Status: "Started" })}\n`); + + if (!routerRes.ok) { + const text = await routerRes.text().catch(() => ""); + let errObj; + try { + errObj = JSON.parse(text); + } catch { + errObj = { message: text || `HTTP ${status}` }; + } + res.write(`${JSON.stringify({ Status: { Failed: errObj } })}\n`); + res.write(`${JSON.stringify({ Status: "StreamEnded" })}\n`); + res.end(); + return; + } + + const ct = (routerRes.headers.get("content-type") || "").toLowerCase(); + + // Non-stream JSON body + if (!ct.includes("text/event-stream") && !routerRes.body) { + const text = await routerRes.text().catch(() => ""); + try { + const parsed = JSON.parse(text); + res.write(`${JSON.stringify({ Event: parsed })}\n`); + } catch { + if (text) res.write(`${JSON.stringify({ Event: { text } })}\n`); + } + res.write(`${JSON.stringify({ Status: "StreamEnded" })}\n`); + res.end(); + return; + } + + if (!routerRes.body) { + res.write(`${JSON.stringify({ Status: "StreamEnded" })}\n`); + res.end(); + return; + } + + const reader = routerRes.body.getReader(); + const decoder = new TextDecoder("utf-8", { fatal: false }); + let buffer = ""; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) continue; + + // OpenAI / Claude SSE: "data: {...}" + if (trimmed.startsWith("data:")) { + const data = trimmed.slice(5).trim(); + if (!data || data === "[DONE]") continue; + try { + const parsed = JSON.parse(data); + res.write(`${JSON.stringify({ Event: parsed })}\n`); + } catch { + /* skip */ + } + continue; + } + + // Already JSONL event + if (trimmed.startsWith("{")) { + try { + const parsed = JSON.parse(trimmed); + if (parsed.Event !== undefined || parsed.Status !== undefined) { + res.write(`${trimmed}\n`); + } else { + res.write(`${JSON.stringify({ Event: parsed })}\n`); + } + } catch { + /* skip */ + } + } + } + } + + res.write(`${JSON.stringify({ Status: "StreamEnded" })}\n`); + res.end(); +} + +/** + * Intercept Zed Hosted AI /completions — unwrap provider_request, remap model, + * forward to 9Router, wrap response as Zed JSONL. + */ +async function intercept(req, res, bodyBuffer, mappedModel) { + try { + // Non-completion paths should never reach here (URL_PATTERNS), but be safe + if (!String(req.url || "").includes("/completions")) { + if (!res.headersSent) res.writeHead(404, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: { message: "Unsupported Zed MITM path", type: "mitm_error" } })); + return; + } + + const body = JSON.parse(bodyBuffer.toString()); + const provider = body.provider || "open_ai"; + const providerRequest = body.provider_request || body; + + // Remap model on envelope + nested request + body.model = mappedModel; + if (providerRequest && typeof providerRequest === "object") { + providerRequest.model = mappedModel; + // Ensure streaming for chat-style clients + if (providerRequest.stream === undefined) providerRequest.stream = true; + } + + const routerPath = resolveRouterPath(provider); + const routerBody = + provider === "anthropic" + ? { ...providerRequest, model: mappedModel } + : { + ...providerRequest, + model: mappedModel, + // Hint for router if nested OpenAI body lacked stream + stream: providerRequest.stream !== false, + }; + + const routerRes = await fetchRouter(routerBody, routerPath, req.headers); + + // If router already returns Zed-like JSONL somehow, pipe through; else wrap + const ct = (routerRes.headers.get("content-type") || "").toLowerCase(); + if (ct.includes("ndjson") || ct.includes("jsonl")) { + await pipeSSE(routerRes, res); + return; + } + + await pipeAsZedJsonl(routerRes, res); + } catch (error) { + err(`[zed] ${error.message}`); + if (!res.headersSent) { + res.writeHead(500, { "Content-Type": "application/x-ndjson" }); + } + try { + res.write(`${JSON.stringify({ Status: { Failed: { message: error.message } } })}\n`); + res.write(`${JSON.stringify({ Status: "StreamEnded" })}\n`); + } catch { + /* ignore */ + } + res.end(); + } +} + +module.exports = { intercept }; diff --git a/src/mitm/server.js b/src/mitm/server.js index 550d1ba083..0da5ff9e26 100644 --- a/src/mitm/server.js +++ b/src/mitm/server.js @@ -31,6 +31,7 @@ const handlers = { copilot: require("./handlers/copilot"), kiro: require("./handlers/kiro"), cursor: require("./handlers/cursor"), + zed: require("./handlers/zed"), }; // ── SSL / SNI ───────────────────────────────────────────────── diff --git a/src/shared/components/OAuthModal.js b/src/shared/components/OAuthModal.js index 805301daf8..ccfb2ff62a 100644 --- a/src/shared/components/OAuthModal.js +++ b/src/shared/components/OAuthModal.js @@ -198,12 +198,19 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess, // sent via POST body so the private key never lands in URL/query logs. const regBody = { state: authData.state }; if (authData.codeVerifier) regBody.codeVerifier = authData.codeVerifier; - await fetch(`/api/oauth/${providerId}/register-session`, { + if (authData.systemId) regBody.systemId = authData.systemId; + if (authData.authUrl) regBody.authUrl = authData.authUrl; + const regRes = await fetch(`/api/oauth/${providerId}/register-session`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(regBody), }); + const regData = await regRes.json().catch(() => ({})); + if (!regRes.ok || regData.success === false) { + throw new Error(regData.error || "Failed to register OAuth session"); + } // 4. Open popup; proxy auto-exchanges on callback, modal polls poll-status. + // Named window reuses the same tab on StrictMode double-start. setAuthData({ ...authData, proxyProvider: providerId }); setStep("waiting"); popupRef.current = window.open(authData.authUrl, "oauth_popup", "width=600,height=700"); @@ -682,25 +689,27 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess, return (
- {/* Trae/Windsurf: browser OAuth (proxy) + paste-token fallback */} + {/* Proxy OAuth (trae/windsurf/zed): browser callback; paste-token only when configured */} {PROXY_OAUTH_PROVIDERS.has(provider) && (step === "waiting" || step === "input" || step === "error") && ( <> -
- - -
+ {PASTE_TOKEN_PROVIDERS[provider] && ( +
+ + +
+ )} {authMode === "browser" && ( <> @@ -730,7 +739,7 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess, )} - {authMode === "paste-token" && ( + {authMode === "paste-token" && PASTE_TOKEN_PROVIDERS[provider] && (
{ideStatus && !ideStatus.installed && (
diff --git a/src/shared/components/ZedAuthModal.js b/src/shared/components/ZedAuthModal.js new file mode 100644 index 0000000000..9c5cca6632 --- /dev/null +++ b/src/shared/components/ZedAuthModal.js @@ -0,0 +1,232 @@ +"use client"; + +import { useState, useEffect } from "react"; +import PropTypes from "prop-types"; +import { Modal, Button, Input } from "@/shared/components"; + +/** + * Zed Hosted AI Auth Modal + * Auto-detect and/or manually import user_id + access_token from Zed Editor. + */ +export default function ZedAuthModal({ isOpen, onSuccess, onClose }) { + const [userId, setUserId] = useState(""); + const [accessToken, setAccessToken] = useState(""); + const [error, setError] = useState(null); + const [importing, setImporting] = useState(false); + const [autoDetecting, setAutoDetecting] = useState(false); + const [autoDetected, setAutoDetected] = useState(false); + const [manualHint, setManualHint] = useState(false); + + const runAutoDetect = async () => { + setAutoDetecting(true); + setError(null); + setAutoDetected(false); + setManualHint(false); + + try { + const res = await fetch("/api/oauth/zed/auto-import"); + const data = await res.json(); + + if (data.found) { + setUserId(data.userId || ""); + setAccessToken(data.accessToken || ""); + setAutoDetected(true); + } else if (data.windowsManual) { + setManualHint(true); + } else { + setError(data.error || "Could not auto-detect Zed credentials"); + } + } catch { + setError("Failed to auto-detect credentials"); + } finally { + setAutoDetecting(false); + } + }; + + useEffect(() => { + if (!isOpen) return; + runAutoDetect(); + }, [isOpen]); + + const handleImportToken = async () => { + let uid = userId.trim(); + let token = accessToken.trim(); + + // Allow pasting "user_id access_token" or a callback URL into the token field. + if ((!uid || !token) && token) { + try { + const url = new URL(token.startsWith("http") || token.startsWith("/") ? token : `http://127.0.0.1/?${token.replace(/^\?/, "")}`, "http://127.0.0.1"); + uid = uid || url.searchParams.get("user_id") || ""; + token = url.searchParams.get("access_token") || token; + } catch { + /* ignore */ + } + } + if (!uid && token.includes(" ")) { + const idx = token.indexOf(" "); + uid = token.slice(0, idx).trim(); + token = token.slice(idx + 1).trim(); + } + + if (!uid) { + setError("Please enter a user ID"); + return; + } + if (!token) { + setError("Please enter an access token"); + return; + } + + // Encrypted native-app callback tokens need the RSA private key — reject clearly. + if (token.includes("==") && token.length > 200 && !token.trimStart().startsWith("{")) { + setError( + "That looks like an encrypted browser-callback token. Use “Sign in with browser”, or paste the plain Zed access token (Authorization: \"{user_id} {access_token}\").", + ); + return; + } + + setImporting(true); + setError(null); + + try { + const res = await fetch("/api/oauth/zed/import", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + userId: uid, + accessToken: token, + }), + }); + + const data = await res.json(); + if (!res.ok) { + throw new Error(data.error || "Import failed"); + } + + onSuccess?.(); + onClose(); + } catch (err) { + setError(err.message); + } finally { + setImporting(false); + } + }; + + return ( + +
+ {autoDetecting && ( +
+
+ + progress_activity + +
+

Auto-detecting credentials...

+

+ Looking for Zed Editor credentials +

+
+ )} + + {!autoDetecting && ( + <> + {autoDetected && ( +
+
+ check_circle +

+ Credentials auto-detected from Zed successfully! +

+
+
+ )} + + {manualHint && ( +
+
+ info +

+ Could not read Zed credentials automatically. +

+
+

+ Paste your Zed user ID and access token below + (Authorization format: {"{user_id} {access_token}"}). +

+ +
+ )} + + {!autoDetected && !manualHint && !error && ( +
+
+ info +

+ Zed credentials not detected. Paste your user ID and access token manually. +

+
+
+ )} + +
+ + setUserId(e.target.value)} + placeholder="Numeric Zed user id" + className="font-mono text-sm" + /> +
+ +
+ +