feat: implemented dynamic model resolution across all providers with ModelManager API

- Added ModelManager API with createModelManager() factory for managing bundled and dynamically discovered models with configurable refresh strategies.
- Exported discovery utilities for fetching models from Antigravity, Codex, Cursor, Gemini, and OpenAI-compatible endpoints with provider-specific model manager configuration helpers.
- Renamed public API functions for clarity: getModel() -> getBundledModel(), getModels() -> getBundledModels(), getProviders() -> getBundledProviders().
- Added on-disk model caching with TTL-based invalidation and resolveProviderModels() function for runtime model resolution with source precedence.
- Refactored model discovery script to dynamically fetch models from Codex, Cursor, and Antigravity using OAuth credentials instead of hardcoded lists.
This commit is contained in:
can1357
2026-02-18 00:30:27 +01:00
parent 09f6c5d7bb
commit bc69fd207d
50 changed files with 3982 additions and 1521 deletions
+1
View File
@@ -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": {
+2 -1
View File
@@ -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 `"<no-auth>"` 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:
+3
View File
@@ -1,6 +1,9 @@
# Changelog
## [Unreleased]
### Changed
- Renamed `getModel` to `getBundledModel` for clarity on bundled model initialization
## [12.8.0] - 2026-02-16
+2 -2
View File
@@ -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: [],
+3 -3
View File
@@ -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);
+17
View File
@@ -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
+4 -1
View File
@@ -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"
},
File diff suppressed because it is too large Load Diff
+3
View File
@@ -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";
+438
View File
@@ -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<TApi extends Api = Api, TPayload = unknown> {
/** Fetches raw fallback payload (for example from models.dev). */
fetch(): Promise<TPayload>;
/** Maps payload into provider models. */
map(payload: TPayload, providerId: Provider): readonly Model<TApi>[];
}
/**
* Configuration for provider model resolution.
*/
export interface ModelManagerOptions<TApi extends Api = Api, TModelsDevPayload = unknown> {
/** 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<TApi>[];
/** Optional absolute cache path override. Default: <agent-dir>/models/<provider>.json. */
cachePath?: string;
/** Maximum cache age in milliseconds before considered stale. Default: 24h. */
cacheTtlMs?: number;
/** Optional dynamic endpoint fetcher. */
fetchDynamicModels?: () => Promise<readonly Model<TApi>[] | null>;
/** Optional models.dev fallback hook. */
modelsDev?: ModelsDevFallback<TApi, TModelsDevPayload>;
/** 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<TApi extends Api = Api> {
models: Model<TApi>[];
stale: boolean;
}
/**
* Stateful facade over provider model resolution.
*/
export interface ModelManager<TApi extends Api = Api> {
refresh(strategy?: ModelRefreshStrategy): Promise<ModelResolutionResult<TApi>>;
}
interface CachedProviderModels<TApi extends Api = Api> {
version: number;
providerId: string;
updatedAt: number;
models: Model<TApi>[];
authoritative: boolean;
}
interface CacheReadResult<TApi extends Api = Api> {
models: Model<TApi>[];
fresh: boolean;
authoritative: boolean;
}
/**
* Creates a reusable provider model manager.
*/
export function createModelManager<TApi extends Api = Api, TModelsDevPayload = unknown>(
options: ModelManagerOptions<TApi, TModelsDevPayload>,
): ModelManager<TApi> {
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<TApi extends Api = Api, TModelsDevPayload = unknown>(
options: ModelManagerOptions<TApi, TModelsDevPayload>,
strategy: ModelRefreshStrategy = "online-if-uncached",
): Promise<ModelResolutionResult<TApi>> {
const now = options.now ?? Date.now;
const ttlMs = options.cacheTtlMs ?? DEFAULT_CACHE_TTL_MS;
const cachePath = options.cachePath ?? getDefaultCachePath(options.providerId);
const staticModels = normalizeModelList<TApi>(
options.staticModels ?? getBundledModels(options.providerId as GeneratedProvider),
);
const cache = await readCache<TApi>(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<TApi>(fetchedModelsDevModels ?? []);
const dynamicFetcher = options.fetchDynamicModels;
const shouldUseFreshCacheAsAuthoritative =
strategy === "online-if-uncached" && (cache?.fresh ?? false) && (cache?.authoritative ?? false);
let fetchedDynamicModels: Model<TApi>[] | 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<TApi extends Api, TModelsDevPayload>(
options: ModelManagerOptions<TApi, TModelsDevPayload>,
): Promise<Model<TApi>[] | null> {
if (!options.modelsDev) {
return null;
}
try {
const payload = await options.modelsDev.fetch();
return normalizeModelList<TApi>(options.modelsDev.map(payload, options.providerId));
} catch {
return null;
}
}
async function fetchDynamicModels<TApi extends Api>(
fetcher: () => Promise<readonly Model<TApi>[] | null>,
): Promise<Model<TApi>[] | null> {
try {
const models = await fetcher();
if (models === null) {
return null;
}
return normalizeModelList<TApi>(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<TApi extends Api>(
cachePath: string,
expectedProviderId: string,
ttlMs: number,
now: () => number,
): Promise<CacheReadResult<TApi> | 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<TApi>(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<TApi extends Api>(value: unknown): CachedProviderModels<TApi> | 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<TApi>(rawModels),
authoritative,
};
}
async function writeCache<TApi extends Api>(cachePath: string, cache: CachedProviderModels<TApi>): Promise<void> {
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<TApi extends Api>(...sources: readonly (readonly Model<TApi>[])[]): Model<TApi>[] {
const merged = new Map<string, Model<TApi>>();
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<TApi extends Api>(
baseModels: readonly Model<TApi>[],
dynamicModels: readonly Model<TApi>[],
): Model<TApi>[] {
const merged = new Map<string, Model<TApi>>(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<TApi extends Api>(existingModel: Model<TApi>, dynamicModel: Model<TApi>): Model<TApi> {
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<TApi extends Api>(value: unknown): Model<TApi>[] {
if (!Array.isArray(value)) {
return [];
}
const models: Model<TApi>[] = [];
for (const item of value) {
if (isModelLike(item)) {
models.push(item as Model<TApi>);
}
}
return models;
}
function isModelLike(value: unknown): value is Model<Api> {
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<string, unknown> {
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<Api>["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)
);
}
+459 -53
View File
@@ -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)",
+12 -6
View File
@@ -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<string, Map<string, Model<Api>>> = new Map();
// Initialize registry from MODELS on module load
for (const [provider, models] of Object.entries(MODELS)) {
const providerModels = new Map<string, Model<Api>>();
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<Api> {
export function getBundledModel(provider: GeneratedProvider, modelId: string): Model<Api> {
const providerModels = modelRegistry.get(provider);
return providerModels?.get(modelId) as Model<Api>;
}
export function getProviders(): KnownProvider[] {
export function getBundledProviders(): KnownProvider[] {
return Array.from(modelRegistry.keys()) as KnownProvider[];
}
export function getModels(provider: GeneratedProvider): Model<Api>[] {
export function getBundledModels(provider: GeneratedProvider): Model<Api>[] {
const models = modelRegistry.get(provider);
return models ? (Array.from(models.values()) as Model<Api>[]) : [];
}
+90
View File
@@ -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),
};
}
+3
View File
@@ -0,0 +1,3 @@
export * from "./google";
export * from "./openai-compat";
export * from "./special";
@@ -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<string, unknown> {
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<unknown> {
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<string, string> {
const oauthToken = isAnthropicOAuthToken(apiKey);
const headers: Record<string, string> = {
"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<string, Model<"anthropic-messages">> {
const merged = new Map<string, Model<"anthropic-messages">>();
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<TApi extends Api>(
entry: OpenAICompatibleModelRecord,
defaults: Model<TApi>,
reference: Model<TApi> | undefined,
): Model<TApi> {
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<TApi extends Api>(
provider: Parameters<typeof getBundledModels>[0],
): Map<string, Model<TApi>> {
const references = new Map<string, Model<TApi>>();
for (const model of getBundledModels(provider)) {
references.set(model.id, model as Model<TApi>);
}
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<string, unknown> | undefined;
const params = Array.isArray(entry.supported_parameters)
? (entry.supported_parameters as string[])
: [];
const modality = String((entry.architecture as Record<string, unknown> | undefined)?.modality ?? "");
const topProvider = entry.top_provider as Record<string, unknown> | 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<string, unknown> | 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<string, string> = {
"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<Api> {
const apiKey = config?.apiKey;
const baseUrl = config?.baseUrl ?? "https://api.individual.githubcopilot.com";
const references = new Map((getBundledModels("github-copilot") as Model<Api>[]).map(model => [model.id, model]));
return {
providerId: "github-copilot",
...(apiKey && {
fetchDynamicModels: () =>
fetchOpenAICompatibleModels<Api>({
api: "openai-completions",
provider: "github-copilot",
baseUrl,
apiKey,
headers: GITHUB_COPILOT_HEADERS,
mapModel: (
entry: OpenAICompatibleModelRecord,
defaults: Model<Api>,
_context: OpenAICompatibleModelMapperContext<Api>,
): Model<Api> => {
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
);
},
}),
};
}
+106
View File
@@ -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" };
}
@@ -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<string, AntigravityDiscoveryApiModel>;
agentModelSorts?: AntigravityDiscoveryAgentModelSort[];
}
const AntigravityDiscoveryApiModelSchema: z.ZodType<AntigravityDiscoveryApiModel> = 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<AntigravityDiscoveryAgentModelGroup> = 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<AntigravityDiscoveryAgentModelSort> = 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<AntigravityDiscoveryApiResponse> = z
.object({
models: z.preprocess(
value => (typeof value === "object" && value !== null ? value : undefined),
z
.record(z.string(), z.unknown())
.transform(models => {
const normalized: Record<string, AntigravityDiscoveryApiModel> = {};
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>`. */
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<Model<"google-gemini-cli">[] | 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<string> {
const ids = new Set<string>();
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;
}
+373
View File
@@ -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<typeof codexModelEntrySchema>;
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<string, string>;
/** 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<CodexModelDiscoveryResult | null> {
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<string> {
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<string, unknown> {
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;
}
+239
View File
@@ -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<typeof CursorModelDetailsSchema>;
/**
* 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<Model<"cursor-agent">[] | 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<string>();
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<string, Model<"cursor-agent">>();
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;
}
+248
View File
@@ -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<typeof geminiModelListItemSchema>;
/**
* 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<Model<"google-generative-ai">[] | 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<string, Model<"google-generative-ai">>();
const seenTokens = new Set<string>();
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<string, Model<"google-generative-ai">>,
): 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"];
}
+5
View File
@@ -0,0 +1,5 @@
export * from "./antigravity";
export * from "./codex";
export * from "./cursor";
export * from "./gemini";
export * from "./openai-compatible";
@@ -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<typeof openAICompatibleModelRecordSchema>;
/**
* Context passed to custom OpenAI-compatible model mappers.
*/
export interface OpenAICompatibleModelMapperContext<TApi extends Api> {
api: TApi;
provider: Provider;
baseUrl: string;
}
/**
* Options for fetching and normalizing OpenAI-compatible `/models` catalogs.
*/
export interface FetchOpenAICompatibleModelsOptions<TApi extends Api> {
/** 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<string, string>;
/** 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<TApi>) => boolean;
/**
* Optional mapper override for provider-specific quirks.
* Return null to skip a model.
*/
mapModel?: (
entry: OpenAICompatibleModelRecord,
defaults: Model<TApi>,
context: OpenAICompatibleModelMapperContext<TApi>,
) => Model<TApi> | 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<TApi extends Api>(
options: FetchOpenAICompatibleModelsOptions<TApi>,
): Promise<Model<TApi>[] | null> {
const baseUrl = normalizeBaseUrl(options.baseUrl);
if (!baseUrl) {
return null;
}
const requestHeaders: Record<string, string> = {
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<TApi> = {
api: options.api,
provider: options.provider,
baseUrl,
};
const deduped = new Map<string, Model<TApi>>();
for (const entry of entries) {
const defaults: Model<TApi> = {
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;
}
@@ -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<void> {
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);
+10 -10
View File
@@ -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<TApi extends Api>(llm: Model<TApi>, 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 },
+22 -22
View File
@@ -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);
+39 -39
View File
@@ -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<TApi extends Api>(llm: Model<TApi>, 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 },
@@ -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");
});
+9 -6
View File
@@ -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<TApi extends Api>(
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");
+20 -17
View File
@@ -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];
+21 -18
View File
@@ -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<TApi extends Api>(model: Model<T
describe("Tool Results with Images", () => {
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 },
@@ -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",
+30 -27
View File
@@ -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<TApi extends Api>(model: Model<TApi>, 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",
+18 -18
View File
@@ -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<TApi extends Api>(llm: Model<TApi>, 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 },
@@ -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 },
+23 -23
View File
@@ -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 });
+32 -32
View File
@@ -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<TApi extends Api>(llm: Model<TApi>, 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 },
+4 -4
View File
@@ -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" });
+3
View File
@@ -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)
@@ -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<ModelRole, ModelRoleInfo> = {
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<Api>["compat"],
overrideCompat: ModelOverride["compat"],
): Model<Api>["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<Model<Api>["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<string, ProviderOverride>,
modelOverrides: Map<string, Map<string, ModelOverride>>,
): Model<Api>[] {
return getProviders().flatMap(provider => {
const models = getModels(provider as any) as Model<Api>[];
return getBundledProviders().flatMap(provider => {
const models = getBundledModels(provider as Parameters<typeof getBundledModels>[0]) as Model<Api>[];
const providerOverride = overrides.get(provider);
const perModelOverrides = modelOverrides.get(provider);
@@ -516,11 +554,35 @@ export class ModelRegistry {
}
async #refreshRuntimeDiscoveries(): Promise<void> {
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<Model<Api>[]>([])
: 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<Model<Api>[]> {
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<ModelManagerOptions<Api>[]> {
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<Api>[] = [];
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<Api>): Promise<Model<Api>[]> {
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<Model<Api>[]> {
const endpoint = this.#normalizeOllamaBaseUrl(providerConfig.baseUrl);
const tagsUrl = `${endpoint}/api/tags`;
@@ -698,7 +952,7 @@ export class ModelRegistry {
*/
async getApiKey(model: Model<Api>, sessionId?: string): Promise<string | undefined> {
if (this.#keylessProviders.has(model.provider)) {
return "<no-auth>";
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<string | undefined> {
if (this.#keylessProviders.has(provider)) {
return "<no-auth>";
return kNoAuth;
}
return this.authStorage.getApiKey(provider, sessionId, { baseUrl });
}
@@ -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");
}
@@ -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: {
@@ -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: {
@@ -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: {
@@ -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");
}
@@ -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: {
@@ -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
@@ -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();
@@ -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("<no-auth>");
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("<no-auth>");
expect(await registry.getApiKey(available[0])).toBe(kNoAuth);
} finally {
globalThis.fetch = originalFetch;
}
@@ -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<AgentSession> {
const model = getModel("anthropic", "claude-sonnet-4-5")!;
const model = getBundledModel("anthropic", "claude-sonnet-4-5")!;
const agent = new Agent({
getApiKey: () => "test-key",
initialState: {
+2 -2
View File
@@ -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: {