239 lines
8.2 KiB
TypeScript
239 lines
8.2 KiB
TypeScript
|
|
import assert from "node:assert/strict";
|
||
|
|
import test from "node:test";
|
||
|
|
|
||
|
|
import { createModelFromConfig, resolveProviderEndpoint } from "./sdk/providers";
|
||
|
|
import type { ProviderConfig } from "./types";
|
||
|
|
|
||
|
|
function makeConfig(overrides: Partial<ProviderConfig> = {}): ProviderConfig {
|
||
|
|
return {
|
||
|
|
id: "p",
|
||
|
|
providerId: "custom",
|
||
|
|
name: "Test",
|
||
|
|
enabled: true,
|
||
|
|
defaultModel: "m",
|
||
|
|
...overrides,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
test("createModelFromConfig routes by explicit style: anthropic on top of custom providerId", () => {
|
||
|
|
const model = createModelFromConfig(makeConfig({ style: "anthropic", defaultModel: "claude-3-5-sonnet" }));
|
||
|
|
assert.match(String((model as { provider?: string }).provider ?? ""), /^anthropic/);
|
||
|
|
assert.equal((model as { modelId?: string }).modelId, "claude-3-5-sonnet");
|
||
|
|
});
|
||
|
|
|
||
|
|
test("createModelFromConfig routes by explicit style: google on top of custom providerId", () => {
|
||
|
|
const model = createModelFromConfig(makeConfig({ style: "google", defaultModel: "gemini-2.0-flash" }));
|
||
|
|
assert.match(String((model as { provider?: string }).provider ?? ""), /^google/);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("createModelFromConfig defaults legacy custom providerId to the OpenAI-compatible client", () => {
|
||
|
|
const model = createModelFromConfig(makeConfig({ providerId: "custom", defaultModel: "gpt-4o" }));
|
||
|
|
assert.match(String((model as { provider?: string }).provider ?? ""), /^openai\.chat/);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("createModelFromConfig keeps Chat Completions as the OpenAI default", () => {
|
||
|
|
const model = createModelFromConfig(makeConfig({ providerId: "openai", defaultModel: "gpt-4o" }));
|
||
|
|
assert.equal((model as { provider?: string }).provider, "openai.chat");
|
||
|
|
});
|
||
|
|
|
||
|
|
test("createModelFromConfig uses Responses API when openaiApi is responses", () => {
|
||
|
|
const model = createModelFromConfig(makeConfig({
|
||
|
|
providerId: "openai",
|
||
|
|
defaultModel: "gpt-4o",
|
||
|
|
openaiApi: "responses",
|
||
|
|
}));
|
||
|
|
assert.equal((model as { provider?: string }).provider, "openai.responses");
|
||
|
|
});
|
||
|
|
|
||
|
|
test("createModelFromConfig ignores openaiApi when style is not openai", () => {
|
||
|
|
const model = createModelFromConfig(makeConfig({
|
||
|
|
style: "anthropic",
|
||
|
|
openaiApi: "responses",
|
||
|
|
defaultModel: "claude",
|
||
|
|
}));
|
||
|
|
assert.match(String((model as { provider?: string }).provider ?? ""), /^anthropic/);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("createModelFromConfig keeps the Anthropic providerId fallback when style is unset", () => {
|
||
|
|
const model = createModelFromConfig(makeConfig({ providerId: "anthropic", defaultModel: "claude" }));
|
||
|
|
assert.match(String((model as { provider?: string }).provider ?? ""), /^anthropic/);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("createModelFromConfig keeps the Google providerId fallback when style is unset", () => {
|
||
|
|
const model = createModelFromConfig(makeConfig({ providerId: "google", defaultModel: "gemini" }));
|
||
|
|
assert.match(String((model as { provider?: string }).provider ?? ""), /^google/);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("createModelFromConfig keeps ollama's baseURL fallback and disposable apiKey", () => {
|
||
|
|
const model = createModelFromConfig(makeConfig({ providerId: "ollama", defaultModel: "llama3" }));
|
||
|
|
assert.match(String((model as { provider?: string }).provider ?? ""), /^openai/);
|
||
|
|
// Ollama leaves URL building to the SDK, but we can at least confirm it's still treated as OpenAI-style.
|
||
|
|
});
|
||
|
|
|
||
|
|
test("resolveProviderEndpoint applies the openrouter URL fallback for every style override", () => {
|
||
|
|
// Regression for codex feedback on #1105: gating the fallback on
|
||
|
|
// style === 'openai' silently misrouted traffic away from openrouter.ai
|
||
|
|
// when users overrode the wire format.
|
||
|
|
for (const style of ["openai", "anthropic", "google"] as const) {
|
||
|
|
const result = resolveProviderEndpoint(
|
||
|
|
{ id: "p", providerId: "openrouter", name: "OR", enabled: true },
|
||
|
|
style,
|
||
|
|
"sk-test",
|
||
|
|
);
|
||
|
|
assert.equal(result.baseURL, "https://openrouter.ai/api/v1", `style=${style} should still hit openrouter.ai`);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
test("resolveProviderEndpoint keeps an explicit openrouter baseURL untouched", () => {
|
||
|
|
const result = resolveProviderEndpoint(
|
||
|
|
{ id: "p", providerId: "openrouter", name: "OR", enabled: true, baseURL: "https://proxy.example/v1" },
|
||
|
|
"anthropic",
|
||
|
|
"sk-test",
|
||
|
|
);
|
||
|
|
assert.equal(result.baseURL, "https://proxy.example/v1");
|
||
|
|
});
|
||
|
|
|
||
|
|
test("resolveProviderEndpoint normalizes Anthropic-compat Base URL for @ai-sdk/anthropic", () => {
|
||
|
|
// Claude Code style (bare host) must gain /v1 so chat hits …/v1/messages.
|
||
|
|
assert.equal(
|
||
|
|
resolveProviderEndpoint(
|
||
|
|
makeConfig({ style: "anthropic", baseURL: "https://gateway.example/" }),
|
||
|
|
"anthropic",
|
||
|
|
"sk-test",
|
||
|
|
).baseURL,
|
||
|
|
"https://gateway.example/v1",
|
||
|
|
);
|
||
|
|
// AI SDK style already includes /v1 — leave it alone.
|
||
|
|
assert.equal(
|
||
|
|
resolveProviderEndpoint(
|
||
|
|
makeConfig({ style: "anthropic", baseURL: "https://gateway.example/v1" }),
|
||
|
|
"anthropic",
|
||
|
|
"sk-test",
|
||
|
|
).baseURL,
|
||
|
|
"https://gateway.example/v1",
|
||
|
|
);
|
||
|
|
// Official Anthropic preset default (no /v1) also needs the suffix.
|
||
|
|
assert.equal(
|
||
|
|
resolveProviderEndpoint(
|
||
|
|
makeConfig({ providerId: "anthropic", baseURL: "https://api.anthropic.com" }),
|
||
|
|
"anthropic",
|
||
|
|
"sk-test",
|
||
|
|
).baseURL,
|
||
|
|
"https://api.anthropic.com/v1",
|
||
|
|
);
|
||
|
|
// Custom complete SDK prefix without /v1 must not gain /v1
|
||
|
|
// (proxy serves …/anthropic/messages, not …/anthropic/v1/messages).
|
||
|
|
assert.equal(
|
||
|
|
resolveProviderEndpoint(
|
||
|
|
makeConfig({ style: "anthropic", baseURL: "https://proxy.example/anthropic" }),
|
||
|
|
"anthropic",
|
||
|
|
"sk-test",
|
||
|
|
).baseURL,
|
||
|
|
"https://proxy.example/anthropic",
|
||
|
|
);
|
||
|
|
// OpenAI-compat style must not rewrite the path.
|
||
|
|
assert.equal(
|
||
|
|
resolveProviderEndpoint(
|
||
|
|
makeConfig({ style: "openai", baseURL: "https://api.deepseek.com" }),
|
||
|
|
"openai",
|
||
|
|
"sk-test",
|
||
|
|
).baseURL,
|
||
|
|
"https://api.deepseek.com",
|
||
|
|
);
|
||
|
|
});
|
||
|
|
test("resolveProviderEndpoint applies the ollama URL fallback for every style override", () => {
|
||
|
|
for (const style of ["openai", "anthropic", "google"] as const) {
|
||
|
|
const result = resolveProviderEndpoint(
|
||
|
|
{ id: "p", providerId: "ollama", name: "Ollama", enabled: true },
|
||
|
|
style,
|
||
|
|
undefined,
|
||
|
|
);
|
||
|
|
assert.equal(result.baseURL, "http://localhost:11434/v1", `style=${style} should still hit localhost ollama`);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
test("resolveProviderEndpoint only swaps in the literal 'ollama' apiKey when no key is configured", () => {
|
||
|
|
const local = resolveProviderEndpoint(
|
||
|
|
{ id: "p", providerId: "ollama", name: "Ollama", enabled: true },
|
||
|
|
"openai",
|
||
|
|
undefined,
|
||
|
|
);
|
||
|
|
assert.equal(local.apiKey, "ollama");
|
||
|
|
|
||
|
|
// Cloud / any configured key must keep the IPC placeholder so the main
|
||
|
|
// process can inject the decrypted key. Overwriting it with 'ollama'
|
||
|
|
// produced Authorization: Bearer ollama and 401s against ollama.com.
|
||
|
|
const cloud = resolveProviderEndpoint(
|
||
|
|
{
|
||
|
|
id: "p",
|
||
|
|
providerId: "ollama",
|
||
|
|
name: "Ollama",
|
||
|
|
enabled: true,
|
||
|
|
baseURL: "https://ollama.com/v1",
|
||
|
|
},
|
||
|
|
"openai",
|
||
|
|
"__IPC_SECURED__",
|
||
|
|
);
|
||
|
|
assert.equal(cloud.apiKey, "__IPC_SECURED__");
|
||
|
|
assert.equal(cloud.baseURL, "https://ollama.com/v1");
|
||
|
|
|
||
|
|
const anthropic = resolveProviderEndpoint(
|
||
|
|
{ id: "p", providerId: "ollama", name: "Ollama", enabled: true },
|
||
|
|
"anthropic",
|
||
|
|
"PLACEHOLDER",
|
||
|
|
);
|
||
|
|
assert.equal(anthropic.apiKey, "PLACEHOLDER");
|
||
|
|
|
||
|
|
const empty = resolveProviderEndpoint(
|
||
|
|
{ id: "p", providerId: "ollama", name: "Ollama", enabled: true },
|
||
|
|
"openai",
|
||
|
|
"",
|
||
|
|
);
|
||
|
|
assert.equal(empty.apiKey, "ollama");
|
||
|
|
});
|
||
|
|
|
||
|
|
test("resolveProviderEndpoint adds /v1 to a bare ollama.com Cloud host", () => {
|
||
|
|
assert.equal(
|
||
|
|
resolveProviderEndpoint(
|
||
|
|
{
|
||
|
|
id: "p",
|
||
|
|
providerId: "ollama",
|
||
|
|
name: "Ollama",
|
||
|
|
enabled: true,
|
||
|
|
baseURL: "https://ollama.com",
|
||
|
|
},
|
||
|
|
"openai",
|
||
|
|
"__IPC_SECURED__",
|
||
|
|
).baseURL,
|
||
|
|
"https://ollama.com/v1",
|
||
|
|
);
|
||
|
|
assert.equal(
|
||
|
|
resolveProviderEndpoint(
|
||
|
|
{
|
||
|
|
id: "p",
|
||
|
|
providerId: "ollama",
|
||
|
|
name: "Ollama",
|
||
|
|
enabled: true,
|
||
|
|
baseURL: "https://ollama.com/",
|
||
|
|
},
|
||
|
|
"openai",
|
||
|
|
"__IPC_SECURED__",
|
||
|
|
).baseURL,
|
||
|
|
"https://ollama.com/v1",
|
||
|
|
);
|
||
|
|
assert.equal(
|
||
|
|
resolveProviderEndpoint(
|
||
|
|
{
|
||
|
|
id: "p",
|
||
|
|
providerId: "ollama",
|
||
|
|
name: "Ollama",
|
||
|
|
enabled: true,
|
||
|
|
baseURL: "https://ollama.com/api",
|
||
|
|
},
|
||
|
|
"openai",
|
||
|
|
"__IPC_SECURED__",
|
||
|
|
).baseURL,
|
||
|
|
"https://ollama.com/v1",
|
||
|
|
);
|
||
|
|
});
|