657aaa9c92
resolveCommandConfig used execSync with a 10s timeout and only cached non-empty successful results, so empty/failing/timeout !command values in models.yml re-ran on every API key lookup and blocked the event loop repeatedly. It now probes the cache with has(), stores empty stdout and caught failures as undefined, and returns the cached undefined on later lookups. Extension-registered fetchDynamicModels is now wrapped with a 15s Promise.race timeout so a stuck extension cannot hang model refresh indefinitely. Verified by Bun transpiler syntax check of packages/coding-agent/src/config/model-registry.ts and the model-registry test suites. Closes #4237
136 lines
4.4 KiB
TypeScript
136 lines
4.4 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
import * as fs from "node:fs";
|
|
import * as os from "node:os";
|
|
import * as path from "node:path";
|
|
import type { Api, Model } from "@oh-my-pi/pi-ai/types";
|
|
import { buildModel } from "@oh-my-pi/pi-catalog/build";
|
|
import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry";
|
|
import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage";
|
|
import { removeSyncWithRetries, Snowflake } from "@oh-my-pi/pi-utils";
|
|
|
|
function stdoutCommand(value: string): string {
|
|
return `${JSON.stringify(process.execPath)} -e ${JSON.stringify(`process.stdout.write(${JSON.stringify(value)})`)}`;
|
|
}
|
|
|
|
describe("ModelRegistry command-resolved models.yml values", () => {
|
|
let tempDir = "";
|
|
let authStorage: AuthStorage;
|
|
let modelsPath = "";
|
|
|
|
beforeEach(async () => {
|
|
tempDir = path.join(os.tmpdir(), `pi-test-model-command-values-${Snowflake.next()}`);
|
|
fs.mkdirSync(tempDir, { recursive: true });
|
|
modelsPath = path.join(tempDir, "models.json");
|
|
authStorage = await AuthStorage.create(":memory:");
|
|
});
|
|
|
|
afterEach(() => {
|
|
authStorage.close();
|
|
if (!tempDir || !fs.existsSync(tempDir)) return;
|
|
try {
|
|
removeSyncWithRetries(tempDir);
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code !== "EBUSY") throw error;
|
|
}
|
|
});
|
|
|
|
test("provider apiKey and headers resolve from command stdout", async () => {
|
|
fs.writeFileSync(
|
|
modelsPath,
|
|
JSON.stringify({
|
|
providers: {
|
|
anthropic: {
|
|
baseUrl: "https://anthropic-proxy.example.com/v1",
|
|
apiKey: `!${stdoutCommand("cmd-api-key")}`,
|
|
authHeader: true,
|
|
headers: { "X-Api-Key": `!${stdoutCommand("cmd-header")}` },
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
|
|
const registry = new ModelRegistry(authStorage, modelsPath);
|
|
const models = registry.getAll().filter(model => model.provider === "anthropic");
|
|
|
|
expect(models.length).toBeGreaterThan(1);
|
|
for (const model of models) {
|
|
expect(model.headers?.Authorization).toBe("Bearer cmd-api-key");
|
|
expect(model.headers?.["X-Api-Key"]).toBe("cmd-header");
|
|
}
|
|
expect(await registry.getApiKey(models[0])).toBe("cmd-api-key");
|
|
});
|
|
|
|
test("modelOverrides headers resolve from command stdout", async () => {
|
|
fs.writeFileSync(
|
|
modelsPath,
|
|
JSON.stringify({
|
|
providers: {
|
|
"custom-proxy": {
|
|
baseUrl: "https://custom-proxy.example.com/v1",
|
|
api: "openai-completions",
|
|
apiKey: `!${stdoutCommand("cmd-api-key")}`,
|
|
authHeader: true,
|
|
models: [{ id: "custom-model", name: "Custom Model" }],
|
|
modelOverrides: {
|
|
"custom-model": { headers: { "X-Model-Key": `!${stdoutCommand("cmd-model-header")}` } },
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
|
|
const registry = new ModelRegistry(authStorage, modelsPath);
|
|
const model = registry.find("custom-proxy", "custom-model");
|
|
|
|
expect(model).toBeDefined();
|
|
expect(model?.headers?.["X-Model-Key"]).toBe("cmd-model-header");
|
|
expect(model?.headers?.Authorization).toBe("Bearer cmd-api-key");
|
|
});
|
|
|
|
test("resolveCommandConfig caches failed executions so they do not retry", async () => {
|
|
const counterFile = path.join(tempDir, "counter.txt");
|
|
fs.writeFileSync(counterFile, "0");
|
|
|
|
// Command increments a counter and then fails (exit 1).
|
|
const trackingCommand = `node -e "const fs=require('fs'); fs.writeFileSync('${counterFile.replace(/\\/g, "/")}', String(Number(fs.readFileSync('${counterFile.replace(/\\/g, "/")}', 'utf8')) + 1)); process.exit(1);"`;
|
|
|
|
fs.writeFileSync(
|
|
modelsPath,
|
|
JSON.stringify({
|
|
providers: {
|
|
"custom-proxy": {
|
|
baseUrl: "https://custom-proxy.example.com/v1",
|
|
api: "openai-completions",
|
|
apiKey: `!${trackingCommand}`,
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
|
|
// Init triggers the first command resolution.
|
|
const registry = new ModelRegistry(authStorage, modelsPath);
|
|
|
|
const dummyModel: Model<Api> = buildModel({
|
|
id: "foo",
|
|
name: "foo",
|
|
api: "openai-completions",
|
|
provider: "custom-proxy",
|
|
baseUrl: "a",
|
|
reasoning: false,
|
|
input: ["text"],
|
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
contextWindow: 4096,
|
|
maxTokens: 1024,
|
|
});
|
|
|
|
// Trigger the fallback resolver which also calls resolveConfigValue.
|
|
await registry.getApiKey(dummyModel);
|
|
|
|
// Another call to ensure it hits cache multiple times.
|
|
await registry.getApiKey(dummyModel);
|
|
|
|
// The command should have only run once.
|
|
expect(fs.readFileSync(counterFile, "utf8")).toBe("1");
|
|
});
|
|
});
|