From 35150c66e73a6b259d78606b5b2d1faf73c994f4 Mon Sep 17 00:00:00 2001 From: Feeling <24210240085@m.fudan.edu.cn> Date: Tue, 22 Sep 2026 12:45:54 +0800 Subject: [PATCH] fix(oauth): offer xAI chat models returned by GET /v1/models The pinned pi-ai catalog stops at grok-4.6, so a signed-in Grok account rejects grok-4.7 before the request is sent. When the account token can read /v1/models, that chat-model list is the authority and the pinned catalog is only the fallback. --- .../desktop/electron/main/ipc/provider-ipc.ts | 6 +- apps/desktop/electron/main/oauth.ts | 159 +++++++++++++++++- apps/desktop/test/vendor-oauth-login.test.mjs | 91 +++++++++- docs/adr/0095-vendor-account-oauth-login.md | 4 +- .../03-runtime/11-provider-model-system.md | 9 +- .../03-runtime/12-provider-config-schema.md | 4 +- .../03-runtime/11-provider-model-system.md | 5 +- 7 files changed, 261 insertions(+), 17 deletions(-) diff --git a/apps/desktop/electron/main/ipc/provider-ipc.ts b/apps/desktop/electron/main/ipc/provider-ipc.ts index 155f3d61e7..06a4ec1344 100644 --- a/apps/desktop/electron/main/ipc/provider-ipc.ts +++ b/apps/desktop/electron/main/ipc/provider-ipc.ts @@ -398,9 +398,9 @@ export function registerProviderIpc({ } }; - // A signed-in vendor account has no key to probe /models with, and pi-ai - // already knows which models the account may use (Copilot narrows the - // list to the subscription). + // A signed-in vendor account has no API key. Copilot and the static + // vendors are listed from pi-ai. xAI instead reads GET /v1/models with + // the account token inside VendorOAuth.listModels. if (req.source !== "cache" && provider?.authKind === OAUTH_AUTH_KIND) { try { const options = await vendorOAuth.listModels(provider.id); diff --git a/apps/desktop/electron/main/oauth.ts b/apps/desktop/electron/main/oauth.ts index e88c2fb4df..9d1a342197 100644 --- a/apps/desktop/electron/main/oauth.ts +++ b/apps/desktop/electron/main/oauth.ts @@ -41,6 +41,7 @@ import { type ModelConfig, type VendorModelBinding, } from "@pi-desktop/agent-runtime"; +import { discoverProviderModels } from "./model-discovery.ts"; import { OAUTH_AUTH_KIND, type OAuthLoginEvent, @@ -90,6 +91,42 @@ export function protocolForApiStyle(apiStyle: string): string { return PROTOCOL_BY_API_STYLE[apiStyle] ?? "openai_compatible"; } +const XAI_VENDOR_ID = "xai"; +const XAI_MODELS_URL_BASE = "https://api.x.ai/v1"; +const XAI_LIVE_MODELS_TTL_MS = 30_000; +const THINKING_LEVEL_ORDER = [ + "off", + "minimal", + "low", + "medium", + "high", + "xhigh", + "max", +] as const satisfies readonly ThinkingLevel[]; + +/** + * xAI's `/models` list also publishes image and video generators. Those are not + * conversation models; the chat picker and session launch both reject them. + */ +const XAI_NON_CONVERSATION_MODEL = + /(?:^|[-_/])(?:imagine|image|video|tts|stt|embed(?:ding)?|whisper|aurora|flux)(?:$|[-_/])/i; + +export function isXaiConversationModel(modelId: string): boolean { + const id = modelId.trim(); + return id.length > 0 && !XAI_NON_CONVERSATION_MODEL.test(id); +} + +function thinkingLevelsFromPiModel(model: Model): ThinkingLevel[] { + const map = model.thinkingLevelMap as Partial> | undefined; + if (!map) return model.reasoning ? ["low", "medium", "high"] : ["off"]; + const levels = THINKING_LEVEL_ORDER.filter((level) => typeof map[level] === "string"); + return levels.length > 0 + ? [...levels] + : model.reasoning + ? ["low", "medium", "high"] + : ["off"]; +} + export type HostCall = ( method: string, params?: unknown, @@ -197,6 +234,8 @@ export class VendorOAuth { private readonly logins = new Map(); /** One pi-ai collection and credential store per local OAuth account row. */ private readonly accountModels = new Map(); + /** Successful xAI `/models` responses, so one login does not refetch per model. */ + private readonly xaiLiveModels = new Map(); /** Per-account write chain: `modify` must be a serialized read-modify-write. */ private readonly chains = new Map>(); private catalogPromise?: Promise; @@ -323,6 +362,7 @@ export class VendorOAuth { await running.finished?.catch(() => undefined); } this.accountModels.delete(providerId); + this.xaiLiveModels.delete(providerId); await this.deps.call("providers.delete", { id: providerId }); } @@ -342,9 +382,14 @@ export class VendorOAuth { } /** - * Models the signed-in account may actually use. This replaces the `/models` - * probe: `getAvailable` applies the vendor's own `filterModels`, which is how - * Copilot narrows the list to the user's subscription. + * Models the signed-in account may actually use. + * + * Copilot and the other static vendors still come from pi-ai: `getAvailable` + * applies that vendor's `filterModels`. xAI is different. Its pinned catalog + * stops at whatever shipped in pi-ai, so a model Grok has already entitled + * (grok-4.7 today) never becomes selectable. For that vendor the account's + * own `GET /v1/models` list is the authority, and the pinned catalog is only + * the fallback when that request fails. */ async listModels(providerId: string): Promise { return this.withRowHeaders(providerId, async () => { @@ -353,6 +398,8 @@ export class VendorOAuth { // Dynamic catalogs (radius, Copilot) are empty until refreshed; static and // unconfigured providers are skipped inside pi-ai. await account.models.refresh({ providers: [account.vendorId] }); + const live = await this.liveXaiModels(account); + if (live) return live; const available = await account.models.getAvailable(account.vendorId); return available.map((model) => this.optionFor(model)); }); @@ -396,12 +443,77 @@ export class VendorOAuth { await account.models.refresh({ providers: [account.vendorId] }); model = account.models.getModel(account.vendorId, modelId); } + const live = await this.liveXaiModels(account); + if (live) { + const option = live.find((item) => item.modelId === modelId); + // A successful Grok list replaces the pinned catalog. An id it did not + // return is not offered, even when pi-ai still ships that id. + if (!option) return undefined; + return this.bindingFromOption(account, option); + } if (!model) return undefined; - const option = this.optionFor(model); - const modelConfig = await this.deps.modelConfigFor?.({ + return this.bindingFromOption(account, this.optionFor(model)); + } + + /** + * Chat models the signed-in xAI account can call right now. + * `undefined` means the live list could not be read; callers keep the + * pinned catalog. Image and video generators are dropped. + */ + private async liveXaiModels( + account: AccountModels, + ): Promise { + if (account.vendorId !== XAI_VENDOR_ID) return undefined; + const cached = this.xaiLiveModels.get(account.providerId); + if (cached && Date.now() - cached.at < XAI_LIVE_MODELS_TTL_MS) return cached.models; + let apiKey: string | undefined; + let baseUrl = XAI_MODELS_URL_BASE; + try { + const resolved = await account.models.getAuth(account.vendorId); + apiKey = resolved?.auth.apiKey; + if (resolved?.auth.baseUrl) baseUrl = resolved.auth.baseUrl.replace(/\/+$/, ""); + } catch (error) { + this.log("warn", "xAI account auth unavailable for model list", { + vendorId: account.vendorId, + message: error instanceof Error ? error.message : String(error), + }); + return undefined; + } + if (!apiKey) return undefined; + try { + const discovered = await discoverProviderModels({ + baseUrl, + apiKey, + apiStyle: "responses", + }); + const models = discovered + .filter((model) => isXaiConversationModel(model.modelId)) + .map((model) => ({ + modelId: model.modelId, + apiStyle: apiStyleForWireApi("openai-responses"), + baseUrl, + })); + if (models.length === 0) return undefined; + this.xaiLiveModels.set(account.providerId, { at: Date.now(), models }); + return models; + } catch (error) { + this.log("warn", "xAI account model list failed", { + vendorId: account.vendorId, + message: error instanceof Error ? error.message : String(error), + }); + return undefined; + } + } + + private async bindingFromOption( + account: AccountModels, + option: OAuthModelOption, + ): Promise { + const published = await this.deps.modelConfigFor?.({ vendorKey: account.vendorId, option, - }).catch(() => undefined) ?? genericModelConfig(modelId, model.baseUrl); + }).catch(() => undefined); + const modelConfig = this.withPinnedXaiFallback(account, option, published); const capabilities = capabilitiesFromModelConfig(modelConfig); return { apiStyle: option.apiStyle, @@ -411,6 +523,41 @@ export class VendorOAuth { }; } + /** + * models.dev is the metadata source when it already knows the id. A model + * that exists only on the live xAI list (the usual case for a just-released + * Grok) otherwise inherits limits and thinking levels from the newest pinned + * sibling, instead of the 128k generic shape. + */ + private withPinnedXaiFallback( + account: AccountModels, + option: OAuthModelOption, + published: ModelConfig | undefined, + ): ModelConfig { + const config = published ?? genericModelConfig(option.modelId, option.baseUrl); + if (account.vendorId !== XAI_VENDOR_ID || config.source !== "generic") return config; + const sibling = ["grok-4.6", "grok-4.5", "grok-4.3"] + .map((id) => account.models.getModel(account.vendorId, id)) + .find((model) => model !== undefined); + if (!sibling) return config; + const input = (sibling.input ?? []).filter( + (modality): modality is "text" | "image" => modality === "text" || modality === "image", + ); + return { + ...config, + reasoning: sibling.reasoning, + input: input.length > 0 ? input : config.input, + contextWindow: sibling.contextWindow, + maxTokens: sibling.maxTokens, + limit: { + context: sibling.contextWindow, + input: sibling.contextWindow, + output: sibling.maxTokens, + }, + supportedThinkingLevels: thinkingLevelsFromPiModel(sibling), + }; + } + private async run(session: LoginSession, provider: Provider): Promise { try { await this.withRowHeaders(session.providerId, () => diff --git a/apps/desktop/test/vendor-oauth-login.test.mjs b/apps/desktop/test/vendor-oauth-login.test.mjs index bbbcf7349e..d6722dc144 100644 --- a/apps/desktop/test/vendor-oauth-login.test.mjs +++ b/apps/desktop/test/vendor-oauth-login.test.mjs @@ -4,6 +4,7 @@ import test from "node:test"; import { VendorOAuth, apiStyleForWireApi, + isXaiConversationModel, protocolForApiStyle, secretRefForProviderOauth, } from "../electron/main/oauth.ts"; @@ -66,8 +67,8 @@ function fakeHost() { * fall back to a pasted code — and persists through the injected store, so the * test exercises the credential path rather than mocking it away. */ -function fakeModels(credentials, { login, models: configuredModels } = {}) { - const provider = { +function fakeModels(credentials, { login, models: configuredModels, provider: providerOverride } = {}) { + const provider = providerOverride ?? { id: "anthropic", name: "Anthropic", baseUrl: "https://api.anthropic.com", @@ -138,7 +139,7 @@ function fakeModels(credentials, { login, models: configuredModels } = {}) { access: `access-for-${code}`, expires: 4102444800000, }; - await credentials.modify("anthropic", async () => credential); + await credentials.modify(provider.id, async () => credential); return credential; }), logout: async (id) => credentials.delete(id), @@ -590,3 +591,87 @@ test("credential writes for one account run one at a time", async () => { assert.deepEqual(seen, ["access-for-abc", "rotated-1"]); assert.equal(host.secrets.size, 1); }); + +test("conversation-model filter drops xAI image and video ids", () => { + assert.equal(isXaiConversationModel("grok-4.7"), true); + assert.equal(isXaiConversationModel("grok-4.7-build-fast"), true); + assert.equal(isXaiConversationModel("grok-imagine-image"), false); + assert.equal(isXaiConversationModel("grok-imagine-video-1.5"), false); + assert.equal(isXaiConversationModel(" "), false); +}); + +test("an xAI account offers the chat models its /models endpoint returns", async () => { + const seen = []; + const previousFetch = globalThis.fetch; + globalThis.fetch = async (url, init) => { + seen.push({ + url: String(url), + authorization: init?.headers?.Authorization, + }); + return new Response(JSON.stringify({ + data: [ + { id: "grok-4.6" }, + { id: "grok-4.7" }, + { id: "grok-imagine-image" }, + ], + }), { status: 200, headers: { "content-type": "application/json" } }); + }; + const xaiModel = { + id: "grok-4.6", + name: "Grok 4.6", + api: "openai-responses", + provider: "xai", + baseUrl: "https://api.x.ai/v1", + input: ["text", "image"], + reasoning: true, + thinkingLevelMap: { + off: null, + minimal: null, + low: "low", + medium: "medium", + high: "high", + xhigh: "xhigh", + max: null, + }, + cost: { input: 2, output: 6, cacheRead: 0.5, cacheWrite: 0 }, + contextWindow: 500_000, + maxTokens: 500_000, + }; + try { + const { host, events, oauth } = harness({ + provider: { + id: "xai", + name: "xAI", + baseUrl: "https://api.x.ai/v1", + auth: { + oauth: { + name: "xAI (Grok/X subscription)", + isSubscription: true, + loginLabel: "Sign in with SuperGrok or X Premium", + }, + }, + }, + models: [ + xaiModel, + { ...xaiModel, id: "grok-2", name: "Grok 2" }, + ], + }); + const { loginId } = await oauth.start("xai"); + const prompt = await waitFor(events, "prompt"); + oauth.respond({ loginId, promptId: prompt.request.promptId, value: "abc" }); + const done = await waitFor(events, "done"); + const row = host.providers.get(done.providerId); + assert.deepEqual(row.models.map((model) => model.id), ["grok-4.6", "grok-4.7"]); + const offered = row.models.find((model) => model.id === "grok-4.7"); + assert.equal(offered.contextWindow, 500_000); + assert.deepEqual(offered.thinkingLevels, ["low", "medium", "high", "xhigh"]); + assert.equal(await oauth.bindingFor(done.providerId, "grok-2"), undefined); + const binding = await oauth.bindingFor(done.providerId, "grok-4.7"); + assert.equal(binding.apiStyle, "responses"); + assert.equal(binding.baseUrl, "https://api.x.ai/v1"); + assert.equal(seen[0].url, "https://api.x.ai/v1/models"); + assert.equal(seen[0].authorization, "Bearer access-for-abc"); + } finally { + globalThis.fetch = previousFetch; + } +}); diff --git a/docs/adr/0095-vendor-account-oauth-login.md b/docs/adr/0095-vendor-account-oauth-login.md index c7334b8d67..df21ea8344 100644 --- a/docs/adr/0095-vendor-account-oauth-login.md +++ b/docs/adr/0095-vendor-account-oauth-login.md @@ -76,7 +76,9 @@ hand it to the sidecar at launch" pattern was not acceptable for it. (`models.getAvailable`, which applies the vendor's own `filterModels`, so Copilot shows what the subscription actually includes) instead of probing `/models` with a key it does not have, and the connection test proves the - account by resolving auth. Login stores a non-secret account label in the row + account by resolving auth. xAI is the exception: the resolved account token + calls `GET /v1/models`, and that chat-model list replaces the pinned catalog + when the request succeeds. Login stores a non-secret account label in the row config and picks the row's `apiStyle` from the selected model — a vendor may span wire APIs. The original one-row-per-vendor assumption is amended by ADR 0098: every login now creates an independent row and credential scope. diff --git a/docs/spec/03-runtime/11-provider-model-system.md b/docs/spec/03-runtime/11-provider-model-system.md index 95fd453dda..318e9ccded 100644 --- a/docs/spec/03-runtime/11-provider-model-system.md +++ b/docs/spec/03-runtime/11-provider-model-system.md @@ -453,8 +453,13 @@ than probing `/models`, and the connection test proves the account by resolving auth. For static OAuth vendors such as ChatGPT Plus/Pro (`openai-codex`), that catalog is the pinned pi-ai model list rather than a live vendor `/models` probe, so a newly published account model such as `gpt-6-astra` appears only -after the pin includes it. models.dev still supplies metadata once the ID is -available, but it cannot add the ID to the authenticated list. A vendor may +after the pin includes it. xAI (`xai`, the Grok/X subscription) is the +exception: a successful `GET /v1/models` with the resolved account token is the +list of conversation models the account may use, including an id the pinned +pi-ai catalog does not know yet. Image and video generators in that payload +are dropped. When the request fails, the pinned catalog remains the fallback. +models.dev still supplies metadata once the ID is available, but it cannot add +the ID to the authenticated list. A vendor may span wire APIs — Copilot serves Anthropic, Chat Completions and Responses models — so the row's `apiStyle` follows the selected model. Deleting a row calls the normal host `providers.delete` path, which removes its diff --git a/docs/spec/03-runtime/12-provider-config-schema.md b/docs/spec/03-runtime/12-provider-config-schema.md index 7f8086f30a..22d79ddb25 100644 --- a/docs/spec/03-runtime/12-provider-config-schema.md +++ b/docs/spec/03-runtime/12-provider-config-schema.md @@ -543,7 +543,9 @@ The canonical DDL lives in [04-data-storage](04-data-storage.md) (D086). Summary Copilot account lists what its subscription includes) instead of calling `/models`; each returned model carries the apiStyle its wire API implies. Static vendors such as `openai-codex` use the pinned pi-ai catalog (0.86.1 - includes `gpt-6-astra`); models.dev does not invent those IDs. + includes `gpt-6-astra`); models.dev does not invent those IDs. `xai` is the + exception: conversation models come from `GET /v1/models` on the resolved + account token, and the pinned catalog is used only when that request fails. - out: `{ models: ModelCatalogItem[] }`; each known model carries the complete models.dev metadata including `reasoning`, `supportedThinkingLevels`, limits, modalities, output types, and capability tags. Cached/provider claims cannot diff --git a/docs/zh-CN/spec/03-runtime/11-provider-model-system.md b/docs/zh-CN/spec/03-runtime/11-provider-model-system.md index c36f5413e9..e5cd4ba212 100644 --- a/docs/zh-CN/spec/03-runtime/11-provider-model-system.md +++ b/docs/zh-CN/spec/03-runtime/11-provider-model-system.md @@ -379,7 +379,10 @@ sidecar 请求 自己的 `filterModels`),而不是探测 `/models`;连接测试通过解析认证来 证明账户。对 ChatGPT Plus/Pro(`openai-codex`)这类静态 OAuth 厂商,该 目录是已固定的 pi-ai 模型列表,而不是实时 `/models` 探测,因此 `gpt-6-astra` -这类新账户模型只有在 pin 包含它之后才会出现。models.dev 在 ID 可用后仍 +这类新账户模型只有在 pin 包含它之后才会出现。xAI(Grok/X 订阅)是例外: +用已解析的账户令牌请求 `GET /v1/models` 成功时,返回的对话模型就是该账户 +可选的模型,包括 pin 里还没有的 id;图像和视频模型会被丢掉。请求失败时 +仍回退到 pin 目录。models.dev 在 ID 可用后仍 提供元数据,但不能把 ID 加进已认证列表。一个厂商可以跨越多种线路 API —— Copilot 同时提供 Anthropic、Chat Completions 与 Responses 模型 —— 因此行 的 `apiStyle` 跟随所选模型。