From 5220aba61970fc9f284a7c71e95669b52b4f47a8 Mon Sep 17 00:00:00 2001 From: Milosz Jankiewicz <25470423+Jaaneek@users.noreply.github.com> Date: Thu, 16 Jul 2026 08:58:24 +0100 Subject: [PATCH 1/8] feat(ai): add xAI device OAuth and route grok-4.5 through Responses (#6651) * feat(ai): add xAI device OAuth and route grok-4.5 through Responses Add xAI device-code OAuth alongside XAI_API_KEY. Route only grok-4.5 through Responses with low/medium/high reasoning; other xAI models stay on Completions. * fix(ai): tolerate xAI device-code interval 0 and correct token poll error label --------- Co-authored-by: Jaaneek --- packages/ai/scripts/generate-models.ts | 15 +- packages/ai/src/api/openai-responses.ts | 1 + packages/ai/src/auth/oauth/load.ts | 3 + packages/ai/src/auth/oauth/xai.ts | 231 +++++++++++++++++++ packages/ai/src/providers/xai.models.ts | 7 +- packages/ai/src/providers/xai.ts | 16 +- packages/ai/test/oauth-auth.test.ts | 6 + packages/ai/test/xai-oauth.test.ts | 285 ++++++++++++++++++++++++ packages/ai/test/xai-responses.test.ts | 105 +++++++++ packages/coding-agent/docs/providers.md | 6 + 10 files changed, 667 insertions(+), 8 deletions(-) create mode 100644 packages/ai/src/auth/oauth/xai.ts create mode 100644 packages/ai/test/xai-oauth.test.ts create mode 100644 packages/ai/test/xai-responses.test.ts diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index 5fad8aaf..6f194818 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/ai/scripts/generate-models.ts @@ -255,6 +255,14 @@ const OPENAI_RESPONSES_NONE_REASONING_MODELS = new Set([ "gpt-5.6-terra", "gpt-5.6-luna", ]); +const XAI_RESPONSES_MODEL_ID = "grok-4.5"; +const XAI_RESPONSES_EFFORT_LEVEL_MAP = { + off: null, + minimal: null, +} as const; +const XAI_RESPONSES_COMPAT: OpenAIResponsesCompat = { + supportsLongCacheRetention: false, +}; const OPENCODE_OPENAI_COMPLETIONS_LONG_CACHE_RETENTION_UNSUPPORTED_MODELS = new Set([ "opencode:deepseek-v4-flash", @@ -538,6 +546,9 @@ function applyThinkingLevelMetadata(model: Model): void { ) { mergeThinkingLevelMap(model, { off: "none" }); } + if (model.provider === "xai" && model.api === "openai-responses" && model.id === XAI_RESPONSES_MODEL_ID) { + mergeThinkingLevelMap(model, XAI_RESPONSES_EFFORT_LEVEL_MAP); + } if (supportsOpenAiXhigh(model.id)) { mergeThinkingLevelMap(model, { xhigh: "xhigh" }); } @@ -1126,13 +1137,15 @@ async function loadModelsDevData(): Promise[]> { for (const [modelId, model] of Object.entries(data.xai.models)) { const m = model as ModelsDevModel; if (m.tool_call !== true) continue; + const useResponsesApi = modelId === XAI_RESPONSES_MODEL_ID; models.push({ id: modelId, name: m.name || modelId, - api: "openai-completions", + api: useResponsesApi ? "openai-responses" : "openai-completions", provider: "xai", baseUrl: "https://api.x.ai/v1", + ...(useResponsesApi ? { compat: { ...XAI_RESPONSES_COMPAT } } : {}), reasoning: m.reasoning === true, input: m.modalities?.input?.includes("image") ? ["text", "image"] : ["text"], cost: { diff --git a/packages/ai/src/api/openai-responses.ts b/packages/ai/src/api/openai-responses.ts index cb3a9c93..049e00ec 100644 --- a/packages/ai/src/api/openai-responses.ts +++ b/packages/ai/src/api/openai-responses.ts @@ -282,6 +282,7 @@ function buildParams(model: Model<"openai-responses">, context: Context, options effort: (model.thinkingLevelMap?.off ?? "none") as NonNullable["effort"], }; } + if (model.provider === "xai") params.include = ["reasoning.encrypted_content"]; } return params; diff --git a/packages/ai/src/auth/oauth/load.ts b/packages/ai/src/auth/oauth/load.ts index 6984da00..1e5fb862 100644 --- a/packages/ai/src/auth/oauth/load.ts +++ b/packages/ai/src/auth/oauth/load.ts @@ -20,6 +20,9 @@ export const loadOpenAICodexOAuth = async (): Promise => export const loadGitHubCopilotOAuth = async (): Promise => ((await importOAuthModule("./github-copilot.ts")) as { githubCopilotOAuth: OAuthAuth }).githubCopilotOAuth; +export const loadXaiOAuth = async (): Promise => + ((await importOAuthModule("./xai.ts")) as { xaiOAuth: OAuthAuth }).xaiOAuth; + export const loadRadiusOAuth = async (options: { name: string; gateway: string }): Promise => ( (await importOAuthModule("./radius.ts")) as { diff --git a/packages/ai/src/auth/oauth/xai.ts b/packages/ai/src/auth/oauth/xai.ts new file mode 100644 index 00000000..4c508b6b --- /dev/null +++ b/packages/ai/src/auth/oauth/xai.ts @@ -0,0 +1,231 @@ +/** + * xAI OAuth device-code flow. + */ + +import type { AuthInteraction, OAuthAuth, OAuthCredential } from "../types.ts"; +import { pollOAuthDeviceCodeFlow } from "./device-code.ts"; + +const XAI_CLIENT_ID = "b1a00492-073a-47ea-816f-4c329264a828"; +const XAI_SCOPE = "openid profile email offline_access grok-cli:access api:access"; +const XAI_DEVICE_CODE_URL = "https://auth.x.ai/oauth2/device/code"; +const XAI_TOKEN_URL = "https://auth.x.ai/oauth2/token"; +// Refresh slightly before the reported expiry to avoid using a token that dies mid-request. +const REFRESH_SKEW_MS = 5 * 60 * 1000; +const DEFAULT_TOKEN_LIFETIME_SECONDS = 3600; + +type JsonObject = Record; + +type OAuthHttpResponse = { + ok: boolean; + status: number; + body: JsonObject; +}; + +type XaiDeviceCode = { + deviceCode: string; + userCode: string; + verificationUri: string; + intervalSeconds?: number; + expiresInSeconds: number; +}; + +function requiredString(body: JsonObject, field: string): string { + const value = body[field]; + if (typeof value !== "string" || value.length === 0) { + throw new Error(`Invalid xAI OAuth response field: ${field}`); + } + return value; +} + +function positiveNumber(body: JsonObject, field: string): number { + const value = body[field]; + if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) { + throw new Error(`Invalid xAI OAuth response field: ${field}`); + } + return value; +} + +// The verification URI is opened in the user's browser; force it to be an https URL +// so a malicious response cannot make `open` launch something else. +function validateVerificationUri(raw: string): string { + let url: URL; + try { + url = new URL(raw); + } catch { + throw new Error("Untrusted verification URI in xAI OAuth response"); + } + if (url.protocol !== "https:") { + throw new Error("Untrusted verification URI in xAI OAuth response"); + } + return url.href; +} + +async function postForm(url: string, fields: Record, signal?: AbortSignal): Promise { + let response: Response; + try { + response = await fetch(url, { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams(fields), + signal, + }); + } catch (error) { + if (signal?.aborted) { + throw new Error("Login cancelled"); + } + throw error; + } + + let body: JsonObject; + try { + const parsed = (await response.json()) as unknown; + body = parsed && typeof parsed === "object" && !Array.isArray(parsed) ? (parsed as JsonObject) : {}; + } catch { + if (signal?.aborted) { + throw new Error("Login cancelled"); + } + throw new Error(`xAI OAuth returned invalid JSON (HTTP ${response.status})`); + } + return { + ok: response.ok, + status: response.status, + body, + }; +} + +function requestFailure(action: string, response: OAuthHttpResponse): Error { + const error = typeof response.body.error === "string" ? response.body.error : undefined; + const description = + typeof response.body.error_description === "string" ? response.body.error_description : undefined; + const detail = [error, description].filter(Boolean).join(": "); + return new Error(`xAI OAuth ${action} failed (HTTP ${response.status})${detail ? `: ${detail}` : ""}`); +} + +function parseDeviceCode(body: JsonObject): XaiDeviceCode { + // RFC 8628 allows interval 0 (no minimum wait); fall back to the poller's + // default instead of failing on non-positive or malformed values. + const interval = body.interval; + const intervalSeconds = + typeof interval === "number" && Number.isFinite(interval) && interval > 0 ? interval : undefined; + return { + deviceCode: requiredString(body, "device_code"), + userCode: requiredString(body, "user_code"), + verificationUri: validateVerificationUri(requiredString(body, "verification_uri")), + intervalSeconds, + expiresInSeconds: positiveNumber(body, "expires_in"), + }; +} + +function credentialsFromTokenResponse(body: JsonObject, previousRefreshToken?: string): OAuthCredential { + const access = requiredString(body, "access_token"); + // xAI may omit refresh_token on refresh when the token is not rotated. + const refresh = + body.refresh_token === undefined && previousRefreshToken + ? previousRefreshToken + : requiredString(body, "refresh_token"); + const expiresInSeconds = + body.expires_in === undefined ? DEFAULT_TOKEN_LIFETIME_SECONDS : positiveNumber(body, "expires_in"); + return { + type: "oauth", + access, + refresh, + expires: Date.now() + expiresInSeconds * 1000 - REFRESH_SKEW_MS, + }; +} + +async function requestDeviceCode(signal?: AbortSignal): Promise { + const response = await postForm( + XAI_DEVICE_CODE_URL, + { + client_id: XAI_CLIENT_ID, + scope: XAI_SCOPE, + referrer: "pi", + }, + signal, + ); + if (!response.ok) { + throw requestFailure("device authorization", response); + } + return parseDeviceCode(response.body); +} + +async function pollForTokens(device: XaiDeviceCode, signal?: AbortSignal): Promise { + return pollOAuthDeviceCodeFlow({ + intervalSeconds: device.intervalSeconds, + expiresInSeconds: device.expiresInSeconds, + waitBeforeFirstPoll: true, + signal, + poll: async () => { + const response = await postForm( + XAI_TOKEN_URL, + { + grant_type: "urn:ietf:params:oauth:grant-type:device_code", + client_id: XAI_CLIENT_ID, + device_code: device.deviceCode, + }, + signal, + ); + + if (response.ok) { + return { status: "complete", value: credentialsFromTokenResponse(response.body) }; + } + + const error = response.body.error; + if (error === "authorization_pending") { + return { status: "pending" }; + } + if (error === "slow_down") { + const interval = response.body.interval; + return { status: "slow_down", intervalSeconds: typeof interval === "number" ? interval : undefined }; + } + if (error === "access_denied" || error === "authorization_denied") { + return { status: "failed", message: "xAI device authorization was denied" }; + } + if (error === "expired_token") { + return { status: "failed", message: "xAI device code expired" }; + } + return { status: "failed", message: requestFailure("device token polling", response).message }; + }, + }); +} + +async function loginXai(interaction: AuthInteraction): Promise { + const device = await requestDeviceCode(interaction.signal); + interaction.notify({ + type: "device_code", + userCode: device.userCode, + verificationUri: device.verificationUri, + intervalSeconds: device.intervalSeconds, + expiresInSeconds: device.expiresInSeconds, + }); + return pollForTokens(device, interaction.signal); +} + +async function refreshXaiToken(refreshToken: string, signal?: AbortSignal): Promise { + const response = await postForm( + XAI_TOKEN_URL, + { + grant_type: "refresh_token", + client_id: XAI_CLIENT_ID, + refresh_token: refreshToken, + }, + signal, + ); + if (!response.ok) { + throw requestFailure("token refresh", response); + } + return credentialsFromTokenResponse(response.body, refreshToken); +} + +export const xaiOAuth: OAuthAuth = { + name: "xAI (Grok/X subscription)", + login: loginXai, + refresh: (credential, signal) => refreshXaiToken(credential.refresh, signal), + + async toAuth(credential) { + return { apiKey: credential.access }; + }, +}; diff --git a/packages/ai/src/providers/xai.models.ts b/packages/ai/src/providers/xai.models.ts index 57580102..2c781afe 100644 --- a/packages/ai/src/providers/xai.models.ts +++ b/packages/ai/src/providers/xai.models.ts @@ -97,11 +97,12 @@ export const XAI_MODELS = { "grok-4.5": { id: "grok-4.5", name: "Grok 4.5", - api: "openai-completions", + api: "openai-responses", provider: "xai", baseUrl: "https://api.x.ai/v1", - compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false}, + compat: {"supportsLongCacheRetention":false}, reasoning: true, + thinkingLevelMap: {"off":null,"minimal":null}, input: ["text", "image"], cost: { input: 2, @@ -111,7 +112,7 @@ export const XAI_MODELS = { }, contextWindow: 500000, maxTokens: 500000, - } satisfies Model<"openai-completions">, + } satisfies Model<"openai-responses">, "grok-build-0.1": { id: "grok-build-0.1", name: "Grok Build 0.1", diff --git a/packages/ai/src/providers/xai.ts b/packages/ai/src/providers/xai.ts index 3373fbf5..3a679a2f 100644 --- a/packages/ai/src/providers/xai.ts +++ b/packages/ai/src/providers/xai.ts @@ -1,15 +1,23 @@ import { openAICompletionsApi } from "../api/openai-completions.lazy.ts"; -import { envApiKeyAuth } from "../auth/helpers.ts"; +import { openAIResponsesApi } from "../api/openai-responses.lazy.ts"; +import { envApiKeyAuth, lazyOAuth } from "../auth/helpers.ts"; +import { loadXaiOAuth } from "../auth/oauth/load.ts"; import { createProvider, type Provider } from "../models.ts"; import { XAI_MODELS } from "./xai.models.ts"; -export function xaiProvider(): Provider<"openai-completions"> { +export function xaiProvider(): Provider<"openai-completions" | "openai-responses"> { return createProvider({ id: "xai", name: "xAI", baseUrl: "https://api.x.ai/v1", - auth: { apiKey: envApiKeyAuth("xAI API key", ["XAI_API_KEY"]) }, + auth: { + apiKey: envApiKeyAuth("xAI API key", ["XAI_API_KEY"]), + oauth: lazyOAuth({ name: "xAI (Grok/X subscription)", load: loadXaiOAuth }), + }, models: Object.values(XAI_MODELS), - api: openAICompletionsApi(), + api: { + "openai-completions": openAICompletionsApi(), + "openai-responses": openAIResponsesApi(), + }, }); } diff --git a/packages/ai/test/oauth-auth.test.ts b/packages/ai/test/oauth-auth.test.ts index 68534901..6620002d 100644 --- a/packages/ai/test/oauth-auth.test.ts +++ b/packages/ai/test/oauth-auth.test.ts @@ -3,6 +3,7 @@ import { InMemoryCredentialStore } from "../src/auth/credential-store.ts"; import { anthropicOAuth } from "../src/auth/oauth/anthropic.ts"; import { githubCopilotOAuth } from "../src/auth/oauth/github-copilot.ts"; import { openaiCodexOAuth } from "../src/auth/oauth/openai-codex.ts"; +import { xaiOAuth } from "../src/auth/oauth/xai.ts"; import { createModels } from "../src/models.ts"; import * as extensionOAuthCompatibility from "../src/oauth.ts"; import { anthropicProvider } from "../src/providers/anthropic.ts"; @@ -32,6 +33,11 @@ describe.sequential("OAuthAuth adapters", () => { expect(auth).toEqual({ apiKey: "token" }); }); + it("xAI toAuth derives the api key from the access token", async () => { + const auth = await xaiOAuth.toAuth({ type: "oauth", access: "token", refresh: "r", expires: 0 }); + expect(auth).toEqual({ apiKey: "token" }); + }); + it("github-copilot toAuth derives baseUrl from the token proxy endpoint", async () => { const access = "tid=abc;exp=123;proxy-ep=proxy.enterprise.example;rest"; const auth = await githubCopilotOAuth.toAuth({ type: "oauth", access, refresh: "r", expires: 0 }); diff --git a/packages/ai/test/xai-oauth.test.ts b/packages/ai/test/xai-oauth.test.ts new file mode 100644 index 00000000..fb71a621 --- /dev/null +++ b/packages/ai/test/xai-oauth.test.ts @@ -0,0 +1,285 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { xaiOAuth } from "../src/auth/oauth/xai.ts"; +import type { OAuthCredential } from "../src/auth/types.ts"; + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +function requestUrl(input: unknown): string { + if (typeof input === "string") return input; + if (input instanceof URL) return input.toString(); + if (input instanceof Request) return input.url; + throw new Error(`Unsupported request input: ${String(input)}`); +} + +function requestForm(init: RequestInit | undefined): URLSearchParams { + return new URLSearchParams(String(init?.body)); +} + +function deviceCodeResponse(overrides: Record = {}): Record { + return { + device_code: "device-code", + user_code: "ABCD-1234", + verification_uri: "https://accounts.x.ai/oauth2/device", + expires_in: 900, + interval: 5, + ...overrides, + }; +} + +function tokenResponse(overrides: Record = {}): Record { + return { + access_token: "access-token", + refresh_token: "refresh-token", + expires_in: 21_600, + token_type: "Bearer", + ...overrides, + }; +} + +type DeviceCodeInfo = { + userCode: string; + verificationUri: string; + intervalSeconds?: number; + expiresInSeconds?: number; +}; + +function loginXaiForTest(options: { + onDeviceCode: (info: DeviceCodeInfo) => void; + signal?: AbortSignal; +}): Promise { + return xaiOAuth.login({ + signal: options.signal, + prompt: () => { + throw new Error("Unexpected prompt"); + }, + notify: (event) => { + if (event.type === "device_code") { + const { type: _, ...info } = event; + options.onDeviceCode(info); + } + }, + }); +} + +function refreshXaiForTest(refreshToken: string): Promise { + return xaiOAuth.refresh({ type: "oauth", access: "old-access", refresh: refreshToken, expires: 0 }); +} + +describe("xAI OAuth device flow", () => { + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + vi.useRealTimers(); + }); + + it("uses the device grant, delays polling, and handles pending and slow_down", async () => { + vi.useFakeTimers(); + const startTime = new Date("2026-07-09T20:00:00Z"); + vi.setSystemTime(startTime); + const pollTimes: number[] = []; + const tokenReplies = [ + jsonResponse({ error: "authorization_pending" }, 400), + jsonResponse({ error: "slow_down", interval: 10 }, 400), + jsonResponse(tokenResponse()), + ]; + + const fetchMock = vi.fn(async (input: unknown, init?: RequestInit) => { + const url = requestUrl(input); + + if (url === "https://auth.x.ai/oauth2/device/code") { + const form = requestForm(init); + expect(form.get("client_id")).toBe("b1a00492-073a-47ea-816f-4c329264a828"); + expect(form.get("scope")).toBe("openid profile email offline_access grok-cli:access api:access"); + expect(form.get("referrer")).toBe("pi"); + return jsonResponse(deviceCodeResponse()); + } + + if (url === "https://auth.x.ai/oauth2/token") { + pollTimes.push(Date.now()); + const form = requestForm(init); + expect(form.get("grant_type")).toBe("urn:ietf:params:oauth:grant-type:device_code"); + expect(form.get("client_id")).toBe("b1a00492-073a-47ea-816f-4c329264a828"); + expect(form.get("device_code")).toBe("device-code"); + const reply = tokenReplies.shift(); + if (!reply) throw new Error("Unexpected token poll"); + return reply; + } + + throw new Error(`Unexpected request: ${url}`); + }); + vi.stubGlobal("fetch", fetchMock); + + const deviceCodes: DeviceCodeInfo[] = []; + const loginPromise = loginXaiForTest({ onDeviceCode: (info) => deviceCodes.push(info) }); + + await vi.advanceTimersByTimeAsync(0); + expect(deviceCodes).toEqual([ + { + userCode: "ABCD-1234", + verificationUri: "https://accounts.x.ai/oauth2/device", + intervalSeconds: 5, + expiresInSeconds: 900, + }, + ]); + expect(pollTimes).toEqual([]); + + await vi.advanceTimersByTimeAsync(5000); + expect(pollTimes).toEqual([startTime.getTime() + 5000]); + + // slow_down raised the interval to 10 seconds + await vi.advanceTimersByTimeAsync(5000); + expect(pollTimes).toEqual([startTime.getTime() + 5000, startTime.getTime() + 10_000]); + + await vi.advanceTimersByTimeAsync(10_000); + const credentials = await loginPromise; + expect(pollTimes).toEqual([ + startTime.getTime() + 5000, + startTime.getTime() + 10_000, + startTime.getTime() + 20_000, + ]); + expect(credentials).toEqual({ + type: "oauth", + access: "access-token", + refresh: "refresh-token", + expires: startTime.getTime() + 20_000 + 21_600_000 - 300_000, + }); + }); + + it("falls back to the default poll interval when the response reports interval 0", async () => { + vi.useFakeTimers(); + const startTime = new Date("2026-07-09T20:00:00Z"); + vi.setSystemTime(startTime); + const pollTimes: number[] = []; + vi.stubGlobal( + "fetch", + vi.fn(async (input: unknown) => { + if (requestUrl(input) === "https://auth.x.ai/oauth2/device/code") { + return jsonResponse(deviceCodeResponse({ interval: 0 })); + } + pollTimes.push(Date.now()); + return jsonResponse(tokenResponse()); + }), + ); + + const loginPromise = loginXaiForTest({ onDeviceCode: () => {} }); + // RFC 8628 default interval is 5 seconds when the server does not require a wait. + await vi.advanceTimersByTimeAsync(5000); + await loginPromise; + expect(pollTimes).toEqual([startTime.getTime() + 5000]); + }); + + it.each(["http://accounts.x.ai/oauth2/device", "file:///etc/passwd", "not a url"])( + "rejects a non-https verification URI: %s", + async (verificationUri) => { + vi.stubGlobal( + "fetch", + vi.fn(async () => jsonResponse(deviceCodeResponse({ verification_uri: verificationUri }))), + ); + + await expect(loginXaiForTest({ onDeviceCode: () => {} })).rejects.toThrow("Untrusted verification URI"); + }, + ); + + it.each(["access_denied", "authorization_denied"])( + "fails when device authorization is denied: %s", + async (error) => { + vi.useFakeTimers(); + let requestCount = 0; + vi.stubGlobal( + "fetch", + vi.fn(async () => { + requestCount += 1; + return requestCount === 1 + ? jsonResponse(deviceCodeResponse({ interval: 1 })) + : jsonResponse({ error }, 400); + }), + ); + + const loginPromise = loginXaiForTest({ onDeviceCode: () => {} }); + const assertion = expect(loginPromise).rejects.toThrow("xAI device authorization was denied"); + await vi.advanceTimersByTimeAsync(1000); + await assertion; + }, + ); + + it("cancels while waiting for the first token poll", async () => { + vi.useFakeTimers(); + const controller = new AbortController(); + const fetchMock = vi.fn(async () => jsonResponse(deviceCodeResponse())); + vi.stubGlobal("fetch", fetchMock); + + const loginPromise = loginXaiForTest({ + onDeviceCode: () => controller.abort(), + signal: controller.signal, + }); + + await expect(loginPromise).rejects.toThrow("Login cancelled"); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("refreshes tokens and preserves an unrotated refresh token", async () => { + let requestCount = 0; + const fetchMock = vi.fn(async (input: unknown, init?: RequestInit) => { + expect(requestUrl(input)).toBe("https://auth.x.ai/oauth2/token"); + const form = requestForm(init); + expect(form.get("grant_type")).toBe("refresh_token"); + expect(form.get("client_id")).toBe("b1a00492-073a-47ea-816f-4c329264a828"); + requestCount += 1; + if (requestCount === 1) { + expect(form.get("refresh_token")).toBe("old-refresh"); + return jsonResponse(tokenResponse({ access_token: "new-access", refresh_token: "new-refresh" })); + } + expect(form.get("refresh_token")).toBe("keep-refresh"); + return jsonResponse(tokenResponse({ access_token: "newer-access", refresh_token: undefined })); + }); + vi.stubGlobal("fetch", fetchMock); + + const rotated = await refreshXaiForTest("old-refresh"); + const preserved = await refreshXaiForTest("keep-refresh"); + expect(rotated.type).toBe("oauth"); + expect(rotated.refresh).toBe("new-refresh"); + expect(rotated.access).toBe("new-access"); + expect(preserved.refresh).toBe("keep-refresh"); + expect(preserved.access).toBe("newer-access"); + expect(xaiOAuth.name).toBe("xAI (Grok/X subscription)"); + await expect(xaiOAuth.toAuth(preserved)).resolves.toEqual({ apiKey: "newer-access" }); + }); + + it("assumes a one-hour lifetime when expires_in is missing", async () => { + vi.useFakeTimers(); + const startTime = new Date("2026-07-09T20:00:00Z"); + vi.setSystemTime(startTime); + vi.stubGlobal( + "fetch", + vi.fn(async () => jsonResponse(tokenResponse({ expires_in: undefined }))), + ); + + const credentials = await refreshXaiForTest("old-refresh"); + expect(credentials.expires).toBe(startTime.getTime() + 3_600_000 - 300_000); + }); + + it("rejects token responses with missing fields", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => jsonResponse(tokenResponse({ access_token: undefined }))), + ); + + await expect(refreshXaiForTest("old-refresh")).rejects.toThrow("Invalid xAI OAuth response field: access_token"); + }); + + it("surfaces the upstream error code and description on refresh failure", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => jsonResponse({ error: "invalid_grant", error_description: "refresh token revoked" }, 400)), + ); + + await expect(refreshXaiForTest("old-refresh")).rejects.toThrow( + "xAI OAuth token refresh failed (HTTP 400): invalid_grant: refresh token revoked", + ); + }); +}); diff --git a/packages/ai/test/xai-responses.test.ts b/packages/ai/test/xai-responses.test.ts new file mode 100644 index 00000000..303c62c5 --- /dev/null +++ b/packages/ai/test/xai-responses.test.ts @@ -0,0 +1,105 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { OpenAIResponsesOptions } from "../src/api/openai-responses.ts"; +import { getSupportedThinkingLevels } from "../src/models.ts"; +import { XAI_MODELS } from "../src/providers/xai.models.ts"; +import { xaiProvider } from "../src/providers/xai.ts"; +import type { Context, Model } from "../src/types.ts"; + +type CapturedRequest = { + url: string; + headers: Headers; + body: Record; +}; + +function completedResponse(): Response { + const event = { + type: "response.completed", + sequence_number: 0, + response: { + id: "resp_xai_test", + status: "completed", + output: [], + usage: { + input_tokens: 1, + output_tokens: 1, + total_tokens: 2, + input_tokens_details: { cached_tokens: 0 }, + }, + }, + }; + return new Response(`data: ${JSON.stringify(event)}\n\ndata: [DONE]\n\n`, { + status: 200, + headers: { "content-type": "text/event-stream" }, + }); +} + +async function captureRequest( + model: Model<"openai-responses">, + context: Context, + options: OpenAIResponsesOptions, +): Promise { + let captured: CapturedRequest | undefined; + vi.spyOn(globalThis, "fetch").mockImplementation(async (input, init) => { + const request = new Request(input, init); + captured = { + url: request.url, + headers: request.headers, + body: JSON.parse(await request.clone().text()) as Record, + }; + return completedResponse(); + }); + + const result = await xaiProvider().stream(model, context, options).result(); + expect(result.stopReason, result.errorMessage).toBe("stop"); + expect(captured).toBeDefined(); + return captured!; +} + +describe("xAI Responses provider", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("uses Responses with low/medium/high efforts only for Grok 4.5", () => { + expect(XAI_MODELS["grok-4.5"].api).toBe("openai-responses"); + expect(getSupportedThinkingLevels(XAI_MODELS["grok-4.5"])).toEqual(["low", "medium", "high"]); + expect(XAI_MODELS["grok-4.3"].api).toBe("openai-completions"); + }); + + it("uses /responses with bearer auth and xAI-compatible request fields", async () => { + const captured = await captureRequest( + XAI_MODELS["grok-4.5"], + { + systemPrompt: "You are a careful coding assistant.", + messages: [{ role: "user", content: "hello", timestamp: 1 }], + }, + { + apiKey: "xai-test-token", + sessionId: "pi-session-123", + cacheRetention: "long", + reasoningEffort: "medium", + }, + ); + + expect(captured.url).toBe("https://api.x.ai/v1/responses"); + expect(captured.headers.get("authorization")).toBe("Bearer xai-test-token"); + expect(captured.headers.get("session_id")).toBe("pi-session-123"); + expect(captured.body).toMatchObject({ + model: "grok-4.5", + store: false, + stream: true, + prompt_cache_key: "pi-session-123", + reasoning: { effort: "medium" }, + include: ["reasoning.encrypted_content"], + }); + expect(captured.body).not.toHaveProperty("prompt_cache_retention"); + expect(captured.body.input).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + role: "developer", + content: "You are a careful coding assistant.", + }), + ]), + ); + }); +}); diff --git a/packages/coding-agent/docs/providers.md b/packages/coding-agent/docs/providers.md index f7d9983f..d3f832ef 100644 --- a/packages/coding-agent/docs/providers.md +++ b/packages/coding-agent/docs/providers.md @@ -18,6 +18,7 @@ Use `/login` in interactive mode, then select a provider: - ChatGPT Plus/Pro (Codex) - Claude Pro/Max - GitHub Copilot +- xAI (Grok/X subscription) - Radius Use `/logout` to clear credentials. Tokens are stored in `~/.pi/agent/auth.json` and auto-refresh when expired. @@ -36,6 +37,11 @@ Anthropic subscription auth is active for Claude Pro/Max accounts. Third-party h - Press Enter for github.com, or enter your GitHub Enterprise Server domain - If you get "model not supported", enable it in VS Code: Copilot Chat → model selector → select model → "Enable" +### xAI (Grok/X subscription) + +- Run `/login xai`, then select **Use a subscription** +- `XAI_API_KEY` remains available through **Use an API key** + ### Radius Radius is a dynamic `pi-messages` gateway. `/login radius` stores OAuth tokens in `auth.json`; the gateway catalog is refreshed independently and cached in `models-store.json`. Custom Radius gateways can be declared in `models.json` with `"oauth": "radius"` and a gateway `baseUrl`. From 2be9efa19cd64aed40ca63f92c0c0f9a6bac7c9d Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Thu, 16 Jul 2026 11:43:47 +0200 Subject: [PATCH 2/8] feat(ai): publish generated model catalogs to R2 (#6720) --- .github/workflows/publish-model-catalog.yml | 112 ++++++++ .gitignore | 1 + package.json | 2 + packages/ai/package.json | 1 + packages/ai/scripts/generate-models.ts | 204 +++++++++----- scripts/publish-model-catalog.mjs | 295 ++++++++++++++++++++ 6 files changed, 548 insertions(+), 67 deletions(-) create mode 100644 .github/workflows/publish-model-catalog.yml create mode 100644 scripts/publish-model-catalog.mjs diff --git a/.github/workflows/publish-model-catalog.yml b/.github/workflows/publish-model-catalog.yml new file mode 100644 index 00000000..03641352 --- /dev/null +++ b/.github/workflows/publish-model-catalog.yml @@ -0,0 +1,112 @@ +name: Publish Model Catalog + +on: + workflow_run: + workflows: + - CI + types: + - completed + pull_request: + paths: + - '.github/workflows/publish-model-catalog.yml' + - '.gitignore' + - 'package.json' + - 'packages/ai/**' + - 'scripts/publish-model-catalog.mjs' + schedule: + - cron: '17 */4 * * *' + workflow_dispatch: + inputs: + source_ref: + description: 'Commit, branch, or tag to generate from' + required: false + default: 'main' + type: string + publish: + description: 'Upload the generated catalog to production R2' + required: true + default: false + type: boolean + +permissions: + contents: read + +jobs: + generate: + if: ${{ github.event_name != 'workflow_run' || (github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.head_branch == 'main') }} + runs-on: ubuntu-latest + env: + SOURCE_REF: ${{ github.event.workflow_run.head_sha || github.event.pull_request.head.sha || inputs.source_ref || github.sha }} + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + ref: ${{ env.SOURCE_REF }} + persist-credentials: false + + - name: Setup Node.js + uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0 + with: + node-version: '22' + cache: npm + + - name: Install dependencies + run: npm ci --ignore-scripts + + - name: Generate model catalog JSON + run: npm run generate:model-catalog + + - name: Validate model catalog JSON + run: npm run check:model-catalog + + - name: Upload model catalog JSON + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: model-catalog-json + path: .artifacts/model-catalog + if-no-files-found: error + retention-days: 14 + + publish: + if: ${{ github.event_name == 'schedule' || github.event_name == 'workflow_run' || (github.event_name == 'workflow_dispatch' && inputs.publish) }} + needs: generate + runs-on: ubuntu-latest + environment: pi-model-upload + concurrency: + group: publish-model-catalog-r2 + cancel-in-progress: true + env: + SOURCE_REF: ${{ github.event.workflow_run.head_sha || inputs.source_ref || github.sha }} + AWS_ACCESS_KEY_ID: ${{ secrets.PI_ARTIFACTS_R2_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.PI_ARTIFACTS_R2_SECRET_ACCESS_KEY }} + AWS_DEFAULT_REGION: auto + AWS_EC2_METADATA_DISABLED: 'true' + R2_ENDPOINT: https://67c0d357268b0fca6e0b465bb9d01b84.r2.cloudflarestorage.com + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + ref: ${{ env.SOURCE_REF }} + persist-credentials: false + + - name: Setup Node.js + uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0 + with: + node-version: '22' + + - name: Download model catalog JSON + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: model-catalog-json + path: .artifacts/model-catalog + + - name: Verify AWS CLI + run: aws --version + + - name: Publish model catalog to R2 + run: | + node scripts/publish-model-catalog.mjs \ + --input .artifacts/model-catalog \ + --bucket pi-artifacts \ + --endpoint "$R2_ENDPOINT" \ + --source-commit "$(git rev-parse HEAD)" diff --git a/.gitignore b/.gitignore index 12a09a8f..49dc29c7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ node_modules/ dist/ +.artifacts/ *.log .DS_Store *.tsbuildinfo diff --git a/package.json b/package.json index 4ec90289..83462d28 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,8 @@ "check:shrinkwrap": "node scripts/generate-coding-agent-shrinkwrap.mjs --check", "check:install-lock:coding-agent": "node scripts/generate-coding-agent-install-lock.mjs --check", "check:ts-imports": "node scripts/check-ts-relative-imports.mjs", + "generate:model-catalog": "npm --prefix packages/ai run generate-model-catalog", + "check:model-catalog": "node scripts/publish-model-catalog.mjs --input .artifacts/model-catalog --dry-run", "profile:tui": "node scripts/profile-coding-agent-node.mjs --mode tui", "profile:rpc": "node scripts/profile-coding-agent-node.mjs --mode rpc", "test": "npm run test --workspaces --if-present", diff --git a/packages/ai/package.json b/packages/ai/package.json index d0edb56b..e2ecd28c 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -46,6 +46,7 @@ "scripts": { "clean": "shx rm -rf dist", "generate-models": "node scripts/generate-models.ts", + "generate-model-catalog": "node scripts/generate-models.ts --strict --json-only --json-output ../../.artifacts/model-catalog", "generate-image-models": "node scripts/generate-image-models.ts", "build": "npm run generate-models && npm run generate-image-models && tsgo -p tsconfig.build.json", "test": "vitest --run", diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index 6f194818..d740182b 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/ai/scripts/generate-models.ts @@ -1,7 +1,7 @@ #!/usr/bin/env node -import { readdirSync, rmSync, writeFileSync } from "fs"; -import { join, dirname } from "path"; +import { mkdirSync, readdirSync, rmSync, writeFileSync } from "fs"; +import { dirname, join, resolve } from "path"; import { fileURLToPath } from "url"; import { CLOUDFLARE_AI_GATEWAY_ANTHROPIC_BASE_URL, @@ -22,6 +22,40 @@ const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const packageRoot = join(__dirname, ".."); +function readGeneratorOptions(args: string[]): { + strict: boolean; + jsonOnly: boolean; + jsonOutputDir: string | undefined; +} { + let strict = false; + let jsonOnly = false; + let jsonOutputDir: string | undefined; + + for (let index = 0; index < args.length; index++) { + const arg = args[index]; + if (arg === "--strict") { + strict = true; + continue; + } + if (arg === "--json-only") { + jsonOnly = true; + continue; + } + if (arg === "--json-output") { + const value = args[++index]; + if (!value) throw new Error("--json-output requires a directory"); + jsonOutputDir = resolve(value); + continue; + } + throw new Error(`Unknown argument: ${arg}`); + } + + if (jsonOnly && !jsonOutputDir) throw new Error("--json-only requires --json-output"); + return { strict, jsonOnly, jsonOutputDir }; +} + +const generatorOptions = readGeneratorOptions(process.argv.slice(2)); + interface ModelsDevModel { id: string; name: string; @@ -688,6 +722,7 @@ async function fetchNvidiaNimModelIds(): Promise> { try { console.log("Fetching models from NVIDIA NIM API..."); const response = await fetch(`${NVIDIA_BASE_URL}/models`); + if (!response.ok) throw new Error(`NVIDIA NIM API returned ${response.status}`); const data = (await response.json()) as { data?: NvidiaNimModelListItem[] }; const modelIds = new Map(); @@ -700,6 +735,7 @@ async function fetchNvidiaNimModelIds(): Promise> { return modelIds; } catch (error) { console.error("Failed to fetch NVIDIA NIM models:", error); + if (generatorOptions.strict) throw error; return new Map(); } } @@ -708,6 +744,7 @@ async function fetchOpenRouterModels(): Promise[]> { try { console.log("Fetching models from OpenRouter API..."); const response = await fetch("https://openrouter.ai/api/v1/models"); + if (!response.ok) throw new Error(`OpenRouter API returned ${response.status}`); const data = await response.json(); const models: Model[] = []; @@ -760,6 +797,7 @@ async function fetchOpenRouterModels(): Promise[]> { return models; } catch (error) { console.error("Failed to fetch OpenRouter models:", error); + if (generatorOptions.strict) throw error; return []; } } @@ -768,6 +806,7 @@ async function fetchAiGatewayModels(): Promise[]> { try { console.log("Fetching models from Vercel AI Gateway API..."); const response = await fetch(`${AI_GATEWAY_MODELS_URL}/models`); + if (!response.ok) throw new Error(`Vercel AI Gateway API returned ${response.status}`); const data = await response.json(); const models: Model[] = []; @@ -818,6 +857,7 @@ async function fetchAiGatewayModels(): Promise[]> { return models; } catch (error) { console.error("Failed to fetch Vercel AI Gateway models:", error); + if (generatorOptions.strict) throw error; return []; } } @@ -826,6 +866,7 @@ async function loadModelsDevData(): Promise[]> { try { console.log("Fetching models from models.dev API..."); const response = await fetch("https://models.dev/api.json"); + if (!response.ok) throw new Error(`models.dev API returned ${response.status}`); const data = await response.json(); const models: Model[] = []; @@ -1703,6 +1744,7 @@ async function loadModelsDevData(): Promise[]> { return models; } catch (error) { console.error("Failed to load models.dev data:", error); + if (generatorOptions.strict) throw error; return []; } } @@ -2236,85 +2278,110 @@ async function generateModels() { } } - // Generate TypeScript files: one catalog per provider plus an aggregator - const generatedHeader = `// This file is auto-generated by scripts/generate-models.ts + const sortedProviderIds = Object.keys(providers).sort(); + + if (!generatorOptions.jsonOnly) { + // Generate TypeScript files: one catalog per provider plus an aggregator + const generatedHeader = `// This file is auto-generated by scripts/generate-models.ts // Do not edit manually - run 'npm run generate-models' to update `; - const catalogConstName = (providerId: string) => `${providerId.toUpperCase().replace(/[^A-Z0-9]+/g, "_")}_MODELS`; + const catalogConstName = (providerId: string) => + `${providerId.toUpperCase().replace(/[^A-Z0-9]+/g, "_")}_MODELS`; - function emitModel(model: Model, indent: string): string { - let output = `${indent}"${model.id}": {\n`; - output += `${indent}\tid: "${model.id}",\n`; - output += `${indent}\tname: "${model.name}",\n`; - output += `${indent}\tapi: "${model.api}",\n`; - output += `${indent}\tprovider: "${model.provider}",\n`; - if (model.baseUrl !== undefined) { - output += `${indent}\tbaseUrl: "${model.baseUrl}",\n`; + function emitModel(model: Model, indent: string): string { + let output = `${indent}"${model.id}": {\n`; + output += `${indent}\tid: "${model.id}",\n`; + output += `${indent}\tname: "${model.name}",\n`; + output += `${indent}\tapi: "${model.api}",\n`; + output += `${indent}\tprovider: "${model.provider}",\n`; + if (model.baseUrl !== undefined) { + output += `${indent}\tbaseUrl: "${model.baseUrl}",\n`; + } + if (model.headers) { + output += `${indent}\theaders: ${JSON.stringify(model.headers)},\n`; + } + if (model.compat) { + output += `${indent}\tcompat: ${JSON.stringify(model.compat)},\n`; + } + output += `${indent}\treasoning: ${model.reasoning},\n`; + if (model.thinkingLevelMap) { + output += `${indent}\tthinkingLevelMap: ${JSON.stringify(model.thinkingLevelMap)},\n`; + } + output += `${indent}\tinput: [${model.input.map(i => `"${i}"`).join(", ")}],\n`; + output += `${indent}\tcost: {\n`; + output += `${indent}\t\tinput: ${model.cost.input},\n`; + output += `${indent}\t\toutput: ${model.cost.output},\n`; + output += `${indent}\t\tcacheRead: ${model.cost.cacheRead},\n`; + output += `${indent}\t\tcacheWrite: ${model.cost.cacheWrite},\n`; + if (model.cost.tiers) { + output += `${indent}\t\ttiers: ${JSON.stringify(model.cost.tiers)},\n`; + } + output += `${indent}\t},\n`; + output += `${indent}\tcontextWindow: ${model.contextWindow},\n`; + output += `${indent}\tmaxTokens: ${model.maxTokens},\n`; + output += `${indent}} satisfies Model<"${model.api}">,\n`; + return output; } - if (model.headers) { - output += `${indent}\theaders: ${JSON.stringify(model.headers)},\n`; - } - if (model.compat) { - output += `${indent}\tcompat: ${JSON.stringify(model.compat)},\n`; - } - output += `${indent}\treasoning: ${model.reasoning},\n`; - if (model.thinkingLevelMap) { - output += `${indent}\tthinkingLevelMap: ${JSON.stringify(model.thinkingLevelMap)},\n`; - } - output += `${indent}\tinput: [${model.input.map(i => `"${i}"`).join(", ")}],\n`; - output += `${indent}\tcost: {\n`; - output += `${indent}\t\tinput: ${model.cost.input},\n`; - output += `${indent}\t\toutput: ${model.cost.output},\n`; - output += `${indent}\t\tcacheRead: ${model.cost.cacheRead},\n`; - output += `${indent}\t\tcacheWrite: ${model.cost.cacheWrite},\n`; - if (model.cost.tiers) { - output += `${indent}\t\ttiers: ${JSON.stringify(model.cost.tiers)},\n`; - } - output += `${indent}\t},\n`; - output += `${indent}\tcontextWindow: ${model.contextWindow},\n`; - output += `${indent}\tmaxTokens: ${model.maxTokens},\n`; - output += `${indent}} satisfies Model<"${model.api}">,\n`; - return output; - } - const sortedProviderIds = Object.keys(providers).sort(); - const providersDir = join(packageRoot, "src/providers"); + const providersDir = join(packageRoot, "src/providers"); - // Remove stale per-provider catalogs - for (const entry of readdirSync(providersDir)) { - if (entry.endsWith(".models.ts")) { - rmSync(join(providersDir, entry)); + // Remove stale per-provider catalogs + for (const entry of readdirSync(providersDir)) { + if (entry.endsWith(".models.ts")) { + rmSync(join(providersDir, entry)); + } } - } - // Per-provider catalogs (sorted for deterministic output) - for (const providerId of sortedProviderIds) { - const models = providers[providerId]; + // Per-provider catalogs (sorted for deterministic output) + for (const providerId of sortedProviderIds) { + const models = providers[providerId]; + let output = generatedHeader; + output += `import type { Model } from "../types.ts";\n\n`; + output += `export const ${catalogConstName(providerId)} = {\n`; + const sortedModelIds = Object.keys(models).sort(); + for (const modelId of sortedModelIds) { + output += emitModel(models[modelId], "\t"); + } + output += `} as const;\n`; + writeFileSync(join(providersDir, `${providerId}.models.ts`), output); + } + console.log(`Generated ${sortedProviderIds.length} catalogs under src/providers/`); + + // Aggregator let output = generatedHeader; - output += `import type { Model } from "../types.ts";\n\n`; - output += `export const ${catalogConstName(providerId)} = {\n`; - const sortedModelIds = Object.keys(models).sort(); - for (const modelId of sortedModelIds) { - output += emitModel(models[modelId], "\t"); + for (const providerId of sortedProviderIds) { + output += `import { ${catalogConstName(providerId)} } from "./providers/${providerId}.models.ts";\n`; + } + output += `\nexport const MODELS = {\n`; + for (const providerId of sortedProviderIds) { + output += `\t${JSON.stringify(providerId)}: ${catalogConstName(providerId)},\n`; } output += `} as const;\n`; - writeFileSync(join(providersDir, `${providerId}.models.ts`), output); + writeFileSync(join(packageRoot, "src/models.generated.ts"), output); + console.log("Generated src/models.generated.ts"); } - console.log(`Generated ${sortedProviderIds.length} catalogs under src/providers/`); - // Aggregator - let output = generatedHeader; - for (const providerId of sortedProviderIds) { - output += `import { ${catalogConstName(providerId)} } from "./providers/${providerId}.models.ts";\n`; + if (generatorOptions.jsonOutputDir) { + const jsonProviders: Record>> = {}; + for (const providerId of sortedProviderIds) { + jsonProviders[providerId] = {}; + for (const modelId of Object.keys(providers[providerId]).sort()) { + jsonProviders[providerId][modelId] = providers[providerId][modelId]; + } + } + + const providerOutputDir = join(generatorOptions.jsonOutputDir, "providers"); + rmSync(generatorOptions.jsonOutputDir, { recursive: true, force: true }); + mkdirSync(providerOutputDir, { recursive: true }); + const writeJson = (path: string, value: unknown) => writeFileSync(path, `${JSON.stringify(value)}\n`); + writeJson(join(generatorOptions.jsonOutputDir, "models.json"), jsonProviders); + writeJson(join(generatorOptions.jsonOutputDir, "providers.json"), sortedProviderIds); + for (const providerId of sortedProviderIds) { + writeJson(join(providerOutputDir, `${providerId}.json`), jsonProviders[providerId]); + } + console.log(`Generated JSON model catalog under ${generatorOptions.jsonOutputDir}`); } - output += `\nexport const MODELS = {\n`; - for (const providerId of sortedProviderIds) { - output += `\t${JSON.stringify(providerId)}: ${catalogConstName(providerId)},\n`; - } - output += `} as const;\n`; - writeFileSync(join(packageRoot, "src/models.generated.ts"), output); - console.log("Generated src/models.generated.ts"); // Print statistics const totalModels = allModels.length; @@ -2330,4 +2397,7 @@ async function generateModels() { } // Run the generator -generateModels().catch(console.error); +generateModels().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/scripts/publish-model-catalog.mjs b/scripts/publish-model-catalog.mjs new file mode 100644 index 00000000..a8a30d43 --- /dev/null +++ b/scripts/publish-model-catalog.mjs @@ -0,0 +1,295 @@ +#!/usr/bin/env node + +import { createHash } from "node:crypto"; +import { + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { spawnSync } from "node:child_process"; +import { isDeepStrictEqual } from "node:util"; + +const CATALOG_SCHEMA_VERSION = 1; +const CATALOG_PREFIX = `models/v${CATALOG_SCHEMA_VERSION}`; +const CATALOG_INDEX_KEY = `${CATALOG_PREFIX}/index.json`; +// Bump this only when generated model metadata requires behavior unavailable in older pi clients. +const MINIMUM_PI_VERSION = "0.80.7"; +const JSON_CONTENT_TYPE = "application/json; charset=utf-8"; +const IMMUTABLE_CACHE_CONTROL = "public, max-age=31536000, immutable"; +const INDEX_CACHE_CONTROL = "no-store"; +const REQUIRED_PROVIDERS = ["anthropic", "openai", "openrouter"]; +const MINIMUM_MODEL_COUNT = 500; + +function parseArgs(args) { + const options = { + input: undefined, + bucket: undefined, + endpoint: undefined, + sourceCommit: undefined, + dryRun: false, + }; + + for (let index = 0; index < args.length; index++) { + const arg = args[index]; + if (arg === "--dry-run") { + options.dryRun = true; + continue; + } + if (arg === "--input" || arg === "--bucket" || arg === "--endpoint" || arg === "--source-commit") { + const value = args[++index]; + if (!value) throw new Error(`${arg} requires a value`); + options[ + { + "--input": "input", + "--bucket": "bucket", + "--endpoint": "endpoint", + "--source-commit": "sourceCommit", + }[arg] + ] = value; + continue; + } + throw new Error(`Unknown argument: ${arg}`); + } + + if (!options.input) throw new Error("--input is required"); + if (!options.dryRun && !options.bucket) throw new Error("--bucket is required when publishing"); + if (!options.dryRun && !options.endpoint) throw new Error("--endpoint is required when publishing"); + return options; +} + +function readJson(path) { + return JSON.parse(readFileSync(path, "utf8")); +} + +function isRecord(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function validateBundle(inputDir) { + const modelsPath = join(inputDir, "models.json"); + const providerIndexPath = join(inputDir, "providers.json"); + const providersDir = join(inputDir, "providers"); + const modelsBytes = readFileSync(modelsPath); + const models = JSON.parse(modelsBytes.toString("utf8")); + const providerIds = readJson(providerIndexPath); + + if (!isRecord(models)) throw new Error("models.json must contain an object"); + if (!Array.isArray(providerIds) || !providerIds.every((value) => typeof value === "string")) { + throw new Error("providers.json must contain an array of provider IDs"); + } + + const expectedProviderIds = Object.keys(models).sort(); + if (!isDeepStrictEqual(providerIds, expectedProviderIds)) { + throw new Error("providers.json does not match the sorted providers in models.json"); + } + for (const providerId of REQUIRED_PROVIDERS) { + if (!Object.hasOwn(models, providerId)) throw new Error(`Required provider is missing: ${providerId}`); + } + + let modelCount = 0; + for (const providerId of providerIds) { + const providerModels = models[providerId]; + if (!isRecord(providerModels)) throw new Error(`Provider catalog must be an object: ${providerId}`); + const providerFile = readJson(join(providersDir, `${providerId}.json`)); + if (!isDeepStrictEqual(providerFile, providerModels)) { + throw new Error(`Provider shard does not match models.json: ${providerId}`); + } + for (const [modelId, model] of Object.entries(providerModels)) { + if (!isRecord(model) || model.id !== modelId || model.provider !== providerId) { + throw new Error(`Invalid model entry: ${providerId}/${modelId}`); + } + modelCount++; + } + } + + const shardFiles = readdirSync(providersDir).filter((name) => name.endsWith(".json")).sort(); + const expectedShardFiles = providerIds.map((providerId) => `${providerId}.json`).sort(); + if (!isDeepStrictEqual(shardFiles, expectedShardFiles)) { + throw new Error("Provider shard files do not match providers.json"); + } + if (modelCount < MINIMUM_MODEL_COUNT) { + throw new Error(`Refusing to publish only ${modelCount} models; expected at least ${MINIMUM_MODEL_COUNT}`); + } + + const digest = createHash("sha256").update(modelsBytes).digest("hex"); + return { + modelsPath, + providerIndexPath, + providersDir, + providerIds, + providerCount: providerIds.length, + modelCount, + revision: `sha256-${digest}`, + }; +} + +function gitSourceCommit() { + const result = spawnSync("git", ["rev-parse", "HEAD"], { encoding: "utf8" }); + if (result.status !== 0) throw new Error(`Unable to determine source commit: ${result.stderr.trim()}`); + return result.stdout.trim(); +} + +function aws(args, { allowNotFound = false } = {}) { + const result = spawnSync("aws", args, { + encoding: "utf8", + env: { + ...process.env, + AWS_DEFAULT_REGION: process.env.AWS_DEFAULT_REGION || "auto", + AWS_EC2_METADATA_DISABLED: "true", + }, + }); + if (result.error) throw result.error; + if (result.status === 0) return true; + const message = `${result.stdout}\n${result.stderr}`.trim(); + if (allowNotFound && /(?:404|NoSuchKey|Not Found)/i.test(message)) return false; + throw new Error(`aws ${args.slice(0, 2).join(" ")} failed:\n${message}`); +} + +function downloadIndex(bucket, endpoint, outputPath) { + return aws( + [ + "s3", + "cp", + `s3://${bucket}/${CATALOG_INDEX_KEY}`, + outputPath, + "--endpoint-url", + endpoint, + "--only-show-errors", + ], + { allowNotFound: true }, + ); +} + +function uploadJson(bucket, endpoint, sourcePath, key, cacheControl) { + aws([ + "s3", + "cp", + sourcePath, + `s3://${bucket}/${key}`, + "--endpoint-url", + endpoint, + "--content-type", + JSON_CONTENT_TYPE, + "--cache-control", + cacheControl, + "--only-show-errors", + ]); +} + +function validateIndex(index) { + if (!isRecord(index) || index.schemaVersion !== CATALOG_SCHEMA_VERSION) { + throw new Error(`Existing ${CATALOG_INDEX_KEY} has an unsupported schema`); + } + if (!Array.isArray(index.catalogs)) throw new Error(`Existing ${CATALOG_INDEX_KEY} has no catalogs array`); + for (const catalog of index.catalogs) { + if ( + !isRecord(catalog) || + typeof catalog.minimumPiVersion !== "string" || + typeof catalog.revision !== "string" + ) { + throw new Error(`Existing ${CATALOG_INDEX_KEY} contains an invalid catalog entry`); + } + } + return index; +} + +function comparePiVersions(left, right) { + const leftParts = left.split(".").map(Number); + const rightParts = right.split(".").map(Number); + for (let index = 0; index < Math.max(leftParts.length, rightParts.length); index++) { + const difference = (leftParts[index] || 0) - (rightParts[index] || 0); + if (difference !== 0) return difference; + } + return left.localeCompare(right); +} + +function buildIndex(existingIndex, publication) { + const entry = { + minimumPiVersion: MINIMUM_PI_VERSION, + revision: publication.revision, + sourceCommit: publication.sourceCommit, + publishedAt: new Date().toISOString(), + providerCount: publication.providerCount, + modelCount: publication.modelCount, + }; + const catalogs = (existingIndex?.catalogs || []) + .filter((catalog) => catalog.minimumPiVersion !== MINIMUM_PI_VERSION) + .concat(entry) + .sort((left, right) => comparePiVersions(left.minimumPiVersion, right.minimumPiVersion)); + return { + schemaVersion: CATALOG_SCHEMA_VERSION, + defaultRevision: publication.revision, + catalogs, + }; +} + +async function main() { + const options = parseArgs(process.argv.slice(2)); + const inputDir = resolve(options.input); + const bundle = validateBundle(inputDir); + const publication = { + schemaVersion: CATALOG_SCHEMA_VERSION, + minimumPiVersion: MINIMUM_PI_VERSION, + revision: bundle.revision, + sourceCommit: options.sourceCommit || gitSourceCommit(), + providerCount: bundle.providerCount, + modelCount: bundle.modelCount, + }; + writeFileSync(join(inputDir, "publication.json"), `${JSON.stringify(publication, null, 2)}\n`); + + console.log(JSON.stringify(publication, null, 2)); + if (options.dryRun) { + console.log(`Validated model catalog at ${inputDir}; no objects uploaded.`); + return; + } + + const temporaryDir = mkdtempSync(join(tmpdir(), "pi-model-catalog-")); + try { + const currentIndexPath = join(temporaryDir, "index-current.json"); + const hasCurrentIndex = downloadIndex(options.bucket, options.endpoint, currentIndexPath); + const currentIndex = hasCurrentIndex ? validateIndex(readJson(currentIndexPath)) : undefined; + const currentEntry = currentIndex?.catalogs.find( + (catalog) => catalog.minimumPiVersion === MINIMUM_PI_VERSION, + ); + if (currentIndex?.defaultRevision === bundle.revision && currentEntry?.revision === bundle.revision) { + console.log(`Model catalog ${bundle.revision} is already current; no objects uploaded.`); + return; + } + + const revisionPrefix = `${CATALOG_PREFIX}/revisions/${bundle.revision}`; + uploadJson(options.bucket, options.endpoint, bundle.modelsPath, `${revisionPrefix}/models.json`, IMMUTABLE_CACHE_CONTROL); + uploadJson( + options.bucket, + options.endpoint, + bundle.providerIndexPath, + `${revisionPrefix}/providers.json`, + IMMUTABLE_CACHE_CONTROL, + ); + for (const providerId of bundle.providerIds) { + uploadJson( + options.bucket, + options.endpoint, + join(bundle.providersDir, `${providerId}.json`), + `${revisionPrefix}/providers/${providerId}.json`, + IMMUTABLE_CACHE_CONTROL, + ); + } + + const nextIndex = buildIndex(currentIndex, publication); + const nextIndexPath = join(temporaryDir, "index-next.json"); + writeFileSync(nextIndexPath, `${JSON.stringify(nextIndex, null, 2)}\n`); + uploadJson(options.bucket, options.endpoint, nextIndexPath, CATALOG_INDEX_KEY, INDEX_CACHE_CONTROL); + console.log(`Published ${bundle.revision} to s3://${options.bucket}/${revisionPrefix}`); + } finally { + rmSync(temporaryDir, { recursive: true, force: true }); + } +} + +main().catch((error) => { + console.error(error instanceof Error ? error.message : error); + process.exitCode = 1; +}); From 97f9978fa66685f78d2da19ae22e20c46d125f74 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Thu, 16 Jul 2026 12:46:49 +0200 Subject: [PATCH 3/8] feat(coding-agent): add model catalog refresh flag --- packages/ai/CHANGELOG.md | 1 + packages/ai/README.md | 2 +- packages/ai/src/models.ts | 12 ++- packages/ai/test/models-runtime.test.ts | 7 +- packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/README.md | 4 +- packages/coding-agent/docs/packages.md | 1 + packages/coding-agent/docs/usage.md | 1 + packages/coding-agent/src/cli/args.ts | 2 +- .../src/core/remote-catalog-provider.ts | 1 + .../coding-agent/src/package-manager-cli.ts | 73 +++++++++++++++++-- .../test/package-command-paths.test.ts | 37 ++++++++++ .../test/remote-catalog-provider.test.ts | 25 +++++-- 13 files changed, 148 insertions(+), 19 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 02d58490..1734415e 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -17,6 +17,7 @@ - Added neutral auth-flow information/link events and provider-owned Amazon Bedrock and Google Vertex AI credential selection flows. - Added `ModelsStore` with an in-memory default for restoring and persisting dynamic provider catalogs. - Added the dynamic Radius `pi-messages` gateway provider with OAuth and credential-specific catalog refresh. +- Added `Models.refresh({ force: true })` to let providers bypass freshness checks for explicit refreshes. ### Changed diff --git a/packages/ai/README.md b/packages/ai/README.md index fcb272a2..e1e03582 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -1047,7 +1047,7 @@ if (result.aborted) console.log('refresh cancelled'); for (const [provider, error] of result.errors) console.error(provider, error); ``` -Use `models.refresh({ allowNetwork: false })` to restore persisted catalogs without network access. Model reads stay synchronous and return the last restored or refreshed list. +Use `models.refresh({ allowNetwork: false })` to restore persisted catalogs without network access, or `models.refresh({ force: true })` to bypass provider freshness checks. Model reads stay synchronous and return the last restored or refreshed list. Custom models can carry `headers` (e.g. proxies behind bot detection) and `compat` flags. `Models.getAuth(model)` includes those model headers, and stream methods merge them before explicit request headers and `transformHeaders`. See [OpenAI Compatibility Settings](#openai-compatibility-settings). diff --git a/packages/ai/src/models.ts b/packages/ai/src/models.ts index 7938ef12..d5eed1a4 100644 --- a/packages/ai/src/models.ts +++ b/packages/ai/src/models.ts @@ -38,11 +38,15 @@ export interface RefreshModelsContext { store: ProviderModelsStore; /** False during offline/cache-only initialization. */ allowNetwork: boolean; + /** Bypass provider freshness checks and fetch immediately when network access is allowed. */ + force?: boolean; signal?: AbortSignal; } export interface ModelsRefreshOptions { allowNetwork?: boolean; + /** Bypass provider freshness checks and fetch immediately when network access is allowed. */ + force?: boolean; signal?: AbortSignal; } @@ -290,7 +294,13 @@ class ModelsImpl implements MutableModels { stored = await this.readCredential(provider.id); const credential = await this.resolveRefreshCredential(provider, stored, allowNetwork, options.signal); if (!credential) return; - await provider.refreshModels({ credential, store, allowNetwork, signal: options.signal }); + await provider.refreshModels({ + credential, + store, + allowNetwork, + force: options.force, + signal: options.signal, + }); } catch (error) { if (!options.signal?.aborted) { errors.set( diff --git a/packages/ai/test/models-runtime.test.ts b/packages/ai/test/models-runtime.test.ts index d0d8da18..eaa6a164 100644 --- a/packages/ai/test/models-runtime.test.ts +++ b/packages/ai/test/models-runtime.test.ts @@ -283,8 +283,9 @@ describe("Models runtime", () => { expect(offline.getModel("dynamic", "fetched")).toBeDefined(); }); - it("passes effective API-key credentials and skips unconfigured providers", async () => { + it("passes effective API-key credentials and refresh options while skipping unconfigured providers", async () => { let effectiveCredential: unknown; + let forceRefresh: boolean | undefined; let unconfiguredRefreshes = 0; const models = createModels(); models.setProvider( @@ -293,6 +294,7 @@ describe("Models runtime", () => { auth: { apiKey: envKeyAuth("ambient-key") }, refreshModels: async (context) => { effectiveCredential = context.credential; + forceRefresh = context.force; }, }), ); @@ -306,8 +308,9 @@ describe("Models runtime", () => { }), ); - await models.refresh(); + await models.refresh({ force: true }); expect(effectiveCredential).toEqual({ type: "api_key", key: "ambient-key", env: undefined }); + expect(forceRefresh).toBe(true); expect(unconfiguredRefreshes).toBe(0); }); diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 584d82e3..27579d2e 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -16,6 +16,7 @@ - Added provider-owned `/login` discovery directly from registered pi-ai providers, including ambient auth status and informational links. - Added file-backed dynamic catalogs in `models-store.json`, per-provider pi.dev catalog overlays, and Radius gateway support including offline migration from legacy credential-cached catalogs. - Added extension provider `refreshModels(context)` support for dynamic model discovery with optional provider-controlled persistence. +- Added `pi update --models` to force an immediate model catalog refresh without updating pi or extensions. ### Changed diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index 1bf18be1..c452fd36 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -96,7 +96,7 @@ Then just talk to pi. By default, pi gives the model four tools: `read`, `write` ## Providers & Models -For each built-in provider, pi maintains a list of tool-capable models, updated with every release. Authenticate via subscription (`/login`) or API key, then select any model from that provider via `/model` (or Ctrl+L). +For each built-in provider, pi maintains a list of tool-capable models. Configured provider catalogs refresh automatically; run `pi update --models` to force an immediate refresh. Authenticate via subscription (`/login`) or API key, then select any model from that provider via `/model` (or Ctrl+L). **Subscriptions:** - Anthropic Claude Pro/Max @@ -421,6 +421,7 @@ pi list pi update # update pi only pi update --all # update pi and packages pi update --extensions # update packages only +pi update --models # refresh model catalogs only pi update --self # update pi only pi update --self --force # reinstall pi even if current pi update npm:@foo/pi-tools # update one package @@ -519,6 +520,7 @@ pi uninstall [-l] # Alias for remove pi update [source|self|pi] # Update pi only, or one package source pi update --all # Update pi and packages pi update --extensions # Update packages only +pi update --models # Refresh model catalogs only pi update --self # Update pi only pi update --self --force # Reinstall pi even if current pi update --extension # Update one package diff --git a/packages/coding-agent/docs/packages.md b/packages/coding-agent/docs/packages.md index a5a0cae9..b2f493b4 100644 --- a/packages/coding-agent/docs/packages.md +++ b/packages/coding-agent/docs/packages.md @@ -31,6 +31,7 @@ pi list # show installed packages from settings pi update # update pi only pi update --all # update pi, update packages, and reconcile pinned git refs pi update --extensions # update packages and reconcile pinned git refs only +pi update --models # refresh model catalogs only pi update --self # update pi only pi update --self --force # reinstall pi even if current pi update npm:@foo/bar # update one package diff --git a/packages/coding-agent/docs/usage.md b/packages/coding-agent/docs/usage.md index 49b41927..7d0b85ec 100644 --- a/packages/coding-agent/docs/usage.md +++ b/packages/coding-agent/docs/usage.md @@ -151,6 +151,7 @@ pi uninstall [-l] # Alias for remove pi update [source|self|pi] # Update pi only, or one package source pi update --all # Update pi and packages; reconcile pinned git refs pi update --extensions # Update packages only; reconcile pinned git refs +pi update --models # Refresh model catalogs only pi update --self # Update pi only pi update --extension # Update one package pi list # List installed packages diff --git a/packages/coding-agent/src/cli/args.ts b/packages/coding-agent/src/cli/args.ts index d06a4752..0ca33969 100644 --- a/packages/coding-agent/src/cli/args.ts +++ b/packages/coding-agent/src/cli/args.ts @@ -229,7 +229,7 @@ ${chalk.bold("Commands:")} ${APP_NAME} install [-l] Install extension source and add to settings ${APP_NAME} remove [-l] Remove extension source from settings ${APP_NAME} uninstall [-l] Alias for remove - ${APP_NAME} update [source|self|pi] Update pi (use --all for pi and extensions) + ${APP_NAME} update [source|self|pi] Update pi, extensions, or model catalogs ${APP_NAME} list List installed extensions from settings ${APP_NAME} config [-l] Open TUI to enable/disable package resources (Tab switches scope) ${APP_NAME} --help Show help for install/remove/uninstall/update/list/config diff --git a/packages/coding-agent/src/core/remote-catalog-provider.ts b/packages/coding-agent/src/core/remote-catalog-provider.ts index 411f2122..6667916d 100644 --- a/packages/coding-agent/src/core/remote-catalog-provider.ts +++ b/packages/coding-agent/src/core/remote-catalog-provider.ts @@ -44,6 +44,7 @@ export function withRemoteCatalog(provider: Provider, catalogBaseUrl: string = D if (stored) dynamicModels = stored.models.filter((model) => model.provider === provider.id); if (!context.allowNetwork || context.signal?.aborted) return; if ( + !context.force && stored?.checkedAt !== undefined && Date.now() - stored.checkedAt < REMOTE_CATALOG_REFRESH_INTERVAL_MS ) { diff --git a/packages/coding-agent/src/package-manager-cli.ts b/packages/coding-agent/src/package-manager-cli.ts index 5c1a0a1c..2fe66563 100644 --- a/packages/coding-agent/src/package-manager-cli.ts +++ b/packages/coding-agent/src/package-manager-cli.ts @@ -1,3 +1,4 @@ +import { join } from "node:path"; import { Markdown, type MarkdownTheme } from "@earendil-works/pi-tui"; import chalk from "chalk"; import { selectConfig } from "./cli/config-selector.ts"; @@ -16,6 +17,7 @@ import { VERSION, } from "./config.ts"; import type { InlineExtension } from "./core/extensions/types.ts"; +import { ModelRuntime } from "./core/model-runtime.ts"; import { DefaultPackageManager } from "./core/package-manager.ts"; import { type AppMode, resolveProjectTrusted } from "./core/project-trust.ts"; import { DefaultResourceLoader } from "./core/resource-loader.ts"; @@ -30,7 +32,7 @@ import { export type PackageCommand = "install" | "remove" | "update" | "list"; -type UpdateTarget = { type: "all" } | { type: "self" } | { type: "extensions"; source?: string }; +type UpdateTarget = { type: "all" } | { type: "self" } | { type: "extensions"; source?: string } | { type: "models" }; const SELF_UPDATE_NOTE_MARKDOWN_THEME: MarkdownTheme = { heading: (text) => chalk.bold(chalk.yellow(text)), @@ -81,7 +83,7 @@ function getPackageCommandUsage(command: PackageCommand): string { case "remove": return `${APP_NAME} remove [-l] [--approve|--no-approve]`; case "update": - return `${APP_NAME} update [source|self|pi] [--self|--extensions|--all] [--extension ] [--approve|--no-approve] [--force]`; + return `${APP_NAME} update [source|self|pi] [--self|--extensions|--models|--all] [--extension ] [--approve|--no-approve] [--force]`; case "list": return `${APP_NAME} list [--approve|--no-approve]`; } @@ -149,11 +151,12 @@ Examples: console.log(`${chalk.bold("Usage:")} ${getPackageCommandUsage("update")} -Update pi and installed packages. +Update pi, installed packages, or model catalogs. Options: --self Update pi only (default when no target is given) --extensions Update installed packages only + --models Refresh model catalogs only --all Update pi and installed packages --extension Update one package only -a, --approve Trust project-local files for this command @@ -163,6 +166,7 @@ Options: Short forms: ${APP_NAME} update Update pi only ${APP_NAME} update --all Update pi and all extensions + ${APP_NAME} update --models Refresh model catalogs only ${APP_NAME} update Update one package ${APP_NAME} update pi Update pi only (self works as alias to pi) `); @@ -205,6 +209,7 @@ function parsePackageCommand(args: string[]): PackageCommandOptions | undefined let source: string | undefined; let selfFlag = false; let extensionsFlag = false; + let modelsFlag = false; let allFlag = false; let extensionFlagSource: string | undefined; @@ -242,6 +247,15 @@ function parsePackageCommand(args: string[]): PackageCommandOptions | undefined continue; } + if (arg === "--models") { + if (command === "update") { + modelsFlag = true; + } else { + invalidOption = invalidOption ?? arg; + } + continue; + } + if (arg === "--all") { if (command === "update") { allFlag = true; @@ -304,15 +318,24 @@ function parsePackageCommand(args: string[]): PackageCommandOptions | undefined let updateTarget: UpdateTarget | undefined; let showExtensionsSkippedNote = false; if (command === "update") { - if (allFlag && (selfFlag || extensionsFlag || extensionFlagSource)) { + if (allFlag && (selfFlag || extensionsFlag || modelsFlag || extensionFlagSource)) { conflictingOptions = - conflictingOptions ?? "--all cannot be combined with --self, --extensions, or --extension"; + conflictingOptions ?? "--all cannot be combined with --self, --extensions, --models, or --extension"; } if (allFlag && source) { conflictingOptions = conflictingOptions ?? "--all cannot be combined with a positional source"; } - if (extensionFlagSource) { + if (modelsFlag) { + if (selfFlag || extensionsFlag || allFlag || extensionFlagSource) { + conflictingOptions = + conflictingOptions ?? "--models cannot be combined with --self, --extensions, --all, or --extension"; + } + if (source) { + conflictingOptions = conflictingOptions ?? "--models cannot be combined with a positional source"; + } + updateTarget = { type: "models" }; + } else if (extensionFlagSource) { if (selfFlag || extensionsFlag || allFlag) { conflictingOptions = conflictingOptions ?? "--extension cannot be combined with --self, --extensions, or --all"; @@ -371,6 +394,33 @@ function updateTargetIncludesExtensions(target: UpdateTarget): boolean { return target.type === "all" || target.type === "extensions"; } +async function refreshModelCatalogs(agentDir: string): Promise { + const modelRuntime = await ModelRuntime.create({ + authPath: join(agentDir, "auth.json"), + modelsPath: join(agentDir, "models.json"), + allowModelNetwork: false, + }); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 15_000); + try { + const result = await modelRuntime.refresh({ + allowNetwork: true, + force: true, + signal: controller.signal, + }); + if (result.aborted) { + throw new Error("Model catalog refresh timed out."); + } + if (result.errors.size > 0) { + const details = Array.from(result.errors, ([provider, error]) => `${provider}: ${error.message}`).join("; "); + throw new Error(`Could not refresh model catalogs: ${details}`); + } + } finally { + clearTimeout(timeout); + } + console.log(chalk.green("Model catalogs refreshed")); +} + function printSelfUpdateUnavailable( npmCommand?: string[], updatePackageTarget: SelfUpdatePackageTarget = PACKAGE_NAME, @@ -673,6 +723,17 @@ export async function handlePackageCommand( return true; } + if (options.command === "update" && options.updateTarget?.type === "models") { + try { + await refreshModelCatalogs(getAgentDir()); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : "Unknown model catalog refresh error"; + console.error(chalk.red(`Error: ${message}`)); + process.exitCode = 1; + } + return true; + } + const cwd = process.cwd(); const agentDir = getAgentDir(); const writesProjectPackageConfig = (options.command === "install" || options.command === "remove") && options.local; diff --git a/packages/coding-agent/test/package-command-paths.test.ts b/packages/coding-agent/test/package-command-paths.test.ts index 8e202253..4158f3f0 100644 --- a/packages/coding-agent/test/package-command-paths.test.ts +++ b/packages/coding-agent/test/package-command-paths.test.ts @@ -3,6 +3,7 @@ import { tmpdir } from "node:os"; import { delimiter, join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { ENV_AGENT_DIR, PACKAGE_NAME, VERSION } from "../src/config.ts"; +import { ModelRuntime } from "../src/core/model-runtime.ts"; import type { ResolvedPaths } from "../src/core/package-manager.ts"; import { InMemorySettingsStorage, SettingsManager } from "../src/core/settings-manager.ts"; import { ProjectTrustStore } from "../src/core/trust-manager.ts"; @@ -371,6 +372,42 @@ describe("package commands", () => { } }); + it("refreshes only model catalogs with update --models", async () => { + const refresh = vi.fn(async () => ({ aborted: false, errors: new Map() })); + const create = vi.spyOn(ModelRuntime, "create").mockResolvedValue({ refresh } as unknown as ModelRuntime); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + await expect(runPackageCommandDirectly(["update", "--models"])).resolves.toBeUndefined(); + + expect(create).toHaveBeenCalledWith({ + authPath: join(agentDir, "auth.json"), + modelsPath: join(agentDir, "models.json"), + allowModelNetwork: false, + }); + expect(refresh).toHaveBeenCalledWith({ + allowNetwork: true, + force: true, + signal: expect.any(AbortSignal), + }); + expect(logSpy.mock.calls.map(([message]) => String(message)).join("\n")).toContain("Model catalogs refreshed"); + expect(errorSpy).not.toHaveBeenCalled(); + expect(process.exitCode).toBeUndefined(); + }); + + it("rejects update --models combined with another update target", async () => { + const create = vi.spyOn(ModelRuntime, "create"); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + await expect(runPackageCommandDirectly(["update", "--models", "--self"])).resolves.toBeUndefined(); + + expect(create).not.toHaveBeenCalled(); + expect(errorSpy.mock.calls.map(([message]) => String(message)).join("\n")).toContain( + "--models cannot be combined with --self", + ); + expect(process.exitCode).toBe(1); + }); + it("cycles project package overrides in config local mode", async () => { const storage = new InMemorySettingsStorage(); storage.withLock("global", () => JSON.stringify({ packages: ["npm:pi-tools"] })); diff --git a/packages/coding-agent/test/remote-catalog-provider.test.ts b/packages/coding-agent/test/remote-catalog-provider.test.ts index cf4d68fa..c8c5f869 100644 --- a/packages/coding-agent/test/remote-catalog-provider.test.ts +++ b/packages/coding-agent/test/remote-catalog-provider.test.ts @@ -21,12 +21,13 @@ function model(id: string): Model<"openai-completions"> { afterEach(() => vi.restoreAllMocks()); describe("remote catalog provider", () => { - it("parses keyed catalogs, sends version headers, and observes the refresh TTL", async () => { - const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue( - new Response(JSON.stringify({ dynamic: model("dynamic") }), { - status: 200, - headers: { "content-type": "application/json" }, - }), + it("parses keyed catalogs, sends version headers, observes the refresh TTL, and supports forced refreshes", async () => { + const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation( + async () => + new Response(JSON.stringify({ dynamic: model("dynamic") }), { + status: 200, + headers: { "content-type": "application/json" }, + }), ); const provider = withRemoteCatalog( createProvider({ @@ -62,10 +63,20 @@ describe("remote catalog provider", () => { }, allowNetwork: true, }); + await provider.refreshModels?.({ + credential: { type: "api_key" }, + store: { + read: () => store.read(provider.id), + write: (entry) => store.write(provider.id, entry), + delete: () => store.delete(provider.id), + }, + allowNetwork: true, + force: true, + }); expect(provider.getModels().map((entry) => entry.id)).toEqual(["static", "dynamic"]); expect((await store.read(provider.id))?.models.map((entry) => entry.id)).toEqual(["dynamic"]); - expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(fetchSpy).toHaveBeenCalledTimes(2); expect(fetchSpy.mock.calls[0]?.[1]?.headers).toMatchObject({ "User-Agent": expect.stringContaining(`pi/${VERSION}`), }); From eb793510ada540e95c1e2942e4fc21b32c33ed83 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Thu, 16 Jul 2026 13:58:56 +0200 Subject: [PATCH 4/8] docs: audit unreleased changelogs --- packages/ai/CHANGELOG.md | 2 ++ packages/coding-agent/CHANGELOG.md | 10 ++++++++++ packages/tui/CHANGELOG.md | 4 ++++ 3 files changed, 16 insertions(+) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 1734415e..6a0df1f9 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -18,6 +18,7 @@ - Added `ModelsStore` with an in-memory default for restoring and persisting dynamic provider catalogs. - Added the dynamic Radius `pi-messages` gateway provider with OAuth and credential-specific catalog refresh. - Added `Models.refresh({ force: true })` to let providers bypass freshness checks for explicit refreshes. +- Added xAI device-code OAuth login and routed Grok 4.5 through OpenAI Responses, with low, medium, and high thinking support ([#6651](https://github.com/earendil-works/pi-mono/pull/6651) by [@Jaaneek](https://github.com/Jaaneek)). ### Changed @@ -27,6 +28,7 @@ - Fixed Cloudflare Workers AI and AI Gateway streams to materialize account and gateway endpoint placeholders after auth resolution, including compat streaming with custom model objects. - Fixed lazy provider streams to preserve their final assistant message when forwarding an inner stream. +- Fixed OpenAI Codex session IDs longer than 64 characters to meet the API limit ([#6630](https://github.com/earendil-works/pi-mono/issues/6630)). ## [0.80.7] - 2026-07-14 diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 27579d2e..0907604c 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,12 @@ ## [Unreleased] +### New Features + +- **Unified model runtime and provider authentication** — `ModelRuntime` centralizes model configuration, provider-owned `/login`, and dynamic provider catalogs. See [Providers](docs/providers.md). +- **Live model catalog refresh** — `/model` refreshes configured providers in the background, and `pi update --models` forces an immediate refresh. See [Install and Manage](docs/packages.md#install-and-manage). +- **xAI device-code OAuth and Grok 4.5 Responses support** — Sign in to xAI with a device code and use Grok 4.5 with low, medium, or high thinking. See [xAI](docs/providers.md#xai-grokx-subscription). + ### Breaking Changes - Replaced the SDK's `CreateAgentSessionOptions.authStorage` and `modelRegistry` options with the async `modelRuntime` option. `AuthStorage` and its storage backends are no longer exported; use `ModelRuntime` (or a custom pi-ai `CredentialStore`), or `readStoredCredential()` for one-off reads of auth.json. @@ -17,6 +23,7 @@ - Added file-backed dynamic catalogs in `models-store.json`, per-provider pi.dev catalog overlays, and Radius gateway support including offline migration from legacy credential-cached catalogs. - Added extension provider `refreshModels(context)` support for dynamic model discovery with optional provider-controlled persistence. - Added `pi update --models` to force an immediate model catalog refresh without updating pi or extensions. +- Added inherited xAI device-code OAuth login and Grok 4.5 OpenAI Responses support, with low, medium, and high thinking levels ([#6651](https://github.com/earendil-works/pi-mono/pull/6651) by [@Jaaneek](https://github.com/Jaaneek)). ### Changed @@ -28,6 +35,9 @@ - Fixed configured-provider catalog refresh to parse pi.dev's model-ID keyed responses, throttle checks to once per four hours, send the versioned pi user agent, treat unimplemented routes as unavailable overlays, and show concise refresh status in `/model`. - Fixed adjacent assistant thinking blocks to render as one thinking section. +- Fixed inherited OpenAI Codex session IDs longer than 64 characters to meet the API limit ([#6630](https://github.com/earendil-works/pi-mono/issues/6630)). +- Fixed inherited terminal output to normalize tab characters consistently ([#6697](https://github.com/earendil-works/pi-mono/pull/6697) by [@xz-dev](https://github.com/xz-dev)). +- Fixed the Windows terminal title after checking npm packages ([#6629](https://github.com/earendil-works/pi-mono/issues/6629)). ## [0.80.7] - 2026-07-14 diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index f2ef581f..2c3b81ce 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed terminal output to normalize tab characters consistently ([#6697](https://github.com/earendil-works/pi-mono/pull/6697) by [@xz-dev](https://github.com/xz-dev)). + ## [0.80.7] - 2026-07-14 ### Fixed From 6442536b1e2586f6da29a4301a0795aab672dc63 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Thu, 16 Jul 2026 15:36:16 +0200 Subject: [PATCH 5/8] fix(coding-agent): bundle OAuth flows in Bun binaries --- packages/ai/package.json | 4 + packages/ai/src/auth/oauth/load.ts | 45 ++- packages/ai/src/bun-oauth.ts | 17 ++ .../ai/src/providers/kimi-coding.models.ts | 18 ++ .../ai/src/providers/openrouter.models.ts | 256 +++++++++--------- .../src/providers/vercel-ai-gateway.models.ts | 59 +++- packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/src/bun/cli.ts | 3 + 8 files changed, 263 insertions(+), 140 deletions(-) create mode 100644 packages/ai/src/bun-oauth.ts diff --git a/packages/ai/package.json b/packages/ai/package.json index e2ecd28c..b0e08508 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -34,6 +34,10 @@ "./bedrock-provider": { "types": "./dist/bedrock-provider.d.ts", "import": "./dist/bedrock-provider.js" + }, + "./bun-oauth": { + "types": "./dist/bun-oauth.d.ts", + "import": "./dist/bun-oauth.js" } }, "bin": { diff --git a/packages/ai/src/auth/oauth/load.ts b/packages/ai/src/auth/oauth/load.ts index 1e5fb862..ca1c87d2 100644 --- a/packages/ai/src/auth/oauth/load.ts +++ b/packages/ai/src/auth/oauth/load.ts @@ -11,21 +11,46 @@ const importOAuthModule = (specifier: string): Promise => { return import(runtimeSpecifier); }; -export const loadAnthropicOAuth = async (): Promise => - ((await importOAuthModule("./anthropic.ts")) as { anthropicOAuth: OAuthAuth }).anthropicOAuth; +type OAuthFlowLoaders = { + anthropic: () => OAuthAuth | Promise; + openaiCodex: () => OAuthAuth | Promise; + githubCopilot: () => OAuthAuth | Promise; + xai: () => OAuthAuth | Promise; + radius: (options: { name: string; gateway: string }) => OAuthAuth | Promise; +}; -export const loadOpenAICodexOAuth = async (): Promise => - ((await importOAuthModule("./openai-codex.ts")) as { openaiCodexOAuth: OAuthAuth }).openaiCodexOAuth; +let bundledLoaders: OAuthFlowLoaders | undefined; -export const loadGitHubCopilotOAuth = async (): Promise => - ((await importOAuthModule("./github-copilot.ts")) as { githubCopilotOAuth: OAuthAuth }).githubCopilotOAuth; +/** Registers statically bundled OAuth flows for standalone Bun binaries. */ +export function registerBundledOAuthFlowLoaders(loaders: OAuthFlowLoaders): void { + bundledLoaders = loaders; +} -export const loadXaiOAuth = async (): Promise => - ((await importOAuthModule("./xai.ts")) as { xaiOAuth: OAuthAuth }).xaiOAuth; +export const loadAnthropicOAuth = async (): Promise => { + if (bundledLoaders) return bundledLoaders.anthropic(); + return ((await importOAuthModule("./anthropic.ts")) as { anthropicOAuth: OAuthAuth }).anthropicOAuth; +}; -export const loadRadiusOAuth = async (options: { name: string; gateway: string }): Promise => - ( +export const loadOpenAICodexOAuth = async (): Promise => { + if (bundledLoaders) return bundledLoaders.openaiCodex(); + return ((await importOAuthModule("./openai-codex.ts")) as { openaiCodexOAuth: OAuthAuth }).openaiCodexOAuth; +}; + +export const loadGitHubCopilotOAuth = async (): Promise => { + if (bundledLoaders) return bundledLoaders.githubCopilot(); + return ((await importOAuthModule("./github-copilot.ts")) as { githubCopilotOAuth: OAuthAuth }).githubCopilotOAuth; +}; + +export const loadXaiOAuth = async (): Promise => { + if (bundledLoaders) return bundledLoaders.xai(); + return ((await importOAuthModule("./xai.ts")) as { xaiOAuth: OAuthAuth }).xaiOAuth; +}; + +export const loadRadiusOAuth = async (options: { name: string; gateway: string }): Promise => { + if (bundledLoaders) return bundledLoaders.radius(options); + return ( (await importOAuthModule("./radius.ts")) as { createRadiusOAuth: (input: { name: string; gateway: string }) => OAuthAuth; } ).createRadiusOAuth(options); +}; diff --git a/packages/ai/src/bun-oauth.ts b/packages/ai/src/bun-oauth.ts new file mode 100644 index 00000000..31cdec98 --- /dev/null +++ b/packages/ai/src/bun-oauth.ts @@ -0,0 +1,17 @@ +import { anthropicOAuth } from "./auth/oauth/anthropic.ts"; +import { githubCopilotOAuth } from "./auth/oauth/github-copilot.ts"; +import { registerBundledOAuthFlowLoaders } from "./auth/oauth/load.ts"; +import { openaiCodexOAuth } from "./auth/oauth/openai-codex.ts"; +import { createRadiusOAuth } from "./auth/oauth/radius.ts"; +import { xaiOAuth } from "./auth/oauth/xai.ts"; + +/** Register OAuth flows statically embedded in the standalone Bun binary. */ +export function registerBunOAuthFlows(): void { + registerBundledOAuthFlowLoaders({ + anthropic: () => anthropicOAuth, + openaiCodex: () => openaiCodexOAuth, + githubCopilot: () => githubCopilotOAuth, + xai: () => xaiOAuth, + radius: createRadiusOAuth, + }); +} diff --git a/packages/ai/src/providers/kimi-coding.models.ts b/packages/ai/src/providers/kimi-coding.models.ts index a3b1f266..32b5e643 100644 --- a/packages/ai/src/providers/kimi-coding.models.ts +++ b/packages/ai/src/providers/kimi-coding.models.ts @@ -40,6 +40,24 @@ export const KIMI_CODING_MODELS = { contextWindow: 262144, maxTokens: 32768, } satisfies Model<"anthropic-messages">, + "kimi-for-coding-highspeed": { + id: "kimi-for-coding-highspeed", + name: "Kimi For Coding HighSpeed", + api: "anthropic-messages", + provider: "kimi-coding", + baseUrl: "https://api.kimi.com/coding", + headers: {"User-Agent":"KimiCLI/1.5"}, + reasoning: true, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 32768, + } satisfies Model<"anthropic-messages">, "kimi-k2-thinking": { id: "kimi-k2-thinking", name: "Kimi K2 Thinking", diff --git a/packages/ai/src/providers/openrouter.models.ts b/packages/ai/src/providers/openrouter.models.ts index e18a1af2..e504c4e4 100644 --- a/packages/ai/src/providers/openrouter.models.ts +++ b/packages/ai/src/providers/openrouter.models.ts @@ -385,7 +385,7 @@ export const OPENROUTER_MODELS = { cacheRead: 0.3, cacheWrite: 3.75, }, - contextWindow: 1000000, + contextWindow: 200000, maxTokens: 64000, } satisfies Model<"openai-completions">, "anthropic/claude-sonnet-4.5": { @@ -652,13 +652,13 @@ export const OPENROUTER_MODELS = { reasoning: false, input: ["text"], cost: { - input: 0.24, - output: 0.9, + input: 0.27, + output: 1.12, cacheRead: 0.135, cacheWrite: 0, }, contextWindow: 163840, - maxTokens: 16384, + maxTokens: 65536, } satisfies Model<"openai-completions">, "deepseek/deepseek-chat-v3.1": { id: "deepseek/deepseek-chat-v3.1", @@ -725,11 +725,11 @@ export const OPENROUTER_MODELS = { input: ["text"], cost: { input: 0.27, - output: 0.95, - cacheRead: 0.13, + output: 1, + cacheRead: 0.135, cacheWrite: 0, }, - contextWindow: 163840, + contextWindow: 131072, maxTokens: 32768, } satisfies Model<"openai-completions">, "deepseek/deepseek-v3.2": { @@ -742,13 +742,13 @@ export const OPENROUTER_MODELS = { reasoning: true, input: ["text"], cost: { - input: 0.2145, - output: 0.32175, - cacheRead: 0.02145, + input: 0.269, + output: 0.4, + cacheRead: 0.1345, cacheWrite: 0, }, - contextWindow: 128000, - maxTokens: 64000, + contextWindow: 163840, + maxTokens: 65536, } satisfies Model<"openai-completions">, "deepseek/deepseek-v3.2-exp": { id: "deepseek/deepseek-v3.2-exp", @@ -779,13 +779,13 @@ export const OPENROUTER_MODELS = { thinkingLevelMap: {"minimal":null,"low":null,"medium":null,"high":"high","max":null,"xhigh":"xhigh"}, input: ["text"], cost: { - input: 0.09, - output: 0.18, - cacheRead: 0.018, + input: 0.098, + output: 0.196, + cacheRead: 0.02, cacheWrite: 0, }, - contextWindow: 1048576, - maxTokens: 65536, + contextWindow: 1048575, + maxTokens: 4096, } satisfies Model<"openai-completions">, "deepseek/deepseek-v4-pro": { id: "deepseek/deepseek-v4-pro", @@ -1051,12 +1051,12 @@ export const OPENROUTER_MODELS = { input: ["text", "image"], cost: { input: 0.08, - output: 0.16, - cacheRead: 0, + output: 0.45, + cacheRead: 0.04, cacheWrite: 0, }, contextWindow: 131072, - maxTokens: 16384, + maxTokens: 131072, } satisfies Model<"openai-completions">, "google/gemma-4-26b-a4b-it": { id: "google/gemma-4-26b-a4b-it", @@ -1068,13 +1068,13 @@ export const OPENROUTER_MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 0.06, - output: 0.33, + input: 0.1, + output: 0.3, cacheRead: 0, cacheWrite: 0, }, - contextWindow: 262144, - maxTokens: 4096, + contextWindow: 256000, + maxTokens: 256000, } satisfies Model<"openai-completions">, "google/gemma-4-26b-a4b-it:free": { id: "google/gemma-4-26b-a4b-it:free", @@ -1104,13 +1104,13 @@ export const OPENROUTER_MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 0.06, - output: 0.35, - cacheRead: 0, + input: 0.22, + output: 0.55, + cacheRead: 0.12, cacheWrite: 0, }, contextWindow: 262144, - maxTokens: 8192, + maxTokens: 262144, } satisfies Model<"openai-completions">, "google/gemma-4-31b-it:free": { id: "google/gemma-4-31b-it:free", @@ -1303,13 +1303,13 @@ export const OPENROUTER_MODELS = { reasoning: false, input: ["text"], cost: { - input: 0.02, - output: 0.03, - cacheRead: 0, + input: 0.05, + output: 0.08, + cacheRead: 0.025, cacheWrite: 0, }, contextWindow: 131072, - maxTokens: 16384, + maxTokens: 131072, } satisfies Model<"openai-completions">, "meta-llama/llama-3.3-70b-instruct": { id: "meta-llama/llama-3.3-70b-instruct", @@ -1321,13 +1321,13 @@ export const OPENROUTER_MODELS = { reasoning: false, input: ["text"], cost: { - input: 0.1, - output: 0.32, + input: 0.13, + output: 0.4, cacheRead: 0, cacheWrite: 0, }, contextWindow: 131072, - maxTokens: 16384, + maxTokens: 128000, } satisfies Model<"openai-completions">, "meta-llama/llama-3.3-70b-instruct:free": { id: "meta-llama/llama-3.3-70b-instruct:free", @@ -1393,7 +1393,7 @@ export const OPENROUTER_MODELS = { reasoning: true, input: ["text"], cost: { - input: 0.4, + input: 0.55, output: 2.2, cacheRead: 0, cacheWrite: 0, @@ -1465,13 +1465,13 @@ export const OPENROUTER_MODELS = { reasoning: true, input: ["text"], cost: { - input: 0.24, - output: 0.96, - cacheRead: 0, + input: 0.3, + output: 1.2, + cacheRead: 0.06, cacheWrite: 0, }, - contextWindow: 196608, - maxTokens: 196608, + contextWindow: 204800, + maxTokens: 131072, } satisfies Model<"openai-completions">, "minimax/minimax-m3": { id: "minimax/minimax-m3", @@ -1488,8 +1488,8 @@ export const OPENROUTER_MODELS = { cacheRead: 0.06, cacheWrite: 0, }, - contextWindow: 1000000, - maxTokens: 131072, + contextWindow: 524288, + maxTokens: 512000, } satisfies Model<"openai-completions">, "mistralai/codestral-2508": { id: "mistralai/codestral-2508", @@ -1700,12 +1700,12 @@ export const OPENROUTER_MODELS = { input: ["text"], cost: { input: 0.02, - output: 0.03, + output: 0.04, cacheRead: 0, cacheWrite: 0, }, contextWindow: 131072, - maxTokens: 4096, + maxTokens: 16384, } satisfies Model<"openai-completions">, "mistralai/mistral-saba": { id: "mistralai/mistral-saba", @@ -1753,13 +1753,13 @@ export const OPENROUTER_MODELS = { reasoning: false, input: ["text", "image"], cost: { - input: 0.075, - output: 0.2, - cacheRead: 0, + input: 0.1, + output: 0.3, + cacheRead: 0.01, cacheWrite: 0, }, - contextWindow: 128000, - maxTokens: 16384, + contextWindow: 131072, + maxTokens: 4096, } satisfies Model<"openai-completions">, "mistralai/mixtral-8x22b-instruct": { id: "mistralai/mixtral-8x22b-instruct", @@ -1845,11 +1845,11 @@ export const OPENROUTER_MODELS = { cost: { input: 0.6, output: 2.5, - cacheRead: 0.15, + cacheRead: 0, cacheWrite: 0, }, contextWindow: 262144, - maxTokens: 100352, + maxTokens: 262144, } satisfies Model<"openai-completions">, "moonshotai/kimi-k2.5": { id: "moonshotai/kimi-k2.5", @@ -1879,13 +1879,13 @@ export const OPENROUTER_MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 0.66, - output: 3.41, - cacheRead: 0.15, + input: 0.95, + output: 4, + cacheRead: 0.16, cacheWrite: 0, }, contextWindow: 262144, - maxTokens: 262144, + maxTokens: 4096, } satisfies Model<"openai-completions">, "moonshotai/kimi-k2.7-code": { id: "moonshotai/kimi-k2.7-code", @@ -2023,12 +2023,12 @@ export const OPENROUTER_MODELS = { reasoning: true, input: ["text"], cost: { - input: 0.08, - output: 0.45, - cacheRead: 0, + input: 0.21, + output: 0.455, + cacheRead: 0.06, cacheWrite: 0, }, - contextWindow: 262144, + contextWindow: 1000000, maxTokens: 4096, } satisfies Model<"openai-completions">, "nvidia/nemotron-3-super-120b-a12b:free": { @@ -2059,13 +2059,13 @@ export const OPENROUTER_MODELS = { reasoning: true, input: ["text"], cost: { - input: 0.5, - output: 2.2, - cacheRead: 0.1, + input: 0.6, + output: 3.6, + cacheRead: 0.2, cacheWrite: 0, }, - contextWindow: 262144, - maxTokens: 16384, + contextWindow: 512288, + maxTokens: 4096, } satisfies Model<"openai-completions">, "nvidia/nemotron-3-ultra-550b-a55b:free": { id: "nvidia/nemotron-3-ultra-550b-a55b:free", @@ -2245,7 +2245,7 @@ export const OPENROUTER_MODELS = { cacheWrite: 0, }, contextWindow: 1047576, - maxTokens: 4096, + maxTokens: 32768, } satisfies Model<"openai-completions">, "openai/gpt-4.1-mini": { id: "openai/gpt-4.1-mini", @@ -2295,7 +2295,7 @@ export const OPENROUTER_MODELS = { cost: { input: 2.5, output: 10, - cacheRead: 0, + cacheRead: 1.25, cacheWrite: 0, }, contextWindow: 128000, @@ -2511,11 +2511,11 @@ export const OPENROUTER_MODELS = { cost: { input: 1.25, output: 10, - cacheRead: 0.13, + cacheRead: 0.125, cacheWrite: 0, }, contextWindow: 128000, - maxTokens: 32000, + maxTokens: 16384, } satisfies Model<"openai-completions">, "openai/gpt-5.1-codex": { id: "openai/gpt-5.1-codex", @@ -2529,7 +2529,7 @@ export const OPENROUTER_MODELS = { cost: { input: 1.25, output: 10, - cacheRead: 0.13, + cacheRead: 0.125, cacheWrite: 0, }, contextWindow: 400000, @@ -2977,8 +2977,8 @@ export const OPENROUTER_MODELS = { reasoning: true, input: ["text"], cost: { - input: 0.03, - output: 0.15, + input: 0.037, + output: 0.17, cacheRead: 0, cacheWrite: 0, }, @@ -2995,13 +2995,13 @@ export const OPENROUTER_MODELS = { reasoning: true, input: ["text"], cost: { - input: 0.029, - output: 0.14, - cacheRead: 0, + input: 0.03, + output: 0.13, + cacheRead: 0.03, cacheWrite: 0, }, contextWindow: 131072, - maxTokens: 4096, + maxTokens: 131072, } satisfies Model<"openai-completions">, "openai/gpt-oss-20b:free": { id: "openai/gpt-oss-20b:free", @@ -3517,13 +3517,13 @@ export const OPENROUTER_MODELS = { reasoning: false, input: ["text"], cost: { - input: 0.04815, - output: 0.19305, + input: 0.1, + output: 0.3, cacheRead: 0, cacheWrite: 0, }, - contextWindow: 128000, - maxTokens: 32000, + contextWindow: 262144, + maxTokens: 4096, } satisfies Model<"openai-completions">, "qwen/qwen3-30b-a3b-thinking-2507": { id: "qwen/qwen3-30b-a3b-thinking-2507", @@ -3589,9 +3589,9 @@ export const OPENROUTER_MODELS = { reasoning: false, input: ["text"], cost: { - input: 0.22, - output: 1.8, - cacheRead: 0, + input: 0.3, + output: 1, + cacheRead: 0.1, cacheWrite: 0, }, contextWindow: 262144, @@ -3733,13 +3733,13 @@ export const OPENROUTER_MODELS = { reasoning: false, input: ["text"], cost: { - input: 0.09, + input: 0.1, output: 1.1, - cacheRead: 0, + cacheRead: 0.07, cacheWrite: 0, }, contextWindow: 262144, - maxTokens: 16384, + maxTokens: 262144, } satisfies Model<"openai-completions">, "qwen/qwen3-next-80b-a3b-instruct:free": { id: "qwen/qwen3-next-80b-a3b-instruct:free", @@ -3787,13 +3787,13 @@ export const OPENROUTER_MODELS = { reasoning: false, input: ["text", "image"], cost: { - input: 0.2, - output: 0.88, - cacheRead: 0.11, + input: 0.21, + output: 1.9, + cacheRead: 0.1, cacheWrite: 0, }, - contextWindow: 262144, - maxTokens: 16384, + contextWindow: 131072, + maxTokens: 32768, } satisfies Model<"openai-completions">, "qwen/qwen3-vl-235b-a22b-thinking": { id: "qwen/qwen3-vl-235b-a22b-thinking", @@ -3919,7 +3919,7 @@ export const OPENROUTER_MODELS = { cacheWrite: 0, }, contextWindow: 262144, - maxTokens: 262144, + maxTokens: 65536, } satisfies Model<"openai-completions">, "qwen/qwen3.5-27b": { id: "qwen/qwen3.5-27b", @@ -3951,11 +3951,11 @@ export const OPENROUTER_MODELS = { cost: { input: 0.14, output: 1, - cacheRead: 0.05, + cacheRead: 0, cacheWrite: 0, }, contextWindow: 262144, - maxTokens: 81920, + maxTokens: 262144, } satisfies Model<"openai-completions">, "qwen/qwen3.5-397b-a17b": { id: "qwen/qwen3.5-397b-a17b", @@ -3967,13 +3967,13 @@ export const OPENROUTER_MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 0.385, - output: 2.45, - cacheRead: 0.111, + input: 0.45, + output: 3, + cacheRead: 0.225, cacheWrite: 0, }, - contextWindow: 131072, - maxTokens: 4096, + contextWindow: 262144, + maxTokens: 65536, } satisfies Model<"openai-completions">, "qwen/qwen3.5-9b": { id: "qwen/qwen3.5-9b", @@ -4057,13 +4057,13 @@ export const OPENROUTER_MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 0.289, - output: 2.4, + input: 0.45, + output: 2.7, cacheRead: 0, cacheWrite: 0, }, - contextWindow: 131072, - maxTokens: 131072, + contextWindow: 262144, + maxTokens: 65536, } satisfies Model<"openai-completions">, "qwen/qwen3.6-35b-a3b": { id: "qwen/qwen3.6-35b-a3b", @@ -4147,10 +4147,10 @@ export const OPENROUTER_MODELS = { reasoning: true, input: ["text"], cost: { - input: 1.25, - output: 3.75, - cacheRead: 0.25, - cacheWrite: 1.5625, + input: 1.475, + output: 4.425, + cacheRead: 0.295, + cacheWrite: 1.84375, }, contextWindow: 1000000, maxTokens: 65536, @@ -4291,13 +4291,13 @@ export const OPENROUTER_MODELS = { reasoning: true, input: ["text"], cost: { - input: 0.14, - output: 0.58, - cacheRead: 0.035, + input: 0.2, + output: 0.8, + cacheRead: 0.05, cacheWrite: 0, }, contextWindow: 262144, - maxTokens: 4096, + maxTokens: 131072, } satisfies Model<"openai-completions">, "tencent/hy3-preview": { id: "tencent/hy3-preview", @@ -4453,13 +4453,13 @@ export const OPENROUTER_MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 0.105, + input: 0.14, output: 0.28, - cacheRead: 0.028, + cacheRead: 0.0028, cacheWrite: 0, }, - contextWindow: 262144, - maxTokens: 4096, + contextWindow: 1048576, + maxTokens: 131072, } satisfies Model<"openai-completions">, "xiaomi/mimo-v2.5-pro": { id: "xiaomi/mimo-v2.5-pro", @@ -4543,13 +4543,13 @@ export const OPENROUTER_MODELS = { reasoning: true, input: ["text"], cost: { - input: 0.43, - output: 1.75, - cacheRead: 0.08, + input: 0.5, + output: 2, + cacheRead: 0.1, cacheWrite: 0, }, - contextWindow: 198000, - maxTokens: 16384, + contextWindow: 202752, + maxTokens: 131072, } satisfies Model<"openai-completions">, "z-ai/glm-4.6v": { id: "z-ai/glm-4.6v", @@ -4620,8 +4620,8 @@ export const OPENROUTER_MODELS = { cacheRead: 0.119, cacheWrite: 0, }, - contextWindow: 198000, - maxTokens: 128000, + contextWindow: 202752, + maxTokens: 202752, } satisfies Model<"openai-completions">, "z-ai/glm-5-turbo": { id: "z-ai/glm-5-turbo", @@ -4638,7 +4638,7 @@ export const OPENROUTER_MODELS = { cacheRead: 0.24, cacheWrite: 0, }, - contextWindow: 262144, + contextWindow: 202752, maxTokens: 131072, } satisfies Model<"openai-completions">, "z-ai/glm-5.1": { @@ -4670,13 +4670,13 @@ export const OPENROUTER_MODELS = { thinkingLevelMap: {"xhigh":"xhigh"}, input: ["text"], cost: { - input: 0.924, - output: 2.904, - cacheRead: 0.1716, + input: 0.9464, + output: 2.9744, + cacheRead: 0.17576, cacheWrite: 0, }, - contextWindow: 1024000, - maxTokens: 128000, + contextWindow: 1048576, + maxTokens: 131072, } satisfies Model<"openai-completions">, "z-ai/glm-5v-turbo": { id: "z-ai/glm-5v-turbo", diff --git a/packages/ai/src/providers/vercel-ai-gateway.models.ts b/packages/ai/src/providers/vercel-ai-gateway.models.ts index 00dc85eb..c9d8aa7f 100644 --- a/packages/ai/src/providers/vercel-ai-gateway.models.ts +++ b/packages/ai/src/providers/vercel-ai-gateway.models.ts @@ -622,6 +622,25 @@ export const VERCEL_AI_GATEWAY_MODELS = { contextWindow: 1000000, maxTokens: 128000, } satisfies Model<"anthropic-messages">, + "anthropic/claude-opus-4.7-fast": { + id: "anthropic/claude-opus-4.7-fast", + name: "Claude Opus 4.7 (Fast)", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + compat: {"forceAdaptiveThinking":true,"supportsTemperature":false}, + reasoning: true, + thinkingLevelMap: {"xhigh":"xhigh","max":"max"}, + input: ["text", "image"], + cost: { + input: 30, + output: 150, + cacheRead: 3, + cacheWrite: 37.5, + }, + contextWindow: 1000000, + maxTokens: 128000, + } satisfies Model<"anthropic-messages">, "anthropic/claude-opus-4.8": { id: "anthropic/claude-opus-4.8", name: "Claude Opus 4.8", @@ -641,6 +660,25 @@ export const VERCEL_AI_GATEWAY_MODELS = { contextWindow: 1000000, maxTokens: 128000, } satisfies Model<"anthropic-messages">, + "anthropic/claude-opus-4.8-fast": { + id: "anthropic/claude-opus-4.8-fast", + name: "Claude Opus 4.8 (Fast)", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + compat: {"forceAdaptiveThinking":true,"supportsTemperature":false}, + reasoning: true, + thinkingLevelMap: {"xhigh":"xhigh","max":"max"}, + input: ["text", "image"], + cost: { + input: 10, + output: 50, + cacheRead: 1, + cacheWrite: 12.5, + }, + contextWindow: 1000000, + maxTokens: 128000, + } satisfies Model<"anthropic-messages">, "anthropic/claude-sonnet-4": { id: "anthropic/claude-sonnet-4", name: "Claude Sonnet 4", @@ -841,8 +879,8 @@ export const VERCEL_AI_GATEWAY_MODELS = { reasoning: true, input: ["text"], cost: { - input: 0.21, - output: 0.79, + input: 0.25, + output: 0.95, cacheRead: 0.13, cacheWrite: 0, }, @@ -2717,6 +2755,23 @@ export const VERCEL_AI_GATEWAY_MODELS = { contextWindow: 256000, maxTokens: 256000, } satisfies Model<"anthropic-messages">, + "thinkingmachines/inkling": { + id: "thinkingmachines/inkling", + name: "Inkling", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text", "image"], + cost: { + input: 1, + output: 4.05, + cacheRead: 0.17, + cacheWrite: 0, + }, + contextWindow: 256000, + maxTokens: 256000, + } satisfies Model<"anthropic-messages">, "xai/grok-4.1-fast-non-reasoning": { id: "xai/grok-4.1-fast-non-reasoning", name: "Grok 4.1 Fast Non-Reasoning", diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 0907604c..d7e0806a 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -38,6 +38,7 @@ - Fixed inherited OpenAI Codex session IDs longer than 64 characters to meet the API limit ([#6630](https://github.com/earendil-works/pi-mono/issues/6630)). - Fixed inherited terminal output to normalize tab characters consistently ([#6697](https://github.com/earendil-works/pi-mono/pull/6697) by [@xz-dev](https://github.com/xz-dev)). - Fixed the Windows terminal title after checking npm packages ([#6629](https://github.com/earendil-works/pi-mono/issues/6629)). +- Fixed Bun standalone binaries to bundle OAuth adapters for interactive logins. ## [0.80.7] - 2026-07-14 diff --git a/packages/coding-agent/src/bun/cli.ts b/packages/coding-agent/src/bun/cli.ts index fa68a0ba..d255024c 100644 --- a/packages/coding-agent/src/bun/cli.ts +++ b/packages/coding-agent/src/bun/cli.ts @@ -1,9 +1,12 @@ #!/usr/bin/env node +import { registerBunOAuthFlows } from "@earendil-works/pi-ai/bun-oauth"; import { APP_NAME } from "../config.ts"; process.title = APP_NAME; process.emitWarning = (() => {}) as typeof process.emitWarning; +registerBunOAuthFlows(); + import { restoreSandboxEnv } from "./restore-sandbox-env.ts"; restoreSandboxEnv(); From fae7176cb9f7c4725a40d9d481d8d70b80f18086 Mon Sep 17 00:00:00 2001 From: David Brailovsky Date: Thu, 16 Jul 2026 16:35:16 +0200 Subject: [PATCH 6/8] Release v0.80.8 --- package-lock.json | 30 +++++++++---------- packages/agent/CHANGELOG.md | 2 +- packages/agent/package.json | 4 +-- packages/ai/CHANGELOG.md | 2 +- packages/ai/package.json | 2 +- .../ai/src/providers/openrouter.models.ts | 20 ++++++------- packages/coding-agent/CHANGELOG.md | 2 +- .../package-lock.json | 4 +-- .../custom-provider-anthropic/package.json | 2 +- .../custom-provider-gitlab-duo/package.json | 2 +- .../extensions/gondolin/package-lock.json | 4 +-- .../examples/extensions/gondolin/package.json | 2 +- .../extensions/sandbox/package-lock.json | 4 +-- .../examples/extensions/sandbox/package.json | 2 +- .../extensions/with-deps/package-lock.json | 4 +-- .../extensions/with-deps/package.json | 2 +- .../install-lock/package-lock.json | 30 +++++++++---------- .../coding-agent/install-lock/package.json | 4 +-- packages/coding-agent/npm-shrinkwrap.json | 24 +++++++-------- packages/coding-agent/package.json | 8 ++--- packages/orchestrator/CHANGELOG.md | 2 +- packages/orchestrator/package.json | 4 +-- packages/tui/CHANGELOG.md | 2 +- packages/tui/package.json | 2 +- 24 files changed, 82 insertions(+), 82 deletions(-) diff --git a/package-lock.json b/package-lock.json index c57a391e..2fc9d9f3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5115,10 +5115,10 @@ }, "packages/agent": { "name": "@earendil-works/pi-agent-core", - "version": "0.80.7", + "version": "0.80.8", "license": "MIT", "dependencies": { - "@earendil-works/pi-ai": "^0.80.7", + "@earendil-works/pi-ai": "^0.80.8", "ignore": "7.0.5", "typebox": "1.1.38", "yaml": "2.9.0" @@ -5467,7 +5467,7 @@ }, "packages/ai": { "name": "@earendil-works/pi-ai", - "version": "0.80.7", + "version": "0.80.8", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "0.91.1", @@ -5773,12 +5773,12 @@ }, "packages/coding-agent": { "name": "@earendil-works/pi-coding-agent", - "version": "0.80.7", + "version": "0.80.8", "license": "MIT", "dependencies": { - "@earendil-works/pi-agent-core": "^0.80.7", - "@earendil-works/pi-ai": "^0.80.7", - "@earendil-works/pi-tui": "^0.80.7", + "@earendil-works/pi-agent-core": "^0.80.8", + "@earendil-works/pi-ai": "^0.80.8", + "@earendil-works/pi-tui": "^0.80.8", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", @@ -5819,32 +5819,32 @@ }, "packages/coding-agent/examples/extensions/custom-provider-anthropic": { "name": "pi-extension-custom-provider-anthropic", - "version": "0.80.7", + "version": "0.80.8", "dependencies": { "@anthropic-ai/sdk": "0.52.0" } }, "packages/coding-agent/examples/extensions/custom-provider-gitlab-duo": { "name": "pi-extension-custom-provider-gitlab-duo", - "version": "0.80.7" + "version": "0.80.8" }, "packages/coding-agent/examples/extensions/gondolin": { "name": "pi-extension-gondolin", - "version": "0.80.7", + "version": "0.80.8", "dependencies": { "@earendil-works/gondolin": "0.12.0" } }, "packages/coding-agent/examples/extensions/sandbox": { "name": "pi-extension-sandbox", - "version": "1.10.7", + "version": "1.10.8", "dependencies": { "@anthropic-ai/sandbox-runtime": "0.0.26" } }, "packages/coding-agent/examples/extensions/with-deps": { "name": "pi-extension-with-deps", - "version": "0.80.7", + "version": "0.80.8", "dependencies": { "ms": "2.1.3" }, @@ -6140,10 +6140,10 @@ }, "packages/orchestrator": { "name": "@earendil-works/pi-orchestrator", - "version": "0.80.7", + "version": "0.80.8", "license": "MIT", "dependencies": { - "@earendil-works/pi-coding-agent": "^0.80.7" + "@earendil-works/pi-coding-agent": "^0.80.8" }, "devDependencies": { "shx": "0.4.0" @@ -6154,7 +6154,7 @@ }, "packages/tui": { "name": "@earendil-works/pi-tui", - "version": "0.80.7", + "version": "0.80.8", "license": "MIT", "dependencies": { "get-east-asian-width": "1.6.0", diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index e09fcda0..886316cc 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [0.80.8] - 2026-07-16 ## [0.80.7] - 2026-07-14 diff --git a/packages/agent/package.json b/packages/agent/package.json index 1279d714..1548b288 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-agent-core", - "version": "0.80.7", + "version": "0.80.8", "description": "General-purpose agent with transport abstraction, state management, and attachment support", "type": "module", "main": "./dist/index.js", @@ -29,7 +29,7 @@ "prepublishOnly": "npm run clean && npm run build" }, "dependencies": { - "@earendil-works/pi-ai": "^0.80.7", + "@earendil-works/pi-ai": "^0.80.8", "ignore": "7.0.5", "typebox": "1.1.38", "yaml": "2.9.0" diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 6a0df1f9..6f8c0cdc 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [0.80.8] - 2026-07-16 ### Breaking Changes diff --git a/packages/ai/package.json b/packages/ai/package.json index b0e08508..f57f6eaa 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-ai", - "version": "0.80.7", + "version": "0.80.8", "description": "Unified LLM API with automatic model discovery and provider configuration", "type": "module", "main": "./dist/index.js", diff --git a/packages/ai/src/providers/openrouter.models.ts b/packages/ai/src/providers/openrouter.models.ts index e504c4e4..8d46e8ce 100644 --- a/packages/ai/src/providers/openrouter.models.ts +++ b/packages/ai/src/providers/openrouter.models.ts @@ -1897,9 +1897,9 @@ export const OPENROUTER_MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 0.719, - output: 3.49, - cacheRead: 0.149, + input: 0.75, + output: 3.5, + cacheRead: 0.16, cacheWrite: 0, }, contextWindow: 262144, @@ -4597,13 +4597,13 @@ export const OPENROUTER_MODELS = { reasoning: true, input: ["text"], cost: { - input: 0.06, + input: 0.0605, output: 0.4, - cacheRead: 0.01, + cacheRead: 0, cacheWrite: 0, }, - contextWindow: 202752, - maxTokens: 16384, + contextWindow: 131072, + maxTokens: 131072, } satisfies Model<"openai-completions">, "z-ai/glm-5": { id: "z-ai/glm-5", @@ -4670,9 +4670,9 @@ export const OPENROUTER_MODELS = { thinkingLevelMap: {"xhigh":"xhigh"}, input: ["text"], cost: { - input: 0.9464, - output: 2.9744, - cacheRead: 0.17576, + input: 0.9408, + output: 2.9568, + cacheRead: 0.17472, cacheWrite: 0, }, contextWindow: 1048576, diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index d7e0806a..f08bf733 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [0.80.8] - 2026-07-16 ### New Features diff --git a/packages/coding-agent/examples/extensions/custom-provider-anthropic/package-lock.json b/packages/coding-agent/examples/extensions/custom-provider-anthropic/package-lock.json index efc92273..0cd13eb8 100644 --- a/packages/coding-agent/examples/extensions/custom-provider-anthropic/package-lock.json +++ b/packages/coding-agent/examples/extensions/custom-provider-anthropic/package-lock.json @@ -1,12 +1,12 @@ { "name": "pi-extension-custom-provider", - "version": "0.80.7", + "version": "0.80.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pi-extension-custom-provider", - "version": "0.80.7", + "version": "0.80.8", "dependencies": { "@anthropic-ai/sdk": "^0.52.0" } diff --git a/packages/coding-agent/examples/extensions/custom-provider-anthropic/package.json b/packages/coding-agent/examples/extensions/custom-provider-anthropic/package.json index 14ad1405..2c403d90 100644 --- a/packages/coding-agent/examples/extensions/custom-provider-anthropic/package.json +++ b/packages/coding-agent/examples/extensions/custom-provider-anthropic/package.json @@ -1,7 +1,7 @@ { "name": "pi-extension-custom-provider-anthropic", "private": true, - "version": "0.80.7", + "version": "0.80.8", "type": "module", "scripts": { "clean": "echo 'nothing to clean'", diff --git a/packages/coding-agent/examples/extensions/custom-provider-gitlab-duo/package.json b/packages/coding-agent/examples/extensions/custom-provider-gitlab-duo/package.json index 42101f1e..fda39fde 100644 --- a/packages/coding-agent/examples/extensions/custom-provider-gitlab-duo/package.json +++ b/packages/coding-agent/examples/extensions/custom-provider-gitlab-duo/package.json @@ -1,7 +1,7 @@ { "name": "pi-extension-custom-provider-gitlab-duo", "private": true, - "version": "0.80.7", + "version": "0.80.8", "type": "module", "scripts": { "clean": "echo 'nothing to clean'", diff --git a/packages/coding-agent/examples/extensions/gondolin/package-lock.json b/packages/coding-agent/examples/extensions/gondolin/package-lock.json index dac0d6e9..6e620ac0 100644 --- a/packages/coding-agent/examples/extensions/gondolin/package-lock.json +++ b/packages/coding-agent/examples/extensions/gondolin/package-lock.json @@ -1,12 +1,12 @@ { "name": "pi-extension-gondolin", - "version": "0.80.7", + "version": "0.80.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pi-extension-gondolin", - "version": "0.80.7", + "version": "0.80.8", "dependencies": { "@earendil-works/gondolin": "0.12.0" } diff --git a/packages/coding-agent/examples/extensions/gondolin/package.json b/packages/coding-agent/examples/extensions/gondolin/package.json index c54e563e..7c552d4a 100644 --- a/packages/coding-agent/examples/extensions/gondolin/package.json +++ b/packages/coding-agent/examples/extensions/gondolin/package.json @@ -1,7 +1,7 @@ { "name": "pi-extension-gondolin", "private": true, - "version": "0.80.7", + "version": "0.80.8", "type": "module", "scripts": { "clean": "echo 'nothing to clean'", diff --git a/packages/coding-agent/examples/extensions/sandbox/package-lock.json b/packages/coding-agent/examples/extensions/sandbox/package-lock.json index f35689c6..56bda835 100644 --- a/packages/coding-agent/examples/extensions/sandbox/package-lock.json +++ b/packages/coding-agent/examples/extensions/sandbox/package-lock.json @@ -1,12 +1,12 @@ { "name": "pi-extension-sandbox", - "version": "1.10.7", + "version": "1.10.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pi-extension-sandbox", - "version": "1.10.7", + "version": "1.10.8", "dependencies": { "@anthropic-ai/sandbox-runtime": "^0.0.26" } diff --git a/packages/coding-agent/examples/extensions/sandbox/package.json b/packages/coding-agent/examples/extensions/sandbox/package.json index 4ffb2caf..3f0a63ec 100644 --- a/packages/coding-agent/examples/extensions/sandbox/package.json +++ b/packages/coding-agent/examples/extensions/sandbox/package.json @@ -1,7 +1,7 @@ { "name": "pi-extension-sandbox", "private": true, - "version": "1.10.7", + "version": "1.10.8", "type": "module", "scripts": { "clean": "echo 'nothing to clean'", diff --git a/packages/coding-agent/examples/extensions/with-deps/package-lock.json b/packages/coding-agent/examples/extensions/with-deps/package-lock.json index 3599384e..c145c285 100644 --- a/packages/coding-agent/examples/extensions/with-deps/package-lock.json +++ b/packages/coding-agent/examples/extensions/with-deps/package-lock.json @@ -1,12 +1,12 @@ { "name": "pi-extension-with-deps", - "version": "0.80.7", + "version": "0.80.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pi-extension-with-deps", - "version": "0.80.7", + "version": "0.80.8", "dependencies": { "ms": "^2.1.3" }, diff --git a/packages/coding-agent/examples/extensions/with-deps/package.json b/packages/coding-agent/examples/extensions/with-deps/package.json index fb40ffe1..998370cd 100644 --- a/packages/coding-agent/examples/extensions/with-deps/package.json +++ b/packages/coding-agent/examples/extensions/with-deps/package.json @@ -1,7 +1,7 @@ { "name": "pi-extension-with-deps", "private": true, - "version": "0.80.7", + "version": "0.80.8", "type": "module", "scripts": { "clean": "echo 'nothing to clean'", diff --git a/packages/coding-agent/install-lock/package-lock.json b/packages/coding-agent/install-lock/package-lock.json index 7cc30d87..a9e6723d 100644 --- a/packages/coding-agent/install-lock/package-lock.json +++ b/packages/coding-agent/install-lock/package-lock.json @@ -1,14 +1,14 @@ { "name": "@earendil-works/pi-coding-agent-install", - "version": "0.80.7", + "version": "0.80.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@earendil-works/pi-coding-agent-install", - "version": "0.80.7", + "version": "0.80.8", "dependencies": { - "@earendil-works/pi-coding-agent": "0.80.7" + "@earendil-works/pi-coding-agent": "0.80.8" }, "engines": { "node": ">=22.19.0" @@ -450,11 +450,11 @@ } }, "node_modules/@earendil-works/pi-agent-core": { - "version": "0.80.7", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.80.7.tgz", + "version": "0.80.8", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.80.8.tgz", "license": "MIT", "dependencies": { - "@earendil-works/pi-ai": "^0.80.7", + "@earendil-works/pi-ai": "^0.80.8", "ignore": "7.0.5", "typebox": "1.1.38", "yaml": "2.9.0" @@ -464,8 +464,8 @@ } }, "node_modules/@earendil-works/pi-ai": { - "version": "0.80.7", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.80.7.tgz", + "version": "0.80.8", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.80.8.tgz", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "0.91.1", @@ -488,13 +488,13 @@ } }, "node_modules/@earendil-works/pi-coding-agent": { - "version": "0.80.7", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.80.7.tgz", + "version": "0.80.8", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.80.8.tgz", "license": "MIT", "dependencies": { - "@earendil-works/pi-agent-core": "^0.80.7", - "@earendil-works/pi-ai": "^0.80.7", - "@earendil-works/pi-tui": "^0.80.7", + "@earendil-works/pi-agent-core": "^0.80.8", + "@earendil-works/pi-ai": "^0.80.8", + "@earendil-works/pi-tui": "^0.80.8", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", @@ -522,8 +522,8 @@ } }, "node_modules/@earendil-works/pi-tui": { - "version": "0.80.7", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.80.7.tgz", + "version": "0.80.8", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.80.8.tgz", "license": "MIT", "dependencies": { "get-east-asian-width": "1.6.0", diff --git a/packages/coding-agent/install-lock/package.json b/packages/coding-agent/install-lock/package.json index 9d749d79..5401bc89 100644 --- a/packages/coding-agent/install-lock/package.json +++ b/packages/coding-agent/install-lock/package.json @@ -1,10 +1,10 @@ { "name": "@earendil-works/pi-coding-agent-install", - "version": "0.80.7", + "version": "0.80.8", "private": true, "description": "Lockfile root used by the Pi installer and updater.", "dependencies": { - "@earendil-works/pi-coding-agent": "0.80.7" + "@earendil-works/pi-coding-agent": "0.80.8" }, "overrides": { "rimraf": "6.1.2", diff --git a/packages/coding-agent/npm-shrinkwrap.json b/packages/coding-agent/npm-shrinkwrap.json index bcbab041..7ce0abb4 100644 --- a/packages/coding-agent/npm-shrinkwrap.json +++ b/packages/coding-agent/npm-shrinkwrap.json @@ -1,17 +1,17 @@ { "name": "@earendil-works/pi-coding-agent", - "version": "0.80.7", + "version": "0.80.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@earendil-works/pi-coding-agent", - "version": "0.80.7", + "version": "0.80.8", "license": "MIT", "dependencies": { - "@earendil-works/pi-agent-core": "^0.80.7", - "@earendil-works/pi-ai": "^0.80.7", - "@earendil-works/pi-tui": "^0.80.7", + "@earendil-works/pi-agent-core": "^0.80.8", + "@earendil-works/pi-ai": "^0.80.8", + "@earendil-works/pi-tui": "^0.80.8", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", @@ -474,11 +474,11 @@ } }, "node_modules/@earendil-works/pi-agent-core": { - "version": "0.80.7", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.80.7.tgz", + "version": "0.80.8", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.80.8.tgz", "license": "MIT", "dependencies": { - "@earendil-works/pi-ai": "^0.80.7", + "@earendil-works/pi-ai": "^0.80.8", "ignore": "7.0.5", "typebox": "1.1.38", "yaml": "2.9.0" @@ -488,8 +488,8 @@ } }, "node_modules/@earendil-works/pi-ai": { - "version": "0.80.7", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.80.7.tgz", + "version": "0.80.8", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.80.8.tgz", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "0.91.1", @@ -512,8 +512,8 @@ } }, "node_modules/@earendil-works/pi-tui": { - "version": "0.80.7", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.80.7.tgz", + "version": "0.80.8", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.80.8.tgz", "license": "MIT", "dependencies": { "get-east-asian-width": "1.6.0", diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index aed830a9..1ca00c12 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-coding-agent", - "version": "0.80.7", + "version": "0.80.8", "description": "Coding agent CLI with read, bash, edit, write tools and session management", "type": "module", "piConfig": { @@ -39,9 +39,9 @@ "prepublishOnly": "npm run clean && npm run build && npm run shrinkwrap" }, "dependencies": { - "@earendil-works/pi-agent-core": "^0.80.7", - "@earendil-works/pi-ai": "^0.80.7", - "@earendil-works/pi-tui": "^0.80.7", + "@earendil-works/pi-agent-core": "^0.80.8", + "@earendil-works/pi-ai": "^0.80.8", + "@earendil-works/pi-tui": "^0.80.8", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", diff --git a/packages/orchestrator/CHANGELOG.md b/packages/orchestrator/CHANGELOG.md index 36dbea1f..c050c291 100644 --- a/packages/orchestrator/CHANGELOG.md +++ b/packages/orchestrator/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [0.80.8] - 2026-07-16 ## [0.80.7] - 2026-07-14 diff --git a/packages/orchestrator/package.json b/packages/orchestrator/package.json index 646bcee1..110dc3e7 100644 --- a/packages/orchestrator/package.json +++ b/packages/orchestrator/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-orchestrator", - "version": "0.80.7", + "version": "0.80.8", "description": "experimental orchestrator package for pi", "type": "module", "main": "./dist/index.js", @@ -37,7 +37,7 @@ "node": ">=22.19.0" }, "dependencies": { - "@earendil-works/pi-coding-agent": "^0.80.7" + "@earendil-works/pi-coding-agent": "^0.80.8" }, "devDependencies": { "shx": "0.4.0" diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 2c3b81ce..ff999bd8 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [0.80.8] - 2026-07-16 ### Fixed diff --git a/packages/tui/package.json b/packages/tui/package.json index 87935c04..897644e6 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-tui", - "version": "0.80.7", + "version": "0.80.8", "description": "Terminal User Interface library with differential rendering for efficient text-based applications", "type": "module", "main": "dist/index.js", From e022eec37dee52790564f3af93819c34f3f78af1 Mon Sep 17 00:00:00 2001 From: David Brailovsky Date: Thu, 16 Jul 2026 16:35:19 +0200 Subject: [PATCH 7/8] Add [Unreleased] section for next cycle --- packages/agent/CHANGELOG.md | 2 ++ packages/ai/CHANGELOG.md | 2 ++ packages/coding-agent/CHANGELOG.md | 2 ++ packages/orchestrator/CHANGELOG.md | 2 ++ packages/tui/CHANGELOG.md | 2 ++ 5 files changed, 10 insertions(+) diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index 886316cc..4e80dcd8 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## [Unreleased] + ## [0.80.8] - 2026-07-16 ## [0.80.7] - 2026-07-14 diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 6f8c0cdc..5beb7f66 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## [Unreleased] + ## [0.80.8] - 2026-07-16 ### Breaking Changes diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index f08bf733..0e2983a4 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## [Unreleased] + ## [0.80.8] - 2026-07-16 ### New Features diff --git a/packages/orchestrator/CHANGELOG.md b/packages/orchestrator/CHANGELOG.md index c050c291..c9c9d454 100644 --- a/packages/orchestrator/CHANGELOG.md +++ b/packages/orchestrator/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## [Unreleased] + ## [0.80.8] - 2026-07-16 ## [0.80.7] - 2026-07-14 diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index ff999bd8..f4223b6e 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## [Unreleased] + ## [0.80.8] - 2026-07-16 ### Fixed From 6a884b4a7560f97169a50449ab0de5ef5782e59c Mon Sep 17 00:00:00 2001 From: David Brailovsky Date: Thu, 16 Jul 2026 15:29:32 +0000 Subject: [PATCH 8/8] add kimi-k3 --- packages/ai/scripts/generate-models.ts | 18 ++++++++++++-- .../ai/src/providers/kimi-coding.models.ts | 18 ++++++++++++++ .../ai/src/providers/moonshotai-cn.models.ts | 19 +++++++++++++++ .../ai/src/providers/moonshotai.models.ts | 19 +++++++++++++++ .../ai/src/providers/openrouter.models.ts | 24 ++++++++++++++++--- 5 files changed, 93 insertions(+), 5 deletions(-) diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index d740182b..7452cc26 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/ai/scripts/generate-models.ts @@ -225,6 +225,16 @@ const DEEPSEEK_V4_THINKING_LEVEL_MAP = { max: "max", } as const; +const KIMI_K3_THINKING_LEVEL_MAP = { + off: null, + minimal: null, + low: null, + medium: null, + high: null, + xhigh: null, + max: "max", +} as const; + const ANT_LING_RING_THINKING_LEVEL_MAP = { off: null, minimal: null, @@ -1663,13 +1673,15 @@ async function loadModelsDevData(): Promise[]> { for (const [modelId, m] of Object.entries(moonshotModels[key])) { if (m.tool_call !== true) continue; + const isKimiK3 = modelId === "kimi-k3"; models.push({ id: modelId, name: m.name || modelId, api: "openai-completions", provider, baseUrl, - reasoning: m.reasoning === true, + reasoning: isKimiK3 || m.reasoning === true, + ...(isKimiK3 ? { thinkingLevelMap: KIMI_K3_THINKING_LEVEL_MAP } : {}), input: m.modalities?.input?.includes("image") ? ["text", "image"] : ["text"], cost: { input: m.cost?.input || 0, @@ -1679,7 +1691,9 @@ async function loadModelsDevData(): Promise[]> { }, contextWindow: m.limit?.context || 4096, maxTokens: m.limit?.output || 4096, - compat: moonshotCompat, + compat: isKimiK3 + ? { ...moonshotCompat, requiresReasoningContentOnAssistantMessages: true } + : moonshotCompat, }); } } diff --git a/packages/ai/src/providers/kimi-coding.models.ts b/packages/ai/src/providers/kimi-coding.models.ts index 32b5e643..6e324784 100644 --- a/packages/ai/src/providers/kimi-coding.models.ts +++ b/packages/ai/src/providers/kimi-coding.models.ts @@ -22,6 +22,24 @@ export const KIMI_CODING_MODELS = { contextWindow: 262144, maxTokens: 32768, } satisfies Model<"anthropic-messages">, + "k3": { + id: "k3", + name: "Kimi K3", + api: "anthropic-messages", + provider: "kimi-coding", + baseUrl: "https://api.kimi.com/coding", + headers: {"User-Agent":"KimiCLI/1.5"}, + reasoning: true, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 131072, + } satisfies Model<"anthropic-messages">, "kimi-for-coding": { id: "kimi-for-coding", name: "Kimi For Coding", diff --git a/packages/ai/src/providers/moonshotai-cn.models.ts b/packages/ai/src/providers/moonshotai-cn.models.ts index 899f9b11..b5e20355 100644 --- a/packages/ai/src/providers/moonshotai-cn.models.ts +++ b/packages/ai/src/providers/moonshotai-cn.models.ts @@ -168,4 +168,23 @@ export const MOONSHOTAI_CN_MODELS = { contextWindow: 262144, maxTokens: 262144, } satisfies Model<"openai-completions">, + "kimi-k3": { + id: "kimi-k3", + name: "Kimi K3", + api: "openai-completions", + provider: "moonshotai-cn", + baseUrl: "https://api.moonshot.cn/v1", + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"thinkingFormat":"deepseek","requiresReasoningContentOnAssistantMessages":true}, + reasoning: true, + thinkingLevelMap: {"off":null,"minimal":null,"low":null,"medium":null,"high":null,"xhigh":null,"max":"max"}, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 131072, + } satisfies Model<"openai-completions">, } as const; diff --git a/packages/ai/src/providers/moonshotai.models.ts b/packages/ai/src/providers/moonshotai.models.ts index 2ec685e6..9931870d 100644 --- a/packages/ai/src/providers/moonshotai.models.ts +++ b/packages/ai/src/providers/moonshotai.models.ts @@ -168,4 +168,23 @@ export const MOONSHOTAI_MODELS = { contextWindow: 262144, maxTokens: 262144, } satisfies Model<"openai-completions">, + "kimi-k3": { + id: "kimi-k3", + name: "Kimi K3", + api: "openai-completions", + provider: "moonshotai", + baseUrl: "https://api.moonshot.ai/v1", + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"thinkingFormat":"deepseek","requiresReasoningContentOnAssistantMessages":true}, + reasoning: true, + thinkingLevelMap: {"off":null,"minimal":null,"low":null,"medium":null,"high":null,"xhigh":null,"max":"max"}, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 131072, + } satisfies Model<"openai-completions">, } as const; diff --git a/packages/ai/src/providers/openrouter.models.ts b/packages/ai/src/providers/openrouter.models.ts index 8d46e8ce..b000c871 100644 --- a/packages/ai/src/providers/openrouter.models.ts +++ b/packages/ai/src/providers/openrouter.models.ts @@ -1383,6 +1383,24 @@ export const OPENROUTER_MODELS = { contextWindow: 327680, maxTokens: 16384, } satisfies Model<"openai-completions">, + "meta/muse-spark-1.1": { + id: "meta/muse-spark-1.1", + name: "Meta: Muse Spark 1.1", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + compat: {"supportsDeveloperRole":false,"thinkingFormat":"openrouter"}, + reasoning: true, + input: ["text", "image"], + cost: { + input: 1.25, + output: 4.25, + cacheRead: 0.15, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 4096, + } satisfies Model<"openai-completions">, "minimax/minimax-m1": { id: "minimax/minimax-m1", name: "MiniMax: MiniMax M1", @@ -4670,9 +4688,9 @@ export const OPENROUTER_MODELS = { thinkingLevelMap: {"xhigh":"xhigh"}, input: ["text"], cost: { - input: 0.9408, - output: 2.9568, - cacheRead: 0.17472, + input: 0.9366, + output: 2.9436, + cacheRead: 0.17394, cacheWrite: 0, }, contextWindow: 1048576,