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
4 changes: 4 additions & 0 deletions apps/desktop/electron/main/oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import {
parseVendorModelIds,
pinnedSiblingId,
readVendorModelList,
VendorModelListError,
vendorModelListRequest,
wireForLiveModel,
} from "./vendor-live-models.ts";
Expand Down Expand Up @@ -513,6 +514,9 @@ export class VendorOAuth {
this.log("warn", "vendor account model list failed", {
vendorId: account.vendorId,
message: error instanceof Error ? error.message : String(error),
...(error instanceof VendorModelListError
? { status: error.status, responseExcerpt: error.responseExcerpt }
: {}),
});
return this.rememberLiveModels(account.providerId, null);
}
Expand Down
83 changes: 81 additions & 2 deletions apps/desktop/electron/main/vendor-live-models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,21 @@

const LIVE_MODELS_TIMEOUT_MS = 8_000;
const MAX_RETRY_DELAY_MS = 1_000;
const MAX_ERROR_EXCERPT_CHARS = 300;

/**
* `client_version` sent to ChatGPT's `GET /codex/models`.
*
* The endpoint rejects a request without it (HTTP 400, "client_version Field
* required"). The value may also affect which models the list includes, so
* keep it at a current Codex CLI release.
* pi-ai does not call this endpoint and exposes no Codex client version, so
* this is the version of the official Codex CLI (github.com/openai/codex)
* that was verified to list the current ChatGPT models (0.159.2 lists
* `gpt-6.1-sol`, 2026-09-30). Bump it to a newer Codex CLI release when a
* model the account can use is missing from this list.
*/
export const CODEX_MODELS_CLIENT_VERSION = "0.159.2";

