diff --git a/bun.lock b/bun.lock index 44a5522ab..7b6ff8a40 100644 --- a/bun.lock +++ b/bun.lock @@ -53,6 +53,7 @@ "chalk": "^5.6.2", "openai": "^6.22.0", "partial-json": "^0.1.7", + "zod": "4.3.6", "zod-to-json-schema": "^3.25.1", }, "devDependencies": { diff --git a/docs/models.md b/docs/models.md index ab2409873..f6622ed05 100644 --- a/docs/models.md +++ b/docs/models.md @@ -195,7 +195,7 @@ If `authHeader: true` and provider `apiKey` is set, models get: Keyless providers: - Providers marked `auth: none` are treated as available without credentials. -- `getApiKey*` returns `""` for them. +- `getApiKey*` returns `kNoAuth` for them. ## Model availability vs all models @@ -295,6 +295,7 @@ providers: ``` The built-in model generator also assigns this automatically for `*-spark` models when a same-provider base model exists. + ## Compatibility and routing fields `models.yml` supports this `compat` subset: diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index f38b36e2b..a35f61d81 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -1,6 +1,9 @@ # Changelog ## [Unreleased] +### Changed + +- Renamed `getModel` to `getBundledModel` for clarity on bundled model initialization ## [12.8.0] - 2026-02-16 diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts index 12418cd49..d3278bd9f 100644 --- a/packages/agent/src/agent.ts +++ b/packages/agent/src/agent.ts @@ -6,7 +6,7 @@ import { type AssistantMessage, type CursorExecHandlers, type CursorToolResultHandler, - getModel, + getBundledModel, type ImageContent, type Message, type Model, @@ -153,7 +153,7 @@ interface CursorToolResultEntry { export class Agent { #state: AgentState = { systemPrompt: "", - model: getModel("google", "gemini-2.5-flash-lite-preview-06-17"), + model: getBundledModel("google", "gemini-2.5-flash-lite-preview-06-17"), thinkingLevel: "off", tools: [], messages: [], diff --git a/packages/agent/test/agent.test.ts b/packages/agent/test/agent.test.ts index a1464b565..e72f08be4 100644 --- a/packages/agent/test/agent.test.ts +++ b/packages/agent/test/agent.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "bun:test"; import { Agent } from "@oh-my-pi/pi-agent-core"; -import { type AssistantMessage, getModel, type ThinkingBudgets, type Usage } from "@oh-my-pi/pi-ai"; +import { type AssistantMessage, getBundledModel, type ThinkingBudgets, type Usage } from "@oh-my-pi/pi-ai"; import { AssistantMessageEventStream } from "@oh-my-pi/pi-ai/utils/event-stream"; class MockAssistantStream extends AssistantMessageEventStream {} @@ -49,7 +49,7 @@ describe("Agent", () => { }); it("should create an agent instance with custom initial state", () => { - const customModel = getModel("openai", "gpt-4o-mini"); + const customModel = getBundledModel("openai", "gpt-4o-mini"); const agent = new Agent({ initialState: { systemPrompt: "You are a helpful assistant.", @@ -93,7 +93,7 @@ describe("Agent", () => { expect(agent.state.systemPrompt).toBe("Custom prompt"); // Test setModel - const newModel = getModel("google", "gemini-2.5-flash"); + const newModel = getBundledModel("google", "gemini-2.5-flash"); agent.setModel(newModel); expect(agent.state.model).toBe(newModel); diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 25bf9d813..797386385 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -1,8 +1,20 @@ # Changelog ## [Unreleased] + +### Breaking Changes + +- Renamed public API functions: `getModel()` → `getBundledModel()`, `getModels()` → `getBundledModels()`, `getProviders()` → `getBundledProviders()` + ### Added +- Exported `ModelManager` API for runtime-aware model resolution with dynamic endpoint discovery +- Exported provider-specific model manager configuration helpers for Google, OpenAI-compatible, Codex, and Cursor providers +- Exported discovery utilities for fetching models from Antigravity, Codex, Cursor, Gemini, and OpenAI-compatible endpoints +- Added `createModelManager()` function to manage bundled and dynamically discovered models with configurable refresh strategies +- Added support for on-disk model caching with TTL-based invalidation +- Added `resolveProviderModels()` function for runtime model resolution across multiple providers +- Added EU cross-region inference variants for Claude Haiku 3.5 on Bedrock - Added Claude Sonnet 4.6 and Claude Sonnet 4.6 Thinking models to Antigravity provider - Added GLM-5 Free model via OpenCode provider - Added GLM-4.7-FlashX model via ZAI provider @@ -13,6 +25,11 @@ ### Changed +- Renamed `getModel()` to `getBundledModel()` to clarify it returns compile-time bundled models only +- Renamed `getModels()` to `getBundledModels()` for consistency +- Renamed `getProviders()` to `getBundledProviders()` for consistency +- Refactored model generation script to use modular discovery functions instead of monolithic provider-specific logic +- Updated models.json with new model entries and pricing updates across multiple providers - Updated pricing for deepseek/deepseek-v3 model on OpenRouter - Updated maxTokens from 65536 to 4096 for deepseek/deepseek-v3 on OpenRouter - Updated pricing and maxTokens for mistralai/mistral-large-2411 on OpenRouter diff --git a/packages/ai/package.json b/packages/ai/package.json index 60368f6cc..5ae3be511 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -71,6 +71,7 @@ "chalk": "^5.6.2", "openai": "^6.22.0", "partial-json": "^0.1.7", + "zod": "^4.3.6", "zod-to-json-schema": "^3.25.1" }, "keywords": [ @@ -91,7 +92,9 @@ "directory": "packages/ai" }, "homepage": "https://github.com/can1357/oh-my-pi", - "bugs": { "url": "https://github.com/can1357/oh-my-pi/issues" }, + "bugs": { + "url": "https://github.com/can1357/oh-my-pi/issues" + }, "engines": { "bun": ">=1.3.7" }, diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index 8dfe40512..957babd18 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/ai/scripts/generate-models.ts @@ -4,7 +4,12 @@ import { join } from "node:path"; import { $env } from "@oh-my-pi/pi-utils"; import { CliAuthStorage } from "../src/storage"; import { getOAuthApiKey } from "../src/utils/oauth"; +import { fetchAntigravityDiscoveryModels } from "../src/utils/discovery/antigravity"; +import { fetchCodexModels } from "../src/utils/discovery/codex"; +import { fetchCursorUsableModels } from "../src/utils/discovery/cursor"; +import { JWT_CLAIM_PATH } from "../src/providers/openai-codex/constants"; import type { Api, KnownProvider, Model } from "../src/types"; +import prevModelsJson from "../src/models.json" with { type: "json" }; const packageRoot = join(import.meta.dir, ".."); @@ -193,156 +198,72 @@ interface KimiModelInfo { supports_video_in?: boolean; } + async function fetchKimiCodeModels(): Promise[]> { - // Kimi Code /models endpoint requires authentication - // Use KIMI_API_KEY env var if available, otherwise return fallback models const apiKey = $env.KIMI_API_KEY; - if (apiKey) { - try { - console.log("Fetching models from Kimi Code API..."); - const response = await fetch(`${KIMI_CODE_BASE_URL}/models`, { - headers: { Authorization: `Bearer ${apiKey}` }, - }); - - if (!response.ok) { - console.warn(`Kimi Code API returned ${response.status}, using fallback models`); - return getKimiCodeFallbackModels(); - } - - const data = await response.json(); - const items = Array.isArray(data.data) ? (data.data as KimiModelInfo[]) : []; - const models: Model<"openai-completions">[] = []; - - for (const model of items) { - if (!model.id) continue; - - // Derive capabilities from model info - const hasThinking = model.supports_reasoning || model.id.toLowerCase().includes("thinking"); - const hasImage = model.supports_image_in || model.id.toLowerCase().includes("k2.5"); - - const input: ("text" | "image")[] = ["text"]; - if (hasImage) input.push("image"); - - // Use display_name if available, otherwise format from model ID - const name = - model.display_name || - model.id - .split("-") - .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) - .join(" "); - - models.push({ - id: model.id, - name, - api: "openai-completions", - provider: "kimi-code", - baseUrl: KIMI_CODE_BASE_URL, - headers: { ...KIMI_CODE_HEADERS }, - reasoning: hasThinking, - input, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: model.context_length || 262144, - maxTokens: KIMI_CODE_DEFAULT_MAX_TOKENS, - compat: { - thinkingFormat: "zai", - reasoningContentField: "reasoning_content", - supportsDeveloperRole: false, - }, - }); - } - - // The /models endpoint only returns "kimi-for-coding" but the API - // accepts other model IDs too — merge in fallback models not returned by the API - const fetchedIds = new Set(models.map((m) => m.id)); - const fallbacks = getKimiCodeFallbackModels(); - for (const fb of fallbacks) { - if (!fetchedIds.has(fb.id)) { - models.push(fb); - } - } - models.sort((a, b) => a.id.localeCompare(b.id)); - console.log(`Fetched ${fetchedIds.size} models from Kimi Code API, ${models.length} total with fallbacks`); - return models; - } catch (error) { - console.error("Failed to fetch Kimi Code models:", error); - return getKimiCodeFallbackModels(); - } + if (!apiKey) { + console.log("KIMI_API_KEY not set, will use previous models"); + return []; } - console.log("KIMI_API_KEY not set, using fallback Kimi Code models"); - return getKimiCodeFallbackModels(); -} + try { + console.log("Fetching models from Kimi Code API..."); + const response = await fetch(`${KIMI_CODE_BASE_URL}/models`, { + headers: { Authorization: `Bearer ${apiKey}` }, + }); -function getKimiCodeFallbackModels(): Model<"openai-completions">[] { - // Kimi Code models - the /models endpoint returns "kimi-for-coding" but the API - // accepts various model IDs. "kimi-for-coding" is an alias powered by kimi-k2.5. - const CONTEXT = 262144; - const MAX_TOKENS = KIMI_CODE_DEFAULT_MAX_TOKENS; - const compat = { - thinkingFormat: "zai" as const, - reasoningContentField: "reasoning_content" as const, - supportsDeveloperRole: false, - }; - const headers = { ...KIMI_CODE_HEADERS }; + if (!response.ok) { + console.warn(`Kimi Code API returned ${response.status}, will use previous models`); + return []; + } - return [ - { - id: "kimi-for-coding", - name: "Kimi For Coding", - api: "openai-completions", - provider: "kimi-code", - baseUrl: KIMI_CODE_BASE_URL, - headers, - reasoning: true, - input: ["text", "image"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: CONTEXT, - maxTokens: MAX_TOKENS, - compat, - }, - { - id: "kimi-k2.5", - name: "Kimi K2.5", - api: "openai-completions", - provider: "kimi-code", - baseUrl: KIMI_CODE_BASE_URL, - headers, - reasoning: true, - input: ["text", "image"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: CONTEXT, - maxTokens: MAX_TOKENS, - compat, - }, - { - id: "kimi-k2-turbo-preview", - name: "Kimi K2 Turbo Preview", - api: "openai-completions", - provider: "kimi-code", - baseUrl: KIMI_CODE_BASE_URL, - headers, - reasoning: true, - input: ["text"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: CONTEXT, - maxTokens: MAX_TOKENS, - compat, - }, - { - id: "kimi-k2", - name: "Kimi K2", - api: "openai-completions", - provider: "kimi-code", - baseUrl: KIMI_CODE_BASE_URL, - headers, - reasoning: true, - input: ["text"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: CONTEXT, - maxTokens: MAX_TOKENS, - compat, - }, - ]; + const data = await response.json(); + const items = Array.isArray(data.data) ? (data.data as KimiModelInfo[]) : []; + const models: Model<"openai-completions">[] = []; + + for (const model of items) { + if (!model.id) continue; + + const hasThinking = model.supports_reasoning || model.id.toLowerCase().includes("thinking"); + const hasImage = model.supports_image_in || model.id.toLowerCase().includes("k2.5"); + + const input: ("text" | "image")[] = ["text"]; + if (hasImage) input.push("image"); + + const name = + model.display_name || + model.id + .split("-") + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(" "); + + models.push({ + id: model.id, + name, + api: "openai-completions", + provider: "kimi-code", + baseUrl: KIMI_CODE_BASE_URL, + headers: { ...KIMI_CODE_HEADERS }, + reasoning: hasThinking, + input, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: model.context_length || 262144, + maxTokens: KIMI_CODE_DEFAULT_MAX_TOKENS, + compat: { + thinkingFormat: "zai", + reasoningContentField: "reasoning_content", + supportsDeveloperRole: false, + }, + }); + } + + models.sort((a, b) => a.id.localeCompare(b.id)); + console.log(`Fetched ${models.length} models from Kimi Code API`); + return models; + } catch (error) { + console.error("Failed to fetch Kimi Code models:", error); + return []; + } } async function loadModelsDevData(): Promise { @@ -842,29 +763,6 @@ async function loadModelsDevData(): Promise { } const ANTIGRAVITY_ENDPOINT = "https://daily-cloudcode-pa.sandbox.googleapis.com"; - -interface AntigravityApiModel { - displayName?: string; - supportsImages?: boolean; - supportsThinking?: boolean; - thinkingBudget?: number; - recommended?: boolean; - maxTokens?: number; - maxOutputTokens?: number; - model?: string; - apiProvider?: string; - modelProvider?: string; - isInternal?: boolean; - supportsVideo?: boolean; -} - -interface AntigravityApiResponse { - models: Record; - agentModelSorts?: Array<{ - groups?: Array<{ modelIds?: string[] }>; - }>; -} - /** * Try to get a fresh Antigravity access token from agent.db credentials. */ @@ -876,13 +774,11 @@ async function getAntigravityToken(): Promise<{ token: string; storage: CliAuthS storage.close(); return null; } - const result = await getOAuthApiKey("google-antigravity", { "google-antigravity": creds }); if (!result) { storage.close(); return null; } - // Save refreshed credentials back storage.saveOAuth("google-antigravity", result.newCredentials); return { token: result.newCredentials.access, storage }; @@ -892,253 +788,118 @@ async function getAntigravityToken(): Promise<{ token: string; storage: CliAuthS } /** - * Fetch available Antigravity models from the API. - * Falls back to hardcoded models if no auth is available. + * Fetch available Antigravity models from the API using the discovery module. + * Returns empty array if no auth is available (previous models used as fallback). */ async function fetchAntigravityModels(): Promise[]> { const auth = await getAntigravityToken(); - if (auth) { - try { - console.log("Fetching models from Antigravity API..."); - const response = await fetch(`${ANTIGRAVITY_ENDPOINT}/v1internal:fetchAvailableModels`, { - method: "POST", - headers: { - Authorization: `Bearer ${auth.token}`, - "Content-Type": "application/json", - "User-Agent": "antigravity/1.107.0 linux/amd64", - }, - body: JSON.stringify({ project: "" }), - }); - - if (!response.ok) { - console.warn(`Antigravity API returned ${response.status}, using fallback models`); - return getAntigravityFallbackModels(); - } - - const data = (await response.json()) as AntigravityApiResponse; - - // Collect recommended agent model IDs - const recommendedIds = new Set(); - for (const sort of data.agentModelSorts ?? []) { - for (const group of sort.groups ?? []) { - for (const id of group.modelIds ?? []) { - recommendedIds.add(id); - } - } - } - - const models: Model<"google-gemini-cli">[] = []; - for (const [modelId, m] of Object.entries(data.models)) { - // Skip internal/non-recommended models (tab completion, embeddings, etc.) - if (m.isInternal) continue; - if (!m.recommended && !recommendedIds.has(modelId)) continue; - - const supportsImages = m.supportsImages === true; - const reasoning = m.supportsThinking === true; - - models.push({ - id: modelId, - name: m.displayName ? `${m.displayName} (Antigravity)` : modelId, - api: "google-gemini-cli", - provider: "google-antigravity", - baseUrl: ANTIGRAVITY_ENDPOINT, - reasoning, - input: supportsImages ? ["text", "image"] : ["text"], - // Antigravity is free (quota-based), costs are for tracking only - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: m.maxTokens || 200000, - maxTokens: m.maxOutputTokens || 64000, - }); - } - models.sort((a, b) => a.name.localeCompare(b.name)); - console.log(`Fetched ${models.length} models from Antigravity API`); - return models; - } catch (error) { - console.error("Failed to fetch Antigravity models:", error); - return getAntigravityFallbackModels(); - } finally { - auth.storage.close(); - } + if (!auth) { + console.log("No Antigravity credentials found, will use previous models"); + return []; + } + try { + console.log("Fetching models from Antigravity API..."); + const discovered = await fetchAntigravityDiscoveryModels({ + token: auth.token, + endpoint: ANTIGRAVITY_ENDPOINT, + }); + if (discovered === null) { + console.warn("Antigravity API fetch failed, will use previous models"); + return []; + } + if (discovered.length > 0) { + console.log(`Fetched ${discovered.length} models from Antigravity API`); + return discovered; + } + console.warn("Antigravity API returned no models, will use previous models"); + return []; + } catch (error) { + console.error("Failed to fetch Antigravity models:", error); + return []; + } finally { + auth.storage.close(); } - - console.log("No Antigravity credentials found, using fallback models"); - return getAntigravityFallbackModels(); } -function getAntigravityFallbackModels(): Model<"google-gemini-cli">[] { - const models: Model<"google-gemini-cli">[] = [ - { - id: "gemini-3-pro-high", - name: "Gemini 3 Pro High (Antigravity)", - api: "google-gemini-cli", - provider: "google-antigravity", - baseUrl: ANTIGRAVITY_ENDPOINT, - reasoning: true, - input: ["text", "image"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 1048576, - maxTokens: 65535, - }, - { - id: "gemini-3-pro-low", - name: "Gemini 3 Pro Low (Antigravity)", - api: "google-gemini-cli", - provider: "google-antigravity", - baseUrl: ANTIGRAVITY_ENDPOINT, - reasoning: true, - input: ["text", "image"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 1048576, - maxTokens: 65535, - }, - { - id: "gemini-3-flash", - name: "Gemini 3 Flash (Antigravity)", - api: "google-gemini-cli", - provider: "google-antigravity", - baseUrl: ANTIGRAVITY_ENDPOINT, - reasoning: true, - input: ["text", "image"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 1048576, - maxTokens: 65536, - }, - { - id: "claude-sonnet-4-5", - name: "Claude Sonnet 4.5 (Antigravity)", - api: "google-gemini-cli", - provider: "google-antigravity", - baseUrl: ANTIGRAVITY_ENDPOINT, - reasoning: false, - input: ["text", "image"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 200000, - maxTokens: 64000, - }, - { - id: "claude-sonnet-4-5-thinking", - name: "Claude Sonnet 4.5 Thinking (Antigravity)", - api: "google-gemini-cli", - provider: "google-antigravity", - baseUrl: ANTIGRAVITY_ENDPOINT, - reasoning: true, - input: ["text", "image"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 200000, - maxTokens: 64000, - }, - { - id: "claude-sonnet-4-6", - name: "Claude Sonnet 4.6 (Antigravity)", - api: "google-gemini-cli", - provider: "google-antigravity", - baseUrl: ANTIGRAVITY_ENDPOINT, - reasoning: false, - input: ["text", "image"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 200000, - maxTokens: 128000, - }, - { - id: "claude-sonnet-4-6-thinking", - name: "Claude Sonnet 4.6 Thinking (Antigravity)", - api: "google-gemini-cli", - provider: "google-antigravity", - baseUrl: ANTIGRAVITY_ENDPOINT, - reasoning: true, - input: ["text", "image"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 200000, - maxTokens: 128000, - }, - { - id: "claude-opus-4-5-thinking", - name: "Claude Opus 4.5 Thinking (Antigravity)", - api: "google-gemini-cli", - provider: "google-antigravity", - baseUrl: ANTIGRAVITY_ENDPOINT, - reasoning: true, - input: ["text", "image"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 200000, - maxTokens: 64000, - }, - { - id: "claude-opus-4-6-thinking", - name: "Claude Opus 4.6 Thinking (Antigravity)", - api: "google-gemini-cli", - provider: "google-antigravity", - baseUrl: ANTIGRAVITY_ENDPOINT, - reasoning: true, - input: ["text", "image"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 200000, - maxTokens: 64000, - }, - { - id: "gpt-oss-120b-medium", - name: "GPT-OSS 120B Medium (Antigravity)", - api: "google-gemini-cli", - provider: "google-antigravity", - baseUrl: ANTIGRAVITY_ENDPOINT, - reasoning: true, - input: ["text"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 131072, - maxTokens: 32768, - }, - { - id: "gemini-2.5-pro", - name: "Gemini 2.5 Pro (Antigravity)", - api: "google-gemini-cli", - provider: "google-antigravity", - baseUrl: ANTIGRAVITY_ENDPOINT, - reasoning: true, - input: ["text", "image"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 1048576, - maxTokens: 65535, - }, - { - id: "gemini-2.5-flash", - name: "Gemini 2.5 Flash (Antigravity)", - api: "google-gemini-cli", - provider: "google-antigravity", - baseUrl: ANTIGRAVITY_ENDPOINT, - reasoning: true, - input: ["text", "image"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 1048576, - maxTokens: 65535, - }, - { - id: "gemini-2.5-flash-thinking", - name: "Gemini 2.5 Flash Thinking (Antigravity)", - api: "google-gemini-cli", - provider: "google-antigravity", - baseUrl: ANTIGRAVITY_ENDPOINT, - reasoning: true, - input: ["text", "image"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 1048576, - maxTokens: 65535, - }, - ]; - models.sort((a, b) => a.name.localeCompare(b.name)); - return models; +/** + * Extract accountId from a Codex JWT access token. + */ +function extractCodexAccountId(accessToken: string): string | null { + try { + const parts = accessToken.split("."); + if (parts.length !== 3) return null; + const payload = parts[1] ?? ""; + const decoded = JSON.parse(Buffer.from(payload, "base64").toString("utf-8")); + const accountId = decoded?.[JWT_CLAIM_PATH]?.chatgpt_account_id; + return typeof accountId === "string" && accountId.length > 0 ? accountId : null; + } catch { + return null; + } +} + +/** + * Try to get Codex (ChatGPT) OAuth credentials from agent.db. + */ +async function getCodexCredentials(): Promise<{ accessToken: string; accountId?: string; storage: CliAuthStorage } | null> { + try { + const storage = await CliAuthStorage.create(); + const creds = storage.getOAuth("openai-codex"); + if (!creds) { + storage.close(); + return null; + } + + const result = await getOAuthApiKey("openai-codex", { "openai-codex": creds }); + if (!result) { + storage.close(); + return null; + } + + storage.saveOAuth("openai-codex", result.newCredentials); + const accessToken = result.newCredentials.access; + const accountId = result.newCredentials.accountId ?? extractCodexAccountId(accessToken); + return { + accessToken, + accountId: accountId ?? undefined, + storage, + }; + } catch { + return null; + } +} + +/** + * Try to get Cursor API key from agent.db. + */ +async function getCursorApiKey(): Promise<{ apiKey: string; storage: CliAuthStorage } | null> { + try { + const storage = await CliAuthStorage.create(); + const creds = storage.getOAuth("cursor"); + if (!creds) { + storage.close(); + return null; + } + + const result = await getOAuthApiKey("cursor", { cursor: creds }); + if (!result) { + storage.close(); + return null; + } + + storage.saveOAuth("cursor", result.newCredentials); + return { apiKey: result.newCredentials.access, storage }; + } catch { + return null; + } } async function generateModels() { - // Fetch models from both sources - // models.dev: Anthropic, Google, OpenAI, Groq, Cerebras - // OpenRouter: xAI and other providers (excluding Anthropic, Google, OpenAI) - // AI Gateway: OpenAI-compatible catalog with tool-capable models + // Fetch models from dynamic sources const modelsDevModels = await loadModelsDevData(); const openRouterModels = await fetchOpenRouterModels(); const aiGatewayModels = await fetchAiGatewayModels(); const kimiCodeModels = await fetchKimiCodeModels(); - // Combine models (models.dev has priority) const allModels = [...modelsDevModels, ...openRouterModels, ...aiGatewayModels, ...kimiCodeModels]; // Fix incorrect cache pricing for Claude Opus 4.5 from models.dev @@ -1165,771 +926,52 @@ async function generateModels() { } } - // Add missing gpt models - if (!allModels.some((m) => m.provider === "openai" && m.id === "gpt-5-chat-latest")) { - allModels.push({ - id: "gpt-5-chat-latest", - name: "GPT-5 Chat Latest", - api: "openai-responses", - baseUrl: "https://api.openai.com/v1", - provider: "openai", - reasoning: false, - input: ["text", "image"], - cost: { - input: 1.25, - output: 10, - cacheRead: 0.125, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 16384, - }); - } - - if (!allModels.some((m) => m.provider === "openai" && m.id === "gpt-5.1-codex")) { - allModels.push({ - id: "gpt-5.1-codex", - name: "GPT-5.1 Codex", - api: "openai-responses", - baseUrl: "https://api.openai.com/v1", - provider: "openai", - reasoning: true, - input: ["text", "image"], - cost: { - input: 1.25, - output: 5, - cacheRead: 0.125, - cacheWrite: 1.25, - }, - contextWindow: 272000, - maxTokens: 128000, - }); - } - - if (!allModels.some((m) => m.provider === "openai" && m.id === "gpt-5.1-codex-max")) { - allModels.push({ - id: "gpt-5.1-codex-max", - name: "GPT-5.1 Codex Max", - api: "openai-responses", - baseUrl: "https://api.openai.com/v1", - provider: "openai", - reasoning: true, - input: ["text", "image"], - cost: { - input: 1.25, - output: 10, - cacheRead: 0.125, - cacheWrite: 0, - }, - contextWindow: 272000, - maxTokens: 128000, - }); - } - - // OpenAI Codex (ChatGPT OAuth) models - // NOTE: These are not fetched from models.dev; we keep a small, explicit list to avoid aliases. - // Context window is based on observed server limits (400s above ~272k), not marketing numbers. - const CODEX_BASE_URL = "https://chatgpt.com/backend-api"; - const CODEX_CONTEXT = 272000; - const CODEX_MAX_TOKENS = 128000; - const codexModels: Model<"openai-codex-responses">[] = [ - { - id: "gpt-5.1", - name: "GPT-5.1", - api: "openai-codex-responses", - provider: "openai-codex", - baseUrl: CODEX_BASE_URL, - reasoning: true, - input: ["text", "image"], - cost: { input: 1.25, output: 10, cacheRead: 0.125, cacheWrite: 0 }, - contextWindow: CODEX_CONTEXT, - maxTokens: CODEX_MAX_TOKENS, - }, - { - id: "gpt-5.1-codex-max", - name: "GPT-5.1 Codex Max", - api: "openai-codex-responses", - provider: "openai-codex", - baseUrl: CODEX_BASE_URL, - reasoning: true, - input: ["text", "image"], - cost: { input: 1.25, output: 10, cacheRead: 0.125, cacheWrite: 0 }, - contextWindow: CODEX_CONTEXT, - maxTokens: CODEX_MAX_TOKENS, - }, - { - id: "gpt-5.1-codex-mini", - name: "GPT-5.1 Codex Mini", - api: "openai-codex-responses", - provider: "openai-codex", - baseUrl: CODEX_BASE_URL, - reasoning: true, - input: ["text", "image"], - cost: { input: 0.25, output: 2, cacheRead: 0.025, cacheWrite: 0 }, - contextWindow: CODEX_CONTEXT, - maxTokens: CODEX_MAX_TOKENS, - }, - { - id: "gpt-5.2", - name: "GPT-5.2", - api: "openai-codex-responses", - provider: "openai-codex", - baseUrl: CODEX_BASE_URL, - reasoning: true, - input: ["text", "image"], - cost: { input: 1.75, output: 14, cacheRead: 0.175, cacheWrite: 0 }, - contextWindow: CODEX_CONTEXT, - maxTokens: CODEX_MAX_TOKENS, - }, - { - id: "gpt-5.2-codex", - name: "GPT-5.2 Codex", - api: "openai-codex-responses", - provider: "openai-codex", - baseUrl: CODEX_BASE_URL, - reasoning: true, - input: ["text", "image"], - cost: { input: 1.75, output: 14, cacheRead: 0.175, cacheWrite: 0 }, - contextWindow: CODEX_CONTEXT, - maxTokens: CODEX_MAX_TOKENS, - }, - { - id: "gpt-5.3-codex", - name: "GPT-5.3 Codex", - api: "openai-codex-responses", - provider: "openai-codex", - baseUrl: CODEX_BASE_URL, - reasoning: true, - input: ["text", "image"], - cost: { input: 1.75, output: 14, cacheRead: 0.175, cacheWrite: 0 }, - contextWindow: CODEX_CONTEXT, - maxTokens: CODEX_MAX_TOKENS, - }, - { - id: "gpt-5.3-codex-spark", - name: "GPT-5.3 Codex Spark", - api: "openai-codex-responses", - provider: "openai-codex", - baseUrl: CODEX_BASE_URL, - reasoning: true, - preferWebsockets: true, - input: ["text"], - cost: { input: 1.75, output: 14, cacheRead: 0.175, cacheWrite: 0 }, - contextWindow: 128000, - maxTokens: CODEX_MAX_TOKENS, - }, - ]; - allModels.push(...codexModels); - - - // Add missing Grok models - if (!allModels.some((m) => m.provider === "xai" && m.id === "grok-code-fast-1")) { - allModels.push({ - id: "grok-code-fast-1", - name: "Grok Code Fast 1", - api: "openai-completions", - baseUrl: "https://api.x.ai/v1", - provider: "xai", - reasoning: false, - input: ["text"], - cost: { - input: 0.2, - output: 1.5, - cacheRead: 0.02, - cacheWrite: 0, - }, - contextWindow: 32768, - maxTokens: 8192, - }); - } - - // Add "auto" alias for openrouter/auto - if (!allModels.some((m) => m.provider === "openrouter" && m.id === "auto")) { - allModels.push({ - id: "auto", - name: "Auto", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - input: ["text", "image"], - cost: { - // we dont know about the costs because OpenRouter auto routes to different models - // and then charges you for the underlying used model - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 2000000, - maxTokens: 30000, - }); - } - - // MiniMax Coding Plan fallback models - // These are subscription-based plans with separate API keys - // International endpoint: https://api.minimax.io/v1 - // China endpoint: https://api.minimaxi.com/v1 - const minimaxCodeFallbackModels: Model<"openai-completions">[] = [ - { - id: "MiniMax-M2.1", - name: "MiniMax M2.1 (Coding Plan)", - api: "openai-completions", - provider: "minimax-code", - baseUrl: "https://api.minimax.io/v1", - reasoning: true, - input: ["text"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - compat: { - supportsDeveloperRole: false, - thinkingFormat: "zai", - reasoningContentField: "reasoning_content", - }, - contextWindow: 1000000, - maxTokens: 32000, - }, - { - id: "MiniMax-M2.1-lightning", - name: "MiniMax M2.1 Lightning (Coding Plan)", - api: "openai-completions", - provider: "minimax-code", - baseUrl: "https://api.minimax.io/v1", - reasoning: true, - input: ["text"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - compat: { - supportsDeveloperRole: false, - thinkingFormat: "zai", - reasoningContentField: "reasoning_content", - }, - contextWindow: 1000000, - maxTokens: 32000, - }, - { - id: "MiniMax-M2.5", - name: "MiniMax M2.5 (Coding Plan)", - api: "openai-completions", - provider: "minimax-code", - baseUrl: "https://api.minimax.io/v1", - reasoning: true, - input: ["text"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - compat: { - supportsDeveloperRole: false, - thinkingFormat: "zai", - reasoningContentField: "reasoning_content", - }, - contextWindow: 204800, - maxTokens: 32000, - }, - { - id: "MiniMax-M2.5-lightning", - name: "MiniMax M2.5 Lightning (Coding Plan)", - api: "openai-completions", - provider: "minimax-code", - baseUrl: "https://api.minimax.io/v1", - reasoning: true, - input: ["text"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - compat: { - supportsDeveloperRole: false, - thinkingFormat: "zai", - reasoningContentField: "reasoning_content", - }, - contextWindow: 204800, - maxTokens: 32000, - }, - ]; - - // Only add fallback models if not already present from API - for (const model of minimaxCodeFallbackModels) { - if (!allModels.some((m) => m.provider === model.provider && m.id === model.id)) { - allModels.push(model); - } - } - - // China variants - const minimaxCodeCnFallbackModels: Model<"openai-completions">[] = [ - { - id: "MiniMax-M2.1", - name: "MiniMax M2.1 (Coding Plan CN)", - api: "openai-completions", - provider: "minimax-code-cn", - baseUrl: "https://api.minimaxi.com/v1", - reasoning: true, - input: ["text"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - compat: { - supportsDeveloperRole: false, - thinkingFormat: "zai", - reasoningContentField: "reasoning_content", - }, - contextWindow: 1000000, - maxTokens: 32000, - }, - { - id: "MiniMax-M2.1-lightning", - name: "MiniMax M2.1 Lightning (Coding Plan CN)", - api: "openai-completions", - provider: "minimax-code-cn", - baseUrl: "https://api.minimaxi.com/v1", - reasoning: true, - input: ["text"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - compat: { - supportsDeveloperRole: false, - thinkingFormat: "zai", - reasoningContentField: "reasoning_content", - }, - contextWindow: 1000000, - maxTokens: 32000, - }, - { - id: "MiniMax-M2.5", - name: "MiniMax M2.5 (Coding Plan CN)", - api: "openai-completions", - provider: "minimax-code-cn", - baseUrl: "https://api.minimaxi.com/v1", - reasoning: true, - input: ["text"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - compat: { - supportsDeveloperRole: false, - thinkingFormat: "zai", - reasoningContentField: "reasoning_content", - }, - contextWindow: 204800, - maxTokens: 32000, - }, - { - id: "MiniMax-M2.5-lightning", - name: "MiniMax M2.5 Lightning (Coding Plan CN)", - api: "openai-completions", - provider: "minimax-code-cn", - baseUrl: "https://api.minimaxi.com/v1", - reasoning: true, - input: ["text"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - compat: { - supportsDeveloperRole: false, - thinkingFormat: "zai", - reasoningContentField: "reasoning_content", - }, - contextWindow: 204800, - maxTokens: 32000, - }, - ]; - - for (const model of minimaxCodeCnFallbackModels) { - if (!allModels.some((m) => m.provider === model.provider && m.id === model.id)) { - allModels.push(model); - } - } - - // MiniMax M2.5 Anthropic API fallback models (in case models.dev hasn't been updated yet) - const minimaxAnthropicFallbacks: { id: string; name: string; inputCost: number; outputCost: number }[] = [ - { id: "MiniMax-M2.5", name: "MiniMax M2.5", inputCost: 0.15, outputCost: 1.2 }, - { id: "MiniMax-M2.5-lightning", name: "MiniMax M2.5 Lightning", inputCost: 0.3, outputCost: 2.4 }, - ]; - const minimaxAnthropicVariants = [ - { provider: "minimax" as const, baseUrl: "https://api.minimax.io/anthropic", suffix: "" }, - { provider: "minimax-cn" as const, baseUrl: "https://api.minimaxi.com/anthropic", suffix: " (CN)" }, - ]; - for (const { provider, baseUrl, suffix } of minimaxAnthropicVariants) { - for (const { id, name, inputCost, outputCost } of minimaxAnthropicFallbacks) { - if (!allModels.some((m) => m.provider === provider && m.id === id)) { - allModels.push({ - id, - name: name + suffix, - api: "anthropic-messages", - provider, - baseUrl, - reasoning: true, - input: ["text"], - cost: { - input: inputCost, - output: outputCost, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 204800, - maxTokens: 32000, - }); - } - } - } - - // Google Cloud Code Assist models (Gemini CLI) - // Uses production endpoint, standard Gemini models only - const CLOUD_CODE_ASSIST_ENDPOINT = "https://cloudcode-pa.googleapis.com"; - const cloudCodeAssistModels: Model<"google-gemini-cli">[] = [ - { - id: "gemini-2.5-pro", - name: "Gemini 2.5 Pro (Cloud Code Assist)", - api: "google-gemini-cli", - provider: "google-gemini-cli", - baseUrl: CLOUD_CODE_ASSIST_ENDPOINT, - reasoning: true, - input: ["text", "image"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 1048576, - maxTokens: 65535, - }, - { - id: "gemini-2.5-flash", - name: "Gemini 2.5 Flash (Cloud Code Assist)", - api: "google-gemini-cli", - provider: "google-gemini-cli", - baseUrl: CLOUD_CODE_ASSIST_ENDPOINT, - reasoning: true, - input: ["text", "image"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 1048576, - maxTokens: 65535, - }, - { - id: "gemini-2.0-flash", - name: "Gemini 2.0 Flash (Cloud Code Assist)", - api: "google-gemini-cli", - provider: "google-gemini-cli", - baseUrl: CLOUD_CODE_ASSIST_ENDPOINT, - reasoning: false, - input: ["text", "image"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 1048576, - maxTokens: 8192, - }, - { - id: "gemini-3-pro-preview", - name: "Gemini 3 Pro Preview (Cloud Code Assist)", - api: "google-gemini-cli", - provider: "google-gemini-cli", - baseUrl: CLOUD_CODE_ASSIST_ENDPOINT, - reasoning: true, - input: ["text", "image"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 1048576, - maxTokens: 65535, - }, - { - id: "gemini-3-flash-preview", - name: "Gemini 3 Flash Preview (Cloud Code Assist)", - api: "google-gemini-cli", - provider: "google-gemini-cli", - baseUrl: CLOUD_CODE_ASSIST_ENDPOINT, - reasoning: true, - input: ["text", "image"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 1048576, - maxTokens: 65535, - }, - ]; - allModels.push(...cloudCodeAssistModels); - // Antigravity models (Gemini 3, Claude, GPT-OSS via Google Cloud) - // Fetched from API if credentials available, otherwise uses hardcoded fallback const antigravityModels = await fetchAntigravityModels(); allModels.push(...antigravityModels); - const VERTEX_BASE_URL = "https://{location}-aiplatform.googleapis.com"; - const vertexModels: Model<"google-vertex">[] = [ - { - id: "gemini-3-pro-preview", - name: "Gemini 3 Pro Preview (Vertex)", - api: "google-vertex", - provider: "google-vertex", - baseUrl: VERTEX_BASE_URL, - reasoning: true, - input: ["text", "image"], - cost: { input: 2, output: 12, cacheRead: 0.2, cacheWrite: 0 }, - contextWindow: 1000000, - maxTokens: 64000, - }, - { - id: "gemini-3-flash-preview", - name: "Gemini 3 Flash Preview (Vertex)", - api: "google-vertex", - provider: "google-vertex", - baseUrl: VERTEX_BASE_URL, - reasoning: true, - input: ["text", "image"], - cost: { input: 0.5, output: 3, cacheRead: 0.05, cacheWrite: 0 }, - contextWindow: 1048576, - maxTokens: 65536, - }, - { - id: "gemini-2.0-flash", - name: "Gemini 2.0 Flash (Vertex)", - api: "google-vertex", - provider: "google-vertex", - baseUrl: VERTEX_BASE_URL, - reasoning: false, - input: ["text", "image"], - cost: { input: 0.15, output: 0.6, cacheRead: 0.0375, cacheWrite: 0 }, - contextWindow: 1048576, - maxTokens: 8192, - }, - { - id: "gemini-2.0-flash-lite", - name: "Gemini 2.0 Flash Lite (Vertex)", - api: "google-vertex", - provider: "google-vertex", - baseUrl: VERTEX_BASE_URL, - reasoning: true, - input: ["text", "image"], - cost: { input: 0.075, output: 0.3, cacheRead: 0.01875, cacheWrite: 0 }, - contextWindow: 1048576, - maxTokens: 65536, - }, - { - id: "gemini-2.5-pro", - name: "Gemini 2.5 Pro (Vertex)", - api: "google-vertex", - provider: "google-vertex", - baseUrl: VERTEX_BASE_URL, - reasoning: true, - input: ["text", "image"], - cost: { input: 1.25, output: 10, cacheRead: 0.125, cacheWrite: 0 }, - contextWindow: 1048576, - maxTokens: 65536, - }, - { - id: "gemini-2.5-flash", - name: "Gemini 2.5 Flash (Vertex)", - api: "google-vertex", - provider: "google-vertex", - baseUrl: VERTEX_BASE_URL, - reasoning: true, - input: ["text", "image"], - cost: { input: 0.3, output: 2.5, cacheRead: 0.03, cacheWrite: 0 }, - contextWindow: 1048576, - maxTokens: 65536, - }, - { - id: "gemini-2.5-flash-lite-preview-09-2025", - name: "Gemini 2.5 Flash Lite Preview 09-25 (Vertex)", - api: "google-vertex", - provider: "google-vertex", - baseUrl: VERTEX_BASE_URL, - reasoning: true, - input: ["text", "image"], - cost: { input: 0.1, output: 0.4, cacheRead: 0.01, cacheWrite: 0 }, - contextWindow: 1048576, - maxTokens: 65536, - }, - { - id: "gemini-2.5-flash-lite", - name: "Gemini 2.5 Flash Lite (Vertex)", - api: "google-vertex", - provider: "google-vertex", - baseUrl: VERTEX_BASE_URL, - reasoning: true, - input: ["text", "image"], - cost: { input: 0.1, output: 0.4, cacheRead: 0.01, cacheWrite: 0 }, - contextWindow: 1048576, - maxTokens: 65536, - }, - { - id: "gemini-1.5-pro", - name: "Gemini 1.5 Pro (Vertex)", - api: "google-vertex", - provider: "google-vertex", - baseUrl: VERTEX_BASE_URL, - reasoning: false, - input: ["text", "image"], - cost: { input: 1.25, output: 5, cacheRead: 0.3125, cacheWrite: 0 }, - contextWindow: 1000000, - maxTokens: 8192, - }, - { - id: "gemini-1.5-flash", - name: "Gemini 1.5 Flash (Vertex)", - api: "google-vertex", - provider: "google-vertex", - baseUrl: VERTEX_BASE_URL, - reasoning: false, - input: ["text", "image"], - cost: { input: 0.075, output: 0.3, cacheRead: 0.01875, cacheWrite: 0 }, - contextWindow: 1000000, - maxTokens: 8192, - }, - { - id: "gemini-1.5-flash-8b", - name: "Gemini 1.5 Flash-8B (Vertex)", - api: "google-vertex", - provider: "google-vertex", - baseUrl: VERTEX_BASE_URL, - reasoning: false, - input: ["text", "image"], - cost: { input: 0.0375, output: 0.15, cacheRead: 0.01, cacheWrite: 0 }, - contextWindow: 1000000, - maxTokens: 8192, - }, - ]; - allModels.push(...vertexModels); + // OpenAI Codex (ChatGPT OAuth) models + const codexAuth = await getCodexCredentials(); + if (codexAuth) { + try { + console.log("Fetching models from Codex API..."); + const codexDiscovery = await fetchCodexModels({ + accessToken: codexAuth.accessToken, + accountId: codexAuth.accountId, + }); + if (codexDiscovery === null) { + console.warn("Codex API fetch failed"); + } else if (codexDiscovery.models.length > 0) { + console.log(`Fetched ${codexDiscovery.models.length} models from Codex API`); + allModels.push(...codexDiscovery.models); + } + } catch (error) { + console.error("Failed to fetch Codex models:", error); + } finally { + codexAuth.storage.close(); + } + } - // Cursor Agent models (subscription-based, costs are 0) - // Model IDs fetched from GetUsableModels RPC - const CURSOR_BASE_URL = "https://api2.cursor.sh"; - const cursorModels: Model<"cursor-agent">[] = [ - { - id: "default", - name: "Auto (Cursor)", - api: "cursor-agent", - provider: "cursor", - baseUrl: CURSOR_BASE_URL, - reasoning: false, - input: ["text", "image"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 200000, - maxTokens: 64000, - }, - { - id: "claude-4.5-sonnet", - name: "Claude 4.5 Sonnet (Cursor)", - api: "cursor-agent", - provider: "cursor", - baseUrl: CURSOR_BASE_URL, - reasoning: false, - input: ["text", "image"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 200000, - maxTokens: 64000, - }, - { - id: "claude-4.5-sonnet-thinking", - name: "Claude 4.5 Sonnet Thinking (Cursor)", - api: "cursor-agent", - provider: "cursor", - baseUrl: CURSOR_BASE_URL, - reasoning: true, - input: ["text", "image"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 200000, - maxTokens: 64000, - }, - { - id: "claude-4.5-opus-high", - name: "Claude 4.5 Opus (Cursor)", - api: "cursor-agent", - provider: "cursor", - baseUrl: CURSOR_BASE_URL, - reasoning: false, - input: ["text", "image"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 200000, - maxTokens: 64000, - }, - { - id: "claude-4.5-opus-high-thinking", - name: "Claude 4.5 Opus Thinking (Cursor)", - api: "cursor-agent", - provider: "cursor", - baseUrl: CURSOR_BASE_URL, - reasoning: true, - input: ["text", "image"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 200000, - maxTokens: 64000, - }, - { - id: "gpt-5.1-codex-max", - name: "GPT-5.1 Codex Max (Cursor)", - api: "cursor-agent", - provider: "cursor", - baseUrl: CURSOR_BASE_URL, - reasoning: true, - input: ["text", "image"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 400000, - maxTokens: 128000, - }, - { - id: "gpt-5.1-codex-max-high", - name: "GPT-5.1 Codex Max High (Cursor)", - api: "cursor-agent", - provider: "cursor", - baseUrl: CURSOR_BASE_URL, - reasoning: true, - input: ["text", "image"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 400000, - maxTokens: 128000, - }, - { - id: "gpt-5.2", - name: "GPT-5.2 (Cursor)", - api: "cursor-agent", - provider: "cursor", - baseUrl: CURSOR_BASE_URL, - reasoning: true, - input: ["text", "image"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 400000, - maxTokens: 128000, - }, - { - id: "gpt-5.2-high", - name: "GPT-5.2 High (Cursor)", - api: "cursor-agent", - provider: "cursor", - baseUrl: CURSOR_BASE_URL, - reasoning: true, - input: ["text", "image"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 400000, - maxTokens: 128000, - }, - { - id: "gemini-3-pro", - name: "Gemini 3 Pro (Cursor)", - api: "cursor-agent", - provider: "cursor", - baseUrl: CURSOR_BASE_URL, - reasoning: true, - input: ["text", "image"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 1048576, - maxTokens: 65535, - }, - { - id: "gemini-3-flash", - name: "Gemini 3 Flash (Cursor)", - api: "cursor-agent", - provider: "cursor", - baseUrl: CURSOR_BASE_URL, - reasoning: true, - input: ["text", "image"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 1048576, - maxTokens: 65535, - }, - { - id: "grok-code-fast-1", - name: "Grok (Cursor)", - api: "cursor-agent", - provider: "cursor", - baseUrl: CURSOR_BASE_URL, - reasoning: false, - input: ["text"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 32768, - maxTokens: 8192, - }, - { - id: "composer-1", - name: "Composer 1 (Cursor)", - api: "cursor-agent", - provider: "cursor", - baseUrl: CURSOR_BASE_URL, - reasoning: false, - input: ["text"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 200000, - maxTokens: 64000, - }, - ]; - allModels.push(...cursorModels); + // Cursor Agent models + const cursorAuth = await getCursorApiKey(); + if (cursorAuth) { + try { + console.log("Fetching models from Cursor API..."); + const discoveredCursor = await fetchCursorUsableModels({ + apiKey: cursorAuth.apiKey, + }); + if (discoveredCursor === null) { + console.warn("Cursor API fetch failed"); + } else if (discoveredCursor.length > 0) { + console.log(`Fetched ${discoveredCursor.length} models from Cursor API`); + allModels.push(...discoveredCursor); + } + } catch (error) { + console.error("Failed to fetch Cursor models:", error); + } finally { + cursorAuth.storage.close(); + } + } // Normalize Codex models to input-token window (272K). The 400K figure includes output budget. for (const candidate of allModels) { @@ -1948,6 +990,20 @@ async function generateModels() { candidate.contextPromotionTarget = `${fallback.provider}/${fallback.id}`; } + // Merge previous models.json entries as fallback for any provider/model + // not fetched dynamically. This replaces all hardcoded fallback lists — + // static-only providers (vertex, gemini-cli), auth-gated providers when + // credentials are unavailable, and ad-hoc model additions all persist + // through the existing models.json seed. + const fetchedKeys = new Set(allModels.map((m) => `${m.provider}/${m.id}`)); + for (const models of Object.values(prevModelsJson as Record>)) { + for (const model of Object.values(models)) { + if (!fetchedKeys.has(`${model.provider}/${model.id}`)) { + allModels.push(model); + } + } + } + // Group by provider and sort each provider's models const providers: Record> = {}; for (const model of allModels) { diff --git a/packages/ai/src/index.ts b/packages/ai/src/index.ts index 8ee7ecfb3..8718c1562 100644 --- a/packages/ai/src/index.ts +++ b/packages/ai/src/index.ts @@ -1,8 +1,10 @@ export type { Static, TSchema } from "@sinclair/typebox"; export { Type } from "@sinclair/typebox"; export * from "./api-registry"; +export * from "./model-manager"; export * from "./models"; export * from "./provider-details"; +export * from "./provider-models"; export * from "./providers/anthropic"; export * from "./providers/azure-openai-responses"; export * from "./providers/cursor"; @@ -23,6 +25,7 @@ export * from "./usage/kimi"; export * from "./usage/minimax-code"; export * from "./usage/openai-codex"; export * from "./usage/zai"; +export * from "./utils/discovery"; export * from "./utils/event-stream"; export * from "./utils/oauth"; export * from "./utils/overflow"; diff --git a/packages/ai/src/model-manager.ts b/packages/ai/src/model-manager.ts new file mode 100644 index 000000000..2393a1882 --- /dev/null +++ b/packages/ai/src/model-manager.ts @@ -0,0 +1,438 @@ +import * as fs from "node:fs/promises"; +import * as path from "node:path"; +import { isEnoent } from "@oh-my-pi/pi-utils"; +import { getAgentDir } from "@oh-my-pi/pi-utils/dirs"; +import { type GeneratedProvider, getBundledModels } from "./models"; +import type { Api, Model, Provider } from "./types"; + +const CACHE_SCHEMA_VERSION = 1; +const DEFAULT_CACHE_TTL_MS = 24 * 60 * 60 * 1000; + +/** + * Controls when dynamic endpoint models should be fetched. + */ +export type ModelRefreshStrategy = "online" | "offline" | "online-if-uncached"; + +/** + * Hook for loading and mapping models.dev fallback data into canonical model objects. + */ +export interface ModelsDevFallback { + /** Fetches raw fallback payload (for example from models.dev). */ + fetch(): Promise; + /** Maps payload into provider models. */ + map(payload: TPayload, providerId: Provider): readonly Model[]; +} + +/** + * Configuration for provider model resolution. + */ +export interface ModelManagerOptions { + /** Provider id used for static lookup and cache namespacing. */ + providerId: Provider; + /** Optional static list override. When omitted, bundled models.json is used. */ + staticModels?: readonly Model[]; + /** Optional absolute cache path override. Default: /models/.json. */ + cachePath?: string; + /** Maximum cache age in milliseconds before considered stale. Default: 24h. */ + cacheTtlMs?: number; + /** Optional dynamic endpoint fetcher. */ + fetchDynamicModels?: () => Promise[] | null>; + /** Optional models.dev fallback hook. */ + modelsDev?: ModelsDevFallback; + /** Clock override for deterministic tests. */ + now?: () => number; +} + +/** + * Resolution result. + * + * `stale` is false only when dynamic endpoint data is authoritative: + * - freshly fetched in this call, or + * - a still-fresh dynamic cache hit in `online-if-uncached` mode. + */ +export interface ModelResolutionResult { + models: Model[]; + stale: boolean; +} + +/** + * Stateful facade over provider model resolution. + */ +export interface ModelManager { + refresh(strategy?: ModelRefreshStrategy): Promise>; +} + +interface CachedProviderModels { + version: number; + providerId: string; + updatedAt: number; + models: Model[]; + authoritative: boolean; +} +interface CacheReadResult { + models: Model[]; + fresh: boolean; + authoritative: boolean; +} + +/** + * Creates a reusable provider model manager. + */ +export function createModelManager( + options: ModelManagerOptions, +): ModelManager { + return { + refresh(strategy: ModelRefreshStrategy = "online-if-uncached") { + return resolveProviderModels(options, strategy); + }, + }; +} + +/** + * Resolves provider models with source precedence: + * static -> models.dev -> cache -> dynamic. + * + * Later sources override earlier ones by model id. + */ +export async function resolveProviderModels( + options: ModelManagerOptions, + strategy: ModelRefreshStrategy = "online-if-uncached", +): Promise> { + const now = options.now ?? Date.now; + const ttlMs = options.cacheTtlMs ?? DEFAULT_CACHE_TTL_MS; + const cachePath = options.cachePath ?? getDefaultCachePath(options.providerId); + const staticModels = normalizeModelList( + options.staticModels ?? getBundledModels(options.providerId as GeneratedProvider), + ); + const cache = await readCache(cachePath, options.providerId, ttlMs, now); + const shouldFetchFromNetwork = shouldFetchRemoteSources( + strategy, + cache?.fresh ?? false, + cache?.authoritative ?? false, + ); + const fetchedModelsDevModels = shouldFetchFromNetwork ? await fetchModelsDev(options) : null; + const modelsDevModels = normalizeModelList(fetchedModelsDevModels ?? []); + const dynamicFetcher = options.fetchDynamicModels; + const shouldUseFreshCacheAsAuthoritative = + strategy === "online-if-uncached" && (cache?.fresh ?? false) && (cache?.authoritative ?? false); + let fetchedDynamicModels: Model[] | null = null; + if (dynamicFetcher && shouldFetchFromNetwork) { + fetchedDynamicModels = await fetchDynamicModels(dynamicFetcher); + } + const dynamicFetchSucceeded = fetchedDynamicModels !== null; + const cacheModels = dynamicFetchSucceeded ? [] : (cache?.models ?? []); + const dynamicModels = fetchedDynamicModels ?? []; + const mergedWithoutDynamic = mergeModelSources(staticModels, modelsDevModels, cacheModels); + const models = mergeDynamicModels(mergedWithoutDynamic, dynamicModels); + const dynamicAuthoritative = dynamicFetchSucceeded || shouldUseFreshCacheAsAuthoritative; + if (shouldFetchFromNetwork) { + if (dynamicFetchSucceeded) { + const snapshotModels = mergeDynamicModels(mergeModelSources(staticModels, modelsDevModels), dynamicModels); + await writeCache(cachePath, { + version: CACHE_SCHEMA_VERSION, + providerId: options.providerId, + updatedAt: now(), + models: snapshotModels, + authoritative: true, + }); + } else if (!cache && fetchedModelsDevModels !== null) { + await writeCache(cachePath, { + version: CACHE_SCHEMA_VERSION, + providerId: options.providerId, + updatedAt: now(), + models: mergeModelSources(staticModels, modelsDevModels), + authoritative: false, + }); + } + } + return { + models, + stale: !dynamicAuthoritative, + }; +} + +function getDefaultCachePath(providerId: string): string { + const encodedProvider = encodeURIComponent(providerId); + return path.join(getAgentDir(), "models", `${encodedProvider}.json`); +} + +async function fetchModelsDev( + options: ModelManagerOptions, +): Promise[] | null> { + if (!options.modelsDev) { + return null; + } + + try { + const payload = await options.modelsDev.fetch(); + return normalizeModelList(options.modelsDev.map(payload, options.providerId)); + } catch { + return null; + } +} + +async function fetchDynamicModels( + fetcher: () => Promise[] | null>, +): Promise[] | null> { + try { + const models = await fetcher(); + if (models === null) { + return null; + } + return normalizeModelList(models); + } catch { + return null; + } +} + +function shouldFetchRemoteSources( + strategy: ModelRefreshStrategy, + hasFreshCache: boolean, + hasAuthoritativeCache: boolean, +): boolean { + if (strategy === "offline") { + return false; + } + if (strategy === "online") { + return true; + } + return !hasFreshCache || !hasAuthoritativeCache; +} + +async function readCache( + cachePath: string, + expectedProviderId: string, + ttlMs: number, + now: () => number, +): Promise | null> { + let raw: string; + try { + raw = await Bun.file(cachePath).text(); + } catch (error) { + if (isEnoent(error)) { + return null; + } + return null; + } + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return null; + } + + const cache = parseCache(parsed); + if (!cache || cache.providerId !== expectedProviderId) { + return null; + } + + const ageMs = now() - cache.updatedAt; + const fresh = Number.isFinite(ageMs) && ageMs >= 0 && ageMs <= ttlMs; + return { + models: cache.models, + fresh, + authoritative: cache.authoritative, + }; +} + +function parseCache(value: unknown): CachedProviderModels | null { + if (!isRecord(value)) { + return null; + } + if (value.version !== CACHE_SCHEMA_VERSION) { + return null; + } + if (typeof value.providerId !== "string") { + return null; + } + if (typeof value.updatedAt !== "number" || !Number.isFinite(value.updatedAt)) { + return null; + } + const rawModels = Array.isArray(value.models) + ? value.models + : Array.isArray(value.dynamicModels) + ? value.dynamicModels + : null; + if (!rawModels) { + return null; + } + const authoritative = + typeof value.authoritative === "boolean" ? value.authoritative : Array.isArray(value.dynamicModels); + return { + version: value.version, + providerId: value.providerId, + updatedAt: value.updatedAt, + models: normalizeModelList(rawModels), + authoritative, + }; +} + +async function writeCache(cachePath: string, cache: CachedProviderModels): Promise { + const content = `${JSON.stringify(cache, null, 2)}\n`; + try { + await Bun.write(cachePath, content); + await fs.chmod(cachePath, 0o600).catch(() => undefined); + } catch { + // Cache writes are best-effort; failures should not break model resolution. + } +} + +function mergeModelSources(...sources: readonly (readonly Model[])[]): Model[] { + const merged = new Map>(); + for (const source of sources) { + for (const model of source) { + if (!model?.id) { + continue; + } + merged.set(model.id, model); + } + } + return Array.from(merged.values()); +} + +function mergeDynamicModels( + baseModels: readonly Model[], + dynamicModels: readonly Model[], +): Model[] { + const merged = new Map>(baseModels.map(model => [model.id, model])); + for (const dynamicModel of dynamicModels) { + if (!dynamicModel?.id) { + continue; + } + const existingModel = merged.get(dynamicModel.id); + if (!existingModel) { + merged.set(dynamicModel.id, dynamicModel); + continue; + } + merged.set(dynamicModel.id, mergeDynamicModel(existingModel, dynamicModel)); + } + return Array.from(merged.values()); +} + +function mergeDynamicModel(existingModel: Model, dynamicModel: Model): Model { + const supportsImage = existingModel.input.includes("image") || dynamicModel.input.includes("image"); + return { + ...existingModel, + ...dynamicModel, + name: preferDiscoveryName(dynamicModel.name, existingModel.name, dynamicModel.id), + reasoning: existingModel.reasoning || dynamicModel.reasoning, + input: supportsImage ? ["text", "image"] : ["text"], + cost: { + input: preferDiscoveryCost(dynamicModel.cost.input, existingModel.cost.input), + output: preferDiscoveryCost(dynamicModel.cost.output, existingModel.cost.output), + cacheRead: preferDiscoveryCost(dynamicModel.cost.cacheRead, existingModel.cost.cacheRead), + cacheWrite: preferDiscoveryCost(dynamicModel.cost.cacheWrite, existingModel.cost.cacheWrite), + }, + contextWindow: preferDiscoveryLimit(dynamicModel.contextWindow, existingModel.contextWindow), + maxTokens: preferDiscoveryLimit(dynamicModel.maxTokens, existingModel.maxTokens), + headers: dynamicModel.headers ? { ...existingModel.headers, ...dynamicModel.headers } : existingModel.headers, + compat: dynamicModel.compat ?? existingModel.compat, + contextPromotionTarget: dynamicModel.contextPromotionTarget ?? existingModel.contextPromotionTarget, + }; +} + +function preferDiscoveryCost(discoveryCost: number, fallbackCost: number): number { + if (Number.isFinite(discoveryCost) && discoveryCost > 0) { + return discoveryCost; + } + return fallbackCost; +} + +function preferDiscoveryName(discoveryName: string, fallbackName: string, modelId: string): string { + const normalizedDiscoveryName = discoveryName.trim(); + if (normalizedDiscoveryName.length === 0) { + return fallbackName; + } + if (normalizedDiscoveryName === modelId && fallbackName !== modelId) { + return fallbackName; + } + return normalizedDiscoveryName; +} + +function preferDiscoveryLimit(discoveryLimit: number, fallbackLimit: number): number { + if (!Number.isFinite(discoveryLimit) || discoveryLimit <= 0) { + return fallbackLimit; + } + if (discoveryLimit === 4096 && fallbackLimit > discoveryLimit) { + return fallbackLimit; + } + return discoveryLimit; +} + +function normalizeModelList(value: unknown): Model[] { + if (!Array.isArray(value)) { + return []; + } + const models: Model[] = []; + for (const item of value) { + if (isModelLike(item)) { + models.push(item as Model); + } + } + return models; +} + +function isModelLike(value: unknown): value is Model { + if (!isRecord(value)) { + return false; + } + if (typeof value.id !== "string" || value.id.length === 0) { + return false; + } + if (typeof value.name !== "string" || value.name.length === 0) { + return false; + } + if (typeof value.api !== "string" || value.api.length === 0) { + return false; + } + if (typeof value.provider !== "string" || value.provider.length === 0) { + return false; + } + if (typeof value.baseUrl !== "string" || value.baseUrl.length === 0) { + return false; + } + if (typeof value.reasoning !== "boolean") { + return false; + } + if (!isModelInputArray(value.input)) { + return false; + } + if (!isModelCost(value.cost)) { + return false; + } + if (typeof value.contextWindow !== "number" || !Number.isFinite(value.contextWindow) || value.contextWindow <= 0) { + return false; + } + if (typeof value.maxTokens !== "number" || !Number.isFinite(value.maxTokens) || value.maxTokens <= 0) { + return false; + } + return true; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function isModelInputArray(value: unknown): value is ("text" | "image")[] { + if (!Array.isArray(value) || value.length === 0) { + return false; + } + return value.every(item => item === "text" || item === "image"); +} + +function isModelCost(value: unknown): value is Model["cost"] { + if (!isRecord(value)) { + return false; + } + return ( + typeof value.input === "number" && + Number.isFinite(value.input) && + typeof value.output === "number" && + Number.isFinite(value.output) && + typeof value.cacheRead === "number" && + Number.isFinite(value.cacheRead) && + typeof value.cacheWrite === "number" && + Number.isFinite(value.cacheWrite) + ); +} diff --git a/packages/ai/src/models.json b/packages/ai/src/models.json index ac22b0897..645c0f1b2 100644 --- a/packages/ai/src/models.json +++ b/packages/ai/src/models.json @@ -216,6 +216,146 @@ "contextWindow": 163840, "maxTokens": 81920 }, + "eu.anthropic.claude-3-5-haiku-20241022-v1:0": { + "id": "eu.anthropic.claude-3-5-haiku-20241022-v1:0", + "name": "Claude Haiku 3.5 (EU)", + "api": "bedrock-converse-stream", + "provider": "amazon-bedrock", + "baseUrl": "https://bedrock-runtime.us-east-1.amazonaws.com", + "reasoning": false, + "input": [ + "text", + "image" + ], + "cost": { + "input": 0.8, + "output": 4, + "cacheRead": 0.08, + "cacheWrite": 1 + }, + "contextWindow": 200000, + "maxTokens": 8192 + }, + "eu.anthropic.claude-3-5-sonnet-20240620-v1:0": { + "id": "eu.anthropic.claude-3-5-sonnet-20240620-v1:0", + "name": "Claude Sonnet 3.5 (EU)", + "api": "bedrock-converse-stream", + "provider": "amazon-bedrock", + "baseUrl": "https://bedrock-runtime.us-east-1.amazonaws.com", + "reasoning": false, + "input": [ + "text", + "image" + ], + "cost": { + "input": 3, + "output": 15, + "cacheRead": 0.3, + "cacheWrite": 3.75 + }, + "contextWindow": 200000, + "maxTokens": 8192 + }, + "eu.anthropic.claude-3-5-sonnet-20241022-v2:0": { + "id": "eu.anthropic.claude-3-5-sonnet-20241022-v2:0", + "name": "Claude Sonnet 3.5 v2 (EU)", + "api": "bedrock-converse-stream", + "provider": "amazon-bedrock", + "baseUrl": "https://bedrock-runtime.us-east-1.amazonaws.com", + "reasoning": false, + "input": [ + "text", + "image" + ], + "cost": { + "input": 3, + "output": 15, + "cacheRead": 0.3, + "cacheWrite": 3.75 + }, + "contextWindow": 200000, + "maxTokens": 8192 + }, + "eu.anthropic.claude-3-7-sonnet-20250219-v1:0": { + "id": "eu.anthropic.claude-3-7-sonnet-20250219-v1:0", + "name": "Claude Sonnet 3.7 (EU)", + "api": "bedrock-converse-stream", + "provider": "amazon-bedrock", + "baseUrl": "https://bedrock-runtime.us-east-1.amazonaws.com", + "reasoning": false, + "input": [ + "text", + "image" + ], + "cost": { + "input": 3, + "output": 15, + "cacheRead": 0.3, + "cacheWrite": 3.75 + }, + "contextWindow": 200000, + "maxTokens": 8192 + }, + "eu.anthropic.claude-3-haiku-20240307-v1:0": { + "id": "eu.anthropic.claude-3-haiku-20240307-v1:0", + "name": "Claude Haiku 3 (EU)", + "api": "bedrock-converse-stream", + "provider": "amazon-bedrock", + "baseUrl": "https://bedrock-runtime.us-east-1.amazonaws.com", + "reasoning": false, + "input": [ + "text", + "image" + ], + "cost": { + "input": 0.25, + "output": 1.25, + "cacheRead": 0, + "cacheWrite": 0 + }, + "contextWindow": 200000, + "maxTokens": 4096 + }, + "eu.anthropic.claude-3-opus-20240229-v1:0": { + "id": "eu.anthropic.claude-3-opus-20240229-v1:0", + "name": "Claude Opus 3 (EU)", + "api": "bedrock-converse-stream", + "provider": "amazon-bedrock", + "baseUrl": "https://bedrock-runtime.us-east-1.amazonaws.com", + "reasoning": false, + "input": [ + "text", + "image" + ], + "cost": { + "input": 15, + "output": 75, + "cacheRead": 0, + "cacheWrite": 0 + }, + "contextWindow": 200000, + "maxTokens": 4096 + }, + "eu.anthropic.claude-3-sonnet-20240229-v1:0": { + "id": "eu.anthropic.claude-3-sonnet-20240229-v1:0", + "name": "Claude Sonnet 3 (EU)", + "api": "bedrock-converse-stream", + "provider": "amazon-bedrock", + "baseUrl": "https://bedrock-runtime.us-east-1.amazonaws.com", + "reasoning": false, + "input": [ + "text", + "image" + ], + "cost": { + "input": 3, + "output": 15, + "cacheRead": 0, + "cacheWrite": 0 + }, + "contextWindow": 200000, + "maxTokens": 4096 + }, "eu.anthropic.claude-haiku-4-5-20251001-v1:0": { "id": "eu.anthropic.claude-haiku-4-5-20251001-v1:0", "name": "Claude Haiku 4.5 (EU)", @@ -236,6 +376,46 @@ "contextWindow": 200000, "maxTokens": 64000 }, + "eu.anthropic.claude-opus-4-1-20250805-v1:0": { + "id": "eu.anthropic.claude-opus-4-1-20250805-v1:0", + "name": "Claude Opus 4.1 (EU)", + "api": "bedrock-converse-stream", + "provider": "amazon-bedrock", + "baseUrl": "https://bedrock-runtime.us-east-1.amazonaws.com", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 15, + "output": 75, + "cacheRead": 1.5, + "cacheWrite": 18.75 + }, + "contextWindow": 200000, + "maxTokens": 32000 + }, + "eu.anthropic.claude-opus-4-20250514-v1:0": { + "id": "eu.anthropic.claude-opus-4-20250514-v1:0", + "name": "Claude Opus 4 (EU)", + "api": "bedrock-converse-stream", + "provider": "amazon-bedrock", + "baseUrl": "https://bedrock-runtime.us-east-1.amazonaws.com", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 15, + "output": 75, + "cacheRead": 1.5, + "cacheWrite": 18.75 + }, + "contextWindow": 200000, + "maxTokens": 32000 + }, "eu.anthropic.claude-opus-4-5-20251101-v1:0": { "id": "eu.anthropic.claude-opus-4-5-20251101-v1:0", "name": "Claude Opus 4.5 (EU)", @@ -316,6 +496,26 @@ "contextWindow": 200000, "maxTokens": 64000 }, + "eu.anthropic.claude-sonnet-4-6": { + "id": "eu.anthropic.claude-sonnet-4-6", + "name": "Claude Sonnet 4.6 (EU)", + "api": "bedrock-converse-stream", + "provider": "amazon-bedrock", + "baseUrl": "https://bedrock-runtime.us-east-1.amazonaws.com", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 3, + "output": 15, + "cacheRead": 0.3, + "cacheWrite": 3.75 + }, + "contextWindow": 200000, + "maxTokens": 64000 + }, "global.amazon.nova-2-lite-v1:0": { "id": "global.amazon.nova-2-lite-v1:0", "name": "Nova 2 Lite", @@ -436,6 +636,26 @@ "contextWindow": 200000, "maxTokens": 64000 }, + "global.anthropic.claude-sonnet-4-6": { + "id": "global.anthropic.claude-sonnet-4-6", + "name": "Claude Sonnet 4.6", + "api": "bedrock-converse-stream", + "provider": "amazon-bedrock", + "baseUrl": "https://bedrock-runtime.us-east-1.amazonaws.com", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 3, + "output": 15, + "cacheRead": 0.3, + "cacheWrite": 3.75 + }, + "contextWindow": 200000, + "maxTokens": 64000 + }, "google.gemma-3-27b-it": { "id": "google.gemma-3-27b-it", "name": "Google Gemma 3 27B Instruct", @@ -1155,6 +1375,26 @@ "contextWindow": 200000, "maxTokens": 64000 }, + "us.anthropic.claude-sonnet-4-6": { + "id": "us.anthropic.claude-sonnet-4-6", + "name": "Claude Sonnet 4.6 (US)", + "api": "bedrock-converse-stream", + "provider": "amazon-bedrock", + "baseUrl": "https://bedrock-runtime.us-east-1.amazonaws.com", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 3, + "output": 15, + "cacheRead": 0.3, + "cacheWrite": 3.75 + }, + "contextWindow": 200000, + "maxTokens": 64000 + }, "us.deepseek.r1-v1:0": { "id": "us.deepseek.r1-v1:0", "name": "DeepSeek-R1", @@ -1548,6 +1788,26 @@ "contextWindow": 200000, "maxTokens": 64000 }, + "claude-sonnet-4-6": { + "id": "claude-sonnet-4-6", + "name": "Claude Sonnet 4.6", + "api": "anthropic-messages", + "provider": "opencode", + "baseUrl": "https://opencode.ai/zen", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 3, + "output": 15, + "cacheRead": 0.3, + "cacheWrite": 3.75 + }, + "contextWindow": 200000, + "maxTokens": 64000 + }, "gemini-3-flash": { "id": "gemini-3-flash", "name": "Gemini 3 Flash", @@ -2221,26 +2481,6 @@ "contextWindow": 200000, "maxTokens": 128000 }, - "claude-sonnet-4-6": { - "id": "claude-sonnet-4-6", - "name": "Claude Sonnet 4.6", - "api": "anthropic-messages", - "provider": "anthropic", - "baseUrl": "https://api.anthropic.com", - "reasoning": true, - "input": [ - "text", - "image" - ], - "cost": { - "input": 3, - "output": 15, - "cacheRead": 0.3, - "cacheWrite": 3.75 - }, - "contextWindow": 200000, - "maxTokens": 64000 - }, "claude-sonnet-4-0": { "id": "claude-sonnet-4-0", "name": "Claude Sonnet 4 (latest)", @@ -2320,6 +2560,26 @@ }, "contextWindow": 200000, "maxTokens": 64000 + }, + "claude-sonnet-4-6": { + "id": "claude-sonnet-4-6", + "name": "Claude Sonnet 4.6", + "api": "anthropic-messages", + "provider": "anthropic", + "baseUrl": "https://api.anthropic.com", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 3, + "output": 15, + "cacheRead": 0.3, + "cacheWrite": 3.75 + }, + "contextWindow": 200000, + "maxTokens": 64000 } }, "github-copilot": { @@ -2453,6 +2713,32 @@ "Copilot-Integration-Id": "vscode-chat" } }, + "claude-sonnet-4.6": { + "id": "claude-sonnet-4.6", + "name": "Claude Sonnet 4.6", + "api": "anthropic-messages", + "provider": "github-copilot", + "baseUrl": "https://api.individual.githubcopilot.com", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0 + }, + "contextWindow": 200000, + "maxTokens": 32000, + "headers": { + "User-Agent": "GitHubCopilotChat/0.35.0", + "Editor-Version": "vscode/1.107.0", + "Editor-Plugin-Version": "copilot-chat/0.35.0", + "Copilot-Integration-Id": "vscode-chat" + } + }, "gemini-2.5-pro": { "id": "gemini-2.5-pro", "name": "Gemini 2.5 Pro", @@ -6376,7 +6662,7 @@ "cacheRead": 0.3, "cacheWrite": 3.75 }, - "contextWindow": 1000000, + "contextWindow": 200000, "maxTokens": 128000 }, "arcee-ai/trinity-large-preview:free": { @@ -7343,13 +7629,13 @@ "text" ], "cost": { - "input": 0.049999999999999996, - "output": 0.22, - "cacheRead": 0.024999999999999998, + "input": 0.39999999999999997, + "output": 2, + "cacheRead": 0, "cacheWrite": 0 }, "contextWindow": 262144, - "maxTokens": 65536 + "maxTokens": 4096 }, "mistralai/devstral-medium": { "id": "mistralai/devstral-medium", @@ -11007,8 +11293,8 @@ "cacheRead": 0.3, "cacheWrite": 3.75 }, - "contextWindow": 1000000, - "maxTokens": 64000 + "contextWindow": 200000, + "maxTokens": 128000 }, "arcee-ai/trinity-large-preview": { "id": "arcee-ai/trinity-large-preview", @@ -13136,9 +13422,9 @@ } }, "openai-codex": { - "gpt-5.1": { - "id": "gpt-5.1", - "name": "GPT-5.1", + "gpt-5": { + "id": "gpt-5", + "name": "gpt-5", "api": "openai-codex-responses", "provider": "openai-codex", "baseUrl": "https://chatgpt.com/backend-api", @@ -13148,9 +13434,89 @@ "image" ], "cost": { - "input": 1.25, - "output": 10, - "cacheRead": 0.125, + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0 + }, + "contextWindow": 272000, + "maxTokens": 128000 + }, + "gpt-5-codex": { + "id": "gpt-5-codex", + "name": "gpt-5-codex", + "api": "openai-codex-responses", + "provider": "openai-codex", + "baseUrl": "https://chatgpt.com/backend-api", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0 + }, + "contextWindow": 272000, + "maxTokens": 128000 + }, + "gpt-5-codex-mini": { + "id": "gpt-5-codex-mini", + "name": "gpt-5-codex-mini", + "api": "openai-codex-responses", + "provider": "openai-codex", + "baseUrl": "https://chatgpt.com/backend-api", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0 + }, + "contextWindow": 272000, + "maxTokens": 128000 + }, + "gpt-5.1": { + "id": "gpt-5.1", + "name": "gpt-5.1", + "api": "openai-codex-responses", + "provider": "openai-codex", + "baseUrl": "https://chatgpt.com/backend-api", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0 + }, + "contextWindow": 272000, + "maxTokens": 128000 + }, + "gpt-5.1-codex": { + "id": "gpt-5.1-codex", + "name": "gpt-5.1-codex", + "api": "openai-codex-responses", + "provider": "openai-codex", + "baseUrl": "https://chatgpt.com/backend-api", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 0, + "output": 0, + "cacheRead": 0, "cacheWrite": 0 }, "contextWindow": 272000, @@ -13158,7 +13524,7 @@ }, "gpt-5.1-codex-max": { "id": "gpt-5.1-codex-max", - "name": "GPT-5.1 Codex Max", + "name": "gpt-5.1-codex-max", "api": "openai-codex-responses", "provider": "openai-codex", "baseUrl": "https://chatgpt.com/backend-api", @@ -13168,9 +13534,9 @@ "image" ], "cost": { - "input": 1.25, - "output": 10, - "cacheRead": 0.125, + "input": 0, + "output": 0, + "cacheRead": 0, "cacheWrite": 0 }, "contextWindow": 272000, @@ -13178,7 +13544,7 @@ }, "gpt-5.1-codex-mini": { "id": "gpt-5.1-codex-mini", - "name": "GPT-5.1 Codex Mini", + "name": "gpt-5.1-codex-mini", "api": "openai-codex-responses", "provider": "openai-codex", "baseUrl": "https://chatgpt.com/backend-api", @@ -13188,9 +13554,9 @@ "image" ], "cost": { - "input": 0.25, - "output": 2, - "cacheRead": 0.025, + "input": 0, + "output": 0, + "cacheRead": 0, "cacheWrite": 0 }, "contextWindow": 272000, @@ -13198,7 +13564,7 @@ }, "gpt-5.2": { "id": "gpt-5.2", - "name": "GPT-5.2", + "name": "gpt-5.2", "api": "openai-codex-responses", "provider": "openai-codex", "baseUrl": "https://chatgpt.com/backend-api", @@ -13208,9 +13574,9 @@ "image" ], "cost": { - "input": 1.75, - "output": 14, - "cacheRead": 0.175, + "input": 0, + "output": 0, + "cacheRead": 0, "cacheWrite": 0 }, "contextWindow": 272000, @@ -13218,7 +13584,7 @@ }, "gpt-5.2-codex": { "id": "gpt-5.2-codex", - "name": "GPT-5.2 Codex", + "name": "gpt-5.2-codex", "api": "openai-codex-responses", "provider": "openai-codex", "baseUrl": "https://chatgpt.com/backend-api", @@ -13228,9 +13594,9 @@ "image" ], "cost": { - "input": 1.75, - "output": 14, - "cacheRead": 0.175, + "input": 0, + "output": 0, + "cacheRead": 0, "cacheWrite": 0 }, "contextWindow": 272000, @@ -13238,7 +13604,7 @@ }, "gpt-5.3-codex": { "id": "gpt-5.3-codex", - "name": "GPT-5.3 Codex", + "name": "gpt-5.3-codex", "api": "openai-codex-responses", "provider": "openai-codex", "baseUrl": "https://chatgpt.com/backend-api", @@ -13248,9 +13614,9 @@ "image" ], "cost": { - "input": 1.75, - "output": 14, - "cacheRead": 0.175, + "input": 0, + "output": 0, + "cacheRead": 0, "cacheWrite": 0 }, "contextWindow": 272000, @@ -13461,6 +13827,46 @@ "contextWindow": 200000, "maxTokens": 64000 }, + "claude-sonnet-4-6": { + "id": "claude-sonnet-4-6", + "name": "Claude Sonnet 4.6 (Antigravity)", + "api": "google-gemini-cli", + "provider": "google-antigravity", + "baseUrl": "https://daily-cloudcode-pa.sandbox.googleapis.com", + "reasoning": false, + "input": [ + "text", + "image" + ], + "cost": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0 + }, + "contextWindow": 200000, + "maxTokens": 128000 + }, + "claude-sonnet-4-6-thinking": { + "id": "claude-sonnet-4-6-thinking", + "name": "Claude Sonnet 4.6 Thinking (Antigravity)", + "api": "google-gemini-cli", + "provider": "google-antigravity", + "baseUrl": "https://daily-cloudcode-pa.sandbox.googleapis.com", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0 + }, + "contextWindow": 200000, + "maxTokens": 128000 + }, "gemini-2.5-flash": { "id": "gemini-2.5-flash", "name": "Gemini 2.5 Flash (Antigravity)", diff --git a/packages/ai/src/models.ts b/packages/ai/src/models.ts index 1bc227912..ebf6ec3cf 100644 --- a/packages/ai/src/models.ts +++ b/packages/ai/src/models.ts @@ -1,9 +1,15 @@ import MODELS from "./models.json" with { type: "json" }; import type { Api, KnownProvider, Model, Usage } from "./types"; +/** + * Static bundled model registry loaded from `models.json`. + * + * This module intentionally exposes compile-time defaults only. + * It does not include runtime discovery, models.dev overlays, or on-disk cache state. + * + * For runtime-aware resolution, use `createModelManager()` / `resolveProviderModels()`. + */ const modelRegistry: Map>> = new Map(); - -// Initialize registry from MODELS on module load for (const [provider, models] of Object.entries(MODELS)) { const providerModels = new Map>(); for (const [id, model] of Object.entries(models)) { @@ -12,18 +18,18 @@ for (const [provider, models] of Object.entries(MODELS)) { modelRegistry.set(provider, providerModels); } -type GeneratedProvider = keyof typeof MODELS; +export type GeneratedProvider = keyof typeof MODELS; -export function getModel(provider: GeneratedProvider, modelId: string): Model { +export function getBundledModel(provider: GeneratedProvider, modelId: string): Model { const providerModels = modelRegistry.get(provider); return providerModels?.get(modelId) as Model; } -export function getProviders(): KnownProvider[] { +export function getBundledProviders(): KnownProvider[] { return Array.from(modelRegistry.keys()) as KnownProvider[]; } -export function getModels(provider: GeneratedProvider): Model[] { +export function getBundledModels(provider: GeneratedProvider): Model[] { const models = modelRegistry.get(provider); return models ? (Array.from(models.values()) as Model[]) : []; } diff --git a/packages/ai/src/provider-models/google.ts b/packages/ai/src/provider-models/google.ts new file mode 100644 index 000000000..9574b0d82 --- /dev/null +++ b/packages/ai/src/provider-models/google.ts @@ -0,0 +1,90 @@ +import type { ModelManagerOptions } from "../model-manager"; +import { fetchAntigravityDiscoveryModels } from "../utils/discovery/antigravity"; +import { fetchGeminiModels } from "../utils/discovery/gemini"; + +export interface GoogleModelManagerConfig { + apiKey?: string; +} + +export interface GoogleVertexModelManagerConfig { + apiKey?: string; +} + +export interface GoogleAntigravityModelManagerConfig { + oauthToken?: string; + endpoint?: string; +} + +export interface GoogleGeminiCliModelManagerConfig { + oauthToken?: string; + endpoint?: string; +} + +const CLOUD_CODE_ASSIST_ENDPOINT = "https://cloudcode-pa.googleapis.com"; + +export function googleModelManagerOptions( + config?: GoogleModelManagerConfig, +): ModelManagerOptions<"google-generative-ai"> { + const apiKey = config?.apiKey; + return { + providerId: "google", + ...(apiKey ? { fetchDynamicModels: () => fetchGeminiModels({ apiKey }) } : undefined), + }; +} + +export function googleVertexModelManagerOptions( + _config?: GoogleVertexModelManagerConfig, +): ModelManagerOptions<"google-vertex"> { + // Vertex AI uses Application Default Credentials (ADC) for authentication, + // which is handled at stream time rather than during model discovery. + // Dynamic model discovery is not yet implemented for this provider. + return { + providerId: "google-vertex", + }; +} + +export function googleAntigravityModelManagerOptions( + config?: GoogleAntigravityModelManagerConfig, +): ModelManagerOptions<"google-gemini-cli"> { + const token = config?.oauthToken; + return { + providerId: "google-antigravity", + ...(token + ? { + fetchDynamicModels: () => + fetchAntigravityDiscoveryModels({ + token, + endpoint: config?.endpoint, + }), + } + : undefined), + }; +} + +export function googleGeminiCliModelManagerOptions( + config?: GoogleGeminiCliModelManagerConfig, +): ModelManagerOptions<"google-gemini-cli"> { + const token = config?.oauthToken; + const endpoint = config?.endpoint ?? CLOUD_CODE_ASSIST_ENDPOINT; + return { + providerId: "google-gemini-cli", + ...(token + ? { + fetchDynamicModels: async () => { + const models = await fetchAntigravityDiscoveryModels({ + token, + endpoint, + }); + if (models === null) { + return null; + } + return models.map(m => ({ + ...m, + provider: "google-gemini-cli" as const, + baseUrl: endpoint, + })); + }, + } + : undefined), + }; +} diff --git a/packages/ai/src/provider-models/index.ts b/packages/ai/src/provider-models/index.ts new file mode 100644 index 000000000..0c648b91b --- /dev/null +++ b/packages/ai/src/provider-models/index.ts @@ -0,0 +1,3 @@ +export * from "./google"; +export * from "./openai-compat"; +export * from "./special"; diff --git a/packages/ai/src/provider-models/openai-compat.ts b/packages/ai/src/provider-models/openai-compat.ts new file mode 100644 index 000000000..9d36869d5 --- /dev/null +++ b/packages/ai/src/provider-models/openai-compat.ts @@ -0,0 +1,703 @@ +import type { ModelManagerOptions } from "../model-manager"; +import { getBundledModels } from "../models"; +import type { Api, Model } from "../types"; +import { + fetchOpenAICompatibleModels, + type OpenAICompatibleModelMapperContext, + type OpenAICompatibleModelRecord, +} from "../utils/discovery/openai-compatible"; + +// --------------------------------------------------------------------------- +// Shared helper +// --------------------------------------------------------------------------- + +function toNumber(v: unknown): number { + if (typeof v === "number") return v; + if (typeof v === "string") return parseFloat(v) || 0; + return 0; +} + +const MODELS_DEV_URL = "https://models.dev/api.json"; +const ANTHROPIC_BASE_URL = "https://api.anthropic.com/v1"; +const ANTHROPIC_OAUTH_BETA = "claude-code-20250219,oauth-2025-04-20"; + +interface ModelsDevModel { + id?: string; + name?: string; + tool_call?: boolean; + reasoning?: boolean; + limit?: { + context?: number; + output?: number; + }; + cost?: { + input?: number; + output?: number; + cache_read?: number; + cache_write?: number; + }; + modalities?: { + input?: string[]; + }; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function toPositiveNumber(value: unknown, fallback: number): number { + if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) { + return fallback; + } + return value; +} + +function toModelName(value: unknown, fallback: string): string { + if (typeof value !== "string") { + return fallback; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : fallback; +} + +function toInputCapabilities(value: unknown): ("text" | "image")[] { + if (!Array.isArray(value)) { + return ["text"]; + } + const supportsImage = value.some(item => item === "image"); + return supportsImage ? ["text", "image"] : ["text"]; +} + +async function fetchModelsDevPayload(fetchImpl: typeof fetch = fetch): Promise { + const response = await fetchImpl(MODELS_DEV_URL, { + method: "GET", + headers: { Accept: "application/json" }, + }); + if (!response.ok) { + throw new Error(`models.dev fetch failed: ${response.status}`); + } + return response.json(); +} + +function mapAnthropicModelsDev(payload: unknown, baseUrl: string): Model<"anthropic-messages">[] { + if (!isRecord(payload)) { + return []; + } + const anthropicPayload = payload.anthropic; + if (!isRecord(anthropicPayload)) { + return []; + } + const modelsValue = anthropicPayload.models; + if (!isRecord(modelsValue)) { + return []; + } + + const models: Model<"anthropic-messages">[] = []; + for (const [modelId, rawModel] of Object.entries(modelsValue)) { + if (!isRecord(rawModel)) { + continue; + } + const model = rawModel as ModelsDevModel; + if (model.tool_call !== true) { + continue; + } + models.push({ + id: modelId, + name: toModelName(model.name, modelId), + api: "anthropic-messages", + provider: "anthropic", + baseUrl, + reasoning: model.reasoning === true, + input: toInputCapabilities(model.modalities?.input), + cost: { + input: toNumber(model.cost?.input), + output: toNumber(model.cost?.output), + cacheRead: toNumber(model.cost?.cache_read), + cacheWrite: toNumber(model.cost?.cache_write), + }, + contextWindow: toPositiveNumber(model.limit?.context, 4096), + maxTokens: toPositiveNumber(model.limit?.output, 4096), + }); + } + + models.sort((left, right) => left.id.localeCompare(right.id)); + return models; +} + +function isAnthropicOAuthToken(apiKey: string): boolean { + return apiKey.includes("sk-ant-oat"); +} + +function buildAnthropicDiscoveryHeaders(apiKey: string): Record { + const oauthToken = isAnthropicOAuthToken(apiKey); + const headers: Record = { + "anthropic-version": "2023-06-01", + "anthropic-dangerous-direct-browser-access": "true", + "anthropic-beta": ANTHROPIC_OAUTH_BETA, + }; + if (oauthToken) { + headers.Authorization = `Bearer ${apiKey}`; + } else { + headers["x-api-key"] = apiKey; + } + return headers; +} + +function buildAnthropicReferenceMap( + modelsDevModels: readonly Model<"anthropic-messages">[], +): Map> { + const merged = new Map>(); + for (const model of getBundledModels("anthropic") as Model<"anthropic-messages">[]) { + merged.set(model.id, model); + } + for (const model of modelsDevModels) { + merged.set(model.id, model); + } + return merged; +} + +function mapWithBundledReference( + entry: OpenAICompatibleModelRecord, + defaults: Model, + reference: Model | undefined, +): Model { + const name = toModelName(entry.name, reference?.name ?? defaults.name); + if (!reference) { + return { + ...defaults, + name, + }; + } + return { + ...reference, + id: defaults.id, + name, + baseUrl: defaults.baseUrl, + contextWindow: toPositiveNumber(entry.context_length, reference.contextWindow), + maxTokens: toPositiveNumber(entry.max_completion_tokens, reference.maxTokens), + }; +} + +function createBundledReferenceMap( + provider: Parameters[0], +): Map> { + const references = new Map>(); + for (const model of getBundledModels(provider)) { + references.set(model.id, model as Model); + } + return references; +} + +// --------------------------------------------------------------------------- +// 1. OpenAI +// --------------------------------------------------------------------------- + +export interface OpenAIModelManagerConfig { + apiKey?: string; + baseUrl?: string; +} + +export function openaiModelManagerOptions(config?: OpenAIModelManagerConfig): ModelManagerOptions<"openai-responses"> { + const apiKey = config?.apiKey; + const baseUrl = config?.baseUrl ?? "https://api.openai.com/v1"; + const references = createBundledReferenceMap<"openai-responses">("openai"); + return { + providerId: "openai", + ...(apiKey && { + fetchDynamicModels: () => + fetchOpenAICompatibleModels({ + api: "openai-responses", + provider: "openai", + baseUrl, + apiKey, + mapModel: (entry, defaults) => { + const reference = references.get(defaults.id); + return mapWithBundledReference(entry, defaults, reference); + }, + }), + }), + }; +} + +// --------------------------------------------------------------------------- +// 2. Groq +// --------------------------------------------------------------------------- + +export interface GroqModelManagerConfig { + apiKey?: string; + baseUrl?: string; +} + +export function groqModelManagerOptions(config?: GroqModelManagerConfig): ModelManagerOptions<"openai-completions"> { + const apiKey = config?.apiKey; + const baseUrl = config?.baseUrl ?? "https://api.groq.com/openai/v1"; + const references = createBundledReferenceMap<"openai-completions">("groq"); + return { + providerId: "groq", + ...(apiKey && { + fetchDynamicModels: () => + fetchOpenAICompatibleModels({ + api: "openai-completions", + provider: "groq", + baseUrl, + apiKey, + mapModel: (entry, defaults) => { + const reference = references.get(defaults.id); + return mapWithBundledReference(entry, defaults, reference); + }, + }), + }), + }; +} + +// --------------------------------------------------------------------------- +// 3. Cerebras +// --------------------------------------------------------------------------- + +export interface CerebrasModelManagerConfig { + apiKey?: string; + baseUrl?: string; +} + +export function cerebrasModelManagerOptions( + config?: CerebrasModelManagerConfig, +): ModelManagerOptions<"openai-completions"> { + const apiKey = config?.apiKey; + const baseUrl = config?.baseUrl ?? "https://api.cerebras.ai/v1"; + const references = createBundledReferenceMap<"openai-completions">("cerebras"); + return { + providerId: "cerebras", + ...(apiKey && { + fetchDynamicModels: () => + fetchOpenAICompatibleModels({ + api: "openai-completions", + provider: "cerebras", + baseUrl, + apiKey, + mapModel: (entry, defaults) => { + const reference = references.get(defaults.id); + return mapWithBundledReference(entry, defaults, reference); + }, + }), + }), + }; +} + +// --------------------------------------------------------------------------- +// 4. xAI +// --------------------------------------------------------------------------- + +export interface XaiModelManagerConfig { + apiKey?: string; + baseUrl?: string; +} + +export function xaiModelManagerOptions(config?: XaiModelManagerConfig): ModelManagerOptions<"openai-completions"> { + const apiKey = config?.apiKey; + const baseUrl = config?.baseUrl ?? "https://api.x.ai/v1"; + const references = createBundledReferenceMap<"openai-completions">("xai"); + return { + providerId: "xai", + ...(apiKey && { + fetchDynamicModels: () => + fetchOpenAICompatibleModels({ + api: "openai-completions", + provider: "xai", + baseUrl, + apiKey, + mapModel: (entry, defaults) => { + const reference = references.get(defaults.id); + return mapWithBundledReference(entry, defaults, reference); + }, + }), + }), + }; +} + +// --------------------------------------------------------------------------- +// 5. Mistral +// --------------------------------------------------------------------------- + +export interface MistralModelManagerConfig { + apiKey?: string; + baseUrl?: string; +} + +export function mistralModelManagerOptions( + config?: MistralModelManagerConfig, +): ModelManagerOptions<"openai-completions"> { + const apiKey = config?.apiKey; + const baseUrl = config?.baseUrl ?? "https://api.mistral.ai/v1"; + const references = createBundledReferenceMap<"openai-completions">("mistral"); + return { + providerId: "mistral", + ...(apiKey && { + fetchDynamicModels: () => + fetchOpenAICompatibleModels({ + api: "openai-completions", + provider: "mistral", + baseUrl, + apiKey, + mapModel: (entry, defaults) => { + const reference = references.get(defaults.id); + return mapWithBundledReference(entry, defaults, reference); + }, + }), + }), + }; +} + +// --------------------------------------------------------------------------- +// 6. OpenCode +// --------------------------------------------------------------------------- + +export interface OpenCodeModelManagerConfig { + apiKey?: string; + baseUrl?: string; +} + +export function opencodeModelManagerOptions( + config?: OpenCodeModelManagerConfig, +): ModelManagerOptions<"openai-completions"> { + const apiKey = config?.apiKey; + const baseUrl = config?.baseUrl ?? "https://opencode.ai/zen/v1"; + return { + providerId: "opencode", + ...(apiKey && { + fetchDynamicModels: () => + fetchOpenAICompatibleModels({ + api: "openai-completions", + provider: "opencode", + baseUrl, + apiKey, + }), + }), + }; +} + +// --------------------------------------------------------------------------- +// 7. OpenRouter +// --------------------------------------------------------------------------- + +export interface OpenRouterModelManagerConfig { + apiKey?: string; + baseUrl?: string; +} + +export function openrouterModelManagerOptions( + config?: OpenRouterModelManagerConfig, +): ModelManagerOptions<"openai-completions"> { + const apiKey = config?.apiKey; + const baseUrl = config?.baseUrl ?? "https://openrouter.ai/api/v1"; + return { + providerId: "openrouter", + ...(apiKey && { + fetchDynamicModels: () => + fetchOpenAICompatibleModels({ + api: "openai-completions", + provider: "openrouter", + baseUrl, + apiKey, + filterModel: (entry: OpenAICompatibleModelRecord) => { + const params = entry.supported_parameters; + return Array.isArray(params) && params.includes("tools"); + }, + mapModel: ( + entry: OpenAICompatibleModelRecord, + defaults: Model<"openai-completions">, + _context: OpenAICompatibleModelMapperContext<"openai-completions">, + ): Model<"openai-completions"> => { + const pricing = entry.pricing as Record | undefined; + const params = Array.isArray(entry.supported_parameters) + ? (entry.supported_parameters as string[]) + : []; + const modality = String((entry.architecture as Record | undefined)?.modality ?? ""); + const topProvider = entry.top_provider as Record | undefined; + + const supportsToolChoice = params.includes("tool_choice"); + + return { + ...defaults, + reasoning: params.includes("reasoning"), + input: modality.includes("image") ? ["text", "image"] : ["text"], + cost: { + input: parseFloat(String(pricing?.prompt ?? "0")) * 1_000_000, + output: parseFloat(String(pricing?.completion ?? "0")) * 1_000_000, + cacheRead: parseFloat(String(pricing?.input_cache_read ?? "0")) * 1_000_000, + cacheWrite: parseFloat(String(pricing?.input_cache_write ?? "0")) * 1_000_000, + }, + contextWindow: + typeof entry.context_length === "number" ? entry.context_length : defaults.contextWindow, + maxTokens: + typeof topProvider?.max_completion_tokens === "number" + ? topProvider.max_completion_tokens + : defaults.maxTokens, + ...(!supportsToolChoice && { + compat: { supportsToolChoice: false }, + }), + }; + }, + }), + }), + }; +} + +// --------------------------------------------------------------------------- +// 8. Vercel AI Gateway +// --------------------------------------------------------------------------- + +export interface VercelAiGatewayModelManagerConfig { + apiKey?: string; + baseUrl?: string; +} + +export function vercelAiGatewayModelManagerOptions( + config?: VercelAiGatewayModelManagerConfig, +): ModelManagerOptions<"anthropic-messages"> { + const apiKey = config?.apiKey; + const baseUrl = config?.baseUrl ?? "https://ai-gateway.vercel.sh"; + return { + providerId: "vercel-ai-gateway", + ...(apiKey && { + fetchDynamicModels: () => + fetchOpenAICompatibleModels({ + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl, + apiKey, + filterModel: (entry: OpenAICompatibleModelRecord) => { + const tags = entry.tags; + return Array.isArray(tags) && tags.includes("tool-use"); + }, + mapModel: ( + entry: OpenAICompatibleModelRecord, + defaults: Model<"anthropic-messages">, + _context: OpenAICompatibleModelMapperContext<"anthropic-messages">, + ): Model<"anthropic-messages"> => { + const pricing = entry.pricing as Record | undefined; + const tags = Array.isArray(entry.tags) ? (entry.tags as string[]) : []; + + return { + ...defaults, + reasoning: tags.includes("reasoning"), + input: tags.includes("vision") ? ["text", "image"] : ["text"], + cost: { + input: toNumber(pricing?.input) * 1_000_000, + output: toNumber(pricing?.output) * 1_000_000, + cacheRead: toNumber(pricing?.input_cache_read) * 1_000_000, + cacheWrite: toNumber(pricing?.input_cache_write) * 1_000_000, + }, + contextWindow: + typeof entry.context_window === "number" ? entry.context_window : defaults.contextWindow, + maxTokens: typeof entry.max_tokens === "number" ? entry.max_tokens : defaults.maxTokens, + }; + }, + }), + }), + }; +} + +// --------------------------------------------------------------------------- +// 9. Kimi Code +// --------------------------------------------------------------------------- + +export interface KimiCodeModelManagerConfig { + apiKey?: string; + baseUrl?: string; +} + +export function kimiCodeModelManagerOptions( + config?: KimiCodeModelManagerConfig, +): ModelManagerOptions<"openai-completions"> { + const apiKey = config?.apiKey; + const baseUrl = config?.baseUrl ?? "https://api.kimi.com/coding/v1"; + return { + providerId: "kimi-code", + ...(apiKey && { + fetchDynamicModels: () => + fetchOpenAICompatibleModels({ + api: "openai-completions", + provider: "kimi-code", + baseUrl, + apiKey, + headers: { + "User-Agent": "KimiCLI/1.0", + "X-Msh-Platform": "kimi_cli", + }, + mapModel: ( + entry: OpenAICompatibleModelRecord, + defaults: Model<"openai-completions">, + _context: OpenAICompatibleModelMapperContext<"openai-completions">, + ): Model<"openai-completions"> => { + const id = defaults.id; + return { + ...defaults, + name: typeof entry.display_name === "string" ? entry.display_name : defaults.name, + reasoning: entry.supports_reasoning === true || id.includes("thinking"), + input: entry.supports_image_in === true || id.includes("k2.5") ? ["text", "image"] : ["text"], + contextWindow: typeof entry.context_length === "number" ? entry.context_length : 262144, + maxTokens: 32000, + compat: { + thinkingFormat: "zai", + reasoningContentField: "reasoning_content", + supportsDeveloperRole: false, + }, + }; + }, + }), + }), + }; +} + +// --------------------------------------------------------------------------- +// 10. GitHub Copilot +// --------------------------------------------------------------------------- + +export interface GithubCopilotModelManagerConfig { + apiKey?: string; + baseUrl?: string; +} +const GITHUB_COPILOT_HEADERS: Record = { + "User-Agent": "GitHubCopilotChat/0.35.0", + "Editor-Version": "vscode/1.107.0", + "Editor-Plugin-Version": "copilot-chat/0.35.0", + "Copilot-Integration-Id": "vscode-chat", +}; + +function inferCopilotApi(modelId: string): Api { + if (/^claude-(haiku|sonnet|opus)-4([.-]|$)/.test(modelId)) { + return "anthropic-messages"; + } + if (modelId.startsWith("gpt-5") || modelId.startsWith("oswe")) { + return "openai-responses"; + } + return "openai-completions"; +} + +export function githubCopilotModelManagerOptions(config?: GithubCopilotModelManagerConfig): ModelManagerOptions { + const apiKey = config?.apiKey; + const baseUrl = config?.baseUrl ?? "https://api.individual.githubcopilot.com"; + const references = new Map((getBundledModels("github-copilot") as Model[]).map(model => [model.id, model])); + return { + providerId: "github-copilot", + ...(apiKey && { + fetchDynamicModels: () => + fetchOpenAICompatibleModels({ + api: "openai-completions", + provider: "github-copilot", + baseUrl, + apiKey, + headers: GITHUB_COPILOT_HEADERS, + mapModel: ( + entry: OpenAICompatibleModelRecord, + defaults: Model, + _context: OpenAICompatibleModelMapperContext, + ): Model => { + const reference = references.get(defaults.id); + const contextWindow = + typeof entry.context_length === "number" + ? entry.context_length + : (reference?.contextWindow ?? defaults.contextWindow); + const maxTokens = + typeof entry.max_completion_tokens === "number" + ? entry.max_completion_tokens + : (reference?.maxTokens ?? defaults.maxTokens); + const name = + typeof entry.name === "string" && entry.name.trim().length > 0 + ? entry.name + : (reference?.name ?? defaults.name); + if (reference) { + return { + ...reference, + baseUrl, + name, + contextWindow, + maxTokens, + headers: { ...GITHUB_COPILOT_HEADERS, ...reference.headers }, + }; + } + const api = inferCopilotApi(defaults.id); + return { + ...defaults, + api, + baseUrl, + name, + contextWindow, + maxTokens, + headers: { ...GITHUB_COPILOT_HEADERS }, + ...(api === "openai-completions" + ? { + compat: { + supportsStore: false, + supportsDeveloperRole: false, + supportsReasoningEffort: false, + }, + } + : {}), + }; + }, + }), + }), + }; +} + +// --------------------------------------------------------------------------- +// 11. Anthropic +// --------------------------------------------------------------------------- + +export interface AnthropicModelManagerConfig { + apiKey?: string; + baseUrl?: string; +} + +export function anthropicModelManagerOptions( + config?: AnthropicModelManagerConfig, +): ModelManagerOptions<"anthropic-messages"> { + const apiKey = config?.apiKey; + const baseUrl = config?.baseUrl ?? ANTHROPIC_BASE_URL; + return { + providerId: "anthropic", + modelsDev: { + fetch: fetchModelsDevPayload, + map: payload => mapAnthropicModelsDev(payload, baseUrl), + }, + ...(apiKey && { + fetchDynamicModels: async () => { + const modelsDevModels = await fetchModelsDevPayload() + .then(payload => mapAnthropicModelsDev(payload, baseUrl)) + .catch(() => []); + const references = buildAnthropicReferenceMap(modelsDevModels); + return ( + fetchOpenAICompatibleModels({ + api: "anthropic-messages", + provider: "anthropic", + baseUrl, + headers: buildAnthropicDiscoveryHeaders(apiKey), + mapModel: ( + entry: OpenAICompatibleModelRecord, + defaults: Model<"anthropic-messages">, + _context: OpenAICompatibleModelMapperContext<"anthropic-messages">, + ): Model<"anthropic-messages"> => { + const discoveredName = typeof entry.display_name === "string" ? entry.display_name : defaults.name; + const reference = references.get(defaults.id); + if (!reference) { + return { + ...defaults, + name: discoveredName, + }; + } + return { + ...reference, + id: defaults.id, + name: discoveredName, + api: "anthropic-messages", + provider: "anthropic", + baseUrl, + }; + }, + }) ?? null + ); + }, + }), + }; +} diff --git a/packages/ai/src/provider-models/special.ts b/packages/ai/src/provider-models/special.ts new file mode 100644 index 000000000..da48eacb3 --- /dev/null +++ b/packages/ai/src/provider-models/special.ts @@ -0,0 +1,106 @@ +import type { ModelManagerOptions } from "../model-manager"; +import { fetchCodexModels } from "../utils/discovery/codex"; +import { fetchCursorUsableModels } from "../utils/discovery/cursor"; + +// --------------------------------------------------------------------------- +// OpenAI Codex +// --------------------------------------------------------------------------- + +export interface OpenAICodexModelManagerConfig { + accessToken?: string; + accountId?: string; + clientVersion?: string; +} + +export function openaiCodexModelManagerOptions( + config: OpenAICodexModelManagerConfig = {}, +): ModelManagerOptions<"openai-codex-responses"> { + const { accessToken, accountId, clientVersion } = config; + return { + providerId: "openai-codex", + ...(accessToken + ? { + fetchDynamicModels: async () => { + const result = await fetchCodexModels({ accessToken, accountId, clientVersion }); + return result?.models ?? null; + }, + } + : undefined), + }; +} + +// --------------------------------------------------------------------------- +// Cursor +// --------------------------------------------------------------------------- + +export interface CursorModelManagerConfig { + apiKey?: string; + baseUrl?: string; + clientVersion?: string; +} + +export function cursorModelManagerOptions(config: CursorModelManagerConfig = {}): ModelManagerOptions<"cursor-agent"> { + const { apiKey, baseUrl, clientVersion } = config; + return { + providerId: "cursor", + ...(apiKey + ? { + fetchDynamicModels: () => fetchCursorUsableModels({ apiKey, baseUrl, clientVersion }), + } + : undefined), + }; +} + +// --------------------------------------------------------------------------- +// Amazon Bedrock +// --------------------------------------------------------------------------- + +// Dynamic discovery requires AWS SDK auth (ListFoundationModels). Not yet implemented. + +export interface AmazonBedrockModelManagerConfig {} + +export function amazonBedrockModelManagerOptions( + _config: AmazonBedrockModelManagerConfig = {}, +): ModelManagerOptions<"bedrock-converse-stream"> { + return { providerId: "amazon-bedrock" }; +} + +// --------------------------------------------------------------------------- +// MiniMax variants (subscription-based, no model listing endpoint) +// --------------------------------------------------------------------------- + +export interface MinimaxModelManagerConfig {} + +export function minimaxModelManagerOptions( + _config: MinimaxModelManagerConfig = {}, +): ModelManagerOptions<"anthropic-messages"> { + return { providerId: "minimax" }; +} + +export function minimaxCnModelManagerOptions( + _config: MinimaxModelManagerConfig = {}, +): ModelManagerOptions<"anthropic-messages"> { + return { providerId: "minimax-cn" }; +} + +export function minimaxCodeModelManagerOptions( + _config: MinimaxModelManagerConfig = {}, +): ModelManagerOptions<"openai-completions"> { + return { providerId: "minimax-code" }; +} + +export function minimaxCodeCnModelManagerOptions( + _config: MinimaxModelManagerConfig = {}, +): ModelManagerOptions<"openai-completions"> { + return { providerId: "minimax-code-cn" }; +} + +// --------------------------------------------------------------------------- +// Zai +// --------------------------------------------------------------------------- + +export interface ZaiModelManagerConfig {} + +export function zaiModelManagerOptions(_config: ZaiModelManagerConfig = {}): ModelManagerOptions<"anthropic-messages"> { + return { providerId: "zai" }; +} diff --git a/packages/ai/src/utils/discovery/antigravity.ts b/packages/ai/src/utils/discovery/antigravity.ts new file mode 100644 index 000000000..3fead6ffe --- /dev/null +++ b/packages/ai/src/utils/discovery/antigravity.ts @@ -0,0 +1,266 @@ +import { z } from "zod"; +import type { Model } from "../../types"; + +const DEFAULT_ANTIGRAVITY_DISCOVERY_ENDPOINT = "https://daily-cloudcode-pa.sandbox.googleapis.com"; +const FETCH_AVAILABLE_MODELS_PATH = "/v1internal:fetchAvailableModels"; +const DEFAULT_USER_AGENT = "antigravity/1.107.0 linux/amd64"; + +const DEFAULT_CONTEXT_WINDOW = 200_000; +const DEFAULT_MAX_TOKENS = 64_000; + +/** + * Raw model metadata returned by Antigravity's `fetchAvailableModels` endpoint. + */ +export interface AntigravityDiscoveryApiModel { + displayName?: string; + supportsImages?: boolean; + supportsThinking?: boolean; + thinkingBudget?: number; + recommended?: boolean; + maxTokens?: number; + maxOutputTokens?: number; + model?: string; + apiProvider?: string; + modelProvider?: string; + isInternal?: boolean; + supportsVideo?: boolean; +} + +/** + * Grouping metadata used by Antigravity to surface recommended model ids. + */ +export interface AntigravityDiscoveryAgentModelGroup { + modelIds?: string[]; +} + +/** + * Sort/group metadata used by Antigravity to surface recommended model ids. + */ +export interface AntigravityDiscoveryAgentModelSort { + groups?: AntigravityDiscoveryAgentModelGroup[]; +} + +/** + * Response payload returned by Antigravity's `fetchAvailableModels` endpoint. + */ +export interface AntigravityDiscoveryApiResponse { + models?: Record; + agentModelSorts?: AntigravityDiscoveryAgentModelSort[]; +} +const AntigravityDiscoveryApiModelSchema: z.ZodType = z + .object({ + displayName: z.preprocess(value => (typeof value === "string" ? value : undefined), z.string().optional()), + supportsImages: z.preprocess(value => (typeof value === "boolean" ? value : undefined), z.boolean().optional()), + supportsThinking: z.preprocess(value => (typeof value === "boolean" ? value : undefined), z.boolean().optional()), + thinkingBudget: z.preprocess( + value => (typeof value === "number" && Number.isFinite(value) ? value : undefined), + z.number().optional(), + ), + recommended: z.preprocess(value => (typeof value === "boolean" ? value : undefined), z.boolean().optional()), + maxTokens: z.preprocess( + value => (typeof value === "number" && Number.isFinite(value) ? value : undefined), + z.number().optional(), + ), + maxOutputTokens: z.preprocess( + value => (typeof value === "number" && Number.isFinite(value) ? value : undefined), + z.number().optional(), + ), + model: z.preprocess(value => (typeof value === "string" ? value : undefined), z.string().optional()), + apiProvider: z.preprocess(value => (typeof value === "string" ? value : undefined), z.string().optional()), + modelProvider: z.preprocess(value => (typeof value === "string" ? value : undefined), z.string().optional()), + isInternal: z.preprocess(value => (typeof value === "boolean" ? value : undefined), z.boolean().optional()), + supportsVideo: z.preprocess(value => (typeof value === "boolean" ? value : undefined), z.boolean().optional()), + }) + .passthrough(); +const AntigravityDiscoveryAgentModelGroupSchema: z.ZodType = z + .object({ + modelIds: z.preprocess( + value => + Array.isArray(value) + ? value.filter((modelId): modelId is string => typeof modelId === "string") + : undefined, + z.array(z.string()).optional(), + ), + }) + .passthrough(); +const AntigravityDiscoveryAgentModelSortSchema: z.ZodType = z + .object({ + groups: z.preprocess( + value => (Array.isArray(value) ? value : undefined), + z + .array(z.unknown()) + .transform(groups => + groups.flatMap(group => { + const parsedGroup = AntigravityDiscoveryAgentModelGroupSchema.safeParse(group); + return parsedGroup.success ? [parsedGroup.data] : []; + }), + ) + .optional(), + ), + }) + .passthrough(); +const AntigravityDiscoveryApiResponseSchema: z.ZodType = z + .object({ + models: z.preprocess( + value => (typeof value === "object" && value !== null ? value : undefined), + z + .record(z.string(), z.unknown()) + .transform(models => { + const normalized: Record = {}; + for (const [modelId, modelValue] of Object.entries(models)) { + if (typeof modelValue !== "object" || modelValue === null) { + continue; + } + const parsedModel = AntigravityDiscoveryApiModelSchema.safeParse(modelValue); + if (parsedModel.success) { + normalized[modelId] = parsedModel.data; + } + } + return normalized; + }) + .optional(), + ), + agentModelSorts: z.preprocess( + value => (Array.isArray(value) ? value : undefined), + z + .array(z.unknown()) + .transform(sorts => + sorts.flatMap(sort => { + const parsedSort = AntigravityDiscoveryAgentModelSortSchema.safeParse(sort); + return parsedSort.success ? [parsedSort.data] : []; + }), + ) + .optional(), + ), + }) + .passthrough(); + +/** + * Options for fetching Antigravity discovery models. + */ +export interface FetchAntigravityDiscoveryModelsOptions { + /** OAuth access token used as `Authorization: Bearer `. */ + token: string; + /** Optional endpoint override. Defaults to Antigravity daily endpoint. */ + endpoint?: string; + /** Optional project id. Defaults to an empty string for discovery. */ + project?: string; + /** Optional user agent override. */ + userAgent?: string; + /** Optional abort signal for request cancellation. */ + signal?: AbortSignal; + /** Optional fetch implementation override for tests. */ + fetcher?: typeof fetch; +} + +/** + * Fetches discoverable Antigravity models and normalizes them into canonical model entries. + * + * Returns `null` on network/payload/auth failures. + * Returns `[]` only when the endpoint responds successfully with no usable models. + */ +export async function fetchAntigravityDiscoveryModels( + options: FetchAntigravityDiscoveryModelsOptions, +): Promise[] | null> { + const fetcher = options.fetcher ?? fetch; + const endpoint = trimTrailingSlashes(options.endpoint ?? DEFAULT_ANTIGRAVITY_DISCOVERY_ENDPOINT); + + let response: Response; + try { + response = await fetcher(`${endpoint}${FETCH_AVAILABLE_MODELS_PATH}`, { + method: "POST", + headers: { + Authorization: `Bearer ${options.token}`, + "Content-Type": "application/json", + "User-Agent": options.userAgent ?? DEFAULT_USER_AGENT, + }, + body: JSON.stringify({ project: options.project ?? "" }), + signal: options.signal, + }); + } catch { + return null; + } + + if (!response.ok) { + return null; + } + + let payload: unknown; + try { + payload = await response.json(); + } catch { + return null; + } + + const parsed = parseAntigravityDiscoveryResponse(payload); + if (!parsed) { + return null; + } + + const recommendedIds = collectRecommendedModelIds(parsed.agentModelSorts ?? []); + const models: Model<"google-gemini-cli">[] = []; + + for (const [modelId, model] of Object.entries(parsed.models ?? {})) { + if (model.isInternal === true) { + continue; + } + if (model.recommended !== true && !recommendedIds.has(modelId)) { + continue; + } + + const supportsImages = model.supportsImages === true; + models.push({ + id: modelId, + name: model.displayName ? `${model.displayName} (Antigravity)` : modelId, + api: "google-gemini-cli", + provider: "google-antigravity", + baseUrl: endpoint, + reasoning: model.supportsThinking === true, + input: supportsImages ? ["text", "image"] : ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: toPositiveNumberOr(model.maxTokens, DEFAULT_CONTEXT_WINDOW), + maxTokens: toPositiveNumberOr(model.maxOutputTokens, DEFAULT_MAX_TOKENS), + }); + } + + models.sort((a, b) => a.name.localeCompare(b.name) || a.id.localeCompare(b.id)); + return models; +} + +function collectRecommendedModelIds(sorts: AntigravityDiscoveryAgentModelSort[]): Set { + const ids = new Set(); + for (const sort of sorts) { + for (const group of sort.groups ?? []) { + for (const modelId of group.modelIds ?? []) { + if (typeof modelId === "string" && modelId.length > 0) { + ids.add(modelId); + } + } + } + } + return ids; +} + +function parseAntigravityDiscoveryResponse(value: unknown): AntigravityDiscoveryApiResponse | null { + const parsed = AntigravityDiscoveryApiResponseSchema.safeParse(value); + if (!parsed.success) { + return null; + } + return parsed.data; +} + +function trimTrailingSlashes(value: string): string { + return value.replace(/\/+$/, ""); +} + +function toPositiveNumberOr(value: number | undefined, fallback: number): number { + if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) { + return fallback; + } + return value; +} diff --git a/packages/ai/src/utils/discovery/codex.ts b/packages/ai/src/utils/discovery/codex.ts new file mode 100644 index 000000000..1f5ee6983 --- /dev/null +++ b/packages/ai/src/utils/discovery/codex.ts @@ -0,0 +1,373 @@ +import { z } from "zod"; +import { CODEX_BASE_URL, OPENAI_HEADER_VALUES, OPENAI_HEADERS } from "../../providers/openai-codex/constants"; +import type { Model } from "../../types"; + +const DEFAULT_MODEL_LIST_PATHS = ["/codex/models", "/models"] as const; +const DEFAULT_CONTEXT_WINDOW = 272_000; +const DEFAULT_MAX_TOKENS = 128_000; +const DEFAULT_CODEX_CLIENT_VERSION = "0.99.0"; +const NPM_CODEX_LATEST_URL = "https://registry.npmjs.org/@openai%2Fcodex/latest"; + +const codexReasoningPresetSchema = z + .object({ + effort: z.unknown().optional(), + }) + .passthrough(); + +const codexModelEntrySchema = z + .object({ + slug: z.unknown().optional(), + id: z.unknown().optional(), + display_name: z.unknown().optional(), + context_window: z.unknown().optional(), + default_reasoning_level: z.unknown().optional(), + supported_reasoning_levels: z.unknown().optional(), + input_modalities: z.unknown().optional(), + supported_in_api: z.unknown().optional(), + priority: z.unknown().optional(), + prefer_websockets: z.unknown().optional(), + }) + .passthrough(); + +const codexModelsResponseSchema = z + .object({ + models: z.array(z.unknown()).optional(), + data: z.array(z.unknown()).optional(), + }) + .passthrough(); + +type CodexModelEntry = z.infer; + +interface NormalizedCodexModel { + model: Model<"openai-codex-responses">; + priority: number; +} + +/** + * Fetch options for OpenAI Codex model discovery. + */ +export interface CodexModelDiscoveryOptions { + /** OAuth access token used for `Authorization: Bearer ...`. */ + accessToken: string; + /** ChatGPT account id value used for `chatgpt-account-id` header. */ + accountId?: string; + /** Base URL for Codex backend. Defaults to `https://chatgpt.com/backend-api`. */ + baseUrl?: string; + /** Optional client version attached as `client_version` query parameter. */ + clientVersion?: string; + /** Optional endpoint path candidates. Defaults to `/codex/models`, then `/models`. */ + paths?: readonly string[]; + /** Additional headers merged on top of required Codex headers. */ + headers?: Record; + /** Abort signal for network request cancellation. */ + signal?: AbortSignal; + /** Optional fetch implementation override for tests. */ + fetchFn?: typeof fetch; + /** Optional registry fetch implementation override for client version lookup. */ + registryFetchFn?: typeof fetch; +} + +/** + * Normalized Codex discovery response. + */ +export interface CodexModelDiscoveryResult { + models: Model<"openai-codex-responses">[]; + etag?: string; +} + +/** + * Fetches model metadata from Codex backend and normalizes it for pi model management. + * + * Returns `null` when no supported model-list route can be fetched/parsed. + * Returns `{ models: [] }` when a route succeeds but yields no usable models. + */ +export async function fetchCodexModels(options: CodexModelDiscoveryOptions): Promise { + const fetchFn = options.fetchFn ?? fetch; + const baseUrl = normalizeBaseUrl(options.baseUrl); + const paths = normalizePaths(options.paths); + const headers = buildCodexHeaders(options); + const clientVersion = await resolveCodexClientVersion( + options.clientVersion, + options.registryFetchFn ?? fetchFn, + options.signal, + ); + + let sawSuccessfulResponse = false; + for (const path of paths) { + const requestUrl = buildModelsUrl(baseUrl, path, clientVersion); + let response: Response; + try { + response = await fetchFn(requestUrl, { + method: "GET", + headers, + signal: options.signal, + }); + } catch { + continue; + } + + if (!response.ok) { + continue; + } + + let payload: unknown; + try { + payload = await response.json(); + } catch { + continue; + } + + const models = normalizeCodexModels(payload, baseUrl); + if (models === null) { + continue; + } + sawSuccessfulResponse = true; + const etag = getResponseEtag(response.headers); + return etag ? { models, etag } : { models }; + } + return sawSuccessfulResponse ? { models: [] } : null; +} + +function normalizeBaseUrl(baseUrl: string | undefined): string { + const raw = (baseUrl ?? CODEX_BASE_URL).trim(); + if (!raw) { + return CODEX_BASE_URL; + } + return raw.replace(/\/+$/, ""); +} + +function normalizePaths(paths: readonly string[] | undefined): string[] { + if (!paths || paths.length === 0) { + return [...DEFAULT_MODEL_LIST_PATHS]; + } + const normalized = paths + .map(path => path.trim()) + .filter(path => path.length > 0) + .map(path => (path.startsWith("/") ? path : `/${path}`)); + return normalized.length > 0 ? normalized : [...DEFAULT_MODEL_LIST_PATHS]; +} + +function buildModelsUrl(baseUrl: string, path: string, clientVersion: string | undefined): string { + const url = new URL(`${baseUrl}${path}`); + if (clientVersion && clientVersion.trim().length > 0) { + url.searchParams.set("client_version", clientVersion.trim()); + } + return url.toString(); +} + +function buildCodexHeaders(options: CodexModelDiscoveryOptions): Headers { + const headers = new Headers(options.headers); + headers.set("Authorization", `Bearer ${options.accessToken}`); + if (options.accountId && options.accountId.trim().length > 0) { + headers.set(OPENAI_HEADERS.ACCOUNT_ID, options.accountId); + } + headers.set(OPENAI_HEADERS.BETA, OPENAI_HEADER_VALUES.BETA_RESPONSES); + headers.set(OPENAI_HEADERS.ORIGINATOR, OPENAI_HEADER_VALUES.ORIGINATOR_CODEX); + headers.set("accept", "application/json"); + return headers; +} + +async function resolveCodexClientVersion( + clientVersion: string | undefined, + fetchFn: typeof fetch, + signal: AbortSignal | undefined, +): Promise { + const normalizedClientVersion = normalizeClientVersion(clientVersion); + if (normalizedClientVersion) { + return normalizedClientVersion; + } + try { + const response = await fetchFn(NPM_CODEX_LATEST_URL, { + method: "GET", + headers: { Accept: "application/json" }, + signal, + }); + if (!response.ok) { + return DEFAULT_CODEX_CLIENT_VERSION; + } + const payload: unknown = await response.json(); + if (!isRecord(payload)) { + return DEFAULT_CODEX_CLIENT_VERSION; + } + const npmVersion = normalizeClientVersion(payload.version); + return npmVersion ?? DEFAULT_CODEX_CLIENT_VERSION; + } catch (error) { + if (isAbortError(error)) { + throw error; + } + return DEFAULT_CODEX_CLIENT_VERSION; + } +} + +function normalizeClientVersion(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; + } + const trimmed = value.trim(); + if (!/^\d+\.\d+\.\d+$/.test(trimmed)) { + return undefined; + } + return trimmed; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function isAbortError(error: unknown): error is Error { + return error instanceof Error && error.name === "AbortError"; +} + +function normalizeCodexModels(payload: unknown, baseUrl: string): Model<"openai-codex-responses">[] | null { + const parsedResponse = codexModelsResponseSchema.safeParse(payload); + if (!parsedResponse.success) { + return null; + } + + const entries = parsedResponse.data.models ?? parsedResponse.data.data ?? []; + const normalized: NormalizedCodexModel[] = []; + for (const entry of entries) { + const model = normalizeCodexModelEntry(entry, baseUrl); + if (model) { + normalized.push(model); + } + } + + normalized.sort((left, right) => { + if (left.priority !== right.priority) { + return left.priority - right.priority; + } + return left.model.id.localeCompare(right.model.id); + }); + + return normalized.map(item => item.model); +} + +function normalizeCodexModelEntry(entry: unknown, baseUrl: string): NormalizedCodexModel | null { + const parsedEntry = codexModelEntrySchema.safeParse(entry); + if (!parsedEntry.success) { + return null; + } + + const payload: CodexModelEntry = parsedEntry.data; + const slug = toNonEmptyString(payload.slug) ?? toNonEmptyString(payload.id); + if (!slug) { + return null; + } + + const supportedInApi = toBoolean(payload.supported_in_api); + if (supportedInApi === false) { + return null; + } + + const name = toNonEmptyString(payload.display_name) ?? slug; + const contextWindow = toPositiveInt(payload.context_window) ?? DEFAULT_CONTEXT_WINDOW; + const maxTokens = Math.min(DEFAULT_MAX_TOKENS, contextWindow); + const reasoning = supportsReasoning(payload.default_reasoning_level, payload.supported_reasoning_levels); + const input = normalizeInputModalities(payload.input_modalities); + const preferWebsockets = toBoolean(payload.prefer_websockets) === true; + const priority = toFiniteNumber(payload.priority) ?? Number.MAX_SAFE_INTEGER; + + return { + priority, + model: { + id: slug, + name, + api: "openai-codex-responses", + provider: "openai-codex", + baseUrl, + reasoning, + input, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow, + maxTokens, + ...(preferWebsockets ? { preferWebsockets: true } : {}), + }, + }; +} + +function supportsReasoning(defaultReasoningLevel: unknown, supportedReasoningLevels: unknown): boolean { + const defaultLevel = toNonEmptyString(defaultReasoningLevel)?.toLowerCase(); + if (defaultLevel && defaultLevel !== "none") { + return true; + } + + if (!Array.isArray(supportedReasoningLevels)) { + return false; + } + + for (const level of supportedReasoningLevels) { + const parsedLevel = codexReasoningPresetSchema.safeParse(level); + if (!parsedLevel.success) { + continue; + } + const effort = toNonEmptyString(parsedLevel.data.effort)?.toLowerCase(); + if (effort && effort !== "none") { + return true; + } + } + + return false; +} + +function normalizeInputModalities(inputModalities: unknown): ("text" | "image")[] { + if (!Array.isArray(inputModalities)) { + return ["text", "image"]; + } + + const set = new Set<"text" | "image">(); + for (const modality of inputModalities) { + const normalized = toNonEmptyString(modality)?.toLowerCase(); + if (normalized === "text" || normalized === "image") { + set.add(normalized); + } + } + + if (set.size === 0) { + return ["text", "image"]; + } + + const canonical: ("text" | "image")[] = ["text", "image"]; + return canonical.filter(modality => set.has(modality)); +} + +function getResponseEtag(headers: Headers): string | undefined { + const etag = headers.get("etag"); + if (!etag) { + return undefined; + } + const trimmed = etag.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +function toNonEmptyString(value: unknown): string | null { + if (typeof value !== "string") { + return null; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function toPositiveInt(value: unknown): number | null { + if (typeof value !== "number" || !Number.isFinite(value)) { + return null; + } + if (value <= 0) { + return null; + } + return Math.trunc(value); +} + +function toFiniteNumber(value: unknown): number | null { + if (typeof value !== "number" || !Number.isFinite(value)) { + return null; + } + return value; +} + +function toBoolean(value: unknown): boolean | null { + if (typeof value !== "boolean") { + return null; + } + return value; +} diff --git a/packages/ai/src/utils/discovery/cursor.ts b/packages/ai/src/utils/discovery/cursor.ts new file mode 100644 index 000000000..90cde193a --- /dev/null +++ b/packages/ai/src/utils/discovery/cursor.ts @@ -0,0 +1,239 @@ +import { create, fromBinary, toBinary } from "@bufbuild/protobuf"; +import { z } from "zod"; +import { GetUsableModelsRequestSchema, GetUsableModelsResponseSchema } from "../../providers/cursor/gen/agent_pb"; +import type { Model } from "../../types"; + +const CURSOR_DEFAULT_BASE_URL = "https://api2.cursor.sh"; +const CURSOR_DEFAULT_CLIENT_VERSION = "cli-2026.01.09-231024f"; +const CURSOR_GET_USABLE_MODELS_PATH = "/agent.v1.AgentService/GetUsableModels"; + +const DEFAULT_CONTEXT_WINDOW = 200_000; +const DEFAULT_MAX_TOKENS = 64_000; + +const OptionalDisplayNameSchema = z.string().optional().catch(undefined); +const CursorAliasesSchema = z + .array(z.unknown()) + .optional() + .catch([]) + .transform(aliases => (aliases ?? []).filter((alias: unknown): alias is string => typeof alias === "string")); + +const CursorModelDetailsSchema = z.object({ + modelId: z.string(), + displayName: OptionalDisplayNameSchema, + displayNameShort: OptionalDisplayNameSchema, + displayModelId: OptionalDisplayNameSchema, + aliases: CursorAliasesSchema, + thinkingDetails: z.unknown().optional(), +}); + +const CursorDecodedResponseSchema = z.object({ + models: z.array(z.unknown()).optional().catch([]), +}); + +type CursorModelDetailsValue = z.infer; + +/** + * Options for fetching dynamic Cursor models from `GetUsableModels`. + */ +export interface CursorModelDiscoveryOptions { + /** Cursor access token used for bearer authentication. */ + apiKey: string; + /** Optional Cursor API base URL override. */ + baseUrl?: string; + /** Optional client version override sent as `x-cursor-client-version`. */ + clientVersion?: string; + /** Optional request timeout in milliseconds. */ + timeoutMs?: number; + /** Optional list of custom Cursor model ids to include in request context. */ + customModelIds?: string[]; + /** Optional fetch implementation override for tests. */ + fetchImpl?: typeof fetch; +} + +/** + * Fetches Cursor models through `GetUsableModels` and normalizes them into canonical model entries. + * + * Returns `null` on request/decode failures. + * Returns `[]` only when the endpoint responds successfully with no usable models. + */ +export async function fetchCursorUsableModels( + options: CursorModelDiscoveryOptions, +): Promise[] | null> { + const fetchImpl = options.fetchImpl ?? fetch; + const timeoutMs = options.timeoutMs ?? 15_000; + const signal = AbortSignal.timeout(timeoutMs); + + try { + const requestPayload = create(GetUsableModelsRequestSchema, { + customModelIds: normalizeCustomModelIds(options.customModelIds), + }); + const response = await fetchImpl(buildCursorUrl(options.baseUrl), { + method: "POST", + headers: { + "content-type": "application/connect+proto", + "connect-protocol-version": "1", + te: "trailers", + authorization: `Bearer ${options.apiKey}`, + "x-ghost-mode": "true", + "x-cursor-client-version": options.clientVersion ?? CURSOR_DEFAULT_CLIENT_VERSION, + "x-cursor-client-type": "cli", + }, + body: encodeConnectUnaryMessage(toBinary(GetUsableModelsRequestSchema, requestPayload)), + signal, + }); + + if (!response.ok) { + return null; + } + + const responseBuffer = new Uint8Array(await response.arrayBuffer()); + const decoded = decodeGetUsableModelsResponse(responseBuffer); + const parsedDecoded = CursorDecodedResponseSchema.safeParse(decoded); + if (!parsedDecoded.success) { + return null; + } + + return normalizeCursorModels(parsedDecoded.data.models, options.baseUrl); + } catch { + return null; + } +} + +function buildCursorUrl(baseUrl?: string): string { + return `${(baseUrl ?? CURSOR_DEFAULT_BASE_URL).replace(/\/+$/, "")}${CURSOR_GET_USABLE_MODELS_PATH}`; +} + +function normalizeCustomModelIds(customModelIds: readonly string[] | undefined): string[] { + if (!customModelIds) { + return []; + } + const normalized = new Set(); + for (const value of customModelIds) { + if (typeof value !== "string") { + continue; + } + const trimmed = value.trim(); + if (!trimmed) { + continue; + } + normalized.add(trimmed); + } + return [...normalized]; +} + +function encodeConnectUnaryMessage(payload: Uint8Array): Uint8Array { + const framed = new Uint8Array(5 + payload.length); + framed[0] = 0; + const view = new DataView(framed.buffer, framed.byteOffset, framed.byteLength); + view.setUint32(1, payload.length, false); + framed.set(payload, 5); + return framed; +} + +function decodeGetUsableModelsResponse(payload: Uint8Array) { + if (payload.length === 0) { + return null; + } + + const framedBody = decodeConnectUnaryBody(payload); + if (framedBody) { + try { + return fromBinary(GetUsableModelsResponseSchema, framedBody); + } catch { + return null; + } + } + + try { + return fromBinary(GetUsableModelsResponseSchema, payload); + } catch { + return null; + } +} + +function decodeConnectUnaryBody(payload: Uint8Array): Uint8Array | null { + if (payload.length < 5) { + return null; + } + + const flags = payload[0]; + if (typeof flags !== "number") { + return null; + } + + const view = new DataView(payload.buffer, payload.byteOffset, payload.byteLength); + const messageLength = view.getUint32(1, false); + const totalLength = 5 + messageLength; + if (totalLength !== payload.length) { + return null; + } + + const compressionFlagSet = (flags & 0b0000_0001) !== 0; + const endStreamFlagSet = (flags & 0b0000_0010) !== 0; + if (compressionFlagSet || endStreamFlagSet) { + return null; + } + + return payload.subarray(5); +} + +function normalizeCursorModels( + models: readonly unknown[] | undefined, + baseUrlOverride?: string, +): Model<"cursor-agent">[] { + if (!models || models.length === 0) { + return []; + } + + const byId = new Map>(); + for (const model of models) { + const normalized = normalizeCursorModel(model, baseUrlOverride); + if (!normalized) { + continue; + } + byId.set(normalized.id, normalized); + } + + return [...byId.values()].sort((a, b) => a.id.localeCompare(b.id)); +} + +function normalizeCursorModel(model: unknown, baseUrlOverride?: string): Model<"cursor-agent"> | null { + const parsedModel = CursorModelDetailsSchema.safeParse(model); + if (!parsedModel.success) { + return null; + } + + const details = parsedModel.data; + const id = details.modelId.trim(); + if (!id) { + return null; + } + + const name = pickModelDisplayName(details, id); + return { + id, + name, + api: "cursor-agent", + provider: "cursor", + baseUrl: baseUrlOverride ?? CURSOR_DEFAULT_BASE_URL, + reasoning: Boolean(details.thinkingDetails), + input: ["text", "image"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: DEFAULT_CONTEXT_WINDOW, + maxTokens: DEFAULT_MAX_TOKENS, + }; +} + +function pickModelDisplayName(model: CursorModelDetailsValue, fallbackId: string): string { + const candidates = [model.displayName, model.displayNameShort, model.displayModelId, ...model.aliases, fallbackId]; + for (const candidate of candidates) { + if (typeof candidate !== "string") { + continue; + } + const trimmed = candidate.trim(); + if (trimmed) { + return trimmed; + } + } + return fallbackId; +} diff --git a/packages/ai/src/utils/discovery/gemini.ts b/packages/ai/src/utils/discovery/gemini.ts new file mode 100644 index 000000000..cd78d6d77 --- /dev/null +++ b/packages/ai/src/utils/discovery/gemini.ts @@ -0,0 +1,248 @@ +import { z } from "zod"; +import { getBundledModels } from "../../models"; +import type { Model } from "../../types"; + +const GOOGLE_GENERATIVE_AI_BASE_URL = "https://generativelanguage.googleapis.com/v1beta"; +const DEFAULT_PAGE_SIZE = 100; +const DEFAULT_MAX_PAGES = 25; +const DEFAULT_CONTEXT_WINDOW = 4096; +const DEFAULT_MAX_TOKENS = 4096; + +const geminiModelListItemSchema = z.object({ + name: z.string().optional().catch(undefined), + displayName: z.string().optional().catch(undefined), + supportedGenerationMethods: z.array(z.string()).optional(), + inputTokenLimit: z.number().finite().optional().catch(undefined), + outputTokenLimit: z.number().finite().optional().catch(undefined), +}); + +const geminiModelListResponseSchema = z.object({ + models: z + .array(z.unknown()) + .optional() + .transform(items => { + if (!items) { + return []; + } + const parsedItems: GeminiModelListItem[] = []; + for (const item of items) { + const parsed = geminiModelListItemSchema.safeParse(item); + if (parsed.success) { + parsedItems.push(parsed.data); + } + } + return parsedItems; + }), + nextPageToken: z.string().optional(), +}); + +type GeminiModelListItem = z.infer; + +/** + * Configuration for Google Generative AI model discovery. + */ +export interface GeminiDiscoveryOptions { + /** API key for the Google Generative AI public endpoint. */ + apiKey: string; + /** Optional endpoint override for testing or proxying. */ + baseUrl?: string; + /** Optional requested page size for model listing. */ + pageSize?: number; + /** Maximum number of pages to request before stopping pagination. */ + maxPages?: number; + /** Optional abort signal for HTTP requests. */ + signal?: AbortSignal; + /** Optional fetch implementation override for tests. */ + fetch?: typeof fetch; +} + +/** + * Fetches and normalizes Google Generative AI models from the public models endpoint. + * + * Returns `null` on transport/protocol failures. + * Returns `[]` only when the endpoint responds successfully with no usable models. + */ +export async function fetchGeminiModels( + options: GeminiDiscoveryOptions, +): Promise[] | null> { + if (!options.apiKey.trim()) { + return null; + } + + const fetchImpl = options.fetch ?? fetch; + const baseUrl = normalizeBaseUrl(options.baseUrl); + const pageSize = normalizePositiveInt(options.pageSize, DEFAULT_PAGE_SIZE); + const maxPages = normalizePositiveInt(options.maxPages, DEFAULT_MAX_PAGES); + + const bundledById = new Map( + getBundledModels("google").map(model => [model.id, model as Model<"google-generative-ai">]), + ); + const modelsById = new Map>(); + const seenTokens = new Set(); + let nextPageToken: string | undefined; + + for (let page = 0; page < maxPages; page += 1) { + const requestUrl = buildModelsUrl(baseUrl, options.apiKey, pageSize, nextPageToken); + let response: Response; + try { + response = await fetchImpl(requestUrl, { + method: "GET", + signal: options.signal, + }); + } catch { + return null; + } + + if (!response.ok) { + return null; + } + + let payload: unknown; + try { + payload = await response.json(); + } catch { + return null; + } + + const parsed = geminiModelListResponseSchema.safeParse(payload); + if (!parsed.success) { + return null; + } + + for (const item of parsed.data.models) { + const model = normalizeModel(item, baseUrl, bundledById); + if (model) { + modelsById.set(model.id, model); + } + } + + const token = normalizePageToken(parsed.data.nextPageToken); + if (!token) { + break; + } + if (seenTokens.has(token)) { + break; + } + seenTokens.add(token); + nextPageToken = token; + } + + return Array.from(modelsById.values()).sort((left, right) => left.id.localeCompare(right.id)); +} + +function buildModelsUrl(baseUrl: string, apiKey: string, pageSize: number, pageToken?: string): URL { + const url = new URL(`${baseUrl}/models`); + url.searchParams.set("key", apiKey); + url.searchParams.set("pageSize", String(pageSize)); + if (pageToken) { + url.searchParams.set("pageToken", pageToken); + } + return url; +} + +function normalizeBaseUrl(baseUrl?: string): string { + const value = (baseUrl ?? GOOGLE_GENERATIVE_AI_BASE_URL).trim(); + if (!value) { + return GOOGLE_GENERATIVE_AI_BASE_URL; + } + return value.replace(/\/+$/, ""); +} + +function normalizePositiveInt(value: number | undefined, fallback: number): number { + if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) { + return fallback; + } + return Math.floor(value); +} + +function normalizePageToken(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; + } + const token = value.trim(); + return token.length > 0 ? token : undefined; +} + +function normalizeModel( + item: GeminiModelListItem, + baseUrl: string, + bundledById: Map>, +): Model<"google-generative-ai"> | null { + const id = normalizeModelId(item.name); + if (!id) { + return null; + } + if (!supportsTextGeneration(item.supportedGenerationMethods)) { + return null; + } + + const reference = bundledById.get(id); + const contextWindow = normalizePositiveInt(item.inputTokenLimit, reference?.contextWindow ?? DEFAULT_CONTEXT_WINDOW); + const maxTokens = normalizePositiveInt(item.outputTokenLimit, reference?.maxTokens ?? DEFAULT_MAX_TOKENS); + const name = normalizeModelName(item.displayName, reference?.name ?? id); + + if (reference) { + return { + ...reference, + id, + name, + baseUrl, + contextWindow, + maxTokens, + }; + } + return { + id, + name, + api: "google-generative-ai", + provider: "google", + baseUrl, + reasoning: inferReasoningFromGeminiId(id), + input: inferInputFromGeminiId(id), + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow, + maxTokens, + }; +} + +function normalizeModelId(value: string | undefined): string | null { + if (!value) { + return null; + } + const trimmed = value.trim(); + if (!trimmed) { + return null; + } + return trimmed.startsWith("models/") ? trimmed.slice("models/".length) : trimmed; +} + +function normalizeModelName(displayName: string | undefined, id: string): string { + const trimmed = displayName?.trim(); + return trimmed ? trimmed : id; +} + +function supportsTextGeneration(methods: string[] | undefined): boolean { + if (!methods || methods.length === 0) { + return false; + } + return methods.some(method => method === "generateContent"); +} + +function inferReasoningFromGeminiId(id: string): boolean { + const normalized = id.toLowerCase(); + if (normalized.includes("thinking")) { + return true; + } + if (normalized.includes("pro") || normalized.includes("2.5")) { + return true; + } + return false; +} + +function inferInputFromGeminiId(id: string): ("text" | "image")[] { + const normalized = id.toLowerCase(); + if (normalized.includes("vision") || normalized.includes("image") || normalized.includes("gemini")) { + return ["text", "image"]; + } + return ["text"]; +} diff --git a/packages/ai/src/utils/discovery/index.ts b/packages/ai/src/utils/discovery/index.ts new file mode 100644 index 000000000..cfc12a2fa --- /dev/null +++ b/packages/ai/src/utils/discovery/index.ts @@ -0,0 +1,5 @@ +export * from "./antigravity"; +export * from "./codex"; +export * from "./cursor"; +export * from "./gemini"; +export * from "./openai-compatible"; diff --git a/packages/ai/src/utils/discovery/openai-compatible.ts b/packages/ai/src/utils/discovery/openai-compatible.ts new file mode 100644 index 000000000..17e4d09bf --- /dev/null +++ b/packages/ai/src/utils/discovery/openai-compatible.ts @@ -0,0 +1,225 @@ +import { z } from "zod"; +import type { Api, Model, Provider } from "../../types"; + +const DEFAULT_CONTEXT_WINDOW = 4096; +const DEFAULT_MAX_TOKENS = 4096; +const MODELS_PATH = "/models"; + +/** + * Minimal OpenAI-style model entry shape consumed by discovery. + * + * Providers may return additional fields; this type only captures + * fields that are useful for generic normalization. + */ +export interface OpenAICompatibleModelRecord { + id?: unknown; + name?: unknown; + object?: unknown; + owned_by?: unknown; + [key: string]: unknown; +} + +/** + * Tolerant envelope for OpenAI-compatible `/models` responses. + * + * Common providers return `{ data: [...] }`, but variants such as + * `{ models: [...] }`, `{ result: [...] }`, or direct arrays are also + * accepted during extraction. + */ +export interface OpenAICompatibleModelsEnvelope { + data?: unknown; + models?: unknown; + result?: unknown; + items?: unknown; + [key: string]: unknown; +} + +const openAICompatibleModelRecordSchema = z + .object({ + id: z.string().min(1), + name: z.string().min(1).optional(), + object: z.unknown().optional(), + owned_by: z.unknown().optional(), + }) + .passthrough(); + +const openAICompatibleModelsEnvelopeSchema = z + .object({ + data: z.unknown().optional(), + models: z.unknown().optional(), + result: z.unknown().optional(), + items: z.unknown().optional(), + }) + .passthrough(); + +const openAICompatibleModelsPayloadSchema = z.union([z.array(z.unknown()), openAICompatibleModelsEnvelopeSchema]); + +type ParsedOpenAICompatibleModelRecord = z.infer; + +/** + * Context passed to custom OpenAI-compatible model mappers. + */ +export interface OpenAICompatibleModelMapperContext { + api: TApi; + provider: Provider; + baseUrl: string; +} + +/** + * Options for fetching and normalizing OpenAI-compatible `/models` catalogs. + */ +export interface FetchOpenAICompatibleModelsOptions { + /** API type assigned to normalized models. */ + api: TApi; + /** Provider id assigned to normalized models. */ + provider: Provider; + /** Provider base URL used for both fetch and normalized model records. */ + baseUrl: string; + /** Optional bearer token for Authorization header. */ + apiKey?: string; + /** Additional request headers. */ + headers?: Record; + /** Optional AbortSignal for request cancellation. */ + signal?: AbortSignal; + /** Optional fetch implementation override for testing/custom runtimes. */ + fetch?: typeof globalThis.fetch; + /** + * Optional post-normalization filter. + * Return false to skip a model. + */ + filterModel?: (entry: OpenAICompatibleModelRecord, model: Model) => boolean; + /** + * Optional mapper override for provider-specific quirks. + * Return null to skip a model. + */ + mapModel?: ( + entry: OpenAICompatibleModelRecord, + defaults: Model, + context: OpenAICompatibleModelMapperContext, + ) => Model | null; +} + +/** + * Fetches and normalizes an OpenAI-compatible `/models` catalog. + * + * Returns `null` on transport/protocol failures. + * Returns `[]` only when the endpoint responds successfully with no usable models. + */ +export async function fetchOpenAICompatibleModels( + options: FetchOpenAICompatibleModelsOptions, +): Promise[] | null> { + const baseUrl = normalizeBaseUrl(options.baseUrl); + if (!baseUrl) { + return null; + } + + const requestHeaders: Record = { + Accept: "application/json", + ...options.headers, + }; + if (options.apiKey) { + requestHeaders.Authorization = `Bearer ${options.apiKey}`; + } + + const fetchImpl = options.fetch ?? globalThis.fetch; + let response: Response; + try { + response = await fetchImpl(`${baseUrl}${MODELS_PATH}`, { + method: "GET", + headers: requestHeaders, + signal: options.signal, + }); + } catch { + return null; + } + + if (!response.ok) { + return null; + } + + let payload: unknown; + try { + payload = await response.json(); + } catch { + return null; + } + + const entries = extractModelEntries(payload); + if (entries === null) { + return null; + } + + const context: OpenAICompatibleModelMapperContext = { + api: options.api, + provider: options.provider, + baseUrl, + }; + + const deduped = new Map>(); + for (const entry of entries) { + const defaults: Model = { + id: entry.id, + name: typeof entry.name === "string" && entry.name.length > 0 ? entry.name : entry.id, + api: options.api, + provider: options.provider, + baseUrl, + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: DEFAULT_CONTEXT_WINDOW, + maxTokens: DEFAULT_MAX_TOKENS, + }; + + const mapped = options.mapModel?.(entry, defaults, context) ?? defaults; + if (!mapped || typeof mapped.id !== "string" || mapped.id.length === 0) { + continue; + } + if (options.filterModel && !options.filterModel(entry, mapped)) { + continue; + } + deduped.set(mapped.id, mapped); + } + + return Array.from(deduped.values()).sort((left, right) => left.id.localeCompare(right.id)); +} + +function normalizeBaseUrl(baseUrl: string): string { + const trimmed = baseUrl.trim(); + if (!trimmed) { + return ""; + } + return trimmed.endsWith("/") ? trimmed.slice(0, -1) : trimmed; +} + +function extractModelEntries(payload: unknown): ParsedOpenAICompatibleModelRecord[] | null { + return extractModelEntriesFromNode(payload); +} + +function extractModelEntriesFromNode(node: unknown): ParsedOpenAICompatibleModelRecord[] | null { + const parsedPayload = openAICompatibleModelsPayloadSchema.safeParse(node); + if (!parsedPayload.success) { + return null; + } + if (Array.isArray(parsedPayload.data)) { + const parsedEntries = parsedPayload.data + .map(entry => openAICompatibleModelRecordSchema.safeParse(entry)) + .flatMap(entry => (entry.success ? [entry.data] : [])); + return parsedEntries; + } + for (const candidate of [ + parsedPayload.data.data, + parsedPayload.data.models, + parsedPayload.data.result, + parsedPayload.data.items, + ]) { + if (candidate === undefined) { + continue; + } + const nested = extractModelEntriesFromNode(candidate); + if (nested !== null) { + return nested; + } + } + + return null; +} diff --git a/packages/ai/src/utils/oauth/github-copilot.ts b/packages/ai/src/utils/oauth/github-copilot.ts index e726bfba5..994509cbf 100644 --- a/packages/ai/src/utils/oauth/github-copilot.ts +++ b/packages/ai/src/utils/oauth/github-copilot.ts @@ -2,7 +2,7 @@ * GitHub Copilot OAuth flow */ import { abortableSleep } from "@oh-my-pi/pi-utils"; -import { getModels } from "../../models"; +import { getBundledModels } from "../../models"; import type { OAuthCredentials } from "./types"; const decode = (s: string) => atob(s); @@ -276,7 +276,7 @@ async function enableAllGitHubCopilotModels( enterpriseDomain?: string, onProgress?: (model: string, success: boolean) => void, ): Promise { - const models = getModels("github-copilot"); + const models = getBundledModels("github-copilot"); await Promise.all( models.map(async model => { const success = await enableGitHubCopilotModel(token, model.id, enterpriseDomain); diff --git a/packages/ai/test/abort.test.ts b/packages/ai/test/abort.test.ts index 0ac6d5115..b23b999f6 100644 --- a/packages/ai/test/abort.test.ts +++ b/packages/ai/test/abort.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test"; -import { getModel } from "@oh-my-pi/pi-ai/models"; +import { getBundledModel } from "@oh-my-pi/pi-ai/models"; import { complete, stream } from "@oh-my-pi/pi-ai/stream"; import type { Api, Context, Model, OptionsForApi } from "@oh-my-pi/pi-ai/types"; import { e2eApiKey, resolveApiKey } from "./oauth"; @@ -68,7 +68,7 @@ async function testImmediateAbort(llm: Model, options: O describe("AI Providers Abort Tests", () => { describe.skipIf(!e2eApiKey("GEMINI_API_KEY"))("Google Provider Abort", () => { - const llm = getModel("google", "gemini-2.5-flash"); + const llm = getBundledModel("google", "gemini-2.5-flash"); it( "should abort mid-stream", @@ -89,7 +89,7 @@ describe("AI Providers Abort Tests", () => { describe.skipIf(!e2eApiKey("OPENAI_API_KEY"))("OpenAI Completions Provider Abort", () => { const llm: Model<"openai-completions"> = { - ...getModel("openai", "gpt-4o-mini")!, + ...getBundledModel("openai", "gpt-4o-mini")!, api: "openai-completions", }; @@ -111,7 +111,7 @@ describe("AI Providers Abort Tests", () => { }); describe.skipIf(!e2eApiKey("OPENAI_API_KEY"))("OpenAI Responses Provider Abort", () => { - const llm = getModel("openai", "gpt-5-mini"); + const llm = getBundledModel("openai", "gpt-5-mini"); it( "should abort mid-stream", @@ -131,7 +131,7 @@ describe("AI Providers Abort Tests", () => { }); describe.skipIf(!e2eApiKey("ANTHROPIC_API_KEY"))("Anthropic Provider Abort", () => { - const llm = getModel("anthropic", "claude-opus-4-1-20250805"); + const llm = getBundledModel("anthropic", "claude-opus-4-1-20250805"); it( "should abort mid-stream", @@ -151,7 +151,7 @@ describe("AI Providers Abort Tests", () => { }); describe.skipIf(!e2eApiKey("MISTRAL_API_KEY"))("Mistral Provider Abort", () => { - const llm = getModel("mistral", "devstral-medium-latest"); + const llm = getBundledModel("mistral", "devstral-medium-latest"); it( "should abort mid-stream", @@ -175,7 +175,7 @@ describe("AI Providers Abort Tests", () => { it.skipIf(!geminiCliToken)( "should abort mid-stream", async () => { - const llm = getModel("google-gemini-cli", "gemini-2.5-flash"); + const llm = getBundledModel("google-gemini-cli", "gemini-2.5-flash"); await testAbortSignal(llm, { apiKey: geminiCliToken }); }, { retry: 3 }, @@ -184,7 +184,7 @@ describe("AI Providers Abort Tests", () => { it.skipIf(!geminiCliToken)( "should handle immediate abort", async () => { - const llm = getModel("google-gemini-cli", "gemini-2.5-flash"); + const llm = getBundledModel("google-gemini-cli", "gemini-2.5-flash"); await testImmediateAbort(llm, { apiKey: geminiCliToken }); }, { retry: 3 }, @@ -195,7 +195,7 @@ describe("AI Providers Abort Tests", () => { it.skipIf(!openaiCodexToken)( "should abort mid-stream", async () => { - const llm = getModel("openai-codex", "gpt-5.2-codex"); + const llm = getBundledModel("openai-codex", "gpt-5.2-codex"); await testAbortSignal(llm, { apiKey: openaiCodexToken }); }, { retry: 3 }, @@ -204,7 +204,7 @@ describe("AI Providers Abort Tests", () => { it.skipIf(!openaiCodexToken)( "should handle immediate abort", async () => { - const llm = getModel("openai-codex", "gpt-5.2-codex"); + const llm = getBundledModel("openai-codex", "gpt-5.2-codex"); await testImmediateAbort(llm, { apiKey: openaiCodexToken }); }, { retry: 3 }, diff --git a/packages/ai/test/context-overflow.test.ts b/packages/ai/test/context-overflow.test.ts index 02026db61..2ed300de3 100644 --- a/packages/ai/test/context-overflow.test.ts +++ b/packages/ai/test/context-overflow.test.ts @@ -14,7 +14,7 @@ import { afterAll, beforeAll, describe, expect, it } from "bun:test"; import type { ChildProcess } from "node:child_process"; import { execSync, spawn } from "node:child_process"; -import { getModel } from "@oh-my-pi/pi-ai/models"; +import { getBundledModel } from "@oh-my-pi/pi-ai/models"; import { complete } from "@oh-my-pi/pi-ai/stream"; import type { AssistantMessage, Context, Model, Usage } from "@oh-my-pi/pi-ai/types"; import { isContextOverflow } from "@oh-my-pi/pi-ai/utils/overflow"; @@ -99,7 +99,7 @@ function logResult(result: OverflowResult) { describe("Context overflow error handling", () => { describe.skipIf(!e2eApiKey("ANTHROPIC_API_KEY"))("Anthropic (API Key)", () => { it("claude-haiku-4-5 - should detect overflow via isContextOverflow", async () => { - const model = getModel("anthropic", "claude-haiku-4-5-20251001"); + const model = getBundledModel("anthropic", "claude-haiku-4-5-20251001"); const result = await testContextOverflow(model, Bun.env.ANTHROPIC_API_KEY!); logResult(result); @@ -111,7 +111,7 @@ describe("Context overflow error handling", () => { describe.skipIf(!e2eApiKey("ANTHROPIC_API_KEY"))("Anthropic (OAuth)", () => { it("claude-sonnet-4 - should detect overflow via isContextOverflow", async () => { - const model = getModel("anthropic", "claude-sonnet-4-20250514"); + const model = getBundledModel("anthropic", "claude-sonnet-4-20250514"); const result = await testContextOverflow(model, e2eApiKey("ANTHROPIC_API_KEY")!); logResult(result); @@ -131,7 +131,7 @@ describe("Context overflow error handling", () => { it.skipIf(!githubCopilotToken)( "gpt-4o - should detect overflow via isContextOverflow", async () => { - const model = getModel("github-copilot", "gpt-4o"); + const model = getBundledModel("github-copilot", "gpt-4o"); const result = await testContextOverflow(model, githubCopilotToken!); logResult(result); @@ -146,7 +146,7 @@ describe("Context overflow error handling", () => { it.skipIf(!githubCopilotToken)( "claude-sonnet-4 - should detect overflow via isContextOverflow", async () => { - const model = getModel("github-copilot", "claude-sonnet-4"); + const model = getBundledModel("github-copilot", "claude-sonnet-4"); const result = await testContextOverflow(model, githubCopilotToken!); logResult(result); @@ -165,7 +165,7 @@ describe("Context overflow error handling", () => { describe.skipIf(!e2eApiKey("OPENAI_API_KEY"))("OpenAI Completions", () => { it("gpt-4o-mini - should detect overflow via isContextOverflow", async () => { - const model = { ...getModel("openai", "gpt-4o-mini"), api: "openai-completions" as const }; + const model = { ...getBundledModel("openai", "gpt-4o-mini"), api: "openai-completions" as const }; const result = await testContextOverflow(model, Bun.env.OPENAI_API_KEY!); logResult(result); @@ -177,7 +177,7 @@ describe("Context overflow error handling", () => { describe.skipIf(!e2eApiKey("OPENAI_API_KEY"))("OpenAI Responses", () => { it("gpt-4o - should detect overflow via isContextOverflow", async () => { - const model = getModel("openai", "gpt-4o"); + const model = getBundledModel("openai", "gpt-4o"); const result = await testContextOverflow(model, Bun.env.OPENAI_API_KEY!); logResult(result); @@ -194,7 +194,7 @@ describe("Context overflow error handling", () => { describe.skipIf(!e2eApiKey("GEMINI_API_KEY"))("Google", () => { it("gemini-2.0-flash - should detect overflow via isContextOverflow", async () => { - const model = getModel("google", "gemini-2.0-flash"); + const model = getBundledModel("google", "gemini-2.0-flash"); const result = await testContextOverflow(model, Bun.env.GEMINI_API_KEY!); logResult(result); @@ -213,7 +213,7 @@ describe("Context overflow error handling", () => { it.skipIf(!geminiCliToken)( "gemini-2.5-flash - should detect overflow via isContextOverflow", async () => { - const model = getModel("google-gemini-cli", "gemini-2.5-flash"); + const model = getBundledModel("google-gemini-cli", "gemini-2.5-flash"); const result = await testContextOverflow(model, geminiCliToken!); logResult(result); @@ -235,7 +235,7 @@ describe("Context overflow error handling", () => { it.skipIf(!antigravityToken)( "gemini-3-flash - should detect overflow via isContextOverflow", async () => { - const model = getModel("google-antigravity", "gemini-3-flash"); + const model = getBundledModel("google-antigravity", "gemini-3-flash"); const result = await testContextOverflow(model, antigravityToken!); logResult(result); @@ -250,7 +250,7 @@ describe("Context overflow error handling", () => { it.skipIf(!antigravityToken)( "claude-sonnet-4-5 - should detect overflow via isContextOverflow", async () => { - const model = getModel("google-antigravity", "claude-sonnet-4-5"); + const model = getBundledModel("google-antigravity", "claude-sonnet-4-5"); const result = await testContextOverflow(model, antigravityToken!); logResult(result); @@ -272,7 +272,7 @@ describe("Context overflow error handling", () => { it.skipIf(!openaiCodexToken)( "gpt-5.2-codex - should detect overflow via isContextOverflow", async () => { - const model = getModel("openai-codex", "gpt-5.2-codex"); + const model = getBundledModel("openai-codex", "gpt-5.2-codex"); const result = await testContextOverflow(model, openaiCodexToken!); logResult(result); @@ -290,7 +290,7 @@ describe("Context overflow error handling", () => { describe.skipIf(!e2eApiKey("XAI_API_KEY"))("xAI", () => { it("grok-3-fast - should detect overflow via isContextOverflow", async () => { - const model = getModel("xai", "grok-3-fast"); + const model = getBundledModel("xai", "grok-3-fast"); const result = await testContextOverflow(model, Bun.env.XAI_API_KEY!); logResult(result); @@ -307,7 +307,7 @@ describe("Context overflow error handling", () => { describe.skipIf(!e2eApiKey("GROQ_API_KEY"))("Groq", () => { it("llama-3.3-70b-versatile - should detect overflow via isContextOverflow", async () => { - const model = getModel("groq", "llama-3.3-70b-versatile"); + const model = getBundledModel("groq", "llama-3.3-70b-versatile"); const result = await testContextOverflow(model, Bun.env.GROQ_API_KEY!); logResult(result); @@ -324,7 +324,7 @@ describe("Context overflow error handling", () => { describe.skipIf(!e2eApiKey("CEREBRAS_API_KEY"))("Cerebras", () => { it("qwen-3-235b - should detect overflow via isContextOverflow", async () => { - const model = getModel("cerebras", "qwen-3-235b-a22b-instruct-2507"); + const model = getBundledModel("cerebras", "qwen-3-235b-a22b-instruct-2507"); const result = await testContextOverflow(model, Bun.env.CEREBRAS_API_KEY!); logResult(result); @@ -343,7 +343,7 @@ describe("Context overflow error handling", () => { describe.skipIf(!e2eApiKey("ZAI_API_KEY"))("z.ai", () => { it("glm-4.5-flash - should detect overflow via isContextOverflow (silent overflow or rate limit)", async () => { - const model = getModel("zai", "glm-4.5-flash"); + const model = getBundledModel("zai", "glm-4.5-flash"); const result = await testContextOverflow(model, Bun.env.ZAI_API_KEY!); logResult(result); @@ -369,7 +369,7 @@ describe("Context overflow error handling", () => { describe.skipIf(!e2eApiKey("MISTRAL_API_KEY"))("Mistral", () => { it("devstral-medium-latest - should detect overflow via isContextOverflow", async () => { - const model = getModel("mistral", "devstral-medium-latest"); + const model = getBundledModel("mistral", "devstral-medium-latest"); const result = await testContextOverflow(model, Bun.env.MISTRAL_API_KEY!); logResult(result); @@ -386,7 +386,7 @@ describe("Context overflow error handling", () => { describe.skipIf(!e2eApiKey("OPENROUTER_API_KEY"))("OpenRouter", () => { // Anthropic backend it("anthropic/claude-sonnet-4 via OpenRouter - should detect overflow via isContextOverflow", async () => { - const model = getModel("openrouter", "anthropic/claude-sonnet-4"); + const model = getBundledModel("openrouter", "anthropic/claude-sonnet-4"); const result = await testContextOverflow(model, Bun.env.OPENROUTER_API_KEY!); logResult(result); @@ -397,7 +397,7 @@ describe("Context overflow error handling", () => { // DeepSeek backend it("deepseek/deepseek-v3.2 via OpenRouter - should detect overflow via isContextOverflow", async () => { - const model = getModel("openrouter", "deepseek/deepseek-v3.2"); + const model = getBundledModel("openrouter", "deepseek/deepseek-v3.2"); const result = await testContextOverflow(model, Bun.env.OPENROUTER_API_KEY!); logResult(result); @@ -408,7 +408,7 @@ describe("Context overflow error handling", () => { // Mistral backend it("mistralai/mistral-large-2512 via OpenRouter - should detect overflow via isContextOverflow", async () => { - const model = getModel("openrouter", "mistralai/mistral-large-2512"); + const model = getBundledModel("openrouter", "mistralai/mistral-large-2512"); const result = await testContextOverflow(model, Bun.env.OPENROUTER_API_KEY!); logResult(result); @@ -419,7 +419,7 @@ describe("Context overflow error handling", () => { // Google backend it("google/gemini-2.5-flash via OpenRouter - should detect overflow via isContextOverflow", async () => { - const model = getModel("openrouter", "google/gemini-2.5-flash"); + const model = getBundledModel("openrouter", "google/gemini-2.5-flash"); const result = await testContextOverflow(model, Bun.env.OPENROUTER_API_KEY!); logResult(result); @@ -430,7 +430,7 @@ describe("Context overflow error handling", () => { // Meta/Llama backend it("meta-llama/llama-4-maverick via OpenRouter - should detect overflow via isContextOverflow", async () => { - const model = getModel("openrouter", "meta-llama/llama-4-maverick"); + const model = getBundledModel("openrouter", "meta-llama/llama-4-maverick"); const result = await testContextOverflow(model, Bun.env.OPENROUTER_API_KEY!); logResult(result); diff --git a/packages/ai/test/empty.test.ts b/packages/ai/test/empty.test.ts index 64fe5744b..0c29787c3 100644 --- a/packages/ai/test/empty.test.ts +++ b/packages/ai/test/empty.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test"; -import { getModel } from "@oh-my-pi/pi-ai/models"; +import { getBundledModel } from "@oh-my-pi/pi-ai/models"; import { complete } from "@oh-my-pi/pi-ai/stream"; import type { Api, AssistantMessage, Context, Model, OptionsForApi, UserMessage } from "@oh-my-pi/pi-ai/types"; import { e2eApiKey, resolveApiKey } from "./oauth"; @@ -142,7 +142,7 @@ async function testEmptyAssistantMessage(llm: Model, opt describe("AI Providers Empty Message Tests", () => { describe.skipIf(!e2eApiKey("GEMINI_API_KEY"))("Google Provider Empty Messages", () => { - const llm = getModel("google", "gemini-2.5-flash"); + const llm = getBundledModel("google", "gemini-2.5-flash"); it( "should handle empty content array", @@ -178,7 +178,7 @@ describe("AI Providers Empty Message Tests", () => { }); describe.skipIf(!e2eApiKey("OPENAI_API_KEY"))("OpenAI Completions Provider Empty Messages", () => { - const llm = getModel("openai", "gpt-4o-mini"); + const llm = getBundledModel("openai", "gpt-4o-mini"); it( "should handle empty content array", @@ -214,7 +214,7 @@ describe("AI Providers Empty Message Tests", () => { }); describe.skipIf(!e2eApiKey("OPENAI_API_KEY"))("OpenAI Responses Provider Empty Messages", () => { - const llm = getModel("openai", "gpt-5-mini"); + const llm = getBundledModel("openai", "gpt-5-mini"); it( "should handle empty content array", @@ -250,7 +250,7 @@ describe("AI Providers Empty Message Tests", () => { }); describe.skipIf(!e2eApiKey("ANTHROPIC_API_KEY"))("Anthropic Provider Empty Messages", () => { - const llm = getModel("anthropic", "claude-haiku-4-5-20251001"); + const llm = getBundledModel("anthropic", "claude-haiku-4-5-20251001"); it( "should handle empty content array", @@ -286,7 +286,7 @@ describe("AI Providers Empty Message Tests", () => { }); describe.skipIf(!e2eApiKey("XAI_API_KEY"))("xAI Provider Empty Messages", () => { - const llm = getModel("xai", "grok-3"); + const llm = getBundledModel("xai", "grok-3"); it( "should handle empty content array", @@ -322,7 +322,7 @@ describe("AI Providers Empty Message Tests", () => { }); describe.skipIf(!e2eApiKey("GROQ_API_KEY"))("Groq Provider Empty Messages", () => { - const llm = getModel("groq", "openai/gpt-oss-20b"); + const llm = getBundledModel("groq", "openai/gpt-oss-20b"); it( "should handle empty content array", @@ -358,7 +358,7 @@ describe("AI Providers Empty Message Tests", () => { }); describe.skipIf(!e2eApiKey("CEREBRAS_API_KEY"))("Cerebras Provider Empty Messages", () => { - const llm = getModel("cerebras", "gpt-oss-120b"); + const llm = getBundledModel("cerebras", "gpt-oss-120b"); it( "should handle empty content array", @@ -394,7 +394,7 @@ describe("AI Providers Empty Message Tests", () => { }); describe.skipIf(!e2eApiKey("ZAI_API_KEY"))("zAI Provider Empty Messages", () => { - const llm = getModel("zai", "glm-4.5-air"); + const llm = getBundledModel("zai", "glm-4.5-air"); it( "should handle empty content array", @@ -430,7 +430,7 @@ describe("AI Providers Empty Message Tests", () => { }); describe.skipIf(!e2eApiKey("MISTRAL_API_KEY"))("Mistral Provider Empty Messages", () => { - const llm = getModel("mistral", "devstral-medium-latest"); + const llm = getBundledModel("mistral", "devstral-medium-latest"); it( "should handle empty content array", @@ -466,7 +466,7 @@ describe("AI Providers Empty Message Tests", () => { }); describe("Anthropic OAuth Provider Empty Messages", () => { - const llm = getModel("anthropic", "claude-haiku-4-5-20251001"); + const llm = getBundledModel("anthropic", "claude-haiku-4-5-20251001"); it.skipIf(!anthropicOAuthToken)( "should handle empty content array", @@ -505,7 +505,7 @@ describe("AI Providers Empty Message Tests", () => { it.skipIf(!githubCopilotToken)( "gpt-4o - should handle empty content array", async () => { - const llm = getModel("github-copilot", "gpt-4o"); + const llm = getBundledModel("github-copilot", "gpt-4o"); await testEmptyMessage(llm, { apiKey: githubCopilotToken }); }, { retry: 3, timeout: 30000 }, @@ -514,7 +514,7 @@ describe("AI Providers Empty Message Tests", () => { it.skipIf(!githubCopilotToken)( "gpt-4o - should handle empty string content", async () => { - const llm = getModel("github-copilot", "gpt-4o"); + const llm = getBundledModel("github-copilot", "gpt-4o"); await testEmptyStringMessage(llm, { apiKey: githubCopilotToken }); }, { retry: 3, timeout: 30000 }, @@ -523,7 +523,7 @@ describe("AI Providers Empty Message Tests", () => { it.skipIf(!githubCopilotToken)( "gpt-4o - should handle whitespace-only content", async () => { - const llm = getModel("github-copilot", "gpt-4o"); + const llm = getBundledModel("github-copilot", "gpt-4o"); await testWhitespaceOnlyMessage(llm, { apiKey: githubCopilotToken }); }, { retry: 3, timeout: 30000 }, @@ -532,7 +532,7 @@ describe("AI Providers Empty Message Tests", () => { it.skipIf(!githubCopilotToken)( "gpt-4o - should handle empty assistant message in conversation", async () => { - const llm = getModel("github-copilot", "gpt-4o"); + const llm = getBundledModel("github-copilot", "gpt-4o"); await testEmptyAssistantMessage(llm, { apiKey: githubCopilotToken }); }, { retry: 3, timeout: 30000 }, @@ -541,7 +541,7 @@ describe("AI Providers Empty Message Tests", () => { it.skipIf(!githubCopilotToken)( "claude-sonnet-4 - should handle empty content array", async () => { - const llm = getModel("github-copilot", "claude-sonnet-4"); + const llm = getBundledModel("github-copilot", "claude-sonnet-4"); await testEmptyMessage(llm, { apiKey: githubCopilotToken }); }, { retry: 3, timeout: 30000 }, @@ -550,7 +550,7 @@ describe("AI Providers Empty Message Tests", () => { it.skipIf(!githubCopilotToken)( "claude-sonnet-4 - should handle empty string content", async () => { - const llm = getModel("github-copilot", "claude-sonnet-4"); + const llm = getBundledModel("github-copilot", "claude-sonnet-4"); await testEmptyStringMessage(llm, { apiKey: githubCopilotToken }); }, { retry: 3, timeout: 30000 }, @@ -559,7 +559,7 @@ describe("AI Providers Empty Message Tests", () => { it.skipIf(!githubCopilotToken)( "claude-sonnet-4 - should handle whitespace-only content", async () => { - const llm = getModel("github-copilot", "claude-sonnet-4"); + const llm = getBundledModel("github-copilot", "claude-sonnet-4"); await testWhitespaceOnlyMessage(llm, { apiKey: githubCopilotToken }); }, { retry: 3, timeout: 30000 }, @@ -568,7 +568,7 @@ describe("AI Providers Empty Message Tests", () => { it.skipIf(!githubCopilotToken)( "claude-sonnet-4 - should handle empty assistant message in conversation", async () => { - const llm = getModel("github-copilot", "claude-sonnet-4"); + const llm = getBundledModel("github-copilot", "claude-sonnet-4"); await testEmptyAssistantMessage(llm, { apiKey: githubCopilotToken }); }, { retry: 3, timeout: 30000 }, @@ -579,7 +579,7 @@ describe("AI Providers Empty Message Tests", () => { it.skipIf(!geminiCliToken)( "gemini-2.5-flash - should handle empty content array", async () => { - const llm = getModel("google-gemini-cli", "gemini-2.5-flash"); + const llm = getBundledModel("google-gemini-cli", "gemini-2.5-flash"); await testEmptyMessage(llm, { apiKey: geminiCliToken }); }, { retry: 3, timeout: 30000 }, @@ -588,7 +588,7 @@ describe("AI Providers Empty Message Tests", () => { it.skipIf(!geminiCliToken)( "gemini-2.5-flash - should handle empty string content", async () => { - const llm = getModel("google-gemini-cli", "gemini-2.5-flash"); + const llm = getBundledModel("google-gemini-cli", "gemini-2.5-flash"); await testEmptyStringMessage(llm, { apiKey: geminiCliToken }); }, { retry: 3, timeout: 30000 }, @@ -597,7 +597,7 @@ describe("AI Providers Empty Message Tests", () => { it.skipIf(!geminiCliToken)( "gemini-2.5-flash - should handle whitespace-only content", async () => { - const llm = getModel("google-gemini-cli", "gemini-2.5-flash"); + const llm = getBundledModel("google-gemini-cli", "gemini-2.5-flash"); await testWhitespaceOnlyMessage(llm, { apiKey: geminiCliToken }); }, { retry: 3, timeout: 30000 }, @@ -606,7 +606,7 @@ describe("AI Providers Empty Message Tests", () => { it.skipIf(!geminiCliToken)( "gemini-2.5-flash - should handle empty assistant message in conversation", async () => { - const llm = getModel("google-gemini-cli", "gemini-2.5-flash"); + const llm = getBundledModel("google-gemini-cli", "gemini-2.5-flash"); await testEmptyAssistantMessage(llm, { apiKey: geminiCliToken }); }, { retry: 3, timeout: 30000 }, @@ -617,7 +617,7 @@ describe("AI Providers Empty Message Tests", () => { it.skipIf(!antigravityToken)( "gemini-3-flash - should handle empty content array", async () => { - const llm = getModel("google-antigravity", "gemini-3-flash"); + const llm = getBundledModel("google-antigravity", "gemini-3-flash"); await testEmptyMessage(llm, { apiKey: antigravityToken }); }, { retry: 3, timeout: 30000 }, @@ -626,7 +626,7 @@ describe("AI Providers Empty Message Tests", () => { it.skipIf(!antigravityToken)( "gemini-3-flash - should handle empty string content", async () => { - const llm = getModel("google-antigravity", "gemini-3-flash"); + const llm = getBundledModel("google-antigravity", "gemini-3-flash"); await testEmptyStringMessage(llm, { apiKey: antigravityToken }); }, { retry: 3, timeout: 30000 }, @@ -635,7 +635,7 @@ describe("AI Providers Empty Message Tests", () => { it.skipIf(!antigravityToken)( "gemini-3-flash - should handle whitespace-only content", async () => { - const llm = getModel("google-antigravity", "gemini-3-flash"); + const llm = getBundledModel("google-antigravity", "gemini-3-flash"); await testWhitespaceOnlyMessage(llm, { apiKey: antigravityToken }); }, { retry: 3, timeout: 30000 }, @@ -644,7 +644,7 @@ describe("AI Providers Empty Message Tests", () => { it.skipIf(!antigravityToken)( "gemini-3-flash - should handle empty assistant message in conversation", async () => { - const llm = getModel("google-antigravity", "gemini-3-flash"); + const llm = getBundledModel("google-antigravity", "gemini-3-flash"); await testEmptyAssistantMessage(llm, { apiKey: antigravityToken }); }, { retry: 3, timeout: 30000 }, @@ -653,7 +653,7 @@ describe("AI Providers Empty Message Tests", () => { it.skipIf(!antigravityToken)( "claude-sonnet-4-5 - should handle empty content array", async () => { - const llm = getModel("google-antigravity", "claude-sonnet-4-5"); + const llm = getBundledModel("google-antigravity", "claude-sonnet-4-5"); await testEmptyMessage(llm, { apiKey: antigravityToken }); }, { retry: 3, timeout: 30000 }, @@ -662,7 +662,7 @@ describe("AI Providers Empty Message Tests", () => { it.skipIf(!antigravityToken)( "claude-sonnet-4-5 - should handle empty string content", async () => { - const llm = getModel("google-antigravity", "claude-sonnet-4-5"); + const llm = getBundledModel("google-antigravity", "claude-sonnet-4-5"); await testEmptyStringMessage(llm, { apiKey: antigravityToken }); }, { retry: 3, timeout: 30000 }, @@ -671,7 +671,7 @@ describe("AI Providers Empty Message Tests", () => { it.skipIf(!antigravityToken)( "claude-sonnet-4-5 - should handle whitespace-only content", async () => { - const llm = getModel("google-antigravity", "claude-sonnet-4-5"); + const llm = getBundledModel("google-antigravity", "claude-sonnet-4-5"); await testWhitespaceOnlyMessage(llm, { apiKey: antigravityToken }); }, { retry: 3, timeout: 30000 }, @@ -680,7 +680,7 @@ describe("AI Providers Empty Message Tests", () => { it.skipIf(!antigravityToken)( "claude-sonnet-4-5 - should handle empty assistant message in conversation", async () => { - const llm = getModel("google-antigravity", "claude-sonnet-4-5"); + const llm = getBundledModel("google-antigravity", "claude-sonnet-4-5"); await testEmptyAssistantMessage(llm, { apiKey: antigravityToken }); }, { retry: 3, timeout: 30000 }, @@ -689,7 +689,7 @@ describe("AI Providers Empty Message Tests", () => { it.skipIf(!antigravityToken)( "gpt-oss-120b-medium - should handle empty content array", async () => { - const llm = getModel("google-antigravity", "gpt-oss-120b-medium"); + const llm = getBundledModel("google-antigravity", "gpt-oss-120b-medium"); await testEmptyMessage(llm, { apiKey: antigravityToken }); }, { retry: 3, timeout: 30000 }, @@ -698,7 +698,7 @@ describe("AI Providers Empty Message Tests", () => { it.skipIf(!antigravityToken)( "gpt-oss-120b-medium - should handle empty string content", async () => { - const llm = getModel("google-antigravity", "gpt-oss-120b-medium"); + const llm = getBundledModel("google-antigravity", "gpt-oss-120b-medium"); await testEmptyStringMessage(llm, { apiKey: antigravityToken }); }, { retry: 3, timeout: 30000 }, @@ -707,7 +707,7 @@ describe("AI Providers Empty Message Tests", () => { it.skipIf(!antigravityToken)( "gpt-oss-120b-medium - should handle whitespace-only content", async () => { - const llm = getModel("google-antigravity", "gpt-oss-120b-medium"); + const llm = getBundledModel("google-antigravity", "gpt-oss-120b-medium"); await testWhitespaceOnlyMessage(llm, { apiKey: antigravityToken }); }, { retry: 3, timeout: 30000 }, @@ -716,7 +716,7 @@ describe("AI Providers Empty Message Tests", () => { it.skipIf(!antigravityToken)( "gpt-oss-120b-medium - should handle empty assistant message in conversation", async () => { - const llm = getModel("google-antigravity", "gpt-oss-120b-medium"); + const llm = getBundledModel("google-antigravity", "gpt-oss-120b-medium"); await testEmptyAssistantMessage(llm, { apiKey: antigravityToken }); }, { retry: 3, timeout: 30000 }, @@ -727,7 +727,7 @@ describe("AI Providers Empty Message Tests", () => { it.skipIf(!openaiCodexToken)( "gpt-5.2-codex - should handle empty content array", async () => { - const llm = getModel("openai-codex", "gpt-5.2-codex"); + const llm = getBundledModel("openai-codex", "gpt-5.2-codex"); await testEmptyMessage(llm, { apiKey: openaiCodexToken }); }, { retry: 3, timeout: 30000 }, @@ -736,7 +736,7 @@ describe("AI Providers Empty Message Tests", () => { it.skipIf(!openaiCodexToken)( "gpt-5.2-codex - should handle empty string content", async () => { - const llm = getModel("openai-codex", "gpt-5.2-codex"); + const llm = getBundledModel("openai-codex", "gpt-5.2-codex"); await testEmptyStringMessage(llm, { apiKey: openaiCodexToken }); }, { retry: 3, timeout: 30000 }, @@ -745,7 +745,7 @@ describe("AI Providers Empty Message Tests", () => { it.skipIf(!openaiCodexToken)( "gpt-5.2-codex - should handle whitespace-only content", async () => { - const llm = getModel("openai-codex", "gpt-5.2-codex"); + const llm = getBundledModel("openai-codex", "gpt-5.2-codex"); await testWhitespaceOnlyMessage(llm, { apiKey: openaiCodexToken }); }, { retry: 3, timeout: 30000 }, @@ -754,7 +754,7 @@ describe("AI Providers Empty Message Tests", () => { it.skipIf(!openaiCodexToken)( "gpt-5.2-codex - should handle empty assistant message in conversation", async () => { - const llm = getModel("openai-codex", "gpt-5.2-codex"); + const llm = getBundledModel("openai-codex", "gpt-5.2-codex"); await testEmptyAssistantMessage(llm, { apiKey: openaiCodexToken }); }, { retry: 3, timeout: 30000 }, diff --git a/packages/ai/test/github-copilot-claude-messages-routing.test.ts b/packages/ai/test/github-copilot-claude-messages-routing.test.ts index 20015a8c5..5b721bf5a 100644 --- a/packages/ai/test/github-copilot-claude-messages-routing.test.ts +++ b/packages/ai/test/github-copilot-claude-messages-routing.test.ts @@ -1,49 +1,49 @@ import { describe, expect, it } from "bun:test"; -import { getModel } from "../src/models"; +import { getBundledModel } from "../src/models"; describe("Copilot Claude model routing", () => { it("routes claude-sonnet-4 via anthropic-messages API", () => { - const model = getModel("github-copilot", "claude-sonnet-4"); + const model = getBundledModel("github-copilot", "claude-sonnet-4"); expect(model).toBeDefined(); expect(model.api).toBe("anthropic-messages"); }); it("routes claude-sonnet-4.5 via anthropic-messages API", () => { - const model = getModel("github-copilot", "claude-sonnet-4.5"); + const model = getBundledModel("github-copilot", "claude-sonnet-4.5"); expect(model).toBeDefined(); expect(model.api).toBe("anthropic-messages"); }); it("routes claude-haiku-4.5 via anthropic-messages API", () => { - const model = getModel("github-copilot", "claude-haiku-4.5"); + const model = getBundledModel("github-copilot", "claude-haiku-4.5"); expect(model).toBeDefined(); expect(model.api).toBe("anthropic-messages"); }); it("routes claude-opus-4.5 via anthropic-messages API", () => { - const model = getModel("github-copilot", "claude-opus-4.5"); + const model = getBundledModel("github-copilot", "claude-opus-4.5"); expect(model).toBeDefined(); expect(model.api).toBe("anthropic-messages"); }); it("does not have compat block on Claude models (completions-API-specific)", () => { - const sonnet = getModel("github-copilot", "claude-sonnet-4"); + const sonnet = getBundledModel("github-copilot", "claude-sonnet-4"); expect("compat" in sonnet).toBe(false); }); it("preserves static Copilot headers on Claude models", () => { - const model = getModel("github-copilot", "claude-sonnet-4"); + const model = getBundledModel("github-copilot", "claude-sonnet-4"); expect(model.headers).toBeDefined(); expect(model.headers?.["User-Agent"]).toContain("GitHubCopilotChat"); expect(model.headers?.["Copilot-Integration-Id"]).toBe("vscode-chat"); }); it("keeps non-Claude Copilot models on their existing APIs", () => { - const gpt4o = getModel("github-copilot", "gpt-4o"); + const gpt4o = getBundledModel("github-copilot", "gpt-4o"); expect(gpt4o).toBeDefined(); expect(gpt4o.api).toBe("openai-completions"); - const gpt5 = getModel("github-copilot", "gpt-5"); + const gpt5 = getBundledModel("github-copilot", "gpt-5"); expect(gpt5).toBeDefined(); expect(gpt5.api).toBe("openai-responses"); }); diff --git a/packages/ai/test/handoff.test.ts b/packages/ai/test/handoff.test.ts index 070344b5c..9f30e1397 100644 --- a/packages/ai/test/handoff.test.ts +++ b/packages/ai/test/handoff.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test"; -import { getModel } from "@oh-my-pi/pi-ai/models"; +import { getBundledModel } from "@oh-my-pi/pi-ai/models"; import { complete } from "@oh-my-pi/pi-ai/stream"; import type { Api, AssistantMessage, Context, Message, Model, Tool, ToolResultMessage } from "@oh-my-pi/pi-ai/types"; import { Type } from "@sinclair/typebox"; @@ -368,7 +368,7 @@ async function testProviderHandoff( describe("Cross-Provider Handoff Tests", () => { describe.skipIf(!e2eApiKey("ANTHROPIC_API_KEY"))("Anthropic Provider Handoff", () => { - const model = getModel("anthropic", "claude-haiku-4-5-20251001"); + const model = getBundledModel("anthropic", "claude-haiku-4-5-20251001"); it("should handle contexts from all providers", async () => { console.log("\nTesting Anthropic with pre-built contexts:\n"); @@ -404,7 +404,7 @@ describe("Cross-Provider Handoff Tests", () => { }); describe.skipIf(!e2eApiKey("GEMINI_API_KEY"))("Google Provider Handoff", () => { - const model = getModel("google", "gemini-2.5-flash"); + const model = getBundledModel("google", "gemini-2.5-flash"); it("should handle contexts from all providers", async () => { console.log("\nTesting Google with pre-built contexts:\n"); @@ -440,7 +440,10 @@ describe("Cross-Provider Handoff Tests", () => { }); describe.skipIf(!e2eApiKey("OPENAI_API_KEY"))("OpenAI Completions Provider Handoff", () => { - const model: Model<"openai-completions"> = { ...getModel("openai", "gpt-4o-mini"), api: "openai-completions" }; + const model: Model<"openai-completions"> = { + ...getBundledModel("openai", "gpt-4o-mini"), + api: "openai-completions", + }; it("should handle contexts from all providers", async () => { console.log("\nTesting OpenAI Completions with pre-built contexts:\n"); @@ -476,7 +479,7 @@ describe("Cross-Provider Handoff Tests", () => { }); describe.skipIf(!e2eApiKey("OPENAI_API_KEY"))("OpenAI Responses Provider Handoff", () => { - const model = getModel("openai", "gpt-5-mini"); + const model = getBundledModel("openai", "gpt-5-mini"); it("should handle contexts from all providers", async () => { console.log("\nTesting OpenAI Responses with pre-built contexts:\n"); @@ -512,7 +515,7 @@ describe("Cross-Provider Handoff Tests", () => { }); describe.skipIf(!e2eApiKey("MISTRAL_API_KEY"))("Mistral Provider Handoff", () => { - const model = getModel("mistral", "devstral-medium-latest"); + const model = getBundledModel("mistral", "devstral-medium-latest"); it("should handle contexts from all providers", async () => { console.log("\nTesting Mistral with pre-built contexts:\n"); diff --git a/packages/ai/test/image-limits.test.ts b/packages/ai/test/image-limits.test.ts index 3dd942200..bf5fe91d4 100644 --- a/packages/ai/test/image-limits.test.ts +++ b/packages/ai/test/image-limits.test.ts @@ -71,7 +71,7 @@ import { afterAll, beforeAll, describe, expect, it } from "bun:test"; import { execSync } from "node:child_process"; import * as fs from "node:fs"; import * as path from "node:path"; -import { getModel } from "@oh-my-pi/pi-ai/models"; +import { getBundledModel } from "@oh-my-pi/pi-ai/models"; import { complete } from "@oh-my-pi/pi-ai/stream"; import type { Api, Context, ImageContent, Model, OptionsForApi, UserMessage } from "@oh-my-pi/pi-ai/types"; import { e2eApiKey } from "./oauth"; @@ -279,7 +279,7 @@ describe("Image Limits E2E Tests", () => { // Limits: 100 images, 5MB per image, 8000px max dimension // ------------------------------------------------------------------------- describe.skipIf(!e2eApiKey("ANTHROPIC_API_KEY"))("Anthropic (claude-haiku-4-5-20251001)", () => { - const model = getModel("anthropic", "claude-haiku-4-5-20251001"); + const model = getBundledModel("anthropic", "claude-haiku-4-5-20251001"); it("should accept a small number of images (5)", async () => { const result = await testImageCount(model, 5, smallImage); @@ -369,7 +369,10 @@ describe("Image Limits E2E Tests", () => { // Limits: 500 images, ~20MB per image (documented) // ------------------------------------------------------------------------- describe.skipIf(!e2eApiKey("OPENAI_API_KEY"))("OpenAI (gpt-4o-mini)", () => { - const model: Model<"openai-completions"> = { ...getModel("openai", "gpt-4o-mini"), api: "openai-completions" }; + const model: Model<"openai-completions"> = { + ...getBundledModel("openai", "gpt-4o-mini"), + api: "openai-completions", + }; it("should accept a small number of images (5)", async () => { const result = await testImageCount(model, 5, smallImage); @@ -457,7 +460,7 @@ describe("Image Limits E2E Tests", () => { // Limits: Very high (~2500 images), large size support // ------------------------------------------------------------------------- describe.skipIf(!e2eApiKey("GEMINI_API_KEY"))("Gemini (gemini-2.5-flash)", () => { - const model = getModel("google", "gemini-2.5-flash"); + const model = getBundledModel("google", "gemini-2.5-flash"); it("should accept a small number of images (5)", async () => { const result = await testImageCount(model, 5, smallImage); @@ -544,7 +547,7 @@ describe("Image Limits E2E Tests", () => { // Limits: ~8 images, ~15MB per image // ------------------------------------------------------------------------- describe.skipIf(!e2eApiKey("MISTRAL_API_KEY"))("Mistral (pixtral-12b)", () => { - const model = getModel("mistral", "pixtral-12b"); + const model = getBundledModel("mistral", "pixtral-12b"); it("should accept a small number of images (5)", async () => { const result = await testImageCount(model, 5, smallImage); @@ -625,7 +628,7 @@ describe("Image Limits E2E Tests", () => { // Limits: Context-window limited (~45 images at 100x100), ~15MB per image // ------------------------------------------------------------------------- describe.skipIf(!e2eApiKey("OPENROUTER_API_KEY"))("OpenRouter (z-ai/glm-4.5v)", () => { - const model = getModel("openrouter", "z-ai/glm-4.5v"); + const model = getBundledModel("openrouter", "z-ai/glm-4.5v"); it("should accept a small number of images (5)", async () => { const result = await testImageCount(model, 5, smallImage); @@ -705,7 +708,7 @@ describe("Image Limits E2E Tests", () => { // xAI (grok-2-vision) // ------------------------------------------------------------------------- describe.skipIf(!e2eApiKey("XAI_API_KEY"))("xAI (grok-2-vision)", () => { - const model = getModel("xai", "grok-2-vision"); + const model = getBundledModel("xai", "grok-2-vision"); it("should accept a small number of images (5)", async () => { const result = await testImageCount(model, 5, smallImage); @@ -789,7 +792,7 @@ describe("Image Limits E2E Tests", () => { // Groq (llama-4-scout-17b) // ------------------------------------------------------------------------- describe.skipIf(!e2eApiKey("GROQ_API_KEY"))("Groq (llama-4-scout-17b)", () => { - const model = getModel("groq", "meta-llama/llama-4-scout-17b-16e-instruct"); + const model = getBundledModel("groq", "meta-llama/llama-4-scout-17b-16e-instruct"); it("should accept a small number of images (5)", async () => { const result = await testImageCount(model, 5, smallImage); @@ -868,7 +871,7 @@ describe("Image Limits E2E Tests", () => { // zAI (glm-4.5v) // ------------------------------------------------------------------------- describe.skipIf(!e2eApiKey("ZAI_API_KEY"))("zAI (glm-4.5v)", () => { - const model = getModel("zai", "glm-4.5v"); + const model = getBundledModel("zai", "glm-4.5v"); it("should accept a small number of images (5)", async () => { const result = await testImageCount(model, 5, smallImage); @@ -979,7 +982,7 @@ describe("Image Limits E2E Tests", () => { it.skipIf(!e2eApiKey("ANTHROPIC_API_KEY"))( "Anthropic: max ~3MB images before rejection", async () => { - const model = getModel("anthropic", "claude-haiku-4-5-20251001"); + const model = getBundledModel("anthropic", "claude-haiku-4-5-20251001"); const image3mb = await getImageAtSize(3); // 32MB total limit / ~4MB actual = ~8 images const counts = [1, 2, 4, 6, 8, 10, 12]; @@ -1011,7 +1014,7 @@ describe("Image Limits E2E Tests", () => { it.skipIf(!e2eApiKey("OPENAI_API_KEY"))( "OpenAI: max ~15MB images before rejection", async () => { - const model = getModel("openai", "gpt-4o-mini"); + const model = getBundledModel("openai", "gpt-4o-mini"); const image15mb = await getImageAtSize(15); // Test progressively const counts = [1, 2, 5, 10, 20]; @@ -1043,7 +1046,7 @@ describe("Image Limits E2E Tests", () => { it.skipIf(!e2eApiKey("GEMINI_API_KEY"))( "Gemini: max ~20MB images before rejection", async () => { - const model = getModel("google", "gemini-2.5-flash"); + const model = getBundledModel("google", "gemini-2.5-flash"); const image20mb = await getImageAtSize(20); // Test progressively const counts = [1, 2, 5, 10, 20, 50]; @@ -1075,7 +1078,7 @@ describe("Image Limits E2E Tests", () => { it.skipIf(!e2eApiKey("MISTRAL_API_KEY"))( "Mistral: max ~10MB images before rejection", async () => { - const model = getModel("mistral", "pixtral-12b"); + const model = getBundledModel("mistral", "pixtral-12b"); const image10mb = await getImageAtSize(10); // Known limit is 8 images const counts = [1, 2, 4, 6, 8, 9]; @@ -1107,7 +1110,7 @@ describe("Image Limits E2E Tests", () => { it.skipIf(!e2eApiKey("XAI_API_KEY"))( "xAI: max ~20MB images before rejection", async () => { - const model = getModel("xai", "grok-2-vision"); + const model = getBundledModel("xai", "grok-2-vision"); const image20mb = await getImageAtSize(20); // Test progressively const counts = [1, 2, 5, 10, 20]; @@ -1139,7 +1142,7 @@ describe("Image Limits E2E Tests", () => { it.skipIf(!e2eApiKey("GROQ_API_KEY"))( "Groq: max 5760px images before rejection", async () => { - const model = getModel("groq", "meta-llama/llama-4-scout-17b-16e-instruct"); + const model = getBundledModel("groq", "meta-llama/llama-4-scout-17b-16e-instruct"); // Generate 5760x5760 image (33177600 pixels = Groq's limit) console.log(" Generating 5760x5760 test image for Groq..."); const image5760 = await generateImage(5760, 5760, "stress-5760.png"); @@ -1174,7 +1177,7 @@ describe("Image Limits E2E Tests", () => { it.skipIf(!e2eApiKey("ZAI_API_KEY"))( "zAI: max ~15MB images before rejection", async () => { - const model = getModel("zai", "glm-4.5v"); + const model = getBundledModel("zai", "glm-4.5v"); const image15mb = await getImageAtSize(15); // Context-limited, test progressively const counts = [1, 2, 5, 10, 20]; @@ -1206,7 +1209,7 @@ describe("Image Limits E2E Tests", () => { it.skipIf(!e2eApiKey("OPENROUTER_API_KEY"))( "OpenRouter: max ~5MB images before rejection", async () => { - const model = getModel("openrouter", "z-ai/glm-4.5v"); + const model = getBundledModel("openrouter", "z-ai/glm-4.5v"); const image5mb = await getImageAtSize(5); // Context-limited, test progressively const counts = [1, 2, 5, 10, 20]; diff --git a/packages/ai/test/image-tool-result.test.ts b/packages/ai/test/image-tool-result.test.ts index 1e332a8c9..2a5049008 100644 --- a/packages/ai/test/image-tool-result.test.ts +++ b/packages/ai/test/image-tool-result.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "bun:test"; import * as fs from "node:fs/promises"; import * as path from "node:path"; import type { Api, Context, Model, Tool, ToolResultMessage } from "@oh-my-pi/pi-ai"; -import { complete, getModel } from "@oh-my-pi/pi-ai"; +import { complete, getBundledModel } from "@oh-my-pi/pi-ai"; import type { OptionsForApi } from "@oh-my-pi/pi-ai/types"; import { Type } from "@sinclair/typebox"; import { e2eApiKey, resolveApiKey } from "./oauth"; @@ -202,7 +202,7 @@ async function handleToolWithTextAndImageResult(model: Model { describe.skipIf(!e2eApiKey("GEMINI_API_KEY"))("Google Provider (gemini-2.5-flash)", () => { - const llm = getModel("google", "gemini-2.5-flash"); + const llm = getBundledModel("google", "gemini-2.5-flash"); it( "should handle tool result with only image", @@ -222,7 +222,10 @@ describe("Tool Results with Images", () => { }); describe.skipIf(!e2eApiKey("OPENAI_API_KEY"))("OpenAI Completions Provider (gpt-4o-mini)", () => { - const llm: Model<"openai-completions"> = { ...getModel("openai", "gpt-4o-mini"), api: "openai-completions" }; + const llm: Model<"openai-completions"> = { + ...getBundledModel("openai", "gpt-4o-mini"), + api: "openai-completions", + }; it( "should handle tool result with only image", @@ -242,7 +245,7 @@ describe("Tool Results with Images", () => { }); describe.skipIf(!e2eApiKey("OPENAI_API_KEY"))("OpenAI Responses Provider (gpt-5-mini)", () => { - const llm = getModel("openai", "gpt-5-mini"); + const llm = getBundledModel("openai", "gpt-5-mini"); it( "should handle tool result with only image", @@ -262,7 +265,7 @@ describe("Tool Results with Images", () => { }); describe.skipIf(!e2eApiKey("ANTHROPIC_API_KEY"))("Anthropic Provider (claude-haiku-4-5)", () => { - const model = getModel("anthropic", "claude-haiku-4-5"); + const model = getBundledModel("anthropic", "claude-haiku-4-5"); it( "should handle tool result with only image", @@ -282,7 +285,7 @@ describe("Tool Results with Images", () => { }); describe.skipIf(!e2eApiKey("OPENROUTER_API_KEY"))("OpenRouter Provider (glm-4.5v)", () => { - const llm = getModel("openrouter", "z-ai/glm-4.5v"); + const llm = getBundledModel("openrouter", "z-ai/glm-4.5v"); it( "should handle tool result with only image", @@ -302,7 +305,7 @@ describe("Tool Results with Images", () => { }); describe.skipIf(!e2eApiKey("MISTRAL_API_KEY"))("Mistral Provider (pixtral-12b)", () => { - const llm = getModel("mistral", "pixtral-12b"); + const llm = getBundledModel("mistral", "pixtral-12b"); it( "should handle tool result with only image", @@ -322,7 +325,7 @@ describe("Tool Results with Images", () => { }); describe("Anthropic OAuth Provider (claude-sonnet-4-5)", () => { - const model = getModel("anthropic", "claude-sonnet-4-5"); + const model = getBundledModel("anthropic", "claude-sonnet-4-5"); it.skipIf(!anthropicOAuthToken)( "should handle tool result with only image", @@ -345,7 +348,7 @@ describe("Tool Results with Images", () => { it.skipIf(!githubCopilotToken)( "gpt-4o - should handle tool result with only image", async () => { - const llm = getModel("github-copilot", "gpt-4o"); + const llm = getBundledModel("github-copilot", "gpt-4o"); await handleToolWithImageResult(llm, { apiKey: githubCopilotToken }); }, { retry: 3, timeout: 30000 }, @@ -354,7 +357,7 @@ describe("Tool Results with Images", () => { it.skipIf(!githubCopilotToken)( "gpt-4o - should handle tool result with text and image", async () => { - const llm = getModel("github-copilot", "gpt-4o"); + const llm = getBundledModel("github-copilot", "gpt-4o"); await handleToolWithTextAndImageResult(llm, { apiKey: githubCopilotToken }); }, { retry: 3, timeout: 30000 }, @@ -363,7 +366,7 @@ describe("Tool Results with Images", () => { it.skipIf(!githubCopilotToken)( "claude-sonnet-4 - should handle tool result with only image", async () => { - const llm = getModel("github-copilot", "claude-sonnet-4"); + const llm = getBundledModel("github-copilot", "claude-sonnet-4"); await handleToolWithImageResult(llm, { apiKey: githubCopilotToken }); }, { retry: 3, timeout: 30000 }, @@ -372,7 +375,7 @@ describe("Tool Results with Images", () => { it.skipIf(!githubCopilotToken)( "claude-sonnet-4 - should handle tool result with text and image", async () => { - const llm = getModel("github-copilot", "claude-sonnet-4"); + const llm = getBundledModel("github-copilot", "claude-sonnet-4"); await handleToolWithTextAndImageResult(llm, { apiKey: githubCopilotToken }); }, { retry: 3, timeout: 30000 }, @@ -383,7 +386,7 @@ describe("Tool Results with Images", () => { it.skipIf(!geminiCliToken)( "gemini-2.5-flash - should handle tool result with only image", async () => { - const llm = getModel("google-gemini-cli", "gemini-2.5-flash"); + const llm = getBundledModel("google-gemini-cli", "gemini-2.5-flash"); await handleToolWithImageResult(llm, { apiKey: geminiCliToken }); }, { retry: 3, timeout: 30000 }, @@ -392,7 +395,7 @@ describe("Tool Results with Images", () => { it.skipIf(!geminiCliToken)( "gemini-2.5-flash - should handle tool result with text and image", async () => { - const llm = getModel("google-gemini-cli", "gemini-2.5-flash"); + const llm = getBundledModel("google-gemini-cli", "gemini-2.5-flash"); await handleToolWithTextAndImageResult(llm, { apiKey: geminiCliToken }); }, { retry: 3, timeout: 30000 }, @@ -403,7 +406,7 @@ describe("Tool Results with Images", () => { it.skipIf(!antigravityToken)( "gemini-3-flash - should handle tool result with only image", async () => { - const llm = getModel("google-antigravity", "gemini-3-flash"); + const llm = getBundledModel("google-antigravity", "gemini-3-flash"); await handleToolWithImageResult(llm, { apiKey: antigravityToken }); }, { retry: 3, timeout: 30000 }, @@ -412,7 +415,7 @@ describe("Tool Results with Images", () => { it.skipIf(!antigravityToken)( "gemini-3-flash - should handle tool result with text and image", async () => { - const llm = getModel("google-antigravity", "gemini-3-flash"); + const llm = getBundledModel("google-antigravity", "gemini-3-flash"); await handleToolWithTextAndImageResult(llm, { apiKey: antigravityToken }); }, { retry: 3, timeout: 30000 }, @@ -442,7 +445,7 @@ describe("Tool Results with Images", () => { it.skipIf(!openaiCodexToken)( "gpt-5.2-codex - should handle tool result with only image", async () => { - const llm = getModel("openai-codex", "gpt-5.2-codex"); + const llm = getBundledModel("openai-codex", "gpt-5.2-codex"); await handleToolWithImageResult(llm, { apiKey: openaiCodexToken }); }, { retry: 3, timeout: 30000 }, @@ -451,7 +454,7 @@ describe("Tool Results with Images", () => { it.skipIf(!openaiCodexToken)( "gpt-5.2-codex - should handle tool result with text and image", async () => { - const llm = getModel("openai-codex", "gpt-5.2-codex"); + const llm = getBundledModel("openai-codex", "gpt-5.2-codex"); await handleToolWithTextAndImageResult(llm, { apiKey: openaiCodexToken }); }, { retry: 3, timeout: 30000 }, diff --git a/packages/ai/test/openai-completions-tool-result-images.test.ts b/packages/ai/test/openai-completions-tool-result-images.test.ts index d1bef3a78..bf06aa694 100644 --- a/packages/ai/test/openai-completions-tool-result-images.test.ts +++ b/packages/ai/test/openai-completions-tool-result-images.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test"; -import { getModel } from "@oh-my-pi/pi-ai/models"; +import { getBundledModel } from "@oh-my-pi/pi-ai/models"; import { convertMessages } from "@oh-my-pi/pi-ai/providers/openai-completions"; import type { AssistantMessage, Context, Model, OpenAICompat, ToolResultMessage, Usage } from "@oh-my-pi/pi-ai/types"; @@ -48,7 +48,7 @@ function buildToolResult(toolCallId: string, timestamp: number): ToolResultMessa describe("openai-completions convertMessages", () => { it("batches tool-result images after consecutive tool results", () => { - const baseModel = getModel("openai", "gpt-4o-mini"); + const baseModel = getBundledModel("openai", "gpt-4o-mini"); const model: Model<"openai-completions"> = { ...baseModel, api: "openai-completions", diff --git a/packages/ai/test/stream.test.ts b/packages/ai/test/stream.test.ts index e732127d3..b9bc429f5 100644 --- a/packages/ai/test/stream.test.ts +++ b/packages/ai/test/stream.test.ts @@ -2,7 +2,7 @@ import { afterAll, beforeAll, describe, expect, it } from "bun:test"; import { type ChildProcess, execSync, spawn } from "node:child_process"; import * as fs from "node:fs/promises"; import * as path from "node:path"; -import { getModel } from "@oh-my-pi/pi-ai/models"; +import { getBundledModel } from "@oh-my-pi/pi-ai/models"; import { complete, stream } from "@oh-my-pi/pi-ai/stream"; import type { Api, Context, ImageContent, Model, OptionsForApi, Tool, ToolResultMessage } from "@oh-my-pi/pi-ai/types"; import { StringEnum } from "@oh-my-pi/pi-ai/utils/typebox-helpers"; @@ -352,7 +352,7 @@ async function multiTurn(model: Model, options?: Options describe("Generate E2E Tests", () => { describe.skipIf(!e2eApiKey("GEMINI_API_KEY"))("Gemini Provider (gemini-2.5-flash)", () => { - const llm = getModel("google", "gemini-2.5-flash"); + const llm = getBundledModel("google", "gemini-2.5-flash"); it( "should complete basic text generation", @@ -408,7 +408,7 @@ describe("Generate E2E Tests", () => { const vertexLocation = Bun.env.GOOGLE_CLOUD_LOCATION; const isVertexConfigured = Boolean(vertexProject && vertexLocation); const vertexOptions = { project: vertexProject, location: vertexLocation } as const; - const llm = getModel("google-vertex", "gemini-3-flash-preview"); + const llm = getBundledModel("google-vertex", "gemini-3-flash-preview"); it.skipIf(!isVertexConfigured)( "should complete basic text generation", @@ -466,7 +466,10 @@ describe("Generate E2E Tests", () => { }); describe.skipIf(!e2eApiKey("OPENAI_API_KEY"))("OpenAI Completions Provider (gpt-4o-mini)", () => { - const llm: Model<"openai-completions"> = { ...getModel("openai", "gpt-4o-mini"), api: "openai-completions" }; + const llm: Model<"openai-completions"> = { + ...getBundledModel("openai", "gpt-4o-mini"), + api: "openai-completions", + }; it( "should complete basic text generation", @@ -502,7 +505,7 @@ describe("Generate E2E Tests", () => { }); describe.skipIf(!e2eApiKey("OPENAI_API_KEY"))("OpenAI Responses Provider (gpt-5-mini)", () => { - const llm = getModel("openai", "gpt-5-mini"); + const llm = getBundledModel("openai", "gpt-5-mini"); it( "should complete basic text generation", @@ -554,7 +557,7 @@ describe("Generate E2E Tests", () => { }); describe.skipIf(!e2eApiKey("ANTHROPIC_API_KEY"))("Anthropic Provider (claude-haiku-4-5-20251001)", () => { - const model = getModel("anthropic", "claude-haiku-4-5-20251001"); + const model = getBundledModel("anthropic", "claude-haiku-4-5-20251001"); it( "should complete basic text generation", @@ -590,7 +593,7 @@ describe("Generate E2E Tests", () => { }); describe.skipIf(!e2eApiKey("OPENAI_API_KEY"))("OpenAI Responses Provider (gpt-5-mini)", () => { - const model = getModel("openai", "gpt-5-mini"); + const model = getBundledModel("openai", "gpt-5-mini"); it( "should complete basic text generation", @@ -626,7 +629,7 @@ describe("Generate E2E Tests", () => { }); describe.skipIf(!e2eApiKey("XAI_API_KEY"))("xAI Provider (grok-code-fast-1 via OpenAI Completions)", () => { - const llm = getModel("xai", "grok-code-fast-1"); + const llm = getBundledModel("xai", "grok-code-fast-1"); it( "should complete basic text generation", @@ -670,7 +673,7 @@ describe("Generate E2E Tests", () => { }); describe.skipIf(!e2eApiKey("GROQ_API_KEY"))("Groq Provider (gpt-oss-20b via OpenAI Completions)", () => { - const llm = getModel("groq", "openai/gpt-oss-20b"); + const llm = getBundledModel("groq", "openai/gpt-oss-20b"); it( "should complete basic text generation", @@ -714,7 +717,7 @@ describe("Generate E2E Tests", () => { }); describe.skipIf(!e2eApiKey("CEREBRAS_API_KEY"))("Cerebras Provider (gpt-oss-120b via OpenAI Completions)", () => { - const llm = getModel("cerebras", "gpt-oss-120b"); + const llm = getBundledModel("cerebras", "gpt-oss-120b"); it( "should complete basic text generation", @@ -758,7 +761,7 @@ describe("Generate E2E Tests", () => { }); describe.skipIf(!e2eApiKey("OPENROUTER_API_KEY"))("OpenRouter Provider (glm-4.5v via OpenAI Completions)", () => { - const llm = getModel("openrouter", "z-ai/glm-4.5v"); + const llm = getBundledModel("openrouter", "z-ai/glm-4.5v"); it( "should complete basic text generation", @@ -810,7 +813,7 @@ describe("Generate E2E Tests", () => { }); describe.skipIf(!e2eApiKey("ZAI_API_KEY"))("zAI Provider (glm-4.5-air via OpenAI Completions)", () => { - const llm = getModel("zai", "glm-4.5-air"); + const llm = getBundledModel("zai", "glm-4.5-air"); it( "should complete basic text generation", @@ -854,7 +857,7 @@ describe("Generate E2E Tests", () => { }); describe.skipIf(!e2eApiKey("ZAI_API_KEY"))("zAI Provider (glm-4.5v via OpenAI Completions)", () => { - const llm = getModel("zai", "glm-4.5v"); + const llm = getBundledModel("zai", "glm-4.5v"); it( "should complete basic text generation", @@ -908,7 +911,7 @@ describe("Generate E2E Tests", () => { describe.skipIf(!e2eApiKey("MISTRAL_API_KEY"))( "Mistral Provider (devstral-medium-latest via OpenAI Completions)", () => { - const llm = getModel("mistral", "devstral-medium-latest"); + const llm = getBundledModel("mistral", "devstral-medium-latest"); it( "should complete basic text generation", @@ -955,7 +958,7 @@ describe("Generate E2E Tests", () => { ); describe.skipIf(!e2eApiKey("MISTRAL_API_KEY"))("Mistral Provider (pixtral-12b with image support)", () => { - const llm = getModel("mistral", "pixtral-12b"); + const llm = getBundledModel("mistral", "pixtral-12b"); it( "should complete basic text generation", @@ -991,7 +994,7 @@ describe("Generate E2E Tests", () => { }); describe("Anthropic OAuth Provider (claude-sonnet-4-20250514)", () => { - const model = getModel("anthropic", "claude-sonnet-4-20250514"); + const model = getBundledModel("anthropic", "claude-sonnet-4-20250514"); it.skipIf(!anthropicOAuthToken)( "should complete basic text generation", @@ -1043,7 +1046,7 @@ describe("Generate E2E Tests", () => { }); describe("GitHub Copilot Provider (gpt-4o via OpenAI Completions)", () => { - const llm = getModel("github-copilot", "gpt-4o"); + const llm = getBundledModel("github-copilot", "gpt-4o"); it.skipIf(!githubCopilotToken)( "should complete basic text generation", @@ -1072,7 +1075,7 @@ describe("Generate E2E Tests", () => { it.skipIf(!githubCopilotToken)( "should handle thinking", async () => { - const thinkingModel = getModel("github-copilot", "gpt-5-mini"); + const thinkingModel = getBundledModel("github-copilot", "gpt-5-mini"); await handleThinking(thinkingModel, { apiKey: githubCopilotToken, reasoningEffort: "high" }); }, { retry: 2 }, @@ -1081,7 +1084,7 @@ describe("Generate E2E Tests", () => { it.skipIf(!githubCopilotToken)( "should handle multi-turn with thinking and tools", async () => { - const thinkingModel = getModel("github-copilot", "gpt-5-mini"); + const thinkingModel = getBundledModel("github-copilot", "gpt-5-mini"); await multiTurn(thinkingModel, { apiKey: githubCopilotToken, reasoningEffort: "high" }); }, { retry: 3 }, @@ -1097,7 +1100,7 @@ describe("Generate E2E Tests", () => { }); describe("Google Gemini CLI Provider (gemini-2.5-flash)", () => { - const llm = getModel("google-gemini-cli", "gemini-2.5-flash"); + const llm = getBundledModel("google-gemini-cli", "gemini-2.5-flash"); it.skipIf(!geminiCliToken)( "should complete basic text generation", @@ -1149,7 +1152,7 @@ describe("Generate E2E Tests", () => { }); describe("Google Gemini CLI Provider (gemini-3-flash-preview with thinkingLevel)", () => { - const llm = getModel("google-gemini-cli", "gemini-3-flash-preview"); + const llm = getBundledModel("google-gemini-cli", "gemini-3-flash-preview"); it.skipIf(!geminiCliToken)( "should handle thinking with thinkingLevel", @@ -1169,7 +1172,7 @@ describe("Generate E2E Tests", () => { }); describe("Google Antigravity Provider (gemini-3-pro-high)", () => { - const llm = getModel("google-antigravity", "gemini-3-pro-high"); + const llm = getBundledModel("google-antigravity", "gemini-3-pro-high"); it.skipIf(!antigravityToken)( "should complete basic text generation", @@ -1225,7 +1228,7 @@ describe("Generate E2E Tests", () => { }); describe("Google Antigravity Provider (claude-sonnet-4-5)", () => { - const llm = getModel("google-antigravity", "claude-sonnet-4-5"); + const llm = getBundledModel("google-antigravity", "claude-sonnet-4-5"); it.skipIf(!antigravityToken)( "should complete basic text generation", @@ -1255,7 +1258,7 @@ describe("Generate E2E Tests", () => { "should handle thinking", async () => { // claude-sonnet-4-5 has reasoning: false, use claude-sonnet-4-5-thinking - const thinkingModel = getModel("google-antigravity", "claude-sonnet-4-5-thinking"); + const thinkingModel = getBundledModel("google-antigravity", "claude-sonnet-4-5-thinking"); await handleThinking(thinkingModel, { apiKey: antigravityToken, thinking: { enabled: true, budgetTokens: 4096 }, @@ -1267,7 +1270,7 @@ describe("Generate E2E Tests", () => { it.skipIf(!antigravityToken)( "should handle multi-turn with thinking and tools", async () => { - const thinkingModel = getModel("google-antigravity", "claude-sonnet-4-5-thinking"); + const thinkingModel = getBundledModel("google-antigravity", "claude-sonnet-4-5-thinking"); await multiTurn(thinkingModel, { apiKey: antigravityToken, thinking: { enabled: true, budgetTokens: 4096 }, @@ -1286,7 +1289,7 @@ describe("Generate E2E Tests", () => { }); describe("OpenAI Codex Provider (gpt-5.2-codex)", () => { - const llm = getModel("openai-codex", "gpt-5.2-codex"); + const llm = getBundledModel("openai-codex", "gpt-5.2-codex"); it.skipIf(!openaiCodexToken)( "should complete basic text generation", @@ -1338,7 +1341,7 @@ describe("Generate E2E Tests", () => { }); describe.skipIf(!hasBedrockCredentials())("Amazon Bedrock Provider (claude-opus-4-6 interleaved thinking)", () => { - const llm = getModel("amazon-bedrock", "global.anthropic.claude-opus-4-6-v1"); + const llm = getBundledModel("amazon-bedrock", "global.anthropic.claude-opus-4-6-v1"); it( "should use adaptive thinking without anthropic_beta", diff --git a/packages/ai/test/tokens.test.ts b/packages/ai/test/tokens.test.ts index 8046640f4..857fc7528 100644 --- a/packages/ai/test/tokens.test.ts +++ b/packages/ai/test/tokens.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test"; -import { getModel } from "@oh-my-pi/pi-ai/models"; +import { getBundledModel } from "@oh-my-pi/pi-ai/models"; import { stream } from "@oh-my-pi/pi-ai/stream"; import type { Api, Context, Model, OptionsForApi } from "@oh-my-pi/pi-ai/types"; import { e2eApiKey, resolveApiKey } from "./oauth"; @@ -70,7 +70,7 @@ async function testTokensOnAbort(llm: Model, options: Op describe("Token Statistics on Abort", () => { describe.skipIf(!e2eApiKey("GEMINI_API_KEY"))("Google Provider", () => { - const llm = getModel("google", "gemini-2.5-flash"); + const llm = getBundledModel("google", "gemini-2.5-flash"); it( "should include token stats when aborted mid-stream", @@ -83,7 +83,7 @@ describe("Token Statistics on Abort", () => { describe.skipIf(!e2eApiKey("OPENAI_API_KEY"))("OpenAI Completions Provider", () => { const llm: Model<"openai-completions"> = { - ...getModel("openai", "gpt-4o-mini")!, + ...getBundledModel("openai", "gpt-4o-mini")!, api: "openai-completions", }; @@ -97,7 +97,7 @@ describe("Token Statistics on Abort", () => { }); describe.skipIf(!e2eApiKey("OPENAI_API_KEY"))("OpenAI Responses Provider", () => { - const llm = getModel("openai", "gpt-5-mini"); + const llm = getBundledModel("openai", "gpt-5-mini"); it( "should include token stats when aborted mid-stream", @@ -109,7 +109,7 @@ describe("Token Statistics on Abort", () => { }); describe.skipIf(!e2eApiKey("ANTHROPIC_API_KEY"))("Anthropic Provider", () => { - const llm = getModel("anthropic", "claude-haiku-4-5-20251001"); + const llm = getBundledModel("anthropic", "claude-haiku-4-5-20251001"); it( "should include token stats when aborted mid-stream", @@ -121,7 +121,7 @@ describe("Token Statistics on Abort", () => { }); describe.skipIf(!e2eApiKey("XAI_API_KEY"))("xAI Provider", () => { - const llm = getModel("xai", "grok-3-fast"); + const llm = getBundledModel("xai", "grok-3-fast"); it( "should include token stats when aborted mid-stream", @@ -133,7 +133,7 @@ describe("Token Statistics on Abort", () => { }); describe.skipIf(!e2eApiKey("GROQ_API_KEY"))("Groq Provider", () => { - const llm = getModel("groq", "openai/gpt-oss-20b"); + const llm = getBundledModel("groq", "openai/gpt-oss-20b"); it( "should include token stats when aborted mid-stream", @@ -145,7 +145,7 @@ describe("Token Statistics on Abort", () => { }); describe.skipIf(!e2eApiKey("CEREBRAS_API_KEY"))("Cerebras Provider", () => { - const llm = getModel("cerebras", "gpt-oss-120b"); + const llm = getBundledModel("cerebras", "gpt-oss-120b"); it( "should include token stats when aborted mid-stream", @@ -157,7 +157,7 @@ describe("Token Statistics on Abort", () => { }); describe.skipIf(!e2eApiKey("ZAI_API_KEY"))("zAI Provider", () => { - const llm = getModel("zai", "glm-4.5-flash"); + const llm = getBundledModel("zai", "glm-4.5-flash"); it( "should include token stats when aborted mid-stream", @@ -169,7 +169,7 @@ describe("Token Statistics on Abort", () => { }); describe.skipIf(!e2eApiKey("MISTRAL_API_KEY"))("Mistral Provider", () => { - const llm = getModel("mistral", "devstral-medium-latest"); + const llm = getBundledModel("mistral", "devstral-medium-latest"); it( "should include token stats when aborted mid-stream", @@ -181,7 +181,7 @@ describe("Token Statistics on Abort", () => { }); describe("Anthropic OAuth Provider", () => { - const llm = getModel("anthropic", "claude-haiku-4-5-20251001"); + const llm = getBundledModel("anthropic", "claude-haiku-4-5-20251001"); it.skipIf(!anthropicOAuthToken)( "should include token stats when aborted mid-stream", @@ -196,7 +196,7 @@ describe("Token Statistics on Abort", () => { it.skipIf(!githubCopilotToken)( "gpt-4o - should include token stats when aborted mid-stream", async () => { - const llm = getModel("github-copilot", "gpt-4o"); + const llm = getBundledModel("github-copilot", "gpt-4o"); await testTokensOnAbort(llm, { apiKey: githubCopilotToken }); }, { retry: 3, timeout: 30000 }, @@ -205,7 +205,7 @@ describe("Token Statistics on Abort", () => { it.skipIf(!githubCopilotToken)( "claude-sonnet-4 - should include token stats when aborted mid-stream", async () => { - const llm = getModel("github-copilot", "claude-sonnet-4"); + const llm = getBundledModel("github-copilot", "claude-sonnet-4"); await testTokensOnAbort(llm, { apiKey: githubCopilotToken }); }, { retry: 3, timeout: 30000 }, @@ -216,7 +216,7 @@ describe("Token Statistics on Abort", () => { it.skipIf(!geminiCliToken)( "gemini-2.5-flash - should include token stats when aborted mid-stream", async () => { - const llm = getModel("google-gemini-cli", "gemini-2.5-flash"); + const llm = getBundledModel("google-gemini-cli", "gemini-2.5-flash"); await testTokensOnAbort(llm, { apiKey: geminiCliToken }); }, { retry: 3, timeout: 30000 }, @@ -227,7 +227,7 @@ describe("Token Statistics on Abort", () => { it.skipIf(!antigravityToken)( "gemini-3-flash - should include token stats when aborted mid-stream", async () => { - const llm = getModel("google-antigravity", "gemini-3-flash"); + const llm = getBundledModel("google-antigravity", "gemini-3-flash"); await testTokensOnAbort(llm, { apiKey: antigravityToken }); }, { retry: 3, timeout: 30000 }, @@ -236,7 +236,7 @@ describe("Token Statistics on Abort", () => { it.skipIf(!antigravityToken)( "claude-sonnet-4-5 - should include token stats when aborted mid-stream", async () => { - const llm = getModel("google-antigravity", "claude-sonnet-4-5"); + const llm = getBundledModel("google-antigravity", "claude-sonnet-4-5"); await testTokensOnAbort(llm, { apiKey: antigravityToken }); }, { retry: 3, timeout: 30000 }, @@ -245,7 +245,7 @@ describe("Token Statistics on Abort", () => { it.skipIf(!antigravityToken)( "gpt-oss-120b-medium - should include token stats when aborted mid-stream", async () => { - const llm = getModel("google-antigravity", "gpt-oss-120b-medium"); + const llm = getBundledModel("google-antigravity", "gpt-oss-120b-medium"); await testTokensOnAbort(llm, { apiKey: antigravityToken }); }, { retry: 3, timeout: 30000 }, @@ -256,7 +256,7 @@ describe("Token Statistics on Abort", () => { it.skipIf(!openaiCodexToken)( "gpt-5.2-codex - should include token stats when aborted mid-stream", async () => { - const llm = getModel("openai-codex", "gpt-5.2-codex"); + const llm = getBundledModel("openai-codex", "gpt-5.2-codex"); await testTokensOnAbort(llm, { apiKey: openaiCodexToken }); }, { retry: 3, timeout: 30000 }, diff --git a/packages/ai/test/tool-call-without-result.test.ts b/packages/ai/test/tool-call-without-result.test.ts index 4a860a02e..2ad5f5106 100644 --- a/packages/ai/test/tool-call-without-result.test.ts +++ b/packages/ai/test/tool-call-without-result.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test"; -import { getModel } from "@oh-my-pi/pi-ai/models"; +import { getBundledModel } from "@oh-my-pi/pi-ai/models"; import { complete } from "@oh-my-pi/pi-ai/stream"; import type { Api, Context, Model, OptionsForApi, Tool } from "@oh-my-pi/pi-ai/types"; import { Type } from "@sinclair/typebox"; @@ -96,7 +96,7 @@ describe("Tool Call Without Result Tests", () => { // ========================================================================= describe.skipIf(!e2eApiKey("GEMINI_API_KEY"))("Google Provider", () => { - const model = getModel("google", "gemini-2.5-flash"); + const model = getBundledModel("google", "gemini-2.5-flash"); it( "should filter out tool calls without corresponding tool results", @@ -109,7 +109,7 @@ describe("Tool Call Without Result Tests", () => { describe.skipIf(!e2eApiKey("OPENAI_API_KEY"))("OpenAI Completions Provider", () => { const model: Model<"openai-completions"> = { - ...getModel("openai", "gpt-4o-mini")!, + ...getBundledModel("openai", "gpt-4o-mini")!, api: "openai-completions", }; @@ -123,7 +123,7 @@ describe("Tool Call Without Result Tests", () => { }); describe.skipIf(!e2eApiKey("OPENAI_API_KEY"))("OpenAI Responses Provider", () => { - const model = getModel("openai", "gpt-5-mini"); + const model = getBundledModel("openai", "gpt-5-mini"); it( "should filter out tool calls without corresponding tool results", @@ -135,7 +135,7 @@ describe("Tool Call Without Result Tests", () => { }); describe.skipIf(!e2eApiKey("ANTHROPIC_API_KEY"))("Anthropic Provider", () => { - const model = getModel("anthropic", "claude-haiku-4-5-20251001"); + const model = getBundledModel("anthropic", "claude-haiku-4-5-20251001"); it( "should filter out tool calls without corresponding tool results", @@ -147,7 +147,7 @@ describe("Tool Call Without Result Tests", () => { }); describe.skipIf(!e2eApiKey("XAI_API_KEY"))("xAI Provider", () => { - const model = getModel("xai", "grok-3-fast"); + const model = getBundledModel("xai", "grok-3-fast"); it( "should filter out tool calls without corresponding tool results", @@ -159,7 +159,7 @@ describe("Tool Call Without Result Tests", () => { }); describe.skipIf(!e2eApiKey("GROQ_API_KEY"))("Groq Provider", () => { - const model = getModel("groq", "openai/gpt-oss-20b"); + const model = getBundledModel("groq", "openai/gpt-oss-20b"); it( "should filter out tool calls without corresponding tool results", @@ -171,7 +171,7 @@ describe("Tool Call Without Result Tests", () => { }); describe.skipIf(!e2eApiKey("CEREBRAS_API_KEY"))("Cerebras Provider", () => { - const model = getModel("cerebras", "gpt-oss-120b"); + const model = getBundledModel("cerebras", "gpt-oss-120b"); it( "should filter out tool calls without corresponding tool results", @@ -183,7 +183,7 @@ describe("Tool Call Without Result Tests", () => { }); describe.skipIf(!e2eApiKey("ZAI_API_KEY"))("zAI Provider", () => { - const model = getModel("zai", "glm-4.5-flash"); + const model = getBundledModel("zai", "glm-4.5-flash"); it( "should filter out tool calls without corresponding tool results", @@ -195,7 +195,7 @@ describe("Tool Call Without Result Tests", () => { }); describe.skipIf(!e2eApiKey("MISTRAL_API_KEY"))("Mistral Provider", () => { - const model = getModel("mistral", "devstral-medium-latest"); + const model = getBundledModel("mistral", "devstral-medium-latest"); it( "should filter out tool calls without corresponding tool results", @@ -207,7 +207,7 @@ describe("Tool Call Without Result Tests", () => { }); describe("Anthropic OAuth Provider", () => { - const model = getModel("anthropic", "claude-haiku-4-5-20251001"); + const model = getBundledModel("anthropic", "claude-haiku-4-5-20251001"); it.skipIf(!anthropicOAuthToken)( "should filter out tool calls without corresponding tool results", @@ -222,7 +222,7 @@ describe("Tool Call Without Result Tests", () => { it.skipIf(!githubCopilotToken)( "gpt-4o - should filter out tool calls without corresponding tool results", async () => { - const model = getModel("github-copilot", "gpt-4o"); + const model = getBundledModel("github-copilot", "gpt-4o"); await testToolCallWithoutResult(model, { apiKey: githubCopilotToken }); }, { retry: 3, timeout: 30000 }, @@ -231,7 +231,7 @@ describe("Tool Call Without Result Tests", () => { it.skipIf(!githubCopilotToken)( "claude-sonnet-4 - should filter out tool calls without corresponding tool results", async () => { - const model = getModel("github-copilot", "claude-sonnet-4"); + const model = getBundledModel("github-copilot", "claude-sonnet-4"); await testToolCallWithoutResult(model, { apiKey: githubCopilotToken }); }, { retry: 3, timeout: 30000 }, @@ -242,7 +242,7 @@ describe("Tool Call Without Result Tests", () => { it.skipIf(!geminiCliToken)( "gemini-2.5-flash - should filter out tool calls without corresponding tool results", async () => { - const model = getModel("google-gemini-cli", "gemini-2.5-flash"); + const model = getBundledModel("google-gemini-cli", "gemini-2.5-flash"); await testToolCallWithoutResult(model, { apiKey: geminiCliToken }); }, { retry: 3, timeout: 30000 }, @@ -253,7 +253,7 @@ describe("Tool Call Without Result Tests", () => { it.skipIf(!antigravityToken)( "gemini-3-flash - should filter out tool calls without corresponding tool results", async () => { - const model = getModel("google-antigravity", "gemini-3-flash"); + const model = getBundledModel("google-antigravity", "gemini-3-flash"); await testToolCallWithoutResult(model, { apiKey: antigravityToken }); }, { retry: 3, timeout: 30000 }, @@ -262,7 +262,7 @@ describe("Tool Call Without Result Tests", () => { it.skipIf(!antigravityToken)( "claude-sonnet-4-5 - should filter out tool calls without corresponding tool results", async () => { - const model = getModel("google-antigravity", "claude-sonnet-4-5"); + const model = getBundledModel("google-antigravity", "claude-sonnet-4-5"); await testToolCallWithoutResult(model, { apiKey: antigravityToken }); }, { retry: 3, timeout: 30000 }, @@ -271,7 +271,7 @@ describe("Tool Call Without Result Tests", () => { it.skipIf(!antigravityToken)( "gpt-oss-120b-medium - should filter out tool calls without corresponding tool results", async () => { - const model = getModel("google-antigravity", "gpt-oss-120b-medium"); + const model = getBundledModel("google-antigravity", "gpt-oss-120b-medium"); await testToolCallWithoutResult(model, { apiKey: antigravityToken }); }, { retry: 3, timeout: 30000 }, @@ -282,7 +282,7 @@ describe("Tool Call Without Result Tests", () => { it.skipIf(!openaiCodexToken)( "gpt-5.2-codex - should filter out tool calls without corresponding tool results", async () => { - const model = getModel("openai-codex", "gpt-5.2-codex"); + const model = getBundledModel("openai-codex", "gpt-5.2-codex"); await testToolCallWithoutResult(model, { apiKey: openaiCodexToken }); }, { retry: 3, timeout: 30000 }, diff --git a/packages/ai/test/total-tokens.test.ts b/packages/ai/test/total-tokens.test.ts index 561b30969..77dfad981 100644 --- a/packages/ai/test/total-tokens.test.ts +++ b/packages/ai/test/total-tokens.test.ts @@ -13,7 +13,7 @@ */ import { describe, expect, it } from "bun:test"; -import { getModel } from "@oh-my-pi/pi-ai/models"; +import { getBundledModel } from "@oh-my-pi/pi-ai/models"; import { complete } from "@oh-my-pi/pi-ai/stream"; import type { Api, Context, Model, OptionsForApi, Usage } from "@oh-my-pi/pi-ai/types"; import { e2eApiKey, resolveApiKey } from "./oauth"; @@ -103,7 +103,7 @@ describe("totalTokens field", () => { it( "claude-haiku-4-5 - should return totalTokens equal to sum of components", async () => { - const llm = getModel("anthropic", "claude-haiku-4-5-20251001"); + const llm = getBundledModel("anthropic", "claude-haiku-4-5-20251001"); console.log(`\nAnthropic / ${llm.id}:`); const { first, second } = await testTotalTokensWithCache(llm, { apiKey: Bun.env.ANTHROPIC_API_KEY }); @@ -126,7 +126,7 @@ describe("totalTokens field", () => { it.skipIf(!anthropicOAuthToken)( "claude-sonnet-4 - should return totalTokens equal to sum of components", async () => { - const llm = getModel("anthropic", "claude-sonnet-4-20250514"); + const llm = getBundledModel("anthropic", "claude-sonnet-4-20250514"); console.log(`\nAnthropic OAuth / ${llm.id}:`); const { first, second } = await testTotalTokensWithCache(llm, { apiKey: anthropicOAuthToken }); @@ -154,7 +154,7 @@ describe("totalTokens field", () => { "gpt-4o-mini - should return totalTokens equal to sum of components", async () => { const llm: Model<"openai-completions"> = { - ...getModel("openai", "gpt-4o-mini")!, + ...getBundledModel("openai", "gpt-4o-mini")!, api: "openai-completions", }; @@ -175,7 +175,7 @@ describe("totalTokens field", () => { it( "gpt-4o - should return totalTokens equal to sum of components", async () => { - const llm = getModel("openai", "gpt-4o"); + const llm = getBundledModel("openai", "gpt-4o"); console.log(`\nOpenAI Responses / ${llm.id}:`); const { first, second } = await testTotalTokensWithCache(llm); @@ -198,7 +198,7 @@ describe("totalTokens field", () => { it( "gemini-2.0-flash - should return totalTokens equal to sum of components", async () => { - const llm = getModel("google", "gemini-2.0-flash"); + const llm = getBundledModel("google", "gemini-2.0-flash"); console.log(`\nGoogle / ${llm.id}:`); const { first, second } = await testTotalTokensWithCache(llm); @@ -221,7 +221,7 @@ describe("totalTokens field", () => { it( "grok-3-fast - should return totalTokens equal to sum of components", async () => { - const llm = getModel("xai", "grok-3-fast"); + const llm = getBundledModel("xai", "grok-3-fast"); console.log(`\nxAI / ${llm.id}:`); const { first, second } = await testTotalTokensWithCache(llm, { apiKey: Bun.env.XAI_API_KEY }); @@ -244,7 +244,7 @@ describe("totalTokens field", () => { it( "openai/gpt-oss-120b - should return totalTokens equal to sum of components", async () => { - const llm = getModel("groq", "openai/gpt-oss-120b"); + const llm = getBundledModel("groq", "openai/gpt-oss-120b"); console.log(`\nGroq / ${llm.id}:`); const { first, second } = await testTotalTokensWithCache(llm, { apiKey: Bun.env.GROQ_API_KEY }); @@ -267,7 +267,7 @@ describe("totalTokens field", () => { it( "gpt-oss-120b - should return totalTokens equal to sum of components", async () => { - const llm = getModel("cerebras", "gpt-oss-120b"); + const llm = getBundledModel("cerebras", "gpt-oss-120b"); console.log(`\nCerebras / ${llm.id}:`); const { first, second } = await testTotalTokensWithCache(llm, { apiKey: Bun.env.CEREBRAS_API_KEY }); @@ -290,7 +290,7 @@ describe("totalTokens field", () => { it( "glm-4.5-flash - should return totalTokens equal to sum of components", async () => { - const llm = getModel("zai", "glm-4.5-flash"); + const llm = getBundledModel("zai", "glm-4.5-flash"); console.log(`\nz.ai / ${llm.id}:`); const { first, second } = await testTotalTokensWithCache(llm, { apiKey: Bun.env.ZAI_API_KEY }); @@ -313,7 +313,7 @@ describe("totalTokens field", () => { it( "devstral-medium-latest - should return totalTokens equal to sum of components", async () => { - const llm = getModel("mistral", "devstral-medium-latest"); + const llm = getBundledModel("mistral", "devstral-medium-latest"); console.log(`\nMistral / ${llm.id}:`); const { first, second } = await testTotalTokensWithCache(llm, { apiKey: Bun.env.MISTRAL_API_KEY }); @@ -336,7 +336,7 @@ describe("totalTokens field", () => { it( "anthropic/claude-sonnet-4 - should return totalTokens equal to sum of components", async () => { - const llm = getModel("openrouter", "anthropic/claude-sonnet-4"); + const llm = getBundledModel("openrouter", "anthropic/claude-sonnet-4"); console.log(`\nOpenRouter / ${llm.id}:`); const { first, second } = await testTotalTokensWithCache(llm, { apiKey: Bun.env.OPENROUTER_API_KEY }); @@ -353,7 +353,7 @@ describe("totalTokens field", () => { it( "deepseek/deepseek-chat - should return totalTokens equal to sum of components", async () => { - const llm = getModel("openrouter", "deepseek/deepseek-chat"); + const llm = getBundledModel("openrouter", "deepseek/deepseek-chat"); console.log(`\nOpenRouter / ${llm.id}:`); const { first, second } = await testTotalTokensWithCache(llm, { apiKey: Bun.env.OPENROUTER_API_KEY }); @@ -370,7 +370,7 @@ describe("totalTokens field", () => { it( "mistralai/mistral-small-3.1-24b-instruct - should return totalTokens equal to sum of components", async () => { - const llm = getModel("openrouter", "mistralai/mistral-small-3.1-24b-instruct"); + const llm = getBundledModel("openrouter", "mistralai/mistral-small-3.1-24b-instruct"); console.log(`\nOpenRouter / ${llm.id}:`); const { first, second } = await testTotalTokensWithCache(llm, { apiKey: Bun.env.OPENROUTER_API_KEY }); @@ -387,7 +387,7 @@ describe("totalTokens field", () => { it( "google/gemini-2.0-flash-001 - should return totalTokens equal to sum of components", async () => { - const llm = getModel("openrouter", "google/gemini-2.0-flash-001"); + const llm = getBundledModel("openrouter", "google/gemini-2.0-flash-001"); console.log(`\nOpenRouter / ${llm.id}:`); const { first, second } = await testTotalTokensWithCache(llm, { apiKey: Bun.env.OPENROUTER_API_KEY }); @@ -404,7 +404,7 @@ describe("totalTokens field", () => { it( "meta-llama/llama-4-maverick - should return totalTokens equal to sum of components", async () => { - const llm = getModel("openrouter", "meta-llama/llama-4-maverick"); + const llm = getBundledModel("openrouter", "meta-llama/llama-4-maverick"); console.log(`\nOpenRouter / ${llm.id}:`); const { first, second } = await testTotalTokensWithCache(llm, { apiKey: Bun.env.OPENROUTER_API_KEY }); @@ -427,7 +427,7 @@ describe("totalTokens field", () => { it.skipIf(!githubCopilotToken)( "gpt-4o - should return totalTokens equal to sum of components", async () => { - const llm = getModel("github-copilot", "gpt-4o"); + const llm = getBundledModel("github-copilot", "gpt-4o"); console.log(`\nGitHub Copilot / ${llm.id}:`); const { first, second } = await testTotalTokensWithCache(llm, { apiKey: githubCopilotToken }); @@ -444,7 +444,7 @@ describe("totalTokens field", () => { it.skipIf(!githubCopilotToken)( "claude-sonnet-4 - should return totalTokens equal to sum of components", async () => { - const llm = getModel("github-copilot", "claude-sonnet-4"); + const llm = getBundledModel("github-copilot", "claude-sonnet-4"); console.log(`\nGitHub Copilot / ${llm.id}:`); const { first, second } = await testTotalTokensWithCache(llm, { apiKey: githubCopilotToken }); @@ -467,7 +467,7 @@ describe("totalTokens field", () => { it.skipIf(!geminiCliToken)( "gemini-2.5-flash - should return totalTokens equal to sum of components", async () => { - const llm = getModel("google-gemini-cli", "gemini-2.5-flash"); + const llm = getBundledModel("google-gemini-cli", "gemini-2.5-flash"); console.log(`\nGoogle Gemini CLI / ${llm.id}:`); const { first, second } = await testTotalTokensWithCache(llm, { apiKey: geminiCliToken }); @@ -490,7 +490,7 @@ describe("totalTokens field", () => { it.skipIf(!antigravityToken)( "gemini-3-flash - should return totalTokens equal to sum of components", async () => { - const llm = getModel("google-antigravity", "gemini-3-flash"); + const llm = getBundledModel("google-antigravity", "gemini-3-flash"); console.log(`\nGoogle Antigravity / ${llm.id}:`); const { first, second } = await testTotalTokensWithCache(llm, { apiKey: antigravityToken }); @@ -507,7 +507,7 @@ describe("totalTokens field", () => { it.skipIf(!antigravityToken)( "claude-sonnet-4-5 - should return totalTokens equal to sum of components", async () => { - const llm = getModel("google-antigravity", "claude-sonnet-4-5"); + const llm = getBundledModel("google-antigravity", "claude-sonnet-4-5"); console.log(`\nGoogle Antigravity / ${llm.id}:`); const { first, second } = await testTotalTokensWithCache(llm, { apiKey: antigravityToken }); @@ -524,7 +524,7 @@ describe("totalTokens field", () => { it.skipIf(!antigravityToken)( "gpt-oss-120b-medium - should return totalTokens equal to sum of components", async () => { - const llm = getModel("google-antigravity", "gpt-oss-120b-medium"); + const llm = getBundledModel("google-antigravity", "gpt-oss-120b-medium"); console.log(`\nGoogle Antigravity / ${llm.id}:`); const { first, second } = await testTotalTokensWithCache(llm, { apiKey: antigravityToken }); @@ -547,7 +547,7 @@ describe("totalTokens field", () => { it.skipIf(!openaiCodexToken)( "gpt-5.2-codex - should return totalTokens equal to sum of components", async () => { - const llm = getModel("openai-codex", "gpt-5.2-codex"); + const llm = getBundledModel("openai-codex", "gpt-5.2-codex"); console.log(`\nOpenAI Codex / ${llm.id}:`); const { first, second } = await testTotalTokensWithCache(llm, { apiKey: openaiCodexToken }); diff --git a/packages/ai/test/unicode-surrogate.test.ts b/packages/ai/test/unicode-surrogate.test.ts index 33c74cc6b..b923400c7 100644 --- a/packages/ai/test/unicode-surrogate.test.ts +++ b/packages/ai/test/unicode-surrogate.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test"; -import { getModel } from "@oh-my-pi/pi-ai/models"; +import { getBundledModel } from "@oh-my-pi/pi-ai/models"; import { complete } from "@oh-my-pi/pi-ai/stream"; import type { Api, Context, Model, OptionsForApi, ToolResultMessage } from "@oh-my-pi/pi-ai/types"; import { Type } from "@sinclair/typebox"; @@ -278,7 +278,7 @@ async function testUnpairedHighSurrogate(llm: Model, opt describe("AI Providers Unicode Surrogate Pair Tests", () => { describe.skipIf(!e2eApiKey("GEMINI_API_KEY"))("Google Provider Unicode Handling", () => { - const llm = getModel("google", "gemini-2.5-flash"); + const llm = getBundledModel("google", "gemini-2.5-flash"); it( "should handle emoji in tool results", @@ -306,7 +306,7 @@ describe("AI Providers Unicode Surrogate Pair Tests", () => { }); describe.skipIf(!e2eApiKey("OPENAI_API_KEY"))("OpenAI Completions Provider Unicode Handling", () => { - const llm = getModel("openai", "gpt-4o-mini"); + const llm = getBundledModel("openai", "gpt-4o-mini"); it( "should handle emoji in tool results", @@ -334,7 +334,7 @@ describe("AI Providers Unicode Surrogate Pair Tests", () => { }); describe.skipIf(!e2eApiKey("OPENAI_API_KEY"))("OpenAI Responses Provider Unicode Handling", () => { - const llm = getModel("openai", "gpt-5-mini"); + const llm = getBundledModel("openai", "gpt-5-mini"); it( "should handle emoji in tool results", @@ -362,7 +362,7 @@ describe("AI Providers Unicode Surrogate Pair Tests", () => { }); describe.skipIf(!e2eApiKey("ANTHROPIC_API_KEY"))("Anthropic Provider Unicode Handling", () => { - const llm = getModel("anthropic", "claude-haiku-4-5-20251001"); + const llm = getBundledModel("anthropic", "claude-haiku-4-5-20251001"); it( "should handle emoji in tool results", @@ -390,7 +390,7 @@ describe("AI Providers Unicode Surrogate Pair Tests", () => { }); describe("Anthropic OAuth Provider Unicode Handling", () => { - const llm = getModel("anthropic", "claude-haiku-4-5-20251001"); + const llm = getBundledModel("anthropic", "claude-haiku-4-5-20251001"); it.skipIf(!anthropicOAuthToken)( "should handle emoji in tool results", @@ -421,7 +421,7 @@ describe("AI Providers Unicode Surrogate Pair Tests", () => { it.skipIf(!githubCopilotToken)( "gpt-4o - should handle emoji in tool results", async () => { - const llm = getModel("github-copilot", "gpt-4o"); + const llm = getBundledModel("github-copilot", "gpt-4o"); await testEmojiInToolResults(llm, { apiKey: githubCopilotToken }); }, { retry: 3, timeout: 30000 }, @@ -430,7 +430,7 @@ describe("AI Providers Unicode Surrogate Pair Tests", () => { it.skipIf(!githubCopilotToken)( "gpt-4o - should handle real-world LinkedIn comment data with emoji", async () => { - const llm = getModel("github-copilot", "gpt-4o"); + const llm = getBundledModel("github-copilot", "gpt-4o"); await testRealWorldLinkedInData(llm, { apiKey: githubCopilotToken }); }, { retry: 3, timeout: 30000 }, @@ -439,7 +439,7 @@ describe("AI Providers Unicode Surrogate Pair Tests", () => { it.skipIf(!githubCopilotToken)( "gpt-4o - should handle unpaired high surrogate (0xD83D) in tool results", async () => { - const llm = getModel("github-copilot", "gpt-4o"); + const llm = getBundledModel("github-copilot", "gpt-4o"); await testUnpairedHighSurrogate(llm, { apiKey: githubCopilotToken }); }, { retry: 3, timeout: 30000 }, @@ -448,7 +448,7 @@ describe("AI Providers Unicode Surrogate Pair Tests", () => { it.skipIf(!githubCopilotToken)( "claude-sonnet-4 - should handle emoji in tool results", async () => { - const llm = getModel("github-copilot", "claude-sonnet-4"); + const llm = getBundledModel("github-copilot", "claude-sonnet-4"); await testEmojiInToolResults(llm, { apiKey: githubCopilotToken }); }, { retry: 3, timeout: 30000 }, @@ -457,7 +457,7 @@ describe("AI Providers Unicode Surrogate Pair Tests", () => { it.skipIf(!githubCopilotToken)( "claude-sonnet-4 - should handle real-world LinkedIn comment data with emoji", async () => { - const llm = getModel("github-copilot", "claude-sonnet-4"); + const llm = getBundledModel("github-copilot", "claude-sonnet-4"); await testRealWorldLinkedInData(llm, { apiKey: githubCopilotToken }); }, { retry: 3, timeout: 30000 }, @@ -466,7 +466,7 @@ describe("AI Providers Unicode Surrogate Pair Tests", () => { it.skipIf(!githubCopilotToken)( "claude-sonnet-4 - should handle unpaired high surrogate (0xD83D) in tool results", async () => { - const llm = getModel("github-copilot", "claude-sonnet-4"); + const llm = getBundledModel("github-copilot", "claude-sonnet-4"); await testUnpairedHighSurrogate(llm, { apiKey: githubCopilotToken }); }, { retry: 3, timeout: 30000 }, @@ -477,7 +477,7 @@ describe("AI Providers Unicode Surrogate Pair Tests", () => { it.skipIf(!geminiCliToken)( "gemini-2.5-flash - should handle emoji in tool results", async () => { - const llm = getModel("google-gemini-cli", "gemini-2.5-flash"); + const llm = getBundledModel("google-gemini-cli", "gemini-2.5-flash"); await testEmojiInToolResults(llm, { apiKey: geminiCliToken }); }, { retry: 3, timeout: 30000 }, @@ -486,7 +486,7 @@ describe("AI Providers Unicode Surrogate Pair Tests", () => { it.skipIf(!geminiCliToken)( "gemini-2.5-flash - should handle real-world LinkedIn comment data with emoji", async () => { - const llm = getModel("google-gemini-cli", "gemini-2.5-flash"); + const llm = getBundledModel("google-gemini-cli", "gemini-2.5-flash"); await testRealWorldLinkedInData(llm, { apiKey: geminiCliToken }); }, { retry: 3, timeout: 30000 }, @@ -495,7 +495,7 @@ describe("AI Providers Unicode Surrogate Pair Tests", () => { it.skipIf(!geminiCliToken)( "gemini-2.5-flash - should handle unpaired high surrogate (0xD83D) in tool results", async () => { - const llm = getModel("google-gemini-cli", "gemini-2.5-flash"); + const llm = getBundledModel("google-gemini-cli", "gemini-2.5-flash"); await testUnpairedHighSurrogate(llm, { apiKey: geminiCliToken }); }, { retry: 3, timeout: 30000 }, @@ -506,7 +506,7 @@ describe("AI Providers Unicode Surrogate Pair Tests", () => { it.skipIf(!antigravityToken)( "gemini-3-flash - should handle emoji in tool results", async () => { - const llm = getModel("google-antigravity", "gemini-3-flash"); + const llm = getBundledModel("google-antigravity", "gemini-3-flash"); await testEmojiInToolResults(llm, { apiKey: antigravityToken }); }, { retry: 3, timeout: 30000 }, @@ -515,7 +515,7 @@ describe("AI Providers Unicode Surrogate Pair Tests", () => { it.skipIf(!antigravityToken)( "gemini-3-flash - should handle real-world LinkedIn comment data with emoji", async () => { - const llm = getModel("google-antigravity", "gemini-3-flash"); + const llm = getBundledModel("google-antigravity", "gemini-3-flash"); await testRealWorldLinkedInData(llm, { apiKey: antigravityToken }); }, { retry: 3, timeout: 30000 }, @@ -524,7 +524,7 @@ describe("AI Providers Unicode Surrogate Pair Tests", () => { it.skipIf(!antigravityToken)( "gemini-3-flash - should handle unpaired high surrogate (0xD83D) in tool results", async () => { - const llm = getModel("google-antigravity", "gemini-3-flash"); + const llm = getBundledModel("google-antigravity", "gemini-3-flash"); await testUnpairedHighSurrogate(llm, { apiKey: antigravityToken }); }, { retry: 3, timeout: 30000 }, @@ -533,7 +533,7 @@ describe("AI Providers Unicode Surrogate Pair Tests", () => { it.skipIf(!antigravityToken)( "claude-sonnet-4-5 - should handle emoji in tool results", async () => { - const llm = getModel("google-antigravity", "claude-sonnet-4-5"); + const llm = getBundledModel("google-antigravity", "claude-sonnet-4-5"); await testEmojiInToolResults(llm, { apiKey: antigravityToken }); }, { retry: 3, timeout: 30000 }, @@ -542,7 +542,7 @@ describe("AI Providers Unicode Surrogate Pair Tests", () => { it.skipIf(!antigravityToken)( "claude-sonnet-4-5 - should handle real-world LinkedIn comment data with emoji", async () => { - const llm = getModel("google-antigravity", "claude-sonnet-4-5"); + const llm = getBundledModel("google-antigravity", "claude-sonnet-4-5"); await testRealWorldLinkedInData(llm, { apiKey: antigravityToken }); }, { retry: 3, timeout: 30000 }, @@ -551,7 +551,7 @@ describe("AI Providers Unicode Surrogate Pair Tests", () => { it.skipIf(!antigravityToken)( "claude-sonnet-4-5 - should handle unpaired high surrogate (0xD83D) in tool results", async () => { - const llm = getModel("google-antigravity", "claude-sonnet-4-5"); + const llm = getBundledModel("google-antigravity", "claude-sonnet-4-5"); await testUnpairedHighSurrogate(llm, { apiKey: antigravityToken }); }, { retry: 3, timeout: 30000 }, @@ -560,7 +560,7 @@ describe("AI Providers Unicode Surrogate Pair Tests", () => { it.skipIf(!antigravityToken)( "gpt-oss-120b-medium - should handle emoji in tool results", async () => { - const llm = getModel("google-antigravity", "gpt-oss-120b-medium"); + const llm = getBundledModel("google-antigravity", "gpt-oss-120b-medium"); await testEmojiInToolResults(llm, { apiKey: antigravityToken }); }, { retry: 3, timeout: 30000 }, @@ -569,7 +569,7 @@ describe("AI Providers Unicode Surrogate Pair Tests", () => { it.skipIf(!antigravityToken)( "gpt-oss-120b-medium - should handle real-world LinkedIn comment data with emoji", async () => { - const llm = getModel("google-antigravity", "gpt-oss-120b-medium"); + const llm = getBundledModel("google-antigravity", "gpt-oss-120b-medium"); await testRealWorldLinkedInData(llm, { apiKey: antigravityToken }); }, { retry: 3, timeout: 30000 }, @@ -578,7 +578,7 @@ describe("AI Providers Unicode Surrogate Pair Tests", () => { it.skipIf(!antigravityToken)( "gpt-oss-120b-medium - should handle unpaired high surrogate (0xD83D) in tool results", async () => { - const llm = getModel("google-antigravity", "gpt-oss-120b-medium"); + const llm = getBundledModel("google-antigravity", "gpt-oss-120b-medium"); await testUnpairedHighSurrogate(llm, { apiKey: antigravityToken }); }, { retry: 3, timeout: 30000 }, @@ -586,7 +586,7 @@ describe("AI Providers Unicode Surrogate Pair Tests", () => { }); describe.skipIf(!e2eApiKey("XAI_API_KEY"))("xAI Provider Unicode Handling", () => { - const llm = getModel("xai", "grok-3"); + const llm = getBundledModel("xai", "grok-3"); it( "should handle emoji in tool results", @@ -614,7 +614,7 @@ describe("AI Providers Unicode Surrogate Pair Tests", () => { }); describe.skipIf(!e2eApiKey("GROQ_API_KEY"))("Groq Provider Unicode Handling", () => { - const llm = getModel("groq", "openai/gpt-oss-20b"); + const llm = getBundledModel("groq", "openai/gpt-oss-20b"); it( "should handle emoji in tool results", @@ -642,7 +642,7 @@ describe("AI Providers Unicode Surrogate Pair Tests", () => { }); describe.skipIf(!e2eApiKey("CEREBRAS_API_KEY"))("Cerebras Provider Unicode Handling", () => { - const llm = getModel("cerebras", "gpt-oss-120b"); + const llm = getBundledModel("cerebras", "gpt-oss-120b"); it( "should handle emoji in tool results", @@ -670,7 +670,7 @@ describe("AI Providers Unicode Surrogate Pair Tests", () => { }); describe.skipIf(!e2eApiKey("ZAI_API_KEY"))("zAI Provider Unicode Handling", () => { - const llm = getModel("zai", "glm-4.5-air"); + const llm = getBundledModel("zai", "glm-4.5-air"); it( "should handle emoji in tool results", @@ -698,7 +698,7 @@ describe("AI Providers Unicode Surrogate Pair Tests", () => { }); describe.skipIf(!e2eApiKey("MISTRAL_API_KEY"))("Mistral Provider Unicode Handling", () => { - const llm = getModel("mistral", "devstral-medium-latest"); + const llm = getBundledModel("mistral", "devstral-medium-latest"); it( "should handle emoji in tool results", @@ -729,7 +729,7 @@ describe("AI Providers Unicode Surrogate Pair Tests", () => { it.skipIf(!openaiCodexToken)( "gpt-5.2-codex - should handle emoji in tool results", async () => { - const llm = getModel("openai-codex", "gpt-5.2-codex"); + const llm = getBundledModel("openai-codex", "gpt-5.2-codex"); await testEmojiInToolResults(llm, { apiKey: openaiCodexToken }); }, { retry: 3, timeout: 30000 }, @@ -738,7 +738,7 @@ describe("AI Providers Unicode Surrogate Pair Tests", () => { it.skipIf(!openaiCodexToken)( "gpt-5.2-codex - should handle real-world LinkedIn comment data with emoji", async () => { - const llm = getModel("openai-codex", "gpt-5.2-codex"); + const llm = getBundledModel("openai-codex", "gpt-5.2-codex"); await testRealWorldLinkedInData(llm, { apiKey: openaiCodexToken }); }, { retry: 3, timeout: 30000 }, @@ -747,7 +747,7 @@ describe("AI Providers Unicode Surrogate Pair Tests", () => { it.skipIf(!openaiCodexToken)( "gpt-5.2-codex - should handle unpaired high surrogate (0xD83D) in tool results", async () => { - const llm = getModel("openai-codex", "gpt-5.2-codex"); + const llm = getBundledModel("openai-codex", "gpt-5.2-codex"); await testUnpairedHighSurrogate(llm, { apiKey: openaiCodexToken }); }, { retry: 3, timeout: 30000 }, diff --git a/packages/ai/test/xhigh.test.ts b/packages/ai/test/xhigh.test.ts index 04fc01ea8..6e7301b7f 100644 --- a/packages/ai/test/xhigh.test.ts +++ b/packages/ai/test/xhigh.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test"; -import { getModel } from "@oh-my-pi/pi-ai/models"; +import { getBundledModel } from "@oh-my-pi/pi-ai/models"; import { stream } from "@oh-my-pi/pi-ai/stream"; import type { Context, Model } from "@oh-my-pi/pi-ai/types"; import { e2eApiKey } from "./oauth"; @@ -20,7 +20,7 @@ describe.skipIf(!e2eApiKey("OPENAI_API_KEY"))("xhigh reasoning", () => { describe("codex-max (supports xhigh)", () => { // Note: codex models only support the responses API, not chat completions it("should work with openai-responses", async () => { - const model = getModel("openai", "gpt-5.1-codex-max"); + const model = getBundledModel("openai", "gpt-5.1-codex-max"); const s = stream(model, makeContext(), { reasoningEffort: "xhigh" }); let hasThinking = false; @@ -39,7 +39,7 @@ describe.skipIf(!e2eApiKey("OPENAI_API_KEY"))("xhigh reasoning", () => { describe("gpt-5-mini (does not support xhigh)", () => { it("should error with openai-responses when using xhigh", async () => { - const model = getModel("openai", "gpt-5-mini"); + const model = getBundledModel("openai", "gpt-5-mini"); const s = stream(model, makeContext(), { reasoningEffort: "xhigh" }); for await (const _ of s) { @@ -53,7 +53,7 @@ describe.skipIf(!e2eApiKey("OPENAI_API_KEY"))("xhigh reasoning", () => { it("should error with openai-completions when using xhigh", async () => { const model: Model<"openai-completions"> = { - ...getModel("openai", "gpt-5-mini"), + ...getBundledModel("openai", "gpt-5-mini"), api: "openai-completions", }; const s = stream(model, makeContext(), { reasoningEffort: "xhigh" }); diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 4c98cc778..f88538bcf 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## [Unreleased] + ### Added - Added `condition` and `scope` fields to rule frontmatter for advanced TTSR matching and stream filtering @@ -12,6 +13,8 @@ ### Changed +- Changed model discovery to automatically discover built-in provider models (Anthropic, OpenAI, Groq, Cerebras, Xai, Mistral, OpenCode, OpenRouter, Vercel AI Gateway, Kimi Code, GitHub Copilot, Google, Cursor, Google Antigravity, Google Gemini CLI, OpenAI Codex) when credentials are configured +- Changed `getModel()` and `getModels()` imports to `getBundledModel()` and `getBundledModels()` across test utilities - Changed TTSR rule matching from single `ttsrTrigger` regex to multiple `condition` patterns with scope filtering - Changed TTSR buffer management to use per-stream-key buffers instead of a single global buffer - Changed rule discovery to use unified `buildRuleFromMarkdown` helper across all providers (builtin, cline, cursor, windsurf, agents) diff --git a/packages/coding-agent/src/config/model-registry.ts b/packages/coding-agent/src/config/model-registry.ts index ea5dd22df..f42e65fd2 100644 --- a/packages/coding-agent/src/config/model-registry.ts +++ b/packages/coding-agent/src/config/model-registry.ts @@ -1,27 +1,50 @@ import { type Api, type AssistantMessageEventStream, + anthropicModelManagerOptions, type Context, + cerebrasModelManagerOptions, + createModelManager, + cursorModelManagerOptions, + getBundledModels, + getBundledProviders, getGitHubCopilotBaseUrl, - getModels, - getProviders, + githubCopilotModelManagerOptions, + googleAntigravityModelManagerOptions, + googleGeminiCliModelManagerOptions, + googleModelManagerOptions, + groqModelManagerOptions, + kimiCodeModelManagerOptions, type Model, + type ModelManagerOptions, + mistralModelManagerOptions, normalizeDomain, type OAuthCredentials, type OAuthLoginCallbacks, + openaiCodexModelManagerOptions, + openaiModelManagerOptions, + opencodeModelManagerOptions, + openrouterModelManagerOptions, registerCustomApi, registerOAuthProvider, type SimpleStreamOptions, unregisterCustomApis, unregisterOAuthProviders, + vercelAiGatewayModelManagerOptions, + xaiModelManagerOptions, } from "@oh-my-pi/pi-ai"; import { logger } from "@oh-my-pi/pi-utils"; import { type Static, Type } from "@sinclair/typebox"; -import AjvModule from "ajv"; import { type ConfigError, ConfigFile } from "../config"; import type { ThemeColor } from "../modes/theme/theme"; import type { AuthStorage } from "../session/auth-storage"; +export const kNoAuth = "N/A"; + +export function isAuthenticated(apiKey: string | undefined | null): apiKey is string { + return Boolean(apiKey) && apiKey !== kNoAuth; +} + export type ModelRole = "default" | "smol" | "slow" | "plan" | "commit"; export interface ModelRoleInfo { @@ -40,8 +63,6 @@ export const MODEL_ROLES: Record = { export const MODEL_ROLE_IDS: ModelRole[] = ["default", "smol", "slow", "plan", "commit"]; -const _Ajv = (AjvModule as any).default || AjvModule; - const OpenRouterRoutingSchema = Type.Object({ only: Type.Optional(Type.Array(Type.String())), order: Type.Optional(Type.Array(Type.String())), @@ -252,19 +273,36 @@ function resolveApiKeyConfig(keyConfig: string): string | undefined { return keyConfig; } +function extractGoogleOAuthToken(value: string | undefined): string | undefined { + if (!isAuthenticated(value)) return undefined; + try { + const parsed = JSON.parse(value) as { token?: unknown }; + if (Object.hasOwn(parsed, "token")) { + if (typeof parsed.token !== "string") { + return undefined; + } + const token = parsed.token.trim(); + return token.length > 0 ? token : undefined; + } + } catch { + // OAuth values for Google providers are expected to be JSON, but custom setups may already provide raw token. + } + return value; +} + function mergeCompat( baseCompat: Model["compat"], overrideCompat: ModelOverride["compat"], ): Model["compat"] | undefined { if (!overrideCompat) return baseCompat; - const base = baseCompat as any; - const override = overrideCompat as any; - const merged = { ...base, ...override }; - if (base?.openRouterRouting || override.openRouterRouting) { - merged.openRouterRouting = { ...base?.openRouterRouting, ...override.openRouterRouting }; + const base = baseCompat ?? {}; + const override = overrideCompat; + const merged: NonNullable["compat"]> = { ...base, ...override }; + if (baseCompat?.openRouterRouting || overrideCompat.openRouterRouting) { + merged.openRouterRouting = { ...baseCompat?.openRouterRouting, ...overrideCompat.openRouterRouting }; } - if (base?.vercelGatewayRouting || override.vercelGatewayRouting) { - merged.vercelGatewayRouting = { ...base?.vercelGatewayRouting, ...override.vercelGatewayRouting }; + if (baseCompat?.vercelGatewayRouting || overrideCompat.vercelGatewayRouting) { + merged.vercelGatewayRouting = { ...baseCompat?.vercelGatewayRouting, ...overrideCompat.vercelGatewayRouting }; } return merged; } @@ -384,8 +422,8 @@ export class ModelRegistry { overrides: Map, modelOverrides: Map>, ): Model[] { - return getProviders().flatMap(provider => { - const models = getModels(provider as any) as Model[]; + return getBundledProviders().flatMap(provider => { + const models = getBundledModels(provider as Parameters[0]) as Model[]; const providerOverride = overrides.get(provider); const perModelOverrides = modelOverrides.get(provider); @@ -516,11 +554,35 @@ export class ModelRegistry { } async #refreshRuntimeDiscoveries(): Promise { - if (this.#discoverableProviders.length === 0) return; - const discovered = await Promise.all( - this.#discoverableProviders.map(provider => this.#discoverProviderModels(provider)), + const configuredDiscoveriesPromise = + this.#discoverableProviders.length === 0 + ? Promise.resolve[]>([]) + : Promise.all(this.#discoverableProviders.map(provider => this.#discoverProviderModels(provider))).then( + results => results.flat(), + ); + const [configuredDiscovered, builtInDiscovered] = await Promise.all([ + configuredDiscoveriesPromise, + this.#discoverBuiltInProviderModels(), + ]); + const discovered = [...configuredDiscovered, ...builtInDiscovered]; + if (discovered.length === 0) { + return; + } + const merged = this.#mergeCustomModels( + this.#models, + discovered.map(model => { + const existing = + this.find(model.provider, model.id) ?? + this.#models.find(candidate => candidate.provider === model.provider); + return existing + ? { + ...model, + baseUrl: existing.baseUrl, + headers: existing.headers ? { ...existing.headers, ...model.headers } : model.headers, + } + : model; + }), ); - const merged = this.#mergeCustomModels(this.#models, discovered.flat()); this.#models = this.#applyModelOverrides(merged, this.#modelOverrides); } @@ -531,6 +593,198 @@ export class ModelRegistry { } } + async #discoverBuiltInProviderModels(): Promise[]> { + const managerOptions = await this.#collectBuiltInModelManagerOptions(); + if (managerOptions.length === 0) { + return []; + } + const discoveries = await Promise.all(managerOptions.map(options => this.#discoverWithModelManager(options))); + return discoveries.flat(); + } + + async #collectBuiltInModelManagerOptions(): Promise[]> { + const [ + anthropicApiKey, + openaiApiKey, + groqApiKey, + cerebrasApiKey, + xaiApiKey, + mistralApiKey, + opencodeApiKey, + openrouterApiKey, + vercelGatewayApiKey, + kimiApiKey, + githubCopilotApiKey, + googleApiKey, + cursorApiKey, + googleAntigravityApiKey, + googleGeminiCliApiKey, + codexAccessToken, + ] = await Promise.all([ + this.getApiKeyForProvider("anthropic"), + this.getApiKeyForProvider("openai"), + this.getApiKeyForProvider("groq"), + this.getApiKeyForProvider("cerebras"), + this.getApiKeyForProvider("xai"), + this.getApiKeyForProvider("mistral"), + this.getApiKeyForProvider("opencode"), + this.getApiKeyForProvider("openrouter"), + this.getApiKeyForProvider("vercel-ai-gateway"), + this.getApiKeyForProvider("kimi-code"), + this.getApiKeyForProvider("github-copilot"), + this.getApiKeyForProvider("google"), + this.getApiKeyForProvider("cursor"), + this.getApiKeyForProvider("google-antigravity"), + this.getApiKeyForProvider("google-gemini-cli"), + this.getApiKeyForProvider("openai-codex"), + ]); + + const options: ModelManagerOptions[] = []; + if (isAuthenticated(anthropicApiKey)) { + options.push( + anthropicModelManagerOptions({ + apiKey: anthropicApiKey, + baseUrl: this.getProviderBaseUrl("anthropic"), + }), + ); + } + if (isAuthenticated(openaiApiKey)) { + options.push( + openaiModelManagerOptions({ + apiKey: openaiApiKey, + baseUrl: this.getProviderBaseUrl("openai"), + }), + ); + } + if (isAuthenticated(groqApiKey)) { + options.push( + groqModelManagerOptions({ + apiKey: groqApiKey, + baseUrl: this.getProviderBaseUrl("groq"), + }), + ); + } + if (isAuthenticated(cerebrasApiKey)) { + options.push( + cerebrasModelManagerOptions({ + apiKey: cerebrasApiKey, + baseUrl: this.getProviderBaseUrl("cerebras"), + }), + ); + } + if (isAuthenticated(xaiApiKey)) { + options.push( + xaiModelManagerOptions({ + apiKey: xaiApiKey, + baseUrl: this.getProviderBaseUrl("xai"), + }), + ); + } + if (isAuthenticated(mistralApiKey)) { + options.push( + mistralModelManagerOptions({ + apiKey: mistralApiKey, + baseUrl: this.getProviderBaseUrl("mistral"), + }), + ); + } + if (isAuthenticated(opencodeApiKey)) { + options.push( + opencodeModelManagerOptions({ + apiKey: opencodeApiKey, + baseUrl: this.getProviderBaseUrl("opencode"), + }), + ); + } + if (isAuthenticated(openrouterApiKey)) { + options.push( + openrouterModelManagerOptions({ + apiKey: openrouterApiKey, + baseUrl: this.getProviderBaseUrl("openrouter"), + }), + ); + } + if (isAuthenticated(vercelGatewayApiKey)) { + options.push( + vercelAiGatewayModelManagerOptions({ + apiKey: vercelGatewayApiKey, + baseUrl: this.getProviderBaseUrl("vercel-ai-gateway"), + }), + ); + } + if (isAuthenticated(kimiApiKey)) { + options.push( + kimiCodeModelManagerOptions({ + apiKey: kimiApiKey, + baseUrl: this.getProviderBaseUrl("kimi-code"), + }), + ); + } + if (isAuthenticated(githubCopilotApiKey)) { + options.push( + githubCopilotModelManagerOptions({ + apiKey: githubCopilotApiKey, + baseUrl: this.getProviderBaseUrl("github-copilot"), + }), + ); + } + if (isAuthenticated(googleApiKey)) options.push(googleModelManagerOptions({ apiKey: googleApiKey })); + if (isAuthenticated(cursorApiKey)) { + options.push( + cursorModelManagerOptions({ + apiKey: cursorApiKey, + baseUrl: this.getProviderBaseUrl("cursor"), + }), + ); + } + + const antigravityToken = extractGoogleOAuthToken(googleAntigravityApiKey); + if (isAuthenticated(antigravityToken)) { + options.push( + googleAntigravityModelManagerOptions({ + oauthToken: antigravityToken, + endpoint: this.getProviderBaseUrl("google-antigravity"), + }), + ); + } + + const geminiCliToken = extractGoogleOAuthToken(googleGeminiCliApiKey); + if (isAuthenticated(geminiCliToken)) { + options.push( + googleGeminiCliModelManagerOptions({ + oauthToken: geminiCliToken, + endpoint: this.getProviderBaseUrl("google-gemini-cli"), + }), + ); + } + + const codexCredentials = this.authStorage.getOAuthCredential("openai-codex"); + if (isAuthenticated(codexAccessToken)) { + options.push( + openaiCodexModelManagerOptions({ + accessToken: codexAccessToken, + accountId: codexCredentials?.accountId, + }), + ); + } + + return options; + } + + async #discoverWithModelManager(options: ModelManagerOptions): Promise[]> { + try { + const manager = createModelManager(options); + const result = await manager.refresh(); + return result.models; + } catch (error) { + logger.warn("model discovery failed for provider", { + provider: options.providerId, + error: error instanceof Error ? error.message : String(error), + }); + return []; + } + } + async #discoverOllamaModels(providerConfig: DiscoveryProviderConfig): Promise[]> { const endpoint = this.#normalizeOllamaBaseUrl(providerConfig.baseUrl); const tagsUrl = `${endpoint}/api/tags`; @@ -698,7 +952,7 @@ export class ModelRegistry { */ async getApiKey(model: Model, sessionId?: string): Promise { if (this.#keylessProviders.has(model.provider)) { - return ""; + return kNoAuth; } return this.authStorage.getApiKey(model.provider, sessionId, { baseUrl: model.baseUrl }); } @@ -708,7 +962,7 @@ export class ModelRegistry { */ async getApiKeyForProvider(provider: string, sessionId?: string, baseUrl?: string): Promise { if (this.#keylessProviders.has(provider)) { - return ""; + return kNoAuth; } return this.authStorage.getApiKey(provider, sessionId, { baseUrl }); } diff --git a/packages/coding-agent/test/agent-session-auto-compaction-queue.test.ts b/packages/coding-agent/test/agent-session-auto-compaction-queue.test.ts index a77f1d251..2741e7efe 100644 --- a/packages/coding-agent/test/agent-session-auto-compaction-queue.test.ts +++ b/packages/coding-agent/test/agent-session-auto-compaction-queue.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; import * as fs from "node:fs"; import * as path from "node:path"; import { Agent } from "@oh-my-pi/pi-agent-core"; -import { getModel } from "@oh-my-pi/pi-ai/models"; +import { getBundledModel } from "@oh-my-pi/pi-ai/models"; import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { loadExtensions } from "@oh-my-pi/pi-coding-agent/extensibility/extensions/loader"; @@ -91,7 +91,7 @@ describe("AgentSession auto-compaction queue resume", () => { modelRegistry, ); - const model = getModel("anthropic", "claude-sonnet-4-5"); + const model = getBundledModel("anthropic", "claude-sonnet-4-5"); if (!model) { throw new Error("Expected built-in anthropic model to exist"); } diff --git a/packages/coding-agent/test/agent-session-branching.test.ts b/packages/coding-agent/test/agent-session-branching.test.ts index 4de8748d3..5d1913f42 100644 --- a/packages/coding-agent/test/agent-session-branching.test.ts +++ b/packages/coding-agent/test/agent-session-branching.test.ts @@ -12,7 +12,7 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { Agent } from "@oh-my-pi/pi-agent-core"; -import { getModel } from "@oh-my-pi/pi-ai"; +import { getBundledModel } from "@oh-my-pi/pi-ai"; import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session"; @@ -52,7 +52,7 @@ describe.skipIf(!e2eApiKey("ANTHROPIC_API_KEY"))("AgentSession branching", () => }; const tools = await createTools(toolSession); - const model = getModel("anthropic", "claude-sonnet-4-5")!; + const model = getBundledModel("anthropic", "claude-sonnet-4-5")!; const agent = new Agent({ getApiKey: () => e2eApiKey("ANTHROPIC_API_KEY"), initialState: { diff --git a/packages/coding-agent/test/agent-session-compaction.test.ts b/packages/coding-agent/test/agent-session-compaction.test.ts index d1c84d86b..4fd4fd6e3 100644 --- a/packages/coding-agent/test/agent-session-compaction.test.ts +++ b/packages/coding-agent/test/agent-session-compaction.test.ts @@ -12,7 +12,7 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { Agent } from "@oh-my-pi/pi-agent-core"; -import { getModel } from "@oh-my-pi/pi-ai"; +import { getBundledModel } from "@oh-my-pi/pi-ai"; import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { AgentSession, type AgentSessionEvent } from "@oh-my-pi/pi-coding-agent/session/agent-session"; @@ -56,7 +56,7 @@ describe.skipIf(!e2eApiKey("ANTHROPIC_API_KEY"))("AgentSession compaction e2e", }; const tools = await createTools(toolSession); - const model = getModel("anthropic", "claude-sonnet-4-5")!; + const model = getBundledModel("anthropic", "claude-sonnet-4-5")!; const agent = new Agent({ getApiKey: () => e2eApiKey("ANTHROPIC_API_KEY"), initialState: { diff --git a/packages/coding-agent/test/agent-session-concurrent.test.ts b/packages/coding-agent/test/agent-session-concurrent.test.ts index 18ecec5e4..fcb967a81 100644 --- a/packages/coding-agent/test/agent-session-concurrent.test.ts +++ b/packages/coding-agent/test/agent-session-concurrent.test.ts @@ -7,7 +7,7 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { Agent } from "@oh-my-pi/pi-agent-core"; -import { type AssistantMessage, getModel } from "@oh-my-pi/pi-ai"; +import { type AssistantMessage, getBundledModel } from "@oh-my-pi/pi-ai"; import { AssistantMessageEventStream } from "@oh-my-pi/pi-ai/utils/event-stream"; import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; @@ -58,7 +58,7 @@ describe("AgentSession concurrent prompt guard", () => { }); async function createSession() { - const model = getModel("anthropic", "claude-sonnet-4-5")!; + const model = getBundledModel("anthropic", "claude-sonnet-4-5")!; let abortSignal: AbortSignal | undefined; // Use a stream function that responds to abort @@ -159,7 +159,7 @@ describe("AgentSession concurrent prompt guard", () => { it("should allow prompt() after previous completes", async () => { // Create session with a stream that completes immediately - const model = getModel("anthropic", "claude-sonnet-4-5")!; + const model = getBundledModel("anthropic", "claude-sonnet-4-5")!; const agent = new Agent({ getApiKey: () => "test-key", initialState: { diff --git a/packages/coding-agent/test/agent-session-new-session-todos.test.ts b/packages/coding-agent/test/agent-session-new-session-todos.test.ts index 75e699d29..85a49f06d 100644 --- a/packages/coding-agent/test/agent-session-new-session-todos.test.ts +++ b/packages/coding-agent/test/agent-session-new-session-todos.test.ts @@ -3,7 +3,7 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { Agent } from "@oh-my-pi/pi-agent-core"; -import { getModel } from "@oh-my-pi/pi-ai"; +import { getBundledModel } from "@oh-my-pi/pi-ai"; import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session"; @@ -34,7 +34,7 @@ describe("AgentSession newSession clears todo artifacts", () => { const authStorage = await AuthStorage.create(path.join(tempDir, "testauth.db")); const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir, "models.yml")); - const model = getModel("anthropic", "claude-sonnet-4-5"); + const model = getBundledModel("anthropic", "claude-sonnet-4-5"); if (!model) { throw new Error("Test model not found in registry"); } diff --git a/packages/coding-agent/test/compaction-hooks.test.ts b/packages/coding-agent/test/compaction-hooks.test.ts index 2f8e48947..c9ab23868 100644 --- a/packages/coding-agent/test/compaction-hooks.test.ts +++ b/packages/coding-agent/test/compaction-hooks.test.ts @@ -7,7 +7,7 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { Agent } from "@oh-my-pi/pi-agent-core"; -import { getModel } from "@oh-my-pi/pi-ai"; +import { getBundledModel } from "@oh-my-pi/pi-ai"; import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { @@ -92,7 +92,7 @@ describe.skipIf(!e2eApiKey("ANTHROPIC_API_KEY"))("Compaction hooks", () => { settings: Settings.isolated(), }; const tools = await createTools(toolSession); - const model = getModel("anthropic", "claude-sonnet-4-5")!; + const model = getBundledModel("anthropic", "claude-sonnet-4-5")!; const agent = new Agent({ getApiKey: () => e2eApiKey("ANTHROPIC_API_KEY"), initialState: { diff --git a/packages/coding-agent/test/compaction-thinking-model.test.ts b/packages/coding-agent/test/compaction-thinking-model.test.ts index 5e92aa840..b4855caed 100644 --- a/packages/coding-agent/test/compaction-thinking-model.test.ts +++ b/packages/coding-agent/test/compaction-thinking-model.test.ts @@ -13,7 +13,7 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { Agent, type ThinkingLevel } from "@oh-my-pi/pi-agent-core"; -import { getModel, type Model } from "@oh-my-pi/pi-ai"; +import { getBundledModel, type Model } from "@oh-my-pi/pi-ai"; import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session"; @@ -58,7 +58,7 @@ describe.skipIf(!HAS_ANTIGRAVITY_AUTH)("Compaction with thinking models (Antigra }; const tools = await createTools(toolSession); - const model = getModel("google-antigravity", modelId); + const model = getBundledModel("google-antigravity", modelId); if (!model) { throw new Error(`Model not found: google-antigravity/${modelId}`); } @@ -195,7 +195,7 @@ describe.skipIf(!HAS_ANTHROPIC_AUTH)("Compaction with thinking models (Anthropic } it("should compact successfully with claude-3-7-sonnet and thinking level high", async () => { - const model = getModel("anthropic", "claude-3-7-sonnet-latest")!; + const model = getBundledModel("anthropic", "claude-3-7-sonnet-latest")!; createSession(model, "high"); // Send a simple prompt diff --git a/packages/coding-agent/test/compaction.test.ts b/packages/coding-agent/test/compaction.test.ts index 45bde3d15..43c6ed48e 100644 --- a/packages/coding-agent/test/compaction.test.ts +++ b/packages/coding-agent/test/compaction.test.ts @@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it } from "bun:test"; import * as path from "node:path"; import type { AgentMessage } from "@oh-my-pi/pi-agent-core"; import type { AssistantMessage, Usage } from "@oh-my-pi/pi-ai"; -import { getModel } from "@oh-my-pi/pi-ai"; +import { getBundledModel } from "@oh-my-pi/pi-ai"; import { type CompactionSettings, calculateContextTokens, @@ -398,7 +398,7 @@ describe("Large session fixture", () => { describe.skipIf(!e2eApiKey("ANTHROPIC_API_KEY"))("LLM summarization", () => { it("should generate a compaction result for the large session", async () => { const entries = await loadLargeSessionEntries(); - const model = getModel("anthropic", "claude-sonnet-4-5")!; + const model = getBundledModel("anthropic", "claude-sonnet-4-5")!; const preparation = prepareCompaction(entries, DEFAULT_COMPACTION_SETTINGS); expect(preparation).toBeDefined(); @@ -419,7 +419,7 @@ describe.skipIf(!e2eApiKey("ANTHROPIC_API_KEY"))("LLM summarization", () => { it("should produce valid session after compaction", async () => { const entries = await loadLargeSessionEntries(); const loaded = buildSessionContext(entries); - const model = getModel("anthropic", "claude-sonnet-4-5")!; + const model = getBundledModel("anthropic", "claude-sonnet-4-5")!; const preparation = prepareCompaction(entries, DEFAULT_COMPACTION_SETTINGS); expect(preparation).toBeDefined(); diff --git a/packages/coding-agent/test/model-registry.test.ts b/packages/coding-agent/test/model-registry.test.ts index a1b31c30b..180b6b85c 100644 --- a/packages/coding-agent/test/model-registry.test.ts +++ b/packages/coding-agent/test/model-registry.test.ts @@ -3,7 +3,7 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import type { OpenAICompat } from "@oh-my-pi/pi-ai"; -import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; +import { kNoAuth, ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; import { Snowflake } from "@oh-my-pi/pi-utils"; @@ -562,7 +562,7 @@ describe("ModelRegistry", () => { const ollamaModels = getModelsForProvider(registry, "ollama"); expect(ollamaModels.some(m => m.id === "phi4-mini")).toBe(true); expect(registry.getAvailable().some(m => m.provider === "ollama" && m.id === "phi4-mini")).toBe(true); - expect(await registry.getApiKey(ollamaModels[0])).toBe(""); + expect(await registry.getApiKey(ollamaModels[0])).toBe(kNoAuth); } finally { globalThis.fetch = originalFetch; } @@ -599,7 +599,7 @@ describe("ModelRegistry", () => { const available = registry.getAvailable().filter(m => m.provider === "ollama"); expect(available.length).toBe(2); - expect(await registry.getApiKey(available[0])).toBe(""); + expect(await registry.getApiKey(available[0])).toBe(kNoAuth); } finally { globalThis.fetch = originalFetch; } diff --git a/packages/coding-agent/test/streaming-edit-abort.test.ts b/packages/coding-agent/test/streaming-edit-abort.test.ts index 3419f774f..55a09ad26 100644 --- a/packages/coding-agent/test/streaming-edit-abort.test.ts +++ b/packages/coding-agent/test/streaming-edit-abort.test.ts @@ -7,7 +7,7 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { Agent, type AgentTool } from "@oh-my-pi/pi-agent-core"; -import { type AssistantMessage, getModel, type StopReason, type ToolCall } from "@oh-my-pi/pi-ai"; +import { type AssistantMessage, getBundledModel, type StopReason, type ToolCall } from "@oh-my-pi/pi-ai"; import { AssistantMessageEventStream } from "@oh-my-pi/pi-ai/utils/event-stream"; import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; @@ -80,7 +80,7 @@ function chunkStringRandomly(text: string, seed: number): string[] { } async function createSession(tempDir: string, streamFn: Agent["streamFn"], tool: AgentTool): Promise { - const model = getModel("anthropic", "claude-sonnet-4-5")!; + const model = getBundledModel("anthropic", "claude-sonnet-4-5")!; const agent = new Agent({ getApiKey: () => "test-key", initialState: { diff --git a/packages/coding-agent/test/utilities.ts b/packages/coding-agent/test/utilities.ts index 05d12ee65..79d3430d7 100644 --- a/packages/coding-agent/test/utilities.ts +++ b/packages/coding-agent/test/utilities.ts @@ -5,7 +5,7 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { Agent } from "@oh-my-pi/pi-agent-core"; -import { getModel } from "@oh-my-pi/pi-ai"; +import { getBundledModel } from "@oh-my-pi/pi-ai"; import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session"; @@ -86,7 +86,7 @@ export async function createTestSession(options: TestSessionOptions = {}): Promi }; const tools = await createTools(toolSession); - const model = getModel("anthropic", "claude-sonnet-4-5")!; + const model = getBundledModel("anthropic", "claude-sonnet-4-5")!; const agent = new Agent({ getApiKey: () => e2eApiKey("ANTHROPIC_API_KEY"), initialState: {