Skip to content

Commit 19cfff3

Browse files
committed
feat(hardening): respect local provider endpoints
1 parent 0d3e1a8 commit 19cfff3

4 files changed

Lines changed: 137 additions & 38 deletions

File tree

src/providers/registry.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export interface ProviderRegistryEntry {
1919
baseUrl: string;
2020
authKind: ProviderAuthKind;
2121
keyOptional?: boolean;
22+
allowBaseUrlOverride?: boolean;
2223
modelSuffixBracketStrip?: boolean;
2324
featured?: boolean;
2425
dashboardPreset?: boolean;
@@ -432,9 +433,9 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
432433
{ id: "google-vertex", label: "Google Vertex AI", adapter: "google", baseUrl: "https://aiplatform.googleapis.com", authKind: "key", dashboardUrl: "https://console.cloud.google.com/vertex-ai", defaultModel: "gemini-3-pro", googleMode: "vertex", jawcodeBundle: "google", extraMetadataAliases: ["gemini-vertex"] },
433434
{ id: "google-antigravity", label: "Google Antigravity", adapter: "google", baseUrl: "https://daily-cloudcode-pa.googleapis.com", authKind: "oauth", dashboardUrl: "https://antigravity.google", models: ANTIGRAVITY_MODELS, defaultModel: "gemini-3.5-flash-low", modelContextWindows: ANTIGRAVITY_MODEL_CONTEXT_WINDOWS, googleMode: "cloud-code-assist", jawcodeBundle: "google", extraMetadataAliases: ["antigravity", "gemini-antigravity"] },
434435
{ id: "azure-openai", label: "Azure OpenAI", adapter: "azure-openai", baseUrl: "https://{resource}.openai.azure.com/openai", authKind: "key", featured: true, dashboardUrl: "https://portal.azure.com" },
435-
{ id: "ollama", label: "Ollama (local)", adapter: "openai-chat", baseUrl: "http://localhost:11434/v1", authKind: "local", featured: true, note: "Local — key usually blank" },
436-
{ id: "vllm", label: "vLLM (local)", adapter: "openai-chat", baseUrl: "http://localhost:8000/v1", authKind: "local", featured: true, note: "Local — key usually blank" },
437-
{ id: "lm-studio", label: "LM Studio (local)", adapter: "openai-chat", baseUrl: "http://localhost:1234/v1", authKind: "local", featured: true, note: "Local — no key needed" },
436+
{ id: "ollama", label: "Ollama (local)", adapter: "openai-chat", baseUrl: "http://localhost:11434/v1", authKind: "local", allowBaseUrlOverride: true, featured: true, note: "Local — key usually blank" },
437+
{ id: "vllm", label: "vLLM (local)", adapter: "openai-chat", baseUrl: "http://localhost:8000/v1", authKind: "local", allowBaseUrlOverride: true, featured: true, note: "Local — key usually blank" },
438+
{ id: "lm-studio", label: "LM Studio (local)", adapter: "openai-chat", baseUrl: "http://localhost:1234/v1", authKind: "local", allowBaseUrlOverride: true, featured: true, note: "Local — no key needed" },
438439
{
439440
id: "deepseek",
440441
label: "DeepSeek",
@@ -513,6 +514,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
513514
{
514515
id: "litellm", label: "LiteLLM (self-hosted)", baseUrl: "http://localhost:4000/v1", adapter: "openai-chat", authKind: "key",
515516
dashboardUrl: "https://docs.litellm.ai/docs/proxy/quick_start",
517+
allowBaseUrlOverride: true,
516518
// A self-hosted proxy may legitimately run without a master key.
517519
keyOptional: true,
518520
},
@@ -523,14 +525,16 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
523525
adapter: "openai-chat",
524526
authKind: "key",
525527
dashboardUrl: "https://ollama.com/settings/keys",
526-
models: ["glm-5.2", "deepseek-v4-pro", "qwen3-coder", "gpt-oss:120b", "kimi-k2.6", "minimax-m3", "qwen3.5", "gemma4"],
528+
// Live IDs verified 2026-07-10; qwen3-coder:480b retires 2026-07-15.
529+
// Evidence: .codexclaw/evidence/260710_wp9_ollama_cloud_model_ids.md.
530+
models: ["glm-5.2", "deepseek-v4-pro", "qwen3-coder:480b", "gpt-oss:120b", "kimi-k2.6", "minimax-m3", "qwen3.5:397b", "gemma4:31b"],
527531
defaultModel: "glm-5.2",
528532
noVisionModels: [
529533
"glm-5.2", "glm-5.1", "glm-5", "glm-4.7",
530534
"minimax-m2.7", "minimax-m2.5", "minimax-m2.1",
531535
"nemotron-3-ultra", "nemotron-3-super",
532536
"deepseek-v4-pro", "deepseek-v4-flash",
533-
"gpt-oss", "qwen3-coder",
537+
"gpt-oss", "qwen3-coder:480b",
534538
],
535539
},
536540
// FREEZE 2026-07-10: codestral-latest is unconfirmed behind auth. Evidence: devlog/_plan/260710_provider_hardening/003_research_aggregators.md.

src/router.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -98,11 +98,15 @@ function routedProviderConfig(providerName: string, provider: OcxProviderConfig)
9898
const thinkingToggleModels = mergeStringArray(registryEntry.thinkingToggleModels, provider.thinkingToggleModels);
9999
const thinkingBudgetModels = mergeStringArray(registryEntry.thinkingBudgetModels, provider.thinkingBudgetModels);
100100
const registryBaseUrlIsTemplate = /\{[^}]*\}/.test(registryEntry.baseUrl);
101-
const userBaseUrlIsResolved = typeof provider.baseUrl === "string"
102-
&& provider.baseUrl.trim().length > 0
103-
&& !/\{[^}]*\}/.test(provider.baseUrl);
104-
// Registry template URLs are presets; a resolved user URL is the canonical endpoint for them.
105-
const baseUrl = registryBaseUrlIsTemplate && userBaseUrlIsResolved ? provider.baseUrl : registryEntry.baseUrl;
101+
const userBaseUrl = typeof provider.baseUrl === "string" ? provider.baseUrl.trim() : "";
102+
const userBaseUrlIsResolved = userBaseUrl.length > 0 && !/\{[^}]*\}/.test(userBaseUrl);
103+
if (registryEntry.allowBaseUrlOverride && !userBaseUrlIsResolved) {
104+
throw new Error(`Invalid baseUrl for provider "${providerName}": expected a nonblank URL without unresolved placeholders`);
105+
}
106+
// Registry template URLs are presets; local/self-hosted entries opt in explicitly.
107+
const baseUrl = (registryBaseUrlIsTemplate || registryEntry.allowBaseUrlOverride) && userBaseUrlIsResolved
108+
? userBaseUrl
109+
: registryEntry.baseUrl;
106110

