feat(coding-agent): expose dynamic provider refresh
This commit is contained in:
@@ -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. */
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user