272 lines
11 KiB
TypeScript
272 lines
11 KiB
TypeScript
/**
|
|
* T-03 provider-hook contract tests.
|
|
*
|
|
* Covers `createOmniRouteProviderHook(opts, deps)`:
|
|
* - hook.id binds to resolved providerId (single + multi-instance)
|
|
* - models() narrows ctx.auth, fetches via injected fetcher, caches per
|
|
* (baseURL, apiKey) tuple, refetches after TTL
|
|
* - mapRawModelToModelV2 emits a v2 Model shape matching the
|
|
* @opencode-ai/sdk/v2 type
|
|
*
|
|
* Mocking strategy: the fetcher is dependency-injected at hook construction
|
|
* (`deps.fetcher`). No global fetch monkey-patch needed. `deps.now` lets us
|
|
* fast-forward time deterministically for TTL assertions.
|
|
*/
|
|
|
|
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import {
|
|
createOmniRouteProviderHook,
|
|
mapRawModelToModelV2,
|
|
type OmniRouteRawModelEntry,
|
|
type OmniRouteModelsFetcher,
|
|
} from "../src/index.js";
|
|
|
|
const FIXTURE: OmniRouteRawModelEntry[] = [
|
|
{
|
|
id: "claude-primary",
|
|
object: "model",
|
|
owned_by: "combo",
|
|
capabilities: { tool_calling: true, reasoning: true, vision: true, thinking: true },
|
|
context_length: 200000,
|
|
max_output_tokens: 64000,
|
|
input_modalities: ["text", "image"],
|
|
output_modalities: ["text"],
|
|
},
|
|
{
|
|
id: "claude-low",
|
|
object: "model",
|
|
owned_by: "combo",
|
|
capabilities: { tool_calling: true, reasoning: true, vision: true, thinking: false },
|
|
context_length: 200000,
|
|
max_output_tokens: 64000,
|
|
input_modalities: ["text", "image"],
|
|
output_modalities: ["text"],
|
|
},
|
|
{
|
|
id: "gemini-3-flash",
|
|
object: "model",
|
|
owned_by: "google",
|
|
capabilities: { tool_calling: true, reasoning: false, vision: true, thinking: false },
|
|
context_length: 1000000,
|
|
max_output_tokens: 8192,
|
|
input_modalities: ["text", "image"],
|
|
output_modalities: ["text"],
|
|
},
|
|
];
|
|
|
|
function stubFetcher(payload: OmniRouteRawModelEntry[]): OmniRouteModelsFetcher & {
|
|
callCount: () => number;
|
|
callsBy: () => Array<[string, string]>;
|
|
} {
|
|
let calls: Array<[string, string]> = [];
|
|
const f: OmniRouteModelsFetcher = async (baseURL, apiKey) => {
|
|
calls.push([baseURL, apiKey]);
|
|
return payload;
|
|
};
|
|
return Object.assign(f, {
|
|
callCount: () => calls.length,
|
|
callsBy: () => calls,
|
|
});
|
|
}
|
|
|
|
const apiAuth = (key: string, baseURL?: string): unknown =>
|
|
baseURL ? { type: "api", key, baseURL } : { type: "api", key };
|
|
|
|
test("createOmniRouteProviderHook: default providerId is 'omniroute'", () => {
|
|
const hook = createOmniRouteProviderHook(undefined, { combosFetcher: async () => [] });
|
|
assert.equal(hook.id, "opencode-omniroute");
|
|
});
|
|
|
|
test("createOmniRouteProviderHook: custom providerId binds to hook.id (multi-instance)", () => {
|
|
const a = createOmniRouteProviderHook(
|
|
{ providerId: "omniroute-preprod" },
|
|
{ combosFetcher: async () => [] }
|
|
);
|
|
const b = createOmniRouteProviderHook(
|
|
{ providerId: "omniroute-local" },
|
|
{ combosFetcher: async () => [] }
|
|
);
|
|
assert.equal(a.id, "opencode-omniroute-preprod");
|
|
assert.equal(b.id, "opencode-omniroute-local");
|
|
});
|
|
|
|
test("models: extracts apiKey from ctx.auth (type=api) and calls fetcher with it", async () => {
|
|
const fetcher = stubFetcher(FIXTURE);
|
|
const hook = createOmniRouteProviderHook(
|
|
{ baseURL: "https://or.example.com/v1" },
|
|
{ fetcher, combosFetcher: async () => [] }
|
|
);
|
|
const out = await hook.models!({} as never, { auth: apiAuth("sk-abc") as never });
|
|
assert.equal(fetcher.callCount(), 1);
|
|
assert.deepEqual(fetcher.callsBy()[0], ["https://or.example.com/v1", "sk-abc"]);
|
|
assert.equal(Object.keys(out).length, 3);
|
|
assert.ok(out["opencode-omniroute/claude-primary"]);
|
|
});
|
|
|
|
test("models: returns {} when ctx.auth is null/undefined/wrong-type/empty-key", async () => {
|
|
const fetcher = stubFetcher(FIXTURE);
|
|
const hook = createOmniRouteProviderHook(
|
|
{ baseURL: "https://or.example.com/v1" },
|
|
{ fetcher, combosFetcher: async () => [] }
|
|
);
|
|
|
|
assert.deepEqual(await hook.models!({} as never, {} as never), {});
|
|
assert.deepEqual(await hook.models!({} as never, { auth: undefined } as never), {});
|
|
assert.deepEqual(
|
|
await hook.models!({} as never, {
|
|
auth: { type: "oauth", refresh: "r", access: "a", expires: 0 } as never,
|
|
}),
|
|
{}
|
|
);
|
|
assert.deepEqual(
|
|
await hook.models!({} as never, { auth: { type: "api", key: "" } as never }),
|
|
{}
|
|
);
|
|
assert.equal(fetcher.callCount(), 0, "fetcher must not be called on auth rejection");
|
|
});
|
|
|
|
test("models: returns {} when no baseURL resolvable (no opts.baseURL and no auth.baseURL)", async () => {
|
|
const fetcher = stubFetcher(FIXTURE);
|
|
const hook = createOmniRouteProviderHook({}, { fetcher, combosFetcher: async () => [] });
|
|
// valid api auth but neither opts nor auth carries a baseURL
|
|
assert.deepEqual(await hook.models!({} as never, { auth: apiAuth("sk-x") as never }), {});
|
|
assert.equal(fetcher.callCount(), 0);
|
|
});
|
|
|
|
test("models: baseURL falls back to auth.baseURL when opts.baseURL absent", async () => {
|
|
const fetcher = stubFetcher(FIXTURE);
|
|
const hook = createOmniRouteProviderHook({}, { fetcher, combosFetcher: async () => [] });
|
|
const out = await hook.models!({} as never, {
|
|
auth: apiAuth("sk-y", "https://or.creds-attached.example/v1") as never,
|
|
});
|
|
assert.equal(fetcher.callCount(), 1);
|
|
assert.equal(fetcher.callsBy()[0][0], "https://or.creds-attached.example/v1");
|
|
assert.equal(Object.keys(out).length, 3);
|
|
});
|
|
|
|
test("models: maps a sample /v1/models entry to ModelV2 (sanity)", async () => {
|
|
const fetcher = stubFetcher(FIXTURE);
|
|
const hook = createOmniRouteProviderHook(
|
|
{ providerId: "omniroute", baseURL: "https://or.example.com/v1" },
|
|
{ fetcher, combosFetcher: async () => [] }
|
|
);
|
|
const out = await hook.models!({} as never, { auth: apiAuth("sk-abc") as never });
|
|
const claude = out["opencode-omniroute/claude-primary"];
|
|
assert.ok(claude, "claude-primary present");
|
|
// `mapRawModelToModelV2` stamps the provider prefix on the id so OC's
|
|
// static-catalog reader resolves `(providerID, modelID)` from the key.
|
|
assert.equal(claude.id, "opencode-omniroute/claude-primary");
|
|
assert.equal(claude.name, "claude-primary");
|
|
assert.equal(claude.providerID, "opencode-omniroute");
|
|
assert.equal(claude.api.id, "openai-compatible");
|
|
assert.equal(claude.api.url, "https://or.example.com/v1");
|
|
assert.equal(claude.api.npm, "@ai-sdk/openai-compatible");
|
|
// capabilities: toolcall (one word), reasoning OR thinking, attachment = vision
|
|
assert.equal(claude.capabilities.toolcall, true);
|
|
assert.equal(claude.capabilities.reasoning, true);
|
|
assert.equal(claude.capabilities.attachment, true);
|
|
assert.equal(claude.capabilities.temperature, true);
|
|
// modalities mapped from arrays
|
|
assert.equal(claude.capabilities.input.text, true);
|
|
assert.equal(claude.capabilities.input.image, true);
|
|
assert.equal(claude.capabilities.input.audio, false);
|
|
assert.equal(claude.capabilities.output.text, true);
|
|
assert.equal(claude.capabilities.output.image, false);
|
|
// cost is zeroed (OmniRoute /v1/models has no pricing)
|
|
assert.deepEqual(claude.cost, { input: 0, output: 0, cache: { read: 0, write: 0 } });
|
|
// limits
|
|
assert.equal(claude.limit.context, 200000);
|
|
assert.equal(claude.limit.output, 64000);
|
|
assert.equal(claude.status, "active");
|
|
});
|
|
|
|
test("mapRawModelToModelV2: thinking-only model still surfaces reasoning=true", () => {
|
|
const m = mapRawModelToModelV2(
|
|
{
|
|
id: "thinking-only",
|
|
capabilities: { thinking: true, reasoning: false },
|
|
context_length: 100000,
|
|
max_output_tokens: 8192,
|
|
},
|
|
{ providerId: "omniroute", baseURL: "https://or.example.com/v1" }
|
|
);
|
|
assert.equal(m.capabilities.reasoning, true);
|
|
});
|
|
|
|
test("mapRawModelToModelV2: missing capabilities defaults to all-false (except temperature)", () => {
|
|
const m = mapRawModelToModelV2(
|
|
{ id: "minimal" },
|
|
{ providerId: "omniroute", baseURL: "https://or.example.com/v1" }
|
|
);
|
|
assert.equal(m.capabilities.temperature, true);
|
|
assert.equal(m.capabilities.reasoning, false);
|
|
assert.equal(m.capabilities.attachment, false);
|
|
assert.equal(m.capabilities.toolcall, false);
|
|
// default modalities = text only
|
|
assert.equal(m.capabilities.input.text, true);
|
|
assert.equal(m.capabilities.output.text, true);
|
|
// missing context / output tokens → 0 fallback (ModelV2.limit.{context,output} required)
|
|
assert.equal(m.limit.context, 0);
|
|
assert.equal(m.limit.output, 0);
|
|
});
|
|
|
|
test("models: caches result for second call within TTL (fetcher called once)", async () => {
|
|
const fetcher = stubFetcher(FIXTURE);
|
|
let nowMs = 1_000_000;
|
|
const hook = createOmniRouteProviderHook(
|
|
{ baseURL: "https://or.example.com/v1", modelCacheTtl: 60_000 },
|
|
{ fetcher, now: () => nowMs, combosFetcher: async () => [] }
|
|
);
|
|
|
|
const a = await hook.models!({} as never, { auth: apiAuth("sk-z") as never });
|
|
nowMs += 30_000; // half the TTL
|
|
const b = await hook.models!({} as never, { auth: apiAuth("sk-z") as never });
|
|
assert.equal(fetcher.callCount(), 1, "second call within TTL must hit the cache");
|
|
assert.equal(Object.keys(a).length, 3);
|
|
assert.equal(Object.keys(b).length, 3);
|
|
});
|
|
|
|
test("models: refetches after TTL expires", async () => {
|
|
const fetcher = stubFetcher(FIXTURE);
|
|
let nowMs = 1_000_000;
|
|
const hook = createOmniRouteProviderHook(
|
|
{ baseURL: "https://or.example.com/v1", modelCacheTtl: 60_000 },
|
|
{ fetcher, now: () => nowMs, combosFetcher: async () => [] }
|
|
);
|
|
|
|
await hook.models!({} as never, { auth: apiAuth("sk-z") as never });
|
|
nowMs += 60_001; // just past the TTL
|
|
await hook.models!({} as never, { auth: apiAuth("sk-z") as never });
|
|
assert.equal(fetcher.callCount(), 2, "call past TTL must refetch");
|
|
});
|
|
|
|
test("models: caches per (baseURL, apiKey) tuple (different keys → independent fetches)", async () => {
|
|
const fetcher = stubFetcher(FIXTURE);
|
|
const hook = createOmniRouteProviderHook(
|
|
{ baseURL: "https://or.example.com/v1", modelCacheTtl: 300_000 },
|
|
{ fetcher, combosFetcher: async () => [] }
|
|
);
|
|
|
|
await hook.models!({} as never, { auth: apiAuth("sk-A") as never });
|
|
await hook.models!({} as never, { auth: apiAuth("sk-B") as never });
|
|
await hook.models!({} as never, { auth: apiAuth("sk-A") as never }); // cached
|
|
await hook.models!({} as never, { auth: apiAuth("sk-B") as never }); // cached
|
|
assert.equal(fetcher.callCount(), 2, "one fetch per distinct apiKey, then cache hits");
|
|
});
|
|
|
|
test("models: caches per (baseURL, apiKey) tuple (different baseURL → independent fetches)", async () => {
|
|
const fetcher = stubFetcher(FIXTURE);
|
|
const hook = createOmniRouteProviderHook(
|
|
{ modelCacheTtl: 300_000 }, // no opts.baseURL → falls back to auth.baseURL
|
|
{ fetcher, combosFetcher: async () => [] }
|
|
);
|
|
|
|
await hook.models!({} as never, { auth: apiAuth("sk-same", "https://prod.example/v1") as never });
|
|
await hook.models!({} as never, {
|
|
auth: apiAuth("sk-same", "https://preprod.example/v1") as never,
|
|
});
|
|
await hook.models!({} as never, { auth: apiAuth("sk-same", "https://prod.example/v1") as never }); // cached
|
|
assert.equal(fetcher.callCount(), 2, "distinct baseURLs share apiKey but not cache");
|
|
});
|