Centralizing OAuth refresh in AuthStorage (e6893515) introduced five
follow-on bugs surfaced by an audit of the commit; this fixes all of
them and updates the tests that relied on the old refresh seam.
1. packages/ai/src/auth-storage.ts (#tryOAuthCredential):
For built-in providers the path went directly to `getOAuthApiKey`
with the (possibly still-expired) selection.credential when the
pre-refresh at line 2587 caught a transient error. `getOAuthApiKey`
then threw the "expired … must be refreshed via AuthStorage"
precondition error, which the disable classifier matched against
`/expired.*refresh/` and soft-disabled the row. A single network
blip during refresh could permanently kill a still-valid Anthropic /
OpenAI / Gemini-CLI / Copilot credential. Built-in providers now
route through the broker-aware single-flighted
`#refreshOAuthCredential` first, so transient failures surface as
network errors (5-min temp block) instead of definitive auth
failures.
2. packages/ai/src/auth-storage.ts (#fetchUsageUncached):
The usage refresh check only fired once `Date.now() >= expiresAt`,
missing the 60-second skew that `getApiKey` honors. A token
expiring inside the skew window was posted to the usage endpoint
and 401'd mid-flight, briefly hiding quota in the UI. Aligned with
`OAUTH_REFRESH_SKEW_MS`.
3. packages/coding-agent/src/web/search/index.ts (webSearchCustomTool):
The CustomTool counterpart of WebSearchTool dropped sessionId so
SDK callers that opted into `web_search` via toolNames lost
per-session credential stickiness — multi-account users saw the
provider round-robin between searches in the same session. Threads
`ctx.sessionManager.getSessionId()` through to `executeSearch`.
4. packages/coding-agent/src/web/search/providers/perplexity.ts
(findOAuthToken):
`authStorage.getApiKey("perplexity")` returns runtime/config
overrides, stored api_key credentials, OAuth bearers, and env keys.
Filtering only env keys meant a config-pinned `pplx-…` API key was
POSTed to `www.perplexity.ai/rest/sse/perplexity_ask` (the OAuth
endpoint) instead of falling through to
`api.perplexity.ai/chat/completions`, producing 401s. Switched to
`getOAuthAccess` so only true OAuth bearers reach the OAuth
branch; api_key credentials/overrides correctly fall through.
5. packages/ai/scripts/generate-models.ts:
`getOAuthApiKey` was being called directly with possibly-expired
credentials. The new contract throws on expired, the broad catch
swallowed it, and the build silently fell back to bundled models
instead of refreshing. Both helpers now route through
AuthStorage's `getApiKey` / `getOAuthAccess`, which trigger the
full broker-aware refresh pipeline.
Test updates:
- auth-storage-credential-disabled-event.test.ts,
sdk-credential-disabled-bridge.test.ts: the `failOAuthRefresh`
helper used to spy on `getOAuthApiKey` to inject invalid_grant.
With refresh now happening before that helper, the spy never fired.
Switched to spying on `refreshOAuthToken` so the simulated failure
reaches the disable classifier.
- auth-storage-rotation.test.ts: stub `refreshOAuthToken` so the test
doesn't hit a real OAuth endpoint when the seeded credential lands
inside the 60s skew window.
418 lines
14 KiB
TypeScript
418 lines
14 KiB
TypeScript
#!/usr/bin/env bun
|
|
|
|
// Copilot model premium request multipliers by model identifier.
|
|
const COPILOT_PREMIUM_MULTIPLIERS: Record<string, number> = {
|
|
"github-copilot/claude-haiku-4.5": 0.33,
|
|
"github-copilot/claude-opus-4.6": 3,
|
|
"github-copilot/gpt-4o": 0,
|
|
"github-copilot/gpt-5.4-mini": 0.33,
|
|
"github-copilot/grok-code-fast-1": 0.25,
|
|
};
|
|
|
|
import * as path from "node:path";
|
|
import { $env } from "@oh-my-pi/pi-utils";
|
|
import { AuthStorage, type OAuthAccess, SqliteAuthCredentialStore } from "../src/auth-storage";
|
|
import { createModelManager } from "../src/model-manager";
|
|
import {
|
|
applyGeneratedModelPolicies,
|
|
CLOUDFLARE_FALLBACK_MODEL,
|
|
linkOpenAIPromotionTargets,
|
|
} from "../src/model-thinking";
|
|
import prevModelsJson from "../src/models.json" with { type: "json" };
|
|
import {
|
|
allowsUnauthenticatedCatalogDiscovery,
|
|
type CatalogDiscoveryConfig,
|
|
type CatalogProviderDescriptor,
|
|
isCatalogDescriptor,
|
|
PROVIDER_DESCRIPTORS,
|
|
} from "../src/provider-models/descriptors";
|
|
import {
|
|
MODELS_DEV_PROVIDER_DESCRIPTORS,
|
|
mapModelsDevToModels,
|
|
UNK_CONTEXT_WINDOW,
|
|
UNK_MAX_TOKENS,
|
|
} from "../src/provider-models/openai-compat";
|
|
import { getGitLabDuoModels } from "../src/providers/gitlab-duo";
|
|
import { JWT_CLAIM_PATH } from "../src/providers/openai-codex/constants";
|
|
import type { Model } from "../src/types";
|
|
import { fetchAntigravityDiscoveryModels } from "../src/utils/discovery/antigravity";
|
|
import { fetchCodexModels } from "../src/utils/discovery/codex";
|
|
import type { OAuthProvider } from "../src/utils/oauth/types";
|
|
|
|
const packageRoot = path.join(import.meta.dir, "..");
|
|
|
|
async function resolveProviderApiKey(providerId: string, catalog: CatalogDiscoveryConfig): Promise<string | undefined> {
|
|
for (const envVar of catalog.envVars) {
|
|
const value = $env[envVar as keyof typeof $env];
|
|
if (typeof value === "string" && value.length > 0) {
|
|
return value;
|
|
}
|
|
}
|
|
|
|
try {
|
|
const store = await SqliteAuthCredentialStore.open();
|
|
const authStorage = new AuthStorage(store);
|
|
try {
|
|
await authStorage.reload();
|
|
const storedApiKey = await authStorage.getApiKey(providerId);
|
|
if (storedApiKey) {
|
|
return storedApiKey;
|
|
}
|
|
if (catalog.oauthProvider) {
|
|
// AuthStorage.getApiKey refreshes through the broker-aware
|
|
// single-flighted machinery, so a build-time invocation no
|
|
// longer silently falls back to bundled models when an
|
|
// expired-but-refreshable OAuth credential is on disk.
|
|
const oauthKey = await authStorage.getApiKey(catalog.oauthProvider);
|
|
if (oauthKey) {
|
|
return oauthKey;
|
|
}
|
|
}
|
|
} finally {
|
|
store.close();
|
|
}
|
|
} catch {
|
|
// Ignore missing/unreadable auth storage.
|
|
}
|
|
|
|
return undefined;
|
|
}
|
|
|
|
async function fetchProviderModelsFromCatalog(descriptor: CatalogProviderDescriptor): Promise<Model[]> {
|
|
const apiKey = await resolveProviderApiKey(descriptor.providerId, descriptor.catalogDiscovery);
|
|
|
|
if (!apiKey && !allowsUnauthenticatedCatalogDiscovery(descriptor)) {
|
|
console.log(`No ${descriptor.catalogDiscovery.label} credentials found (env or agent.db), using fallback models`);
|
|
return [];
|
|
}
|
|
|
|
try {
|
|
console.log(`Fetching models from ${descriptor.catalogDiscovery.label} model manager...`);
|
|
const manager = createModelManager(descriptor.createModelManagerOptions({ apiKey }));
|
|
const result = await manager.refresh("online");
|
|
const models = result.models.filter(model => model.provider === descriptor.providerId);
|
|
if (models.length === 0) {
|
|
console.warn(`${descriptor.catalogDiscovery.label} discovery returned no models, using fallback models`);
|
|
return [];
|
|
}
|
|
console.log(`Fetched ${models.length} models from ${descriptor.catalogDiscovery.label} model manager`);
|
|
return models;
|
|
} catch (error) {
|
|
console.error(`Failed to fetch ${descriptor.catalogDiscovery.label} models:`, error);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
async function loadModelsDevData(): Promise<Model[]> {
|
|
try {
|
|
console.log("Fetching models from models.dev API...");
|
|
const response = await fetch("https://models.dev/api.json");
|
|
const data = await response.json();
|
|
const models = mapModelsDevToModels(data as Record<string, unknown>, MODELS_DEV_PROVIDER_DESCRIPTORS);
|
|
models.sort((a, b) => a.id.localeCompare(b.id));
|
|
console.log(`Loaded ${models.length} tool-capable models from models.dev`);
|
|
return models;
|
|
} catch (error) {
|
|
console.error("Failed to load models.dev data:", error);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
function createGlobalModelsDevReferenceMap(modelsDevModels: readonly Model[]): Map<string, Model> {
|
|
const references = new Map<string, Model>();
|
|
for (const model of modelsDevModels) {
|
|
const existing = references.get(model.id);
|
|
if (!existing) {
|
|
references.set(model.id, model);
|
|
continue;
|
|
}
|
|
if (model.contextWindow > existing.contextWindow) {
|
|
references.set(model.id, model);
|
|
continue;
|
|
}
|
|
if (model.contextWindow === existing.contextWindow && model.maxTokens > existing.maxTokens) {
|
|
references.set(model.id, model);
|
|
}
|
|
}
|
|
return references;
|
|
}
|
|
|
|
function inheritModelsDevLimit(value: number, referenceValue: number, unspecifiedValue: number): number {
|
|
return value === unspecifiedValue ? referenceValue : value;
|
|
}
|
|
|
|
function applyGlobalModelsDevFallback(models: readonly Model[], modelsDevModels: readonly Model[]): Model[] {
|
|
const providerScopedKeys = new Set(modelsDevModels.map(model => `${model.provider}/${model.id}`));
|
|
const globalReferences = createGlobalModelsDevReferenceMap(modelsDevModels);
|
|
return models.map(model => {
|
|
if (providerScopedKeys.has(`${model.provider}/${model.id}`)) {
|
|
return model;
|
|
}
|
|
const reference = globalReferences.get(model.id);
|
|
if (!reference) {
|
|
return model;
|
|
}
|
|
return {
|
|
...model,
|
|
name: reference.name,
|
|
reasoning: reference.reasoning,
|
|
input: reference.input,
|
|
// Fill unknown endpoint limits from same-id models.dev references, but keep
|
|
// provider-specific values when discovery returned them explicitly.
|
|
contextWindow: inheritModelsDevLimit(model.contextWindow, reference.contextWindow, UNK_CONTEXT_WINDOW),
|
|
maxTokens: inheritModelsDevLimit(model.maxTokens, reference.maxTokens, UNK_MAX_TOKENS),
|
|
};
|
|
});
|
|
}
|
|
|
|
function applyPremiumMultiplierOverrides(models: readonly Model[]): Model[] {
|
|
return models.map(model => {
|
|
const premiumMultiplier = COPILOT_PREMIUM_MULTIPLIERS[`${model.provider}/${model.id}`];
|
|
if (premiumMultiplier === undefined) {
|
|
return model;
|
|
}
|
|
if (model.premiumMultiplier === premiumMultiplier) {
|
|
return model;
|
|
}
|
|
return {
|
|
...model,
|
|
premiumMultiplier,
|
|
};
|
|
});
|
|
}
|
|
function hasBillableCost(cost: Model["cost"]): boolean {
|
|
return cost.input !== 0 || cost.output !== 0 || cost.cacheRead !== 0 || cost.cacheWrite !== 0;
|
|
}
|
|
|
|
function applyCodexPricingFallback(models: readonly Model[]): Model[] {
|
|
const openAIModels = new Map(
|
|
models
|
|
.filter(model => model.provider === "openai" && hasBillableCost(model.cost))
|
|
.map(model => [model.id, model.cost]),
|
|
);
|
|
|
|
return models.map(model => {
|
|
if (model.provider !== "openai-codex" || model.api !== "openai-codex-responses") {
|
|
return model;
|
|
}
|
|
if (hasBillableCost(model.cost)) {
|
|
return model;
|
|
}
|
|
|
|
const openAICost = openAIModels.get(model.id);
|
|
if (!openAICost) {
|
|
return model;
|
|
}
|
|
|
|
return {
|
|
...model,
|
|
cost: { ...openAICost },
|
|
};
|
|
});
|
|
}
|
|
|
|
const ANTIGRAVITY_ENDPOINT = "https://daily-cloudcode-pa.sandbox.googleapis.com";
|
|
|
|
async function getOAuthAccessFromStorage(provider: OAuthProvider): Promise<OAuthAccess | null> {
|
|
try {
|
|
const store = await SqliteAuthCredentialStore.open();
|
|
const authStorage = new AuthStorage(store);
|
|
try {
|
|
await authStorage.reload();
|
|
// `getOAuthAccess` runs the full AuthStorage refresh pipeline so an
|
|
// expired-but-refreshable credential gets rotated before discovery,
|
|
// and identity metadata (accountId/projectId/email) flows through
|
|
// for Codex/Antigravity downstream calls.
|
|
return (await authStorage.getOAuthAccess(provider)) ?? null;
|
|
} finally {
|
|
store.close();
|
|
}
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Fetch available Antigravity models from the API using the discovery module.
|
|
* Returns empty array if no auth is available (previous models used as fallback).
|
|
*/
|
|
async function fetchAntigravityModels(): Promise<Model<"google-gemini-cli">[]> {
|
|
const access = await getOAuthAccessFromStorage("google-antigravity");
|
|
if (!access) {
|
|
console.log("No Antigravity credentials found, will use previous models");
|
|
return [];
|
|
}
|
|
try {
|
|
console.log("Fetching models from Antigravity API...");
|
|
const discovered = await fetchAntigravityDiscoveryModels({
|
|
token: access.accessToken,
|
|
endpoint: ANTIGRAVITY_ENDPOINT,
|
|
});
|
|
if (discovered === null) {
|
|
console.warn("Antigravity API fetch failed, will use previous models");
|
|
return [];
|
|
}
|
|
if (discovered.length > 0) {
|
|
console.log(`Fetched ${discovered.length} models from Antigravity API`);
|
|
return discovered;
|
|
}
|
|
console.warn("Antigravity API returned no models, will use previous models");
|
|
return [];
|
|
} catch (error) {
|
|
console.error("Failed to fetch Antigravity models:", error);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Extract accountId from a Codex JWT access token.
|
|
*/
|
|
function extractCodexAccountId(accessToken: string): string | null {
|
|
try {
|
|
const parts = accessToken.split(".");
|
|
if (parts.length !== 3) return null;
|
|
const payload = parts[1] ?? "";
|
|
const decoded = JSON.parse(Buffer.from(payload, "base64").toString("utf-8"));
|
|
const accountId = decoded?.[JWT_CLAIM_PATH]?.chatgpt_account_id;
|
|
return typeof accountId === "string" && accountId.length > 0 ? accountId : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function fetchCodexDiscoveryModels(): Promise<Model<"openai-codex-responses">[]> {
|
|
const access = await getOAuthAccessFromStorage("openai-codex");
|
|
if (!access) {
|
|
return [];
|
|
}
|
|
try {
|
|
console.log("Fetching models from Codex API...");
|
|
const accessToken = access.accessToken;
|
|
const accountId = access.accountId ?? extractCodexAccountId(accessToken);
|
|
const codexDiscovery = await fetchCodexModels({
|
|
accessToken,
|
|
accountId: accountId ?? undefined,
|
|
});
|
|
if (codexDiscovery === null) {
|
|
console.warn("Codex API fetch failed");
|
|
return [];
|
|
}
|
|
if (codexDiscovery.models.length > 0) {
|
|
console.log(`Fetched ${codexDiscovery.models.length} models from Codex API`);
|
|
return codexDiscovery.models;
|
|
}
|
|
return [];
|
|
} catch (error) {
|
|
console.error("Failed to fetch Codex models:", error);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
async function generateModels() {
|
|
// Fetch models from dynamic sources
|
|
const modelsDevModels = await loadModelsDevData();
|
|
const catalogProviderModels = (
|
|
await Promise.all(
|
|
PROVIDER_DESCRIPTORS.filter(isCatalogDescriptor).map(descriptor => fetchProviderModelsFromCatalog(descriptor)),
|
|
)
|
|
).flat();
|
|
const gitLabDuoModels = getGitLabDuoModels();
|
|
// Combine models (models.dev has priority)
|
|
let allModels = applyGlobalModelsDevFallback(
|
|
[...modelsDevModels, ...catalogProviderModels, ...gitLabDuoModels],
|
|
modelsDevModels,
|
|
);
|
|
|
|
if (!allModels.some(model => model.provider === "cloudflare-ai-gateway")) {
|
|
allModels.push(CLOUDFLARE_FALLBACK_MODEL);
|
|
}
|
|
|
|
const specialDiscoverySources = [
|
|
{ label: "Antigravity", fetch: fetchAntigravityModels },
|
|
{ label: "Codex", fetch: fetchCodexDiscoveryModels },
|
|
] as const;
|
|
const specialDiscoveries = await Promise.all(
|
|
specialDiscoverySources.map(async source => ({
|
|
label: source.label,
|
|
models: await source.fetch(),
|
|
})),
|
|
);
|
|
for (const discovery of specialDiscoveries) {
|
|
if (discovery.models.length > 0) {
|
|
console.log(`Added ${discovery.models.length} models from ${discovery.label} discovery`);
|
|
allModels.push(...discovery.models);
|
|
}
|
|
}
|
|
|
|
// Merge previous models.json entries as fallback for any provider/model
|
|
// not fetched dynamically. This replaces all hardcoded fallback lists —
|
|
// static-only providers (vertex, gemini-cli), auth-gated providers when
|
|
// credentials are unavailable, and ad-hoc model additions all persist
|
|
// through the existing models.json seed.
|
|
// Discovery-only providers (local inference servers) — never bundle static models.
|
|
const discoveryOnlyProviders = new Set(["ollama", "vllm"]);
|
|
const fetchedKeys = new Set(allModels.map(model => `${model.provider}/${model.id}`));
|
|
|
|
for (const models of Object.values(prevModelsJson as Record<string, Record<string, Model>>)) {
|
|
for (const model of Object.values(models)) {
|
|
if (!fetchedKeys.has(`${model.provider}/${model.id}`) && !discoveryOnlyProviders.has(model.provider)) {
|
|
allModels.push(model);
|
|
}
|
|
}
|
|
}
|
|
|
|
allModels = applyGlobalModelsDevFallback(allModels, modelsDevModels);
|
|
allModels = applyPremiumMultiplierOverrides(allModels);
|
|
allModels = applyCodexPricingFallback(allModels);
|
|
applyGeneratedModelPolicies(allModels);
|
|
linkOpenAIPromotionTargets(allModels);
|
|
|
|
// Group by provider and sort each provider's models
|
|
const providers: Record<string, Record<string, Model>> = {};
|
|
for (const model of allModels) {
|
|
if (discoveryOnlyProviders.has(model.provider)) continue;
|
|
if (!providers[model.provider]) {
|
|
providers[model.provider] = {};
|
|
}
|
|
// Use model ID as key to automatically deduplicate
|
|
// Only add if not already present (models.dev takes priority over endpoint discovery)
|
|
if (!providers[model.provider][model.id]) {
|
|
providers[model.provider][model.id] = model;
|
|
}
|
|
}
|
|
|
|
// Sort providers alphabetically and models within each provider by ID
|
|
const sortObj = <V>(o: Record<string, V>): Record<string, V> => {
|
|
return Object.fromEntries(
|
|
Object.entries(o)
|
|
.sort(([a], [b]) => a.localeCompare(b))
|
|
.map(([id, model]) => [id, model]),
|
|
);
|
|
};
|
|
|
|
const MODELS: Record<string, Record<string, Model>> = sortObj(providers);
|
|
for (const key in MODELS) {
|
|
MODELS[key] = sortObj(MODELS[key]);
|
|
}
|
|
|
|
// Generate JSON file
|
|
await Bun.write(path.join(packageRoot, "src/models.json"), JSON.stringify(MODELS, null, " "));
|
|
console.log("Generated src/models.json");
|
|
|
|
// Print statistics
|
|
const totalModels = allModels.length;
|
|
const reasoningModels = allModels.filter(m => m.reasoning).length;
|
|
|
|
console.log(`
|
|
Model Statistics:`);
|
|
console.log(` Total tool-capable models: ${totalModels}`);
|
|
console.log(` Reasoning-capable models: ${reasoningModels}`);
|
|
|
|
for (const [provider, models] of Object.entries(MODELS)) {
|
|
console.log(` ${provider}: ${Object.keys(models).length} models`);
|
|
}
|
|
}
|
|
|
|
// Run the generator
|
|
generateModels().catch(console.error);
|