const NON_CONVERSATION_MODEL =
/(?:^|[-_/])(?:imagine|image|video|tts|stt|speech|embed(?:ding)?|whisper|aurora|flux|realtime|moderation)(?:$|[-_/])/i;
Expand Down Expand Up @@ -118,7 +133,7 @@ export function vendorModelListRequest(input: {
const accountId = chatgptAccountId(apiKey);
if (!accountId) return undefined;
return {
url: `${base}/codex/models`,
url: `${base}/codex/models?client_version=${encodeURIComponent(CODEX_MODELS_CLIENT_VERSION)}`,
accountBaseUrl: base,
allowPolicyFallback: false,
headers: {
Expand Down Expand Up @@ -355,6 +370,67 @@ function retryDelayMs(response: Response): number {
return Math.min(delay, MAX_RETRY_DELAY_MS);
}

/** A non-2xx model-list response. `responseExcerpt` is safe to log. */
export class VendorModelListError extends Error {
readonly status: number;
readonly responseExcerpt: string | undefined;

constructor(status: number, responseExcerpt: string | undefined) {
super(`model list request failed (${status})`);
this.name = "VendorModelListError";
this.status = status;
this.responseExcerpt = responseExcerpt;
}
}

const CREDENTIAL_HEADER = /^(?:authorization|x-api-key)$/i;
const BEARER_VALUE = /\b(?:bearer|basic)\s+[A-Za-z0-9+/_=.~-]+/gi;
const JWT_VALUE = /\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]*/g;
const REDACTED = "***REDACTED***";

function requestCredentials(headers: Record<string, string>): string[] {
return Object.entries(headers).flatMap(([name, value]) => {
if (!CREDENTIAL_HEADER.test(name)) return [];
const secret = value.replace(/^(?:bearer|basic)\s+/i, "").trim();
return secret.length >= 8 ? [secret] : [];
});
}

/**
* Short, single-line excerpt of an error body with the request's own
* credentials and anything token-shaped removed, so a failure can be
* diagnosed from the log without leaking the account token.
*/
export function summarizeErrorBody(
body: string,
headers: Record<string, string>,
): string | undefined {
let text = body;
for (const secret of requestCredentials(headers)) {
text = text.split(secret).join(REDACTED);
}
text = text
.replace(BEARER_VALUE, (match) => `${match.split(/\s+/, 1)[0]} ${REDACTED}`)
.replace(JWT_VALUE, REDACTED)
.replace(/\s+/g, " ")
.trim();
if (!text) return undefined;
return text.length > MAX_ERROR_EXCERPT_CHARS
? `${text.slice(0, MAX_ERROR_EXCERPT_CHARS)}…`
: text;
}

async function errorExcerpt(
response: Response,
headers: Record<string, string>,
): Promise<string | undefined> {
try {
return summarizeErrorBody(await response.text(), headers);
} catch {
return undefined;
}
}

/** GET the vendor model list. Retries one HTTP 429, then throws. */
export async function readVendorModelList(
request: VendorModelListRequest,
Expand All @@ -378,7 +454,10 @@ export async function readVendorModelList(
continue;
}
if (!response.ok) {
throw new Error(`model list request failed (${response.status})`);
throw new VendorModelListError(
response.status,
await errorExcerpt(response, request.headers),
);
}
return await response.json();
} finally {
Expand Down
41 changes: 40 additions & 1 deletion apps/desktop/test/vendor-live-models.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@ import assert from "node:assert/strict";
import test from "node:test";

import {
CODEX_MODELS_CLIENT_VERSION,
VendorModelListError,
parseVendorModelIds,
pinnedSiblingId,
readVendorModelList,
vendorModelListRequest,
wireForLiveModel,
} from "../electron/main/vendor-live-models.ts";
Expand All @@ -20,7 +23,11 @@ test("ChatGPT accounts list models from the Codex endpoint, not /models", () =>
vendorId: "openai-codex",
apiKey: codexToken(),
});
assert.equal(request?.url, "https://chatgpt.com/backend-api/codex/models");
const url = new URL(request?.url ?? "");
assert.equal(`${url.origin}${url.pathname}`, "https://chatgpt.com/backend-api/codex/models");
// The endpoint answers 400 "client_version Field required" without it.
assert.equal(url.searchParams.get("client_version"), CODEX_MODELS_CLIENT_VERSION);
assert.match(CODEX_MODELS_CLIENT_VERSION, /^\d+\.\d+\.\d+$/);
assert.equal(request?.headers["chatgpt-account-id"], "acct_123");
assert.equal(parseVendorModelIds("openai-codex", {
models: [
Expand Down Expand Up @@ -108,3 +115,35 @@ test("Copilot only keeps a new id when its family has one wire API", () => {
"openai-codex-responses",
);
});

test("a failed model list keeps a token-free excerpt of the response body", async () => {
const token = codexToken();
const request = vendorModelListRequest({ vendorId: "openai-codex", apiKey: token });
const body = JSON.stringify({
detail: [{ loc: ["query", "client_version"], msg: "Field required" }],
echoed: `Bearer ${token}`,
raw: token,
other: "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ4In0.c2ln",
padding: "x".repeat(2_000),
});
const fetchImpl = async () => new Response(body, { status: 400 });
const error = await readVendorModelList(request, fetchImpl).catch((caught) => caught);
assert.ok(error instanceof VendorModelListError);
assert.equal(error.status, 400);
assert.equal(error.message, "model list request failed (400)");
assert.match(error.responseExcerpt, /client_version/);
assert.match(error.responseExcerpt, /Field required/);
assert.equal(error.responseExcerpt.includes(token), false);
assert.equal(error.responseExcerpt.includes(token.split(".")[1]), false);
assert.equal(error.responseExcerpt.includes("eyJhbGciOiJIUzI1NiJ9"), false);
assert.ok(error.responseExcerpt.length <= 301);
});

test("a failed model list with an empty body has no excerpt", async () => {
const request = vendorModelListRequest({ vendorId: "xai", apiKey: "xai-token-123456" });
const fetchImpl = async () => new Response("", { status: 503 });
const error = await readVendorModelList(request, fetchImpl).catch((caught) => caught);
assert.ok(error instanceof VendorModelListError);
assert.equal(error.status, 503);
assert.equal(error.responseExcerpt, undefined);
});
95 changes: 95 additions & 0 deletions apps/desktop/test/vendor-oauth-login.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,7 @@ function harness(options = {}) {
return fakeModels(store, options);
},
modelConfigFor: options.modelConfigFor,
log: options.log,
newId: () => `id-${++counter}`,
fetch:
options.fetch ??
Expand Down Expand Up @@ -963,3 +964,97 @@ test("live-only thinking restrictions survive runtime launch without a saved mod
assert.deepEqual(effective.thinkingLevelMap, sibling.thinkingLevelMap);
}
});

function codexProvider() {
return {
id: "openai-codex",
name: "ChatGPT",
baseUrl: "https://chatgpt.com/backend-api",
auth: { oauth: { name: "ChatGPT Plus/Pro", isSubscription: true, loginLabel: "Sign in" } },
};
}

function codexModel(id) {
return {
id,
name: id,
api: "openai-codex-responses",
provider: "openai-codex",
baseUrl: "https://chatgpt.com/backend-api",
input: ["text"],
reasoning: true,
thinkingLevelMap: { off: null, low: "low", medium: "medium", high: "high" },
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 272_000,
maxTokens: 128_000,
};
}

/** A pasted code that makes `access-for-<code>` a ChatGPT-shaped JWT. */
function codexLoginCode() {
const payload = Buffer.from(JSON.stringify({
"https://api.openai.com/auth": { chatgpt_account_id: "acct_123" },
})).toString("base64url");
return `h.${payload}.sig`;
}

test("a ChatGPT account lists the models /codex/models returns for its client version", async () => {
const seen = [];
// The live endpoint rejects a request without client_version.
const fetchModels = async (input) => {
const url = new URL(String(input));
seen.push(url);
if (!url.searchParams.get("client_version")) {
return new Response(JSON.stringify({
detail: [{ loc: ["query", "client_version"], msg: "Field required" }],
}), { status: 400 });
}
return new Response(JSON.stringify({
models: [{ slug: "gpt-6-luna", visibility: "list" }, { slug: "gpt-6.1-sol", visibility: "list" }],
}), { status: 200, headers: { "content-type": "application/json" } });
};
const { host, events, oauth } = harness({
fetch: fetchModels,
provider: codexProvider(),
models: [codexModel("gpt-6-luna")],
});
const { loginId } = await oauth.start("openai-codex");
const prompt = await waitFor(events, "prompt");
oauth.respond({ loginId, promptId: prompt.request.promptId, value: codexLoginCode() });
const done = await waitFor(events, "done");
const row = host.providers.get(done.providerId);
assert.deepEqual(row.models.map((model) => model.id), ["gpt-6-luna", "gpt-6.1-sol"]);
assert.equal(seen[0].pathname, "/backend-api/codex/models");
const binding = await oauth.bindingFor(done.providerId, "gpt-6.1-sol");
assert.equal(binding.baseUrl, "https://chatgpt.com/backend-api");
});

test("a failed ChatGPT model list logs the status and a token-free response excerpt", async () => {
const logs = [];
const code = codexLoginCode();
const fetchModels = async () => new Response(JSON.stringify({
detail: [{ loc: ["query", "client_version"], msg: "Field required" }],
echoed: `access-for-${code}`,
}), { status: 400 });
const { host, events, oauth } = harness({
fetch: fetchModels,
log: (level, message, data) => logs.push({ level, message, data }),
provider: codexProvider(),
models: [codexModel("gpt-6-luna")],
});
const { loginId } = await oauth.start("openai-codex");
const prompt = await waitFor(events, "prompt");
oauth.respond({ loginId, promptId: prompt.request.promptId, value: code });
const done = await waitFor(events, "done");
// pi-ai's pinned list is still the fallback.
assert.deepEqual(host.providers.get(done.providerId).models.map((model) => model.id), ["gpt-6-luna"]);
const failed = logs.find((entry) => entry.message === "vendor account model list failed");
assert.ok(failed, `no model list failure log; saw ${logs.map((entry) => entry.message).join(", ")}`);
assert.equal(failed.level, "warn");
assert.equal(failed.data.vendorId, "openai-codex");
assert.equal(failed.data.status, 400);
assert.match(failed.data.responseExcerpt, /client_version/);
const logged = JSON.stringify(logs);
assert.equal(logged.includes(code), false);
assert.equal(logged.includes(code.split(".")[1]), false);
});
16 changes: 12 additions & 4 deletions docs/spec/03-runtime/11-provider-model-system.md
Original file line number Diff line number Diff line change
Expand Up @@ -499,10 +499,13 @@ and the connection test still proves the account by resolving auth. pi-ai
when the account request fails or the payload is not a model list. The probe
is the endpoint that vendor actually publishes:

- ChatGPT Plus/Pro (`openai-codex`): `GET {base}/codex/models`, with the
account id taken from the access token. A `{ data: [...] }` payload is not
accepted. A newly published id such as `gpt-6-luna` is selectable without a
client update when that response includes it.
- ChatGPT Plus/Pro (`openai-codex`): `GET {base}/codex/models?client_version=…`,
with the account id taken from the access token. The endpoint requires
`client_version` and hides models whose minimum Codex client is newer, so
the value is a pinned Codex CLI version (`CODEX_MODELS_CLIENT_VERSION`) that
is bumped when an account model goes missing. A `{ data: [...] }` payload is
not accepted. A newly published id such as `gpt-6-luna` is selectable
without a client update when that response includes it.
- GitHub Copilot: `GET {base}/models` with the pinned IDE identity headers and
`X-GitHub-Api-Version`. Only ids with `model_picker_enabled === true` (and
not policy-disabled) are kept. An id the pin does not know is added only when
Expand All @@ -513,6 +516,11 @@ is the endpoint that vendor actually publishes:
for Kimi). xAI still drops image and video generators.
- Radius keeps its gateway catalog refresh and is not probed again.

A failed account request logs the HTTP status and a short, single-line
excerpt of the response body with the request's credentials and any
token-shaped value removed, so an upstream contract change is diagnosable
from the provider log.

Image, video, speech and embedding ids are dropped. A model models.dev does
not know yet inherits limits, reasoning, adapter compatibility, and the wire
effort mapping from a pinned sibling of the same tier; xAI uses an explicit
Expand Down
10 changes: 7 additions & 3 deletions docs/zh-CN/spec/03-runtime/11-provider-model-system.md
Original file line number Diff line number Diff line change
Expand Up @@ -406,13 +406,17 @@ sidecar 请求
这类行的模型发现读取已登录账户自己的模型列表;连接测试仍通过解析认证来证明
账户。请求失败,或返回的不是模型列表时,才回退到 pi-ai(`models.getAvailable`,
含厂商自己的 `filterModels`)。各厂商打自己的接口:ChatGPT Plus/Pro
(`openai-codex`)是 `GET {base}/codex/models`,因此 `gpt-6-luna` 这类账户
已经提供、pin 里还没有的 id 也能出现;普通 `{ data: [...] }` 不当成 Codex
列表。Copilot 是带 IDE 身份头和 `X-GitHub-Api-Version` 的 `GET {base}/models`,
(`openai-codex`)是 `GET {base}/codex/models?client_version=…`,因此
`gpt-6-luna` 这类账户已经提供、pin 里还没有的 id 也能出现;该接口要求
`client_version`,并隐藏最低 Codex 客户端版本更高的模型,所以取值是固定的
Codex CLI 版本(`CODEX_MODELS_CLIENT_VERSION`),账户模型缺失时调高;普通
`{ data: [...] }` 不当成 Codex 列表。Copilot 是带 IDE 身份头和 `X-GitHub-Api-Version` 的 `GET {base}/models`,
只保留 `model_picker_enabled === true` 且未被策略禁用的 id,pin 不认识的 id
只有在其家族已经对应唯一线路 API 时才加入。Anthropic 用 OAuth 身份头请求
`GET {base}/v1/models`。Kimi、Meta、xAI、OpenRouter 请求 `GET {base}/models`
(Kimi 走 Anthropic 风格的 `/v1`)。Radius 继续用网关目录刷新,不再另打一遍。
账户请求失败时,日志记录 HTTP 状态码和一小段单行的响应内容摘要,其中去掉了
请求自身的凭据和任何形似令牌的值,便于从提供商日志诊断上游契约变化。
图像、视频、语音和嵌入模型会被丢掉。models.dev 不认识的 id 只从同档位的 pin
兄弟继承限额,xAI 按 `grok-4.7`、`grok-4.6`、`grok-4.5`、`grok-4.3` 的固定新到旧顺序,
不按 pin 顺序。models.dev 不能把账户列表里没有的 id 加进去。一个厂商可以
Expand Down
Loading