feat(coding-agent): expose dynamic provider refresh

This commit is contained in:
Mario Zechner
2026-07-15 12:56:50 +02:00
parent 45203abfa0
commit bd9e09db44
13 changed files with 164 additions and 55 deletions
@@ -25,6 +25,7 @@ import type {
OAuthCredentials,
OAuthLoginCallbacks,
ProviderHeaders,
RefreshModelsContext,
SimpleStreamOptions,
TextContent,
ToolResultMessage,
@@ -1420,6 +1421,11 @@ export interface ProviderConfig {
authHeader?: boolean;
/** Models to register. If provided, replaces all existing models for this provider. */
models?: ProviderModelConfig[];
/**
* Refresh this provider's model list. The returned list replaces extension-provided models.
* Use context.store explicitly when the catalog should persist across sessions.
*/
refreshModels?(context: RefreshModelsContext): Promise<ProviderModelConfig[]>;
/** OAuth provider for /login support. The `id` is set automatically from the provider name. */
oauth?: {
/** Display name for the provider in login UI. */
+12 -12
View File
@@ -1,23 +1,23 @@
import { join } from "node:path";
import type { Api, Model, ModelsStore } from "@earendil-works/pi-ai";
import type { ModelsStore, ModelsStoreEntry } from "@earendil-works/pi-ai";
import { getAgentDir } from "../config.ts";
import { type AuthStorageBackend, FileAuthStorageBackend } from "./auth-storage.ts";
type StoredModels = Record<string, Model<Api>[]>;
type StoredModels = Record<string, ModelsStoreEntry>;
export class InMemoryCodingAgentModelsStore implements ModelsStore {
private readonly models = new Map<string, readonly Model<Api>[]>();
private readonly entries = new Map<string, ModelsStoreEntry>();
async read(providerId: string): Promise<readonly Model<Api>[] | undefined> {
return this.models.get(providerId);
async read(providerId: string): Promise<ModelsStoreEntry | undefined> {
return this.entries.get(providerId);
}
async write(providerId: string, models: readonly Model<Api>[]): Promise<void> {
this.models.set(providerId, models);
async write(providerId: string, entry: ModelsStoreEntry): Promise<void> {
this.entries.set(providerId, entry);
}
async delete(providerId: string): Promise<void> {
this.models.delete(providerId);
this.entries.delete(providerId);
}
}
@@ -33,16 +33,16 @@ export class FileModelsStore implements ModelsStore {
return content ? (JSON.parse(content) as StoredModels) : {};
}
async read(providerId: string): Promise<readonly Model<Api>[] | undefined> {
async read(providerId: string): Promise<ModelsStoreEntry | undefined> {
return this.storage.withLock((content) => ({
result: this.parse(content)[providerId]?.map((model) => structuredClone(model)),
result: structuredClone(this.parse(content)[providerId]),
}));
}
async write(providerId: string, models: readonly Model<Api>[]): Promise<void> {
async write(providerId: string, entry: ModelsStoreEntry): Promise<void> {
await this.storage.withLockAsync(async (content) => {
const current = this.parse(content);
current[providerId] = models.map((model) => structuredClone(model));
current[providerId] = structuredClone(entry);
return { result: undefined, next: JSON.stringify(current, null, 2) };
});
}
@@ -15,6 +15,7 @@ import {
type OAuthLoginCallbacks,
type Provider,
type ProviderHeaders,
type RefreshModelsContext,
type SimpleStreamOptions,
type StreamOptions,
} from "@earendil-works/pi-ai";
@@ -63,6 +64,7 @@ export interface ProviderConfigInput {
headers?: Record<string, string>;
compat?: Model<Api>["compat"];
}>;
refreshModels?(context: RefreshModelsContext): Promise<NonNullable<ProviderConfigInput["models"]>>;
}
export type AuthStatus = {
@@ -415,10 +417,17 @@ export function composeModelProvider(
): Provider {
const config = modelConfig.getProvider(providerId);
let extensionOAuthCredential: OAuthCredentials | undefined;
let refreshedExtensionModels: ProviderConfigInput["models"];
const currentExtension = (): ProviderConfigInput | undefined =>
extension && refreshedExtensionModels ? { ...extension, models: refreshedExtensionModels } : extension;
// models.json modelOverrides are the topmost user-config layer: they apply once,
// after custom-model upserts, extension model replacement, and legacy OAuth projection.
const getModels = () => {
let models = applyExtension(providerId, applyModelsJson(providerId, base?.getModels() ?? [], config), extension);
let models = applyExtension(
providerId,
applyModelsJson(providerId, base?.getModels() ?? [], config),
currentExtension(),
);
if (extensionOAuthCredential && extension?.oauth?.modifyModels) {
models = extension.oauth.modifyModels(models, extensionOAuthCredential);
}
@@ -464,9 +473,20 @@ export function composeModelProvider(
auth: { ...(apiKey ? { apiKey } : {}), ...(oauth ? { oauth } : {}) },
getModels,
refreshModels:
base?.refreshModels || extension?.oauth?.modifyModels
base?.refreshModels || extension?.refreshModels || extension?.oauth?.modifyModels
? async (context) => {
await base?.refreshModels?.(context);
if (extension?.refreshModels) {
const refreshed = await extension.refreshModels(context);
if (!context.signal?.aborted) {
// Validate before publishing the new synchronous list.
applyExtension(providerId, applyModelsJson(providerId, base?.getModels() ?? [], config), {
...extension,
models: refreshed,
});
refreshedExtensionModels = refreshed;
}
}
extensionOAuthCredential = context.credential?.type === "oauth" ? context.credential : undefined;
}
: undefined,
@@ -1,6 +1,9 @@
import type { Api, Model, Provider } from "@earendil-works/pi-ai";
import { VERSION } from "../config.ts";
import { getPiUserAgent } from "../utils/pi-user-agent.ts";
const DEFAULT_CATALOG_BASE_URL = "https://pi.dev";
export const REMOTE_CATALOG_REFRESH_INTERVAL_MS = 4 * 60 * 60 * 1000;
function mergeModels(baseline: readonly Model<Api>[], dynamic: readonly Model<Api>[]): Model<Api>[] {
const merged = [...baseline];
@@ -38,22 +41,37 @@ export function withRemoteCatalog(provider: Provider, catalogBaseUrl: string = D
inflightRefresh ??= (async () => {
try {
const stored = await context.store.read();
if (stored) dynamicModels = stored.filter((model) => model.provider === provider.id);
if (stored) dynamicModels = stored.models.filter((model) => model.provider === provider.id);
if (!context.allowNetwork || context.signal?.aborted) return;
if (
stored?.checkedAt !== undefined &&
Date.now() - stored.checkedAt < REMOTE_CATALOG_REFRESH_INTERVAL_MS
) {
return;
}
const url = new URL(`/api/models/providers/${encodeURIComponent(provider.id)}`, catalogBaseUrl);
const response = await fetch(url, {
headers: { accept: "application/json" },
headers: {
accept: "application/json",
"User-Agent": getPiUserAgent(VERSION),
},
signal: context.signal,
});
if (response.status === 404 || response.status === 501) return;
if (context.signal?.aborted) return;
const checkedAt = Date.now();
if (response.status === 404 || response.status === 501) {
await context.store.write({ models: dynamicModels, checkedAt });
return;
}
if (!response.ok) {
await context.store.write({ models: dynamicModels, checkedAt });
throw new Error(`Model catalog request failed for ${provider.id}: ${response.status}`);
}
const refreshed = parseCatalog(provider.id, await response.json());
if (context.signal?.aborted) return;
dynamicModels = refreshed;
await context.store.write(refreshed);
await context.store.write({ models: refreshed, checkedAt });
} finally {
inflightRefresh = undefined;
}