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:
@@ -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
@@ -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:
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
### Changed
|
||||
|
||||
- Renamed `getModel` to `getBundledModel` for clarity on bundled model initialization
|
||||
|
||||
## [12.8.0] - 2026-02-16
|
||||
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
+218
-1162
File diff suppressed because it is too large
Load Diff
@@ -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";
|
||||
|
||||
@@ -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
@@ -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)",
|
||||
|
||||
@@ -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>[]) : [];
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"];
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user