107111
return {
108112
...provider,

tests/provider-registry-parity.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,29 @@ describe("provider registry parity", () => {
232232
expect(optionalKeyProviders).toEqual(["litellm"]);
233233
});
234234

235+
test("base URL override permission is registry-only and limited to local/self-hosted providers", () => {
236+
const optedIn = PROVIDER_REGISTRY.filter(entry => entry.allowBaseUrlOverride);
237+
238+
expect(optedIn.map(entry => entry.id)).toEqual(["ollama", "vllm", "lm-studio", "litellm"]);
239+
for (const entry of optedIn) {
240+
expect(providerConfigSeed(entry)).not.toHaveProperty("allowBaseUrlOverride");
241+
}
242+
});
243+
244+
test("Ollama Cloud uses the three live tagged IDs without retaining bare aliases", () => {
245+
const ollamaCloud = PROVIDER_REGISTRY.find(entry => entry.id === "ollama-cloud");
246+
247+
expect(ollamaCloud?.models).toEqual([
248+
"glm-5.2", "deepseek-v4-pro", "qwen3-coder:480b", "gpt-oss:120b",
249+
"kimi-k2.6", "minimax-m3", "qwen3.5:397b", "gemma4:31b",
250+
]);
251+
expect(ollamaCloud?.models).not.toContain("qwen3-coder");
252+
expect(ollamaCloud?.models).not.toContain("qwen3.5");
253+
expect(ollamaCloud?.models).not.toContain("gemma4");
254+
expect(ollamaCloud?.noVisionModels).toContain("qwen3-coder:480b");
255+
expect(ollamaCloud?.noVisionModels).not.toContain("qwen3-coder");
256+
});
257+
235258
test("Fire Pass model data is explicitly frozen pending entitlement proof", () => {
236259
const firepass = PROVIDER_REGISTRY.find(entry => entry.id === "firepass");
237260

Lines changed: 96 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,102 @@
11
import { expect, test } from "bun:test";
22
import { routeModel } from "../src/router";
3-
import type { OcxConfig } from "../src/types";
3+
import type { OcxConfig, OcxProviderConfig } from "../src/types";
44

5-
test("routing preserves resolved user URLs only for registry template providers", () => {
6-
const config: OcxConfig = {
5+
const OVERRIDE_PROVIDERS = [
6+
{ id: "ollama", registryBaseUrl: "http://localhost:11434/v1" },
7+
{ id: "vllm", registryBaseUrl: "http://localhost:8000/v1" },
8+
{ id: "lm-studio", registryBaseUrl: "http://localhost:1234/v1" },
9+
{ id: "litellm", registryBaseUrl: "http://localhost:4000/v1" },
10+
] as const;
11+
12+
function configFor(providerName: string, provider: OcxProviderConfig): OcxConfig {
13+
return {
714
port: 10100,
8-
defaultProvider: "azure-openai",
9-
providers: {
10-
"azure-openai": {
11-
adapter: "azure-openai",
12-
baseUrl: "https://myres.openai.azure.com/openai",
13-
apiKey: "azure-key",
14-
},
15-
"cloudflare-ai-gateway": {
16-
adapter: "anthropic",
17-
baseUrl: "https://gateway.ai.cloudflare.com/v1/my-account/my-gateway/anthropic",
18-
apiKey: "cloudflare-key",
19-
},
20-
anthropic: {
21-
adapter: "anthropic",
22-
baseUrl: "https://user-supplied.example.test/anthropic",
23-
apiKey: "anthropic-key",
24-
},
25-
},
15+
defaultProvider: providerName,
16+
providers: { [providerName]: provider },
2617
};
18+
}
19+
20+
for (const { id, registryBaseUrl } of OVERRIDE_PROVIDERS) {
21+
test(`${id} trims and preserves a configured base URL override`, () => {
22+
const override = `http://${id}.lan:3210/v1`;
23+
const config = configFor(id, {
24+
adapter: "openai-chat",
25+
baseUrl: ` ${override} `,
26+
});
27+
28+
expect(routeModel(config, `${id}/model`).provider.baseUrl).toBe(override);
29+
});
30+
31+
test(`${id} accepts its resolved registry-default base URL`, () => {
32+
const config = configFor(id, {
33+
adapter: "openai-chat",
34+
baseUrl: registryBaseUrl,
35+
});
36+
37+
expect(routeModel(config, `${id}/model`).provider.baseUrl).toBe(registryBaseUrl);
38+
});
39+
40+
for (const [label, invalidBaseUrl] of [
41+
["blank", ""],
42+
["whitespace-only", " \t"],
43+
["unresolved placeholder", "http://{host}:8000/v1"],
44+
] as const) {
45+
test(`${id} rejects a ${label} override instead of falling back`, () => {
46+
const config = configFor(id, {
47+
adapter: "openai-chat",
48+
baseUrl: invalidBaseUrl,
49+
});
50+
51+
expect(() => routeModel(config, `${id}/model`))
52+
.toThrow(`Invalid baseUrl for provider "${id}": expected a nonblank URL without unresolved placeholders`);
53+
});
54+
}
55+
}
56+
57+
for (const { id, registryBaseUrl, adapter } of [
58+
{ id: "ollama-cloud", registryBaseUrl: "https://ollama.com/v1", adapter: "openai-chat" },
59+
{ id: "anthropic", registryBaseUrl: "https://api.anthropic.com", adapter: "anthropic" },
60+
] as const) {
61+
test(`${id} keeps its fixed remote registry endpoint authoritative`, () => {
62+
const config = configFor(id, {
63+
adapter,
64+
baseUrl: "https://user-supplied.example.test/v1",
65+
});
66+
67+
expect(routeModel(config, `${id}/model`).provider.baseUrl).toBe(registryBaseUrl);
68+
});
69+
}
70+
71+
for (const { id, adapter, registryTemplate, resolvedBaseUrl } of [
72+
{
73+
id: "azure-openai",
74+
adapter: "azure-openai",
75+
registryTemplate: "https://{resource}.openai.azure.com/openai",
76+
resolvedBaseUrl: "https://myres.openai.azure.com/openai",
77+
},
78+
{
79+
id: "cloudflare-ai-gateway",
80+
adapter: "anthropic",
81+
registryTemplate: "https://gateway.ai.cloudflare.com/v1/{account-id}/{gateway}/anthropic",
82+
resolvedBaseUrl: "https://gateway.ai.cloudflare.com/v1/my-account/my-gateway/anthropic",
83+
},
84+
] as const) {
85+
test(`${id} keeps resolved template behavior unchanged`, () => {
86+
const config = configFor(id, {
87+
adapter,
88+
baseUrl: ` ${resolvedBaseUrl} `,
89+
});
90+
91+
expect(routeModel(config, `${id}/model`).provider.baseUrl).toBe(resolvedBaseUrl);
92+
});
93+
94+
test(`${id} keeps unresolved template fallback behavior unchanged`, () => {
95+
const config = configFor(id, {
96+
adapter,
97+
baseUrl: registryTemplate,
98+
});
2799

28-
expect(routeModel(config, "azure-openai/deployment").provider.baseUrl)
29-
.toBe("https://myres.openai.azure.com/openai");
30-
expect(routeModel(config, "cloudflare-ai-gateway/claude-sonnet-5").provider.baseUrl)
31-
.toBe("https://gateway.ai.cloudflare.com/v1/my-account/my-gateway/anthropic");
32-
expect(routeModel(config, "anthropic/claude-sonnet-5").provider.baseUrl)
33-
.toBe("https://api.anthropic.com");
34-
});
100+
expect(routeModel(config, `${id}/model`).provider.baseUrl).toBe(registryTemplate);
101+
});
102+
}

0 commit comments

Comments
 (0)