mirror of
https://github.com/harivansh-afk/clanker-agent.git
synced 2026-04-20 13:02:14 +00:00
move pi-mono into companion-cloud as apps/companion-os
- Copy all pi-mono source into apps/companion-os/ - Update Dockerfile to COPY pre-built binary instead of downloading from GitHub Releases - Update deploy-staging.yml to build pi from source (bun compile) before Docker build - Add apps/companion-os/** to path triggers - No more cross-repo dispatch needed Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
0250f72976
579 changed files with 206942 additions and 0 deletions
994
packages/coding-agent/test/model-registry.test.ts
Normal file
994
packages/coding-agent/test/model-registry.test.ts
Normal file
|
|
@ -0,0 +1,994 @@
|
|||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type {
|
||||
Api,
|
||||
Context,
|
||||
Model,
|
||||
OpenAICompletionsCompat,
|
||||
} from "@mariozechner/pi-ai";
|
||||
import { getApiProvider } from "@mariozechner/pi-ai";
|
||||
import { getOAuthProvider } from "@mariozechner/pi-ai/oauth";
|
||||
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||
import { AuthStorage } from "../src/core/auth-storage.js";
|
||||
import { clearApiKeyCache, ModelRegistry } from "../src/core/model-registry.js";
|
||||
|
||||
describe("ModelRegistry", () => {
|
||||
let tempDir: string;
|
||||
let modelsJsonPath: string;
|
||||
let authStorage: AuthStorage;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = join(
|
||||
tmpdir(),
|
||||
`pi-test-model-registry-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
);
|
||||
mkdirSync(tempDir, { recursive: true });
|
||||
modelsJsonPath = join(tempDir, "models.json");
|
||||
authStorage = AuthStorage.create(join(tempDir, "auth.json"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (tempDir && existsSync(tempDir)) {
|
||||
rmSync(tempDir, { recursive: true });
|
||||
}
|
||||
clearApiKeyCache();
|
||||
});
|
||||
|
||||
/** Create minimal provider config */
|
||||
function providerConfig(
|
||||
baseUrl: string,
|
||||
models: Array<{ id: string; name?: string }>,
|
||||
api: string = "anthropic-messages",
|
||||
) {
|
||||
return {
|
||||
baseUrl,
|
||||
apiKey: "TEST_KEY",
|
||||
api,
|
||||
models: models.map((m) => ({
|
||||
id: m.id,
|
||||
name: m.name ?? m.id,
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 100000,
|
||||
maxTokens: 8000,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function writeModelsJson(
|
||||
providers: Record<string, ReturnType<typeof providerConfig>>,
|
||||
) {
|
||||
writeFileSync(modelsJsonPath, JSON.stringify({ providers }));
|
||||
}
|
||||
|
||||
function getModelsForProvider(registry: ModelRegistry, provider: string) {
|
||||
return registry.getAll().filter((m) => m.provider === provider);
|
||||
}
|
||||
|
||||
/** Create a baseUrl-only override (no custom models) */
|
||||
function overrideConfig(baseUrl: string, headers?: Record<string, string>) {
|
||||
return { baseUrl, ...(headers && { headers }) };
|
||||
}
|
||||
|
||||
/** Write raw providers config (for mixed override/replacement scenarios) */
|
||||
function writeRawModelsJson(providers: Record<string, unknown>) {
|
||||
writeFileSync(modelsJsonPath, JSON.stringify({ providers }));
|
||||
}
|
||||
|
||||
const openAiModel: Model<Api> = {
|
||||
id: "test-openai-model",
|
||||
name: "Test OpenAI Model",
|
||||
api: "openai-completions",
|
||||
provider: "openai",
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 128000,
|
||||
maxTokens: 4096,
|
||||
};
|
||||
|
||||
const emptyContext: Context = {
|
||||
messages: [],
|
||||
};
|
||||
|
||||
describe("baseUrl override (no custom models)", () => {
|
||||
test("overriding baseUrl keeps all built-in models", () => {
|
||||
writeRawModelsJson({
|
||||
anthropic: overrideConfig("https://my-proxy.example.com/v1"),
|
||||
});
|
||||
|
||||
const registry = new ModelRegistry(authStorage, modelsJsonPath);
|
||||
const anthropicModels = getModelsForProvider(registry, "anthropic");
|
||||
|
||||
// Should have multiple built-in models, not just one
|
||||
expect(anthropicModels.length).toBeGreaterThan(1);
|
||||
expect(anthropicModels.some((m) => m.id.includes("claude"))).toBe(true);
|
||||
});
|
||||
|
||||
test("overriding baseUrl changes URL on all built-in models", () => {
|
||||
writeRawModelsJson({
|
||||
anthropic: overrideConfig("https://my-proxy.example.com/v1"),
|
||||
});
|
||||
|
||||
const registry = new ModelRegistry(authStorage, modelsJsonPath);
|
||||
const anthropicModels = getModelsForProvider(registry, "anthropic");
|
||||
|
||||
// All models should have the new baseUrl
|
||||
for (const model of anthropicModels) {
|
||||
expect(model.baseUrl).toBe("https://my-proxy.example.com/v1");
|
||||
}
|
||||
});
|
||||
|
||||
test("overriding headers merges with model headers", () => {
|
||||
writeRawModelsJson({
|
||||
anthropic: overrideConfig("https://my-proxy.example.com/v1", {
|
||||
"X-Custom-Header": "custom-value",
|
||||
}),
|
||||
});
|
||||
|
||||
const registry = new ModelRegistry(authStorage, modelsJsonPath);
|
||||
const anthropicModels = getModelsForProvider(registry, "anthropic");
|
||||
|
||||
for (const model of anthropicModels) {
|
||||
expect(model.headers?.["X-Custom-Header"]).toBe("custom-value");
|
||||
}
|
||||
});
|
||||
|
||||
test("baseUrl-only override does not affect other providers", () => {
|
||||
writeRawModelsJson({
|
||||
anthropic: overrideConfig("https://my-proxy.example.com/v1"),
|
||||
});
|
||||
|
||||
const registry = new ModelRegistry(authStorage, modelsJsonPath);
|
||||
const googleModels = getModelsForProvider(registry, "google");
|
||||
|
||||
// Google models should still have their original baseUrl
|
||||
expect(googleModels.length).toBeGreaterThan(0);
|
||||
expect(googleModels[0].baseUrl).not.toBe(
|
||||
"https://my-proxy.example.com/v1",
|
||||
);
|
||||
});
|
||||
|
||||
test("can mix baseUrl override and models merge", () => {
|
||||
writeRawModelsJson({
|
||||
// baseUrl-only for anthropic
|
||||
anthropic: overrideConfig("https://anthropic-proxy.example.com/v1"),
|
||||
// Add custom model for google (merged with built-ins)
|
||||
google: providerConfig(
|
||||
"https://google-proxy.example.com/v1",
|
||||
[{ id: "gemini-custom" }],
|
||||
"google-generative-ai",
|
||||
),
|
||||
});
|
||||
|
||||
const registry = new ModelRegistry(authStorage, modelsJsonPath);
|
||||
|
||||
// Anthropic: multiple built-in models with new baseUrl
|
||||
const anthropicModels = getModelsForProvider(registry, "anthropic");
|
||||
expect(anthropicModels.length).toBeGreaterThan(1);
|
||||
expect(anthropicModels[0].baseUrl).toBe(
|
||||
"https://anthropic-proxy.example.com/v1",
|
||||
);
|
||||
|
||||
// Google: built-ins plus custom model
|
||||
const googleModels = getModelsForProvider(registry, "google");
|
||||
expect(googleModels.length).toBeGreaterThan(1);
|
||||
expect(googleModels.some((m) => m.id === "gemini-custom")).toBe(true);
|
||||
});
|
||||
|
||||
test("refresh() picks up baseUrl override changes", () => {
|
||||
writeRawModelsJson({
|
||||
anthropic: overrideConfig("https://first-proxy.example.com/v1"),
|
||||
});
|
||||
const registry = new ModelRegistry(authStorage, modelsJsonPath);
|
||||
|
||||
expect(getModelsForProvider(registry, "anthropic")[0].baseUrl).toBe(
|
||||
"https://first-proxy.example.com/v1",
|
||||
);
|
||||
|
||||
// Update and refresh
|
||||
writeRawModelsJson({
|
||||
anthropic: overrideConfig("https://second-proxy.example.com/v1"),
|
||||
});
|
||||
registry.refresh();
|
||||
|
||||
expect(getModelsForProvider(registry, "anthropic")[0].baseUrl).toBe(
|
||||
"https://second-proxy.example.com/v1",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("custom models merge behavior", () => {
|
||||
test("custom provider with same name as built-in merges with built-in models", () => {
|
||||
writeModelsJson({
|
||||
anthropic: providerConfig("https://my-proxy.example.com/v1", [
|
||||
{ id: "claude-custom" },
|
||||
]),
|
||||
});
|
||||
|
||||
const registry = new ModelRegistry(authStorage, modelsJsonPath);
|
||||
const anthropicModels = getModelsForProvider(registry, "anthropic");
|
||||
|
||||
expect(anthropicModels.length).toBeGreaterThan(1);
|
||||
expect(anthropicModels.some((m) => m.id === "claude-custom")).toBe(true);
|
||||
expect(anthropicModels.some((m) => m.id.includes("claude"))).toBe(true);
|
||||
});
|
||||
|
||||
test("custom model with same id replaces built-in model by id", () => {
|
||||
writeModelsJson({
|
||||
openrouter: providerConfig(
|
||||
"https://my-proxy.example.com/v1",
|
||||
[{ id: "anthropic/claude-sonnet-4" }],
|
||||
"openai-completions",
|
||||
),
|
||||
});
|
||||
|
||||
const registry = new ModelRegistry(authStorage, modelsJsonPath);
|
||||
const models = getModelsForProvider(registry, "openrouter");
|
||||
const sonnetModels = models.filter(
|
||||
(m) => m.id === "anthropic/claude-sonnet-4",
|
||||
);
|
||||
|
||||
expect(sonnetModels).toHaveLength(1);
|
||||
expect(sonnetModels[0].baseUrl).toBe("https://my-proxy.example.com/v1");
|
||||
});
|
||||
|
||||
test("custom provider with same name as built-in does not affect other built-in providers", () => {
|
||||
writeModelsJson({
|
||||
anthropic: providerConfig("https://my-proxy.example.com/v1", [
|
||||
{ id: "claude-custom" },
|
||||
]),
|
||||
});
|
||||
|
||||
const registry = new ModelRegistry(authStorage, modelsJsonPath);
|
||||
|
||||
expect(getModelsForProvider(registry, "google").length).toBeGreaterThan(
|
||||
0,
|
||||
);
|
||||
expect(getModelsForProvider(registry, "openai").length).toBeGreaterThan(
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
test("provider-level baseUrl applies to both built-in and custom models", () => {
|
||||
writeModelsJson({
|
||||
anthropic: providerConfig("https://merged-proxy.example.com/v1", [
|
||||
{ id: "claude-custom" },
|
||||
]),
|
||||
});
|
||||
|
||||
const registry = new ModelRegistry(authStorage, modelsJsonPath);
|
||||
const anthropicModels = getModelsForProvider(registry, "anthropic");
|
||||
|
||||
for (const model of anthropicModels) {
|
||||
expect(model.baseUrl).toBe("https://merged-proxy.example.com/v1");
|
||||
}
|
||||
});
|
||||
|
||||
test("model-level baseUrl overrides provider-level baseUrl for custom models", () => {
|
||||
writeRawModelsJson({
|
||||
"opencode-go": {
|
||||
baseUrl: "https://opencode.ai/zen/go/v1",
|
||||
apiKey: "TEST_KEY",
|
||||
models: [
|
||||
{
|
||||
id: "minimax-m2.5",
|
||||
api: "anthropic-messages",
|
||||
baseUrl: "https://opencode.ai/zen/go",
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
cost: { input: 0.3, output: 1.2, cacheRead: 0.03, cacheWrite: 0 },
|
||||
contextWindow: 204800,
|
||||
maxTokens: 131072,
|
||||
},
|
||||
{
|
||||
id: "glm-5",
|
||||
api: "openai-completions",
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
cost: { input: 1, output: 3.2, cacheRead: 0.2, cacheWrite: 0 },
|
||||
contextWindow: 204800,
|
||||
maxTokens: 131072,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const registry = new ModelRegistry(authStorage, modelsJsonPath);
|
||||
const m25 = registry.find("opencode-go", "minimax-m2.5");
|
||||
const glm5 = registry.find("opencode-go", "glm-5");
|
||||
|
||||
expect(m25?.baseUrl).toBe("https://opencode.ai/zen/go");
|
||||
expect(glm5?.baseUrl).toBe("https://opencode.ai/zen/go/v1");
|
||||
});
|
||||
|
||||
test("modelOverrides still apply when provider also defines models", () => {
|
||||
writeRawModelsJson({
|
||||
openrouter: {
|
||||
baseUrl: "https://my-proxy.example.com/v1",
|
||||
apiKey: "OPENROUTER_API_KEY",
|
||||
api: "openai-completions",
|
||||
models: [
|
||||
{
|
||||
id: "custom/openrouter-model",
|
||||
name: "Custom OpenRouter Model",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 128000,
|
||||
maxTokens: 16384,
|
||||
},
|
||||
],
|
||||
modelOverrides: {
|
||||
"anthropic/claude-sonnet-4": {
|
||||
name: "Overridden Built-in Sonnet",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const registry = new ModelRegistry(authStorage, modelsJsonPath);
|
||||
const models = getModelsForProvider(registry, "openrouter");
|
||||
|
||||
expect(models.some((m) => m.id === "custom/openrouter-model")).toBe(true);
|
||||
expect(
|
||||
models.some(
|
||||
(m) =>
|
||||
m.id === "anthropic/claude-sonnet-4" &&
|
||||
m.name === "Overridden Built-in Sonnet",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("refresh() reloads merged custom models from disk", () => {
|
||||
writeModelsJson({
|
||||
anthropic: providerConfig("https://first-proxy.example.com/v1", [
|
||||
{ id: "claude-custom" },
|
||||
]),
|
||||
});
|
||||
const registry = new ModelRegistry(authStorage, modelsJsonPath);
|
||||
expect(
|
||||
getModelsForProvider(registry, "anthropic").some(
|
||||
(m) => m.id === "claude-custom",
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
// Update and refresh
|
||||
writeModelsJson({
|
||||
anthropic: providerConfig("https://second-proxy.example.com/v1", [
|
||||
{ id: "claude-custom-2" },
|
||||
]),
|
||||
});
|
||||
registry.refresh();
|
||||
|
||||
const anthropicModels = getModelsForProvider(registry, "anthropic");
|
||||
expect(anthropicModels.some((m) => m.id === "claude-custom")).toBe(false);
|
||||
expect(anthropicModels.some((m) => m.id === "claude-custom-2")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(anthropicModels.some((m) => m.id.includes("claude"))).toBe(true);
|
||||
});
|
||||
|
||||
test("removing custom models from models.json keeps built-in provider models", () => {
|
||||
writeModelsJson({
|
||||
anthropic: providerConfig("https://proxy.example.com/v1", [
|
||||
{ id: "claude-custom" },
|
||||
]),
|
||||
});
|
||||
const registry = new ModelRegistry(authStorage, modelsJsonPath);
|
||||
expect(
|
||||
getModelsForProvider(registry, "anthropic").some(
|
||||
(m) => m.id === "claude-custom",
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
// Remove custom models and refresh
|
||||
writeModelsJson({});
|
||||
registry.refresh();
|
||||
|
||||
const anthropicModels = getModelsForProvider(registry, "anthropic");
|
||||
expect(anthropicModels.some((m) => m.id === "claude-custom")).toBe(false);
|
||||
expect(anthropicModels.some((m) => m.id.includes("claude"))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("modelOverrides (per-model customization)", () => {
|
||||
test("model override applies to a single built-in model", () => {
|
||||
writeRawModelsJson({
|
||||
openrouter: {
|
||||
modelOverrides: {
|
||||
"anthropic/claude-sonnet-4": {
|
||||
name: "Custom Sonnet Name",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const registry = new ModelRegistry(authStorage, modelsJsonPath);
|
||||
const models = getModelsForProvider(registry, "openrouter");
|
||||
|
||||
const sonnet = models.find((m) => m.id === "anthropic/claude-sonnet-4");
|
||||
expect(sonnet?.name).toBe("Custom Sonnet Name");
|
||||
|
||||
// Other models should be unchanged
|
||||
const opus = models.find((m) => m.id === "anthropic/claude-opus-4");
|
||||
expect(opus?.name).not.toBe("Custom Sonnet Name");
|
||||
});
|
||||
|
||||
test("model override with compat.openRouterRouting", () => {
|
||||
writeRawModelsJson({
|
||||
openrouter: {
|
||||
modelOverrides: {
|
||||
"anthropic/claude-sonnet-4": {
|
||||
compat: {
|
||||
openRouterRouting: { only: ["amazon-bedrock"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const registry = new ModelRegistry(authStorage, modelsJsonPath);
|
||||
const models = getModelsForProvider(registry, "openrouter");
|
||||
|
||||
const sonnet = models.find((m) => m.id === "anthropic/claude-sonnet-4");
|
||||
const compat = sonnet?.compat as OpenAICompletionsCompat | undefined;
|
||||
expect(compat?.openRouterRouting).toEqual({ only: ["amazon-bedrock"] });
|
||||
});
|
||||
|
||||
test("model override deep merges compat settings", () => {
|
||||
writeRawModelsJson({
|
||||
openrouter: {
|
||||
modelOverrides: {
|
||||
"anthropic/claude-sonnet-4": {
|
||||
compat: {
|
||||
openRouterRouting: { order: ["anthropic", "together"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const registry = new ModelRegistry(authStorage, modelsJsonPath);
|
||||
const models = getModelsForProvider(registry, "openrouter");
|
||||
const sonnet = models.find((m) => m.id === "anthropic/claude-sonnet-4");
|
||||
|
||||
// Should have both the new routing AND preserve other compat settings
|
||||
const compat = sonnet?.compat as OpenAICompletionsCompat | undefined;
|
||||
expect(compat?.openRouterRouting).toEqual({
|
||||
order: ["anthropic", "together"],
|
||||
});
|
||||
});
|
||||
|
||||
test("multiple model overrides on same provider", () => {
|
||||
writeRawModelsJson({
|
||||
openrouter: {
|
||||
modelOverrides: {
|
||||
"anthropic/claude-sonnet-4": {
|
||||
compat: { openRouterRouting: { only: ["amazon-bedrock"] } },
|
||||
},
|
||||
"anthropic/claude-opus-4": {
|
||||
compat: { openRouterRouting: { only: ["anthropic"] } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const registry = new ModelRegistry(authStorage, modelsJsonPath);
|
||||
const models = getModelsForProvider(registry, "openrouter");
|
||||
|
||||
const sonnet = models.find((m) => m.id === "anthropic/claude-sonnet-4");
|
||||
const opus = models.find((m) => m.id === "anthropic/claude-opus-4");
|
||||
|
||||
const sonnetCompat = sonnet?.compat as
|
||||
| OpenAICompletionsCompat
|
||||
| undefined;
|
||||
const opusCompat = opus?.compat as OpenAICompletionsCompat | undefined;
|
||||
expect(sonnetCompat?.openRouterRouting).toEqual({
|
||||
only: ["amazon-bedrock"],
|
||||
});
|
||||
expect(opusCompat?.openRouterRouting).toEqual({ only: ["anthropic"] });
|
||||
});
|
||||
|
||||
test("model override combined with baseUrl override", () => {
|
||||
writeRawModelsJson({
|
||||
openrouter: {
|
||||
baseUrl: "https://my-proxy.example.com/v1",
|
||||
modelOverrides: {
|
||||
"anthropic/claude-sonnet-4": {
|
||||
name: "Proxied Sonnet",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const registry = new ModelRegistry(authStorage, modelsJsonPath);
|
||||
const models = getModelsForProvider(registry, "openrouter");
|
||||
const sonnet = models.find((m) => m.id === "anthropic/claude-sonnet-4");
|
||||
|
||||
// Both overrides should apply
|
||||
expect(sonnet?.baseUrl).toBe("https://my-proxy.example.com/v1");
|
||||
expect(sonnet?.name).toBe("Proxied Sonnet");
|
||||
|
||||
// Other models should have the baseUrl but not the name override
|
||||
const opus = models.find((m) => m.id === "anthropic/claude-opus-4");
|
||||
expect(opus?.baseUrl).toBe("https://my-proxy.example.com/v1");
|
||||
expect(opus?.name).not.toBe("Proxied Sonnet");
|
||||
});
|
||||
|
||||
test("model override for non-existent model ID is ignored", () => {
|
||||
writeRawModelsJson({
|
||||
openrouter: {
|
||||
modelOverrides: {
|
||||
"nonexistent/model-id": {
|
||||
name: "This should not appear",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const registry = new ModelRegistry(authStorage, modelsJsonPath);
|
||||
const models = getModelsForProvider(registry, "openrouter");
|
||||
|
||||
// Should not create a new model
|
||||
expect(
|
||||
models.find((m) => m.id === "nonexistent/model-id"),
|
||||
).toBeUndefined();
|
||||
// Should not crash or show error
|
||||
expect(registry.getError()).toBeUndefined();
|
||||
});
|
||||
|
||||
test("model override can change cost fields partially", () => {
|
||||
writeRawModelsJson({
|
||||
openrouter: {
|
||||
modelOverrides: {
|
||||
"anthropic/claude-sonnet-4": {
|
||||
cost: { input: 99 },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const registry = new ModelRegistry(authStorage, modelsJsonPath);
|
||||
const models = getModelsForProvider(registry, "openrouter");
|
||||
const sonnet = models.find((m) => m.id === "anthropic/claude-sonnet-4");
|
||||
|
||||
// Input cost should be overridden
|
||||
expect(sonnet?.cost.input).toBe(99);
|
||||
// Other cost fields should be preserved from built-in
|
||||
expect(sonnet?.cost.output).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("model override can add headers", () => {
|
||||
writeRawModelsJson({
|
||||
openrouter: {
|
||||
modelOverrides: {
|
||||
"anthropic/claude-sonnet-4": {
|
||||
headers: { "X-Custom-Model-Header": "value" },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const registry = new ModelRegistry(authStorage, modelsJsonPath);
|
||||
const models = getModelsForProvider(registry, "openrouter");
|
||||
const sonnet = models.find((m) => m.id === "anthropic/claude-sonnet-4");
|
||||
|
||||
expect(sonnet?.headers?.["X-Custom-Model-Header"]).toBe("value");
|
||||
});
|
||||
|
||||
test("refresh() picks up model override changes", () => {
|
||||
writeRawModelsJson({
|
||||
openrouter: {
|
||||
modelOverrides: {
|
||||
"anthropic/claude-sonnet-4": {
|
||||
name: "First Name",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const registry = new ModelRegistry(authStorage, modelsJsonPath);
|
||||
expect(
|
||||
getModelsForProvider(registry, "openrouter").find(
|
||||
(m) => m.id === "anthropic/claude-sonnet-4",
|
||||
)?.name,
|
||||
).toBe("First Name");
|
||||
|
||||
// Update and refresh
|
||||
writeRawModelsJson({
|
||||
openrouter: {
|
||||
modelOverrides: {
|
||||
"anthropic/claude-sonnet-4": {
|
||||
name: "Second Name",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
registry.refresh();
|
||||
|
||||
expect(
|
||||
getModelsForProvider(registry, "openrouter").find(
|
||||
(m) => m.id === "anthropic/claude-sonnet-4",
|
||||
)?.name,
|
||||
).toBe("Second Name");
|
||||
});
|
||||
|
||||
test("removing model override restores built-in values", () => {
|
||||
writeRawModelsJson({
|
||||
openrouter: {
|
||||
modelOverrides: {
|
||||
"anthropic/claude-sonnet-4": {
|
||||
name: "Custom Name",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const registry = new ModelRegistry(authStorage, modelsJsonPath);
|
||||
const customName = getModelsForProvider(registry, "openrouter").find(
|
||||
(m) => m.id === "anthropic/claude-sonnet-4",
|
||||
)?.name;
|
||||
expect(customName).toBe("Custom Name");
|
||||
|
||||
// Remove override and refresh
|
||||
writeRawModelsJson({});
|
||||
registry.refresh();
|
||||
|
||||
const restoredName = getModelsForProvider(registry, "openrouter").find(
|
||||
(m) => m.id === "anthropic/claude-sonnet-4",
|
||||
)?.name;
|
||||
expect(restoredName).not.toBe("Custom Name");
|
||||
});
|
||||
});
|
||||
|
||||
describe("dynamic provider lifecycle", () => {
|
||||
test("unregisterProvider removes custom OAuth provider and restores built-in OAuth provider", () => {
|
||||
const registry = new ModelRegistry(authStorage, modelsJsonPath);
|
||||
|
||||
registry.registerProvider("anthropic", {
|
||||
oauth: {
|
||||
name: "Custom Anthropic OAuth",
|
||||
login: async () => ({
|
||||
access: "custom-access-token",
|
||||
refresh: "custom-refresh-token",
|
||||
expires: Date.now() + 60_000,
|
||||
}),
|
||||
refreshToken: async (credentials) => credentials,
|
||||
getApiKey: (credentials) => credentials.access,
|
||||
},
|
||||
});
|
||||
|
||||
expect(getOAuthProvider("anthropic")?.name).toBe(
|
||||
"Custom Anthropic OAuth",
|
||||
);
|
||||
|
||||
registry.unregisterProvider("anthropic");
|
||||
|
||||
expect(getOAuthProvider("anthropic")?.name).not.toBe(
|
||||
"Custom Anthropic OAuth",
|
||||
);
|
||||
});
|
||||
|
||||
test("unregisterProvider removes custom streamSimple override and restores built-in API stream handler", () => {
|
||||
const registry = new ModelRegistry(authStorage, modelsJsonPath);
|
||||
|
||||
registry.registerProvider("stream-override-provider", {
|
||||
api: "openai-completions",
|
||||
streamSimple: () => {
|
||||
throw new Error("custom streamSimple override");
|
||||
},
|
||||
});
|
||||
|
||||
let threwCustomOverride = false;
|
||||
try {
|
||||
getApiProvider("openai-completions")?.streamSimple(
|
||||
openAiModel,
|
||||
emptyContext,
|
||||
);
|
||||
} catch (error) {
|
||||
threwCustomOverride =
|
||||
error instanceof Error &&
|
||||
error.message === "custom streamSimple override";
|
||||
}
|
||||
expect(threwCustomOverride).toBe(true);
|
||||
|
||||
registry.unregisterProvider("stream-override-provider");
|
||||
|
||||
let threwCustomOverrideAfterUnregister = false;
|
||||
try {
|
||||
getApiProvider("openai-completions")?.streamSimple(
|
||||
openAiModel,
|
||||
emptyContext,
|
||||
);
|
||||
} catch (error) {
|
||||
threwCustomOverrideAfterUnregister =
|
||||
error instanceof Error &&
|
||||
error.message === "custom streamSimple override";
|
||||
}
|
||||
expect(threwCustomOverrideAfterUnregister).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("API key resolution", () => {
|
||||
/** Create provider config with custom apiKey */
|
||||
function providerWithApiKey(apiKey: string) {
|
||||
return {
|
||||
baseUrl: "https://example.com/v1",
|
||||
apiKey,
|
||||
api: "anthropic-messages",
|
||||
models: [
|
||||
{
|
||||
id: "test-model",
|
||||
name: "Test Model",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 100000,
|
||||
maxTokens: 8000,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
test("apiKey with ! prefix executes command and uses stdout", async () => {
|
||||
writeRawModelsJson({
|
||||
"custom-provider": providerWithApiKey(
|
||||
"!echo test-api-key-from-command",
|
||||
),
|
||||
});
|
||||
|
||||
const registry = new ModelRegistry(authStorage, modelsJsonPath);
|
||||
const apiKey = await registry.getApiKeyForProvider("custom-provider");
|
||||
|
||||
expect(apiKey).toBe("test-api-key-from-command");
|
||||
});
|
||||
|
||||
test("apiKey with ! prefix trims whitespace from command output", async () => {
|
||||
writeRawModelsJson({
|
||||
"custom-provider": providerWithApiKey("!echo ' spaced-key '"),
|
||||
});
|
||||
|
||||
const registry = new ModelRegistry(authStorage, modelsJsonPath);
|
||||
const apiKey = await registry.getApiKeyForProvider("custom-provider");
|
||||
|
||||
expect(apiKey).toBe("spaced-key");
|
||||
});
|
||||
|
||||
test("apiKey with ! prefix handles multiline output (uses trimmed result)", async () => {
|
||||
writeRawModelsJson({
|
||||
"custom-provider": providerWithApiKey("!printf 'line1\\nline2'"),
|
||||
});
|
||||
|
||||
const registry = new ModelRegistry(authStorage, modelsJsonPath);
|
||||
const apiKey = await registry.getApiKeyForProvider("custom-provider");
|
||||
|
||||
expect(apiKey).toBe("line1\nline2");
|
||||
});
|
||||
|
||||
test("apiKey with ! prefix returns undefined on command failure", async () => {
|
||||
writeRawModelsJson({
|
||||
"custom-provider": providerWithApiKey("!exit 1"),
|
||||
});
|
||||
|
||||
const registry = new ModelRegistry(authStorage, modelsJsonPath);
|
||||
const apiKey = await registry.getApiKeyForProvider("custom-provider");
|
||||
|
||||
expect(apiKey).toBeUndefined();
|
||||
});
|
||||
|
||||
test("apiKey with ! prefix returns undefined on nonexistent command", async () => {
|
||||
writeRawModelsJson({
|
||||
"custom-provider": providerWithApiKey("!nonexistent-command-12345"),
|
||||
});
|
||||
|
||||
const registry = new ModelRegistry(authStorage, modelsJsonPath);
|
||||
const apiKey = await registry.getApiKeyForProvider("custom-provider");
|
||||
|
||||
expect(apiKey).toBeUndefined();
|
||||
});
|
||||
|
||||
test("apiKey with ! prefix returns undefined on empty output", async () => {
|
||||
writeRawModelsJson({
|
||||
"custom-provider": providerWithApiKey("!printf ''"),
|
||||
});
|
||||
|
||||
const registry = new ModelRegistry(authStorage, modelsJsonPath);
|
||||
const apiKey = await registry.getApiKeyForProvider("custom-provider");
|
||||
|
||||
expect(apiKey).toBeUndefined();
|
||||
});
|
||||
|
||||
test("apiKey as environment variable name resolves to env value", async () => {
|
||||
const originalEnv = process.env.TEST_API_KEY_12345;
|
||||
process.env.TEST_API_KEY_12345 = "env-api-key-value";
|
||||
|
||||
try {
|
||||
writeRawModelsJson({
|
||||
"custom-provider": providerWithApiKey("TEST_API_KEY_12345"),
|
||||
});
|
||||
|
||||
const registry = new ModelRegistry(authStorage, modelsJsonPath);
|
||||
const apiKey = await registry.getApiKeyForProvider("custom-provider");
|
||||
|
||||
expect(apiKey).toBe("env-api-key-value");
|
||||
} finally {
|
||||
if (originalEnv === undefined) {
|
||||
delete process.env.TEST_API_KEY_12345;
|
||||
} else {
|
||||
process.env.TEST_API_KEY_12345 = originalEnv;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("apiKey as literal value is used directly when not an env var", async () => {
|
||||
// Make sure this isn't an env var
|
||||
delete process.env.literal_api_key_value;
|
||||
|
||||
writeRawModelsJson({
|
||||
"custom-provider": providerWithApiKey("literal_api_key_value"),
|
||||
});
|
||||
|
||||
const registry = new ModelRegistry(authStorage, modelsJsonPath);
|
||||
const apiKey = await registry.getApiKeyForProvider("custom-provider");
|
||||
|
||||
expect(apiKey).toBe("literal_api_key_value");
|
||||
});
|
||||
|
||||
test("apiKey command can use shell features like pipes", async () => {
|
||||
writeRawModelsJson({
|
||||
"custom-provider": providerWithApiKey(
|
||||
"!echo 'hello world' | tr ' ' '-'",
|
||||
),
|
||||
});
|
||||
|
||||
const registry = new ModelRegistry(authStorage, modelsJsonPath);
|
||||
const apiKey = await registry.getApiKeyForProvider("custom-provider");
|
||||
|
||||
expect(apiKey).toBe("hello-world");
|
||||
});
|
||||
|
||||
describe("caching", () => {
|
||||
test("command is only executed once per process", async () => {
|
||||
// Use a command that writes to a file to count invocations
|
||||
const counterFile = join(tempDir, "counter");
|
||||
writeFileSync(counterFile, "0");
|
||||
|
||||
const command = `!sh -c 'count=$(cat ${counterFile}); echo $((count + 1)) > ${counterFile}; echo "key-value"'`;
|
||||
writeRawModelsJson({
|
||||
"custom-provider": providerWithApiKey(command),
|
||||
});
|
||||
|
||||
const registry = new ModelRegistry(authStorage, modelsJsonPath);
|
||||
|
||||
// Call multiple times
|
||||
await registry.getApiKeyForProvider("custom-provider");
|
||||
await registry.getApiKeyForProvider("custom-provider");
|
||||
await registry.getApiKeyForProvider("custom-provider");
|
||||
|
||||
// Command should have only run once
|
||||
const count = parseInt(readFileSync(counterFile, "utf-8").trim(), 10);
|
||||
expect(count).toBe(1);
|
||||
});
|
||||
|
||||
test("cache persists across registry instances", async () => {
|
||||
const counterFile = join(tempDir, "counter");
|
||||
writeFileSync(counterFile, "0");
|
||||
|
||||
const command = `!sh -c 'count=$(cat ${counterFile}); echo $((count + 1)) > ${counterFile}; echo "key-value"'`;
|
||||
writeRawModelsJson({
|
||||
"custom-provider": providerWithApiKey(command),
|
||||
});
|
||||
|
||||
// Create multiple registry instances
|
||||
const registry1 = new ModelRegistry(authStorage, modelsJsonPath);
|
||||
await registry1.getApiKeyForProvider("custom-provider");
|
||||
|
||||
const registry2 = new ModelRegistry(authStorage, modelsJsonPath);
|
||||
await registry2.getApiKeyForProvider("custom-provider");
|
||||
|
||||
// Command should still have only run once
|
||||
const count = parseInt(readFileSync(counterFile, "utf-8").trim(), 10);
|
||||
expect(count).toBe(1);
|
||||
});
|
||||
|
||||
test("clearApiKeyCache allows command to run again", async () => {
|
||||
const counterFile = join(tempDir, "counter");
|
||||
writeFileSync(counterFile, "0");
|
||||
|
||||
const command = `!sh -c 'count=$(cat ${counterFile}); echo $((count + 1)) > ${counterFile}; echo "key-value"'`;
|
||||
writeRawModelsJson({
|
||||
"custom-provider": providerWithApiKey(command),
|
||||
});
|
||||
|
||||
const registry = new ModelRegistry(authStorage, modelsJsonPath);
|
||||
await registry.getApiKeyForProvider("custom-provider");
|
||||
|
||||
// Clear cache and call again
|
||||
clearApiKeyCache();
|
||||
await registry.getApiKeyForProvider("custom-provider");
|
||||
|
||||
// Command should have run twice
|
||||
const count = parseInt(readFileSync(counterFile, "utf-8").trim(), 10);
|
||||
expect(count).toBe(2);
|
||||
});
|
||||
|
||||
test("different commands are cached separately", async () => {
|
||||
writeRawModelsJson({
|
||||
"provider-a": providerWithApiKey("!echo key-a"),
|
||||
"provider-b": providerWithApiKey("!echo key-b"),
|
||||
});
|
||||
|
||||
const registry = new ModelRegistry(authStorage, modelsJsonPath);
|
||||
|
||||
const keyA = await registry.getApiKeyForProvider("provider-a");
|
||||
const keyB = await registry.getApiKeyForProvider("provider-b");
|
||||
|
||||
expect(keyA).toBe("key-a");
|
||||
expect(keyB).toBe("key-b");
|
||||
});
|
||||
|
||||
test("failed commands are cached (not retried)", async () => {
|
||||
const counterFile = join(tempDir, "counter");
|
||||
writeFileSync(counterFile, "0");
|
||||
|
||||
const command = `!sh -c 'count=$(cat ${counterFile}); echo $((count + 1)) > ${counterFile}; exit 1'`;
|
||||
writeRawModelsJson({
|
||||
"custom-provider": providerWithApiKey(command),
|
||||
});
|
||||
|
||||
const registry = new ModelRegistry(authStorage, modelsJsonPath);
|
||||
|
||||
// Call multiple times - all should return undefined
|
||||
const key1 = await registry.getApiKeyForProvider("custom-provider");
|
||||
const key2 = await registry.getApiKeyForProvider("custom-provider");
|
||||
|
||||
expect(key1).toBeUndefined();
|
||||
expect(key2).toBeUndefined();
|
||||
|
||||
// Command should have only run once despite failures
|
||||
const count = parseInt(readFileSync(counterFile, "utf-8").trim(), 10);
|
||||
expect(count).toBe(1);
|
||||
});
|
||||
|
||||
test("environment variables are not cached (changes are picked up)", async () => {
|
||||
const envVarName = "TEST_API_KEY_CACHE_TEST_98765";
|
||||
const originalEnv = process.env[envVarName];
|
||||
|
||||
try {
|
||||
process.env[envVarName] = "first-value";
|
||||
|
||||
writeRawModelsJson({
|
||||
"custom-provider": providerWithApiKey(envVarName),
|
||||
});
|
||||
|
||||
const registry = new ModelRegistry(authStorage, modelsJsonPath);
|
||||
|
||||
const key1 = await registry.getApiKeyForProvider("custom-provider");
|
||||
expect(key1).toBe("first-value");
|
||||
|
||||
// Change env var
|
||||
process.env[envVarName] = "second-value";
|
||||
|
||||
const key2 = await registry.getApiKeyForProvider("custom-provider");
|
||||
expect(key2).toBe("second-value");
|
||||
} finally {
|
||||
if (originalEnv === undefined) {
|
||||
delete process.env[envVarName];
|
||||
} else {
|
||||
process.env[envVarName] = originalEnv;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue