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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions apps/desktop/electron/main/ipc/provider-ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
159 changes: 153 additions & 6 deletions apps/desktop/electron/main/oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<Api>): ThinkingLevel[] {
const map = model.thinkingLevelMap as Partial<Record<string, string | null>> | 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 = <T = unknown>(
method: string,
params?: unknown,
Expand Down Expand Up @@ -197,6 +234,8 @@ export class VendorOAuth {
private readonly logins = new Map<string, LoginSession>();
/** One pi-ai collection and credential store per local OAuth account row. */
private readonly accountModels = new Map<string, AccountModels>();
/** Successful xAI `/models` responses, so one login does not refetch per model. */
private readonly xaiLiveModels = new Map<string, { at: number; models: OAuthModelOption[] }>();
/** Per-account write chain: `modify` must be a serialized read-modify-write. */
private readonly chains = new Map<string, Promise<unknown>>();
private catalogPromise?: Promise<MutableModels>;
Expand Down Expand Up @@ -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 });
}

Expand All @@ -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<OAuthModelOption[]> {
return this.withRowHeaders(providerId, async () => {
Expand All @@ -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));
});
Expand Down Expand Up @@ -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<OAuthModelOption[] | undefined> {
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<VendorModelBinding> {
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,
Expand All @@ -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<void> {
try {
await this.withRowHeaders(session.providerId, () =>
Expand Down
91 changes: 88 additions & 3 deletions apps/desktop/test/vendor-oauth-login.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import test from "node:test";
import {
VendorOAuth,
apiStyleForWireApi,
isXaiConversationModel,
protocolForApiStyle,
secretRefForProviderOauth,
} from "../electron/main/oauth.ts";
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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;
}
});
4 changes: 3 additions & 1 deletion docs/adr/0095-vendor-account-oauth-login.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
9 changes: 7 additions & 2 deletions docs/spec/03-runtime/11-provider-model-system.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion docs/spec/03-runtime/12-provider-config-schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion docs/zh-CN/spec/03-runtime/11-provider-model-system.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` 跟随所选模型。
Expand Down
Loading