Merge pull request #400 from pikasTech/fix/provider-model-catalog

增加 provider 模型目录查询接口
This commit is contained in:
Lyon
2026-07-23 16:39:10 +08:00
committed by GitHub
3 changed files with 227 additions and 10 deletions
+181
View File
@@ -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<JsonRecord> {
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<JsonRecord> {
const profiles = new Set<BackendProfile>(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<JsonRecord[]> {
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<string, JsonRecord>();
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<string>();
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 };
+4 -2
View File
@@ -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;
@@ -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<string, string>)?.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<void>((resolve) => server.server.close(() => resolve()));
}