From 5306c93d49de2077634571d94cca42dfecf45d89 Mon Sep 17 00:00:00 2001 From: AgentRun Codex Date: Thu, 23 Jul 2026 10:20:13 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=20provider=20?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B=E7=9B=AE=E5=BD=95=E6=9F=A5=E8=AF=A2=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/mgr/provider-profiles.ts | 181 ++++++++++++++++++ src/mgr/server.ts | 6 +- .../cases/45-provider-profile-management.ts | 50 ++++- 3 files changed, 227 insertions(+), 10 deletions(-) diff --git a/src/mgr/provider-profiles.ts b/src/mgr/provider-profiles.ts index f9a6715..0dd6b7e 100644 --- a/src/mgr/provider-profiles.ts +++ b/src/mgr/provider-profiles.ts @@ -20,6 +20,8 @@ const providerSecretNamePrefix = "agentrun-v01-provider-"; export interface ProviderProfileOptions { namespace?: string; kubectlCommand?: string; + fetchImpl?: typeof fetch; + modelCatalogTimeoutMs?: number; } export interface ProviderProfileValidationOptions extends ProviderProfileOptions { @@ -49,6 +51,73 @@ export async function listProviderProfiles(options: ProviderProfileOptions = {}) return { items, count: items.length, valuesPrinted: false }; } +export async function listProviderProfileModels(profileValue: string, options: ProviderProfileOptions = {}): Promise { + const profile = validateBackendProfile(profileValue); + const spec = requiredSpec(profile); + const namespace = profileNamespace(options); + let secret: JsonRecord | null = null; + let discoveryError: unknown = null; + try { + secret = await kubectlGetSecret(spec.defaultSecretName, namespace, options.kubectlCommand ?? "kubectl"); + } catch (error) { + discoveryError = error; + } + const data = asOptionalRecord(secret?.data); + const fallbackConfig = defaultConfig(profile); + let configToml = renderConfigToml(fallbackConfig); + try { + if (dataKey(data, "config.toml")) configToml = configTomlFromData(data, profile); + } catch (error) { + discoveryError ??= error; + } + const configuredModel = modelFromConfigToml(configToml) ?? fallbackConfig.model; + const localCatalog = modelCatalogFromSecret(data); + const fallbackItems = uniqueModelCatalogItems([...localCatalog, modelCatalogItem(configuredModel, null)]); + const defaultReasoningEfforts = reasoningEffortsFromItems(fallbackItems); + const catalogDefaultEffort = optionalString(fallbackItems.find((item) => item.id === configuredModel)?.defaultReasoningEffort); + const base = { + profile, + defaultModel: configuredModel, + defaultReasoningEffort: catalogDefaultEffort ?? defaultReasoningEffortForProfile(profile), + reasoningEfforts: defaultReasoningEfforts.length > 0 ? defaultReasoningEfforts : ["low", "medium", "high", "xhigh"], + valuesPrinted: false, + }; + + try { + if (discoveryError) throw discoveryError; + const baseUrl = baseUrlFromConfigToml(configToml) ?? fallbackConfig.baseUrl; + const apiKey = providerApiKeyFromSecret(data); + if (!secret || !apiKey) throw new Error(`provider profile ${profile} credential is unavailable for upstream model discovery`); + const upstream = await fetchProviderModels(baseUrl, apiKey, options); + const items = uniqueModelCatalogItems([...upstream, ...fallbackItems]); + const reasoningEfforts = reasoningEffortsFromItems(items); + return { + ...base, + status: "ok", + source: "upstream", + items, + count: items.length, + reasoningEfforts: reasoningEfforts.length > 0 ? reasoningEfforts : base.reasoningEfforts, + warning: null, + }; + } catch (error) { + return { + ...base, + status: "degraded", + source: localCatalog.length > 0 ? "profile-model-catalog" : "profile-default", + items: fallbackItems, + count: fallbackItems.length, + warning: { + code: "provider-model-catalog-unavailable", + message: redactText(error instanceof Error ? error.message : String(error)), + blocking: false, + fallback: "profile-default", + valuesPrinted: false, + }, + }; + } +} + export async function listBackendCapabilities(options: ProviderProfileOptions = {}): Promise { const profiles = new Set(backendProfileSpecs.map((spec) => spec.profile)); try { @@ -657,6 +726,118 @@ function modelCatalogPathFromConfigToml(configToml: string): string | null { return match?.[1] ?? null; } +function modelFromConfigToml(configToml: string): string | null { + const match = configToml.match(/^\s*model\s*=\s*"([^"]+)"\s*$/mu); + return safeCatalogModelId(match?.[1]); +} + +function modelCatalogFromSecret(data: JsonRecord | null): JsonRecord[] { + const encoded = dataKey(data, dsflashGoModelCatalogFile); + if (!encoded) return []; + try { + const parsed = JSON.parse(Buffer.from(encoded, "base64").toString("utf8")) as JsonRecord; + return Array.isArray(parsed.models) + ? parsed.models.map((item) => modelCatalogItemFromUnknown(item)).filter((item): item is JsonRecord => Boolean(item)) + : []; + } catch { + return []; + } +} + +function providerApiKeyFromSecret(data: JsonRecord | null): string | null { + const encoded = dataKey(data, "auth.json"); + if (!encoded) return null; + try { + const auth = JSON.parse(Buffer.from(encoded, "base64").toString("utf8")) as JsonRecord; + return optionalString(auth.OPENAI_API_KEY ?? auth.apiKey ?? auth.api_key ?? auth.token ?? auth.access_token) ?? null; + } catch { + return null; + } +} + +async function fetchProviderModels(baseUrl: string, apiKey: string, options: ProviderProfileOptions): Promise { + const controller = new AbortController(); + const timeoutMs = Math.max(500, Math.trunc(options.modelCatalogTimeoutMs ?? 8_000)); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await (options.fetchImpl ?? fetch)(`${baseUrl.replace(/\/+$/u, "")}/models`, { + headers: { accept: "application/json", authorization: `Bearer ${apiKey}` }, + signal: controller.signal, + }); + if (!response.ok) throw new Error(`upstream model catalog returned HTTP ${response.status}`); + const payload = await response.json() as JsonRecord; + const source = Array.isArray(payload.data) ? payload.data : Array.isArray(payload.models) ? payload.models : []; + const items = source.map((item) => modelCatalogItemFromUnknown(item)).filter((item): item is JsonRecord => Boolean(item)); + if (items.length === 0) throw new Error("upstream model catalog returned no usable models"); + return items; + } finally { + clearTimeout(timer); + } +} + +function modelCatalogItemFromUnknown(value: unknown): JsonRecord | null { + if (!value || typeof value !== "object") return null; + const record = value as JsonRecord; + const id = safeCatalogModelId(record.id ?? record.slug ?? record.model ?? record.name); + if (!id) return null; + const effortsSource = record.supported_reasoning_levels ?? record.supportedReasoningLevels ?? record.reasoningEfforts; + const efforts = Array.isArray(effortsSource) + ? effortsSource.map((item) => optionalString(typeof item === "string" ? item : (item as JsonRecord)?.effort)).filter((item): item is string => Boolean(item)) + : []; + return modelCatalogItem(id, efforts, optionalString(record.default_reasoning_level ?? record.defaultReasoningEffort)?.toLowerCase() ?? null); +} + +function modelCatalogItem(id: string, reasoningEfforts: string[] | null, defaultReasoningEffort: string | null = null): JsonRecord { + return { + id, + ...(reasoningEfforts && reasoningEfforts.length > 0 ? { reasoningEfforts: [...new Set(reasoningEfforts)] } : {}), + ...(defaultReasoningEffort ? { defaultReasoningEffort } : {}), + valuesPrinted: false, + }; +} + +function uniqueModelCatalogItems(items: JsonRecord[]): JsonRecord[] { + const byId = new Map(); + for (const item of items) { + const id = safeCatalogModelId(item.id); + if (!id) continue; + const existing = byId.get(id); + const efforts = reasoningEffortsFromItems(existing ? [existing, item] : [item]); + const defaultReasoningEffort = optionalString(item.defaultReasoningEffort ?? existing?.defaultReasoningEffort)?.toLowerCase() ?? null; + byId.set(id, modelCatalogItem(id, efforts, defaultReasoningEffort)); + } + return [...byId.values()]; +} + +function safeCatalogModelId(value: unknown): string | null { + const model = optionalString(value); + return model && model.length <= 128 && /^[A-Za-z0-9][A-Za-z0-9._:/+-]{0,127}$/u.test(model) ? model : null; +} + +function defaultReasoningEffortForProfile(profile: BackendProfile): string { + return profile === "dsflash-go" ? "xhigh" : "medium"; +} + +function reasoningEffortsFromItems(items: JsonRecord[]): string[] { + const preferred = ["low", "medium", "high", "xhigh"]; + const values = new Set(); + for (const item of items) { + for (const effort of Array.isArray(item.reasoningEfforts) ? item.reasoningEfforts : []) { + const normalized = optionalString(effort)?.toLowerCase(); + if (normalized) values.add(normalized); + } + } + if (values.size === 0) return preferred; + return [...values].sort((left, right) => { + const leftIndex = preferred.indexOf(left); + const rightIndex = preferred.indexOf(right); + if (leftIndex < 0 && rightIndex < 0) return left.localeCompare(right); + if (leftIndex < 0) return 1; + if (rightIndex < 0) return -1; + return leftIndex - rightIndex; + }); +} + function contextWindowSettings(model: string): { contextWindow: number; autoCompactTokenLimit: number } { if (model === "deepseek-v4-pro" || model === "deepseek-v4-flash") { return { contextWindow: 1_000_000, autoCompactTokenLimit: 900_000 }; diff --git a/src/mgr/server.ts b/src/mgr/server.ts index eeb9da3..5831f54 100644 --- a/src/mgr/server.ts +++ b/src/mgr/server.ts @@ -21,7 +21,7 @@ import { createSessionPvc, deleteSessionPvc, getSessionPvcSummary, refreshSessio import type { SessionPvcSummary } from "./session-pvc.js"; import type { SessionPvcOptions } from "./session-pvc.js"; import { assertManagerRequestAuthorized, managerAuthSummary, managerServerAuthConfigFromEnv, type ManagerAuthConfig } from "./auth.js"; -import { getProviderProfileConfig, getProviderProfileValidation, listBackendCapabilities, listProviderProfiles, removeProviderProfile, setProviderProfileConfig, setProviderProfileCredential, showProviderProfile, validateProviderProfile } from "./provider-profiles.js"; +import { getProviderProfileConfig, getProviderProfileValidation, listBackendCapabilities, listProviderProfileModels, listProviderProfiles, removeProviderProfile, setProviderProfileConfig, setProviderProfileCredential, showProviderProfile, validateProviderProfile } from "./provider-profiles.js"; import { listToolCredentials, setGithubSshToolCredential, showToolCredential } from "./tool-credentials.js"; import { aipodSpecFromInput, applyAipodSpec, deleteAipodSpec, listAipodSpecs, renderAipodSpecByName, showAipodSpec } from "../common/aipod-specs.js"; import { staticWorkReadyCapabilitySummary } from "../common/work-ready.js"; @@ -252,7 +252,7 @@ export interface ManagerServerOptions { retention?: RunnerRetentionOptions; }; sessionPvcOptions?: { kubectlHandler?: import("./session-pvc.js").KubectlHandler; kubectlCommand?: string; storageClassName?: string; size?: string }; - providerProfileOptions?: { namespace?: string; kubectlCommand?: string }; + providerProfileOptions?: { namespace?: string; kubectlCommand?: string; fetchImpl?: typeof fetch; modelCatalogTimeoutMs?: number }; toolCredentialOptions?: { namespace?: string; kubectlCommand?: string }; runnerReconcilerOptions?: { enabled?: boolean; namespace?: string; kubectlCommand?: string; intervalMs?: number; batchSize?: number; startupRecovery?: RunnerStartupRecoveryOptions }; runnerDispatcherOptions?: { enabled?: boolean; intervalMs?: number; batchSize?: number; leaseMs?: number; maxAttempts?: number; retryBackoffMs?: number; attemptTimeoutMs?: number; owner?: string }; @@ -709,6 +709,8 @@ async function route({ method, url, body, signal, store, sourceCommit, authSumma const aipodSpecRenderMatch = path.match(/^\/api\/v1\/aipod-specs\/([^/]+)\/render$/u); if (method === "POST" && aipodSpecRenderMatch) return await renderAipodSpecByName(decodeURIComponent(aipodSpecRenderMatch[1] ?? ""), asRecord(body ?? {}, "aipodSpecRender"), aipodSpecDir) as JsonValue; if (method === "GET" && path === "/api/v1/provider-profiles") return await listProviderProfiles(providerProfileDefaults) as JsonValue; + const providerModelsMatch = path.match(/^\/api\/v1\/provider-profiles\/([^/]+)\/models$/u); + if (method === "GET" && providerModelsMatch) return await listProviderProfileModels(providerModelsMatch[1] ?? "", providerProfileDefaults) as JsonValue; const providerProfileMatch = path.match(/^\/api\/v1\/provider-profiles\/([^/]+)$/u); if (method === "GET" && providerProfileMatch) return await showProviderProfile(providerProfileMatch[1] ?? "", providerProfileDefaults) as JsonValue; if (method === "DELETE" && providerProfileMatch) return await removeProviderProfile(providerProfileMatch[1] ?? "", providerProfileDefaults) as JsonValue; diff --git a/src/selftest/cases/45-provider-profile-management.ts b/src/selftest/cases/45-provider-profile-management.ts index b8b3b7a..49cdbe3 100644 --- a/src/selftest/cases/45-provider-profile-management.ts +++ b/src/selftest/cases/45-provider-profile-management.ts @@ -22,6 +22,7 @@ const selfTest: SelfTestCase = async (context) => { const cleanupPatchPath = path.join(context.tmp, "provider-secret-cleanup-patch.json"); const createdJobPath = path.join(context.tmp, "provider-validation-job.json"); const secretStateDir = path.join(context.tmp, "provider-secret-state"); + let modelFetchCount = 0; await writeFile(fakeKubectl, `#!/usr/bin/env bun import { mkdirSync, rmSync } from "node:fs"; const args = Bun.argv.slice(2); @@ -29,7 +30,7 @@ const secretStateDir = ${JSON.stringify(secretStateDir)}; const secretStatePath = (name) => secretStateDir + "/" + name + ".json"; const deletedMarkerPath = (name) => secretStateDir + "/" + name + ".deleted"; mkdirSync(secretStateDir, { recursive: true }); -const fixtureSecret = (name) => ({ apiVersion: "v1", kind: "Secret", metadata: { name, namespace: "agentrun-v01", resourceVersion: "rv-selftest", creationTimestamp: "2026-06-05T00:00:00.000Z" }, data: { "auth.json": Buffer.from(JSON.stringify({ token: "redacted-fixture" })).toString("base64"), "config.toml": Buffer.from("model = \\\"fixture\\\"\\n").toString("base64") } }); +const fixtureSecret = (name) => ({ apiVersion: "v1", kind: "Secret", metadata: { name, namespace: "agentrun-v01", resourceVersion: "rv-selftest", creationTimestamp: "2026-06-05T00:00:00.000Z" }, data: { "auth.json": Buffer.from(JSON.stringify({ token: "redacted-fixture" })).toString("base64"), "config.toml": Buffer.from("model = \\\"fixture\\\"\\nbase_url = \\\"https://models.selftest.invalid/v1\\\"\\n").toString("base64") } }); const readStdin = async () => { const chunks = []; for await (const chunk of Bun.stdin.stream()) chunks.push(Buffer.from(chunk)); @@ -142,19 +143,34 @@ process.exit(1); host: "127.0.0.1", sourceCommit: "self-test", store, - providerProfileOptions: { namespace: "agentrun-v01", kubectlCommand: fakeKubectl }, + providerProfileOptions: { + namespace: "agentrun-v01", + kubectlCommand: fakeKubectl, + fetchImpl: async (_input, init) => { + assert.equal((init?.headers as Record)?.authorization, "Bearer redacted-fixture"); + modelFetchCount += 1; + if (modelFetchCount > 1) return new Response("upstream unavailable", { status: 503 }); + return new Response(JSON.stringify({ data: [ + { id: "gpt-5.6", supported_reasoning_levels: [{ effort: "low" }, { effort: "medium" }, { effort: "high" }, { effort: "xhigh" }] }, + { id: "gpt-5.5" }, + ] }), { status: 200, headers: { "content-type": "application/json" } }); + }, + }, runnerJobDefaults: { namespace: "agentrun-v01", managerUrl: "http://agentrun-mgr.agentrun-v01.svc.cluster.local:8080", runnerApiKeySecretRef: { name: "agentrun-selftest-api-key", key: "HWLAB_API_KEY" }, image: "127.0.0.1:5000/agentrun/agentrun-mgr@sha256:2222222222222222222222222222222222222222222222222222222222222222", + serviceAccountName: "agentrun-v01-runner", + jobNamePrefix: "agentrun-v01-runner", + lane: "v0.1", kubectlCommand: fakeKubectl, }, }); try { const client = new ManagerClient(server.baseUrl); const list = await client.get("/api/v1/provider-profiles") as JsonRecord; - assert.equal(list.count, 4); + assert.equal(list.count, 5); assert.equal(JSON.stringify(list).includes("auth.json"), true); assert.equal(JSON.stringify(list).includes("redacted-fixture"), false); const listItems = (list.items as JsonRecord[]) ?? []; @@ -162,9 +178,27 @@ process.exit(1); assert.equal(dsflashStatus?.configured, false); assert.equal(dsflashStatus?.failureKind, "secret-unavailable"); + const models = await client.get("/api/v1/provider-profiles/codex/models") as JsonRecord; + assert.equal(models.status, "ok"); + assert.equal(models.source, "upstream"); + assert.equal(models.defaultModel, "fixture"); + assert.deepEqual(models.reasoningEfforts, ["low", "medium", "high", "xhigh"]); + assert.deepEqual((models.items as JsonRecord[]).map((item) => item.id), ["gpt-5.6", "gpt-5.5", "fixture"]); + assert.equal(JSON.stringify(models).includes("redacted-fixture"), false); + + const degradedModels = await client.get("/api/v1/provider-profiles/dsflash-go/models") as JsonRecord; + assert.equal(degradedModels.status, "degraded"); + assert.equal((degradedModels.warning as JsonRecord)?.blocking, false); + assert.equal((degradedModels.items as JsonRecord[]).some((item) => item.id === "deepseek-v4-flash"), true); + + const upstreamDegradedModels = await client.get("/api/v1/provider-profiles/deepseek/models") as JsonRecord; + assert.equal(upstreamDegradedModels.status, "degraded"); + assert.equal((upstreamDegradedModels.warning as JsonRecord)?.blocking, false); + assert.equal((upstreamDegradedModels.warning as JsonRecord)?.code, "provider-model-catalog-unavailable"); + const config = await client.get("/api/v1/provider-profiles/deepseek/config") as JsonRecord; assert.equal(config.profile, "deepseek"); - assert.equal(config.configToml, "model = \"fixture\"\n"); + assert.equal(config.configToml, "model = \"fixture\"\nbase_url = \"https://models.selftest.invalid/v1\"\n"); assert.equal(config.configTomlPrinted, true); assert.equal(JSON.stringify(config).includes("redacted-fixture"), false); @@ -302,7 +336,7 @@ process.exit(1); assert.equal(dynamicShown.failureKind, null); const listAfterDynamic = await client.get("/api/v1/provider-profiles") as JsonRecord; - assert.equal(listAfterDynamic.count, 5); + assert.equal(listAfterDynamic.count, 6); const dynamicListItems = (listAfterDynamic.items as JsonRecord[]) ?? []; assert.equal(dynamicListItems.some((item) => item.profile === dynamicProfile), true); const backendsAfterDynamic = await client.get("/api/v1/backends") as JsonRecord; @@ -324,7 +358,7 @@ process.exit(1); assert.equal(deepseekShownAfterRemove.configured, false); assert.equal(deepseekShownAfterRemove.failureKind, "secret-unavailable"); const listAfterBuiltinRemove = await client.get("/api/v1/provider-profiles") as JsonRecord; - assert.equal(listAfterBuiltinRemove.count, 5); + assert.equal(listAfterBuiltinRemove.count, 6); const deepseekAfterRemove = ((listAfterBuiltinRemove.items as JsonRecord[]) ?? []).find((item) => item.profile === "deepseek") as JsonRecord | undefined; assert.equal(deepseekAfterRemove?.configured, false); assert.equal(deepseekAfterRemove?.failureKind, "secret-unavailable"); @@ -335,7 +369,7 @@ process.exit(1); assert.equal(removedDynamic.builtinCapabilityRetained, undefined); assertNoSecretLeak(removedDynamic); const listAfterDynamicRemove = await client.get("/api/v1/provider-profiles") as JsonRecord; - assert.equal(listAfterDynamicRemove.count, 4); + assert.equal(listAfterDynamicRemove.count, 5); const itemsAfterDynamicRemove = (listAfterDynamicRemove.items as JsonRecord[]) ?? []; assert.equal(itemsAfterDynamicRemove.some((item) => item.profile === dynamicProfile), false); @@ -367,7 +401,7 @@ process.exit(1); assert.equal(finalValidation.model, "deepseek-chat"); assert.equal(JSON.stringify(finalValidation).includes(secretText), false); assertNoSecretLeak(finalValidation); - return { name: "provider-profile-management", tests: ["provider-profiles-list-redacted", "provider-profile-config", "provider-profile-set-key-redacted", "provider-profile-set-auth-json-redacted", "provider-profile-secret-replace-annotation-cleanup", "provider-profile-secret-create-upsert", "provider-profile-config-only-create", "provider-profile-dsflash-go-model-catalog", "provider-profile-dynamic-slug-roundtrip", "backends-list-dynamic-profile", "provider-profile-remove-builtin", "provider-profile-remove-dynamic-slug", "provider-profile-deepseek-moon-bridge", "provider-profile-manager-secret-rbac", "provider-profile-validation-runner-job"] }; + return { name: "provider-profile-management", tests: ["provider-profiles-list-redacted", "provider-profile-model-catalog", "provider-profile-config", "provider-profile-set-key-redacted", "provider-profile-set-auth-json-redacted", "provider-profile-secret-replace-annotation-cleanup", "provider-profile-secret-create-upsert", "provider-profile-config-only-create", "provider-profile-dsflash-go-model-catalog", "provider-profile-dynamic-slug-roundtrip", "backends-list-dynamic-profile", "provider-profile-remove-builtin", "provider-profile-remove-dynamic-slug", "provider-profile-deepseek-moon-bridge", "provider-profile-manager-secret-rbac", "provider-profile-validation-runner-job"] }; } finally { await new Promise((resolve) => server.server.close(() => resolve())); }