Skip to content

Commit f5cbb41

Browse files
committed
Rename providers to ic-anthropic/ic-openai with model catalog from models.dev
Custom provider names avoid conflicts with built-in OpenCode providers. An hourly cron fetches models from models.dev and caches them in KV. The discovery endpoint includes the full model catalog per provider.
1 parent 5f09014 commit f5cbb41

10 files changed

Lines changed: 276 additions & 34 deletions

src/discovery.ts

Lines changed: 35 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,48 @@
1-
export const PROVIDERS = ['anthropic', 'openai'] as const;
1+
export const GATEWAY_PROVIDERS = ['anthropic', 'openai'] as const;
22

3-
export type Provider = (typeof PROVIDERS)[number];
3+
export type GatewayProvider = (typeof GATEWAY_PROVIDERS)[number];
44

5-
export function isProvider(value: string): value is Provider {
6-
return (PROVIDERS as readonly string[]).includes(value);
5+
export function isGatewayProvider(value: string): value is GatewayProvider {
6+
return (GATEWAY_PROVIDERS as readonly string[]).includes(value);
77
}
88

9-
export function buildDiscoveryPayload(origin: string) {
9+
type ProviderConfig = {
10+
gateway: GatewayProvider;
11+
npm: string;
12+
name: string;
13+
};
14+
15+
const PROVIDER_CONFIG: Record<string, ProviderConfig> = {
16+
'ic-anthropic': { gateway: 'anthropic', npm: '@ai-sdk/anthropic', name: 'IC Anthropic' },
17+
'ic-openai': { gateway: 'openai', npm: '@ai-sdk/openai', name: 'IC OpenAI' },
18+
};
19+
20+
export const OPENCODE_PROVIDERS = Object.keys(PROVIDER_CONFIG);
21+
22+
type Models = Record<string, Record<string, unknown>>;
23+
24+
export function buildDiscoveryPayload(origin: string, modelsByGateway: Record<string, Models> = {}) {
25+
const provider: Record<string, unknown> = {};
26+
27+
for (const [name, config] of Object.entries(PROVIDER_CONFIG)) {
28+
const models = modelsByGateway[config.gateway] ?? {};
29+
provider[name] = {
30+
npm: config.npm,
31+
name: config.name,
32+
env: ['CF_ACCESS_TOKEN'],
33+
options: { baseURL: `${origin}/v1/${config.gateway}` },
34+
...(Object.keys(models).length > 0 ? { models } : {}),
35+
};
36+
}
37+
1038
return {
1139
auth: {
1240
command: ['cloudflared', 'access', 'login', '--no-verbose', origin],
1341
env: 'CF_ACCESS_TOKEN',
1442
},
1543
config: {
16-
enabled_providers: [...PROVIDERS],
17-
provider: {
18-
anthropic: {
19-
env: ['CF_ACCESS_TOKEN'],
20-
options: { baseURL: `${origin}/v1/anthropic` },
21-
},
22-
openai: {
23-
env: ['CF_ACCESS_TOKEN'],
24-
options: { baseURL: `${origin}/v1/openai` },
25-
},
26-
},
44+
enabled_providers: [...OPENCODE_PROVIDERS],
45+
provider,
2746
},
2847
};
2948
}

src/index.ts

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import { Hono, type MiddlewareHandler } from 'hono';
2-
import { buildDiscoveryPayload, isProvider, type Provider } from './discovery';
2+
import { buildDiscoveryPayload, GATEWAY_PROVIDERS, isGatewayProvider, type GatewayProvider } from './discovery';
33
import { createAuthMiddleware, type AuthVariables } from './auth';
44
import { createLoggingMiddleware } from './logging';
55
import { GatewayProxy } from './gateway';
66
import { JwtVerifier } from './jwt';
7+
import { ModelCatalog } from './models';
78
import { landingPage } from './landing';
89

910
type Bindings = {
@@ -12,6 +13,7 @@ type Bindings = {
1213
CF_ACCOUNT_ID: string;
1314
CF_GATEWAY_NAME: string;
1415
CF_AIG_TOKEN: string;
16+
MODELS: KVNamespace;
1517
};
1618

1719
type App = { Bindings: Bindings; Variables: AuthVariables };
@@ -24,11 +26,23 @@ let authMiddleware: MiddlewareHandler<App> | undefined;
2426

2527
app.use(createLoggingMiddleware());
2628

27-
app.get('/.well-known/opencode', (c) => c.json(buildDiscoveryPayload(new URL(c.req.url).origin)));
29+
app.get('/.well-known/opencode', async (c) => {
30+
const origin = new URL(c.req.url).origin;
31+
const catalog = new ModelCatalog(c.env.MODELS);
32+
const modelsByGateway: Record<string, Record<string, Record<string, unknown>>> = {};
33+
34+
await Promise.all(
35+
GATEWAY_PROVIDERS.map(async (provider) => {
36+
modelsByGateway[provider] = await catalog.getModels(provider);
37+
}),
38+
);
39+
40+
return c.json(buildDiscoveryPayload(origin, modelsByGateway));
41+
});
2842

2943
app.use('/v1/*', async (c, next) => {
3044
const provider = c.req.path.split('/')[2];
31-
if (!provider || !isProvider(provider)) return c.json({ error: 'Unknown provider' }, 404);
45+
if (!provider || !isGatewayProvider(provider)) return c.json({ error: 'Unknown provider' }, 404);
3246
await next();
3347
});
3448

@@ -41,11 +55,17 @@ app.use('/v1/*', (c, next) => {
4155

4256
app.all('/v1/:provider/*', (c) => {
4357
const proxy = new GatewayProxy(c.env.CF_ACCOUNT_ID, c.env.CF_GATEWAY_NAME, c.env.CF_AIG_TOKEN);
44-
return proxy.proxy(c.req.raw, c.req.param('provider') as Provider, c.get('userEmail'));
58+
return proxy.proxy(c.req.raw, c.req.param('provider') as GatewayProvider, c.get('userEmail'));
4559
});
4660

4761
app.get('/', (c) => c.html(landingPage(new URL(c.req.url).origin)));
4862

4963
app.all('*', (c) => c.json({ error: 'Not found' }, 404));
5064

51-
export default app;
65+
export default {
66+
fetch: app.fetch,
67+
async scheduled(_controller: ScheduledController, env: Bindings, _ctx: ExecutionContext) {
68+
const catalog = new ModelCatalog(env.MODELS);
69+
await catalog.refresh();
70+
},
71+
};

src/models.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { GATEWAY_PROVIDERS, type GatewayProvider } from './discovery';
2+
3+
export type FetchFn = typeof fetch;
4+
5+
type ModelsDevProvider = {
6+
models: Record<string, Record<string, unknown>>;
7+
};
8+
9+
export class ModelCatalog {
10+
constructor(
11+
private readonly kv: KVNamespace,
12+
private readonly fetchFn: FetchFn = globalThis.fetch.bind(globalThis),
13+
) {}
14+
15+
async refresh(): Promise<void> {
16+
const response = await this.fetchFn('https://models.dev/api.json');
17+
if (!response.ok) {
18+
throw new Error(`models.dev returned ${response.status}`);
19+
}
20+
21+
const catalog = (await response.json()) as Record<string, ModelsDevProvider>;
22+
23+
await Promise.all(
24+
GATEWAY_PROVIDERS.map((provider) => {
25+
const entry = catalog[provider];
26+
if (!entry?.models) return Promise.resolve();
27+
return this.kv.put(`models:${provider}`, JSON.stringify(entry.models));
28+
}),
29+
);
30+
}
31+
32+
async getModels(provider: GatewayProvider): Promise<Record<string, Record<string, unknown>>> {
33+
const raw = await this.kv.get(`models:${provider}`);
34+
if (!raw) return {};
35+
return JSON.parse(raw) as Record<string, Record<string, unknown>>;
36+
}
37+
}

test/discovery.spec.ts

Lines changed: 36 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,24 +3,51 @@ import { buildDiscoveryPayload } from '../src/discovery';
33

44
describe('buildDiscoveryPayload', () => {
55
const origin = 'https://opencode.icap.dev';
6-
const payload = buildDiscoveryPayload(origin);
76

87
it('includes cloudflared auth command', () => {
8+
const payload = buildDiscoveryPayload(origin);
99
expect(payload.auth.command).toEqual(['cloudflared', 'access', 'login', '--no-verbose', origin]);
1010
expect(payload.auth.env).toBe('CF_ACCESS_TOKEN');
1111
});
1212

13-
it('restricts to proxied providers', () => {
14-
expect(payload.config.enabled_providers).toEqual(['anthropic', 'openai']);
13+
it('restricts to custom-named providers', () => {
14+
const payload = buildDiscoveryPayload(origin);
15+
expect(payload.config.enabled_providers).toEqual(['ic-anthropic', 'ic-openai']);
1516
});
1617

17-
it('configures anthropic provider with proxy base URL', () => {
18-
expect(payload.config.provider.anthropic.env).toEqual(['CF_ACCESS_TOKEN']);
19-
expect(payload.config.provider.anthropic.options.baseURL).toBe(`${origin}/v1/anthropic`);
18+
it('configures ic-anthropic with npm package and proxy base URL', () => {
19+
const payload = buildDiscoveryPayload(origin);
20+
const provider = payload.config.provider['ic-anthropic'] as Record<string, unknown>;
21+
expect(provider.npm).toBe('@ai-sdk/anthropic');
22+
expect(provider.name).toBe('IC Anthropic');
23+
expect(provider.env).toEqual(['CF_ACCESS_TOKEN']);
24+
expect((provider.options as Record<string, string>).baseURL).toBe(`${origin}/v1/anthropic`);
2025
});
2126

22-
it('configures openai provider with proxy base URL', () => {
23-
expect(payload.config.provider.openai.env).toEqual(['CF_ACCESS_TOKEN']);
24-
expect(payload.config.provider.openai.options.baseURL).toBe(`${origin}/v1/openai`);
27+
it('configures ic-openai with npm package and proxy base URL', () => {
28+
const payload = buildDiscoveryPayload(origin);
29+
const provider = payload.config.provider['ic-openai'] as Record<string, unknown>;
30+
expect(provider.npm).toBe('@ai-sdk/openai');
31+
expect(provider.name).toBe('IC OpenAI');
32+
expect(provider.env).toEqual(['CF_ACCESS_TOKEN']);
33+
expect((provider.options as Record<string, string>).baseURL).toBe(`${origin}/v1/openai`);
34+
});
35+
36+
it('omits models when none are provided', () => {
37+
const payload = buildDiscoveryPayload(origin);
38+
const provider = payload.config.provider['ic-anthropic'] as Record<string, unknown>;
39+
expect(provider).not.toHaveProperty('models');
40+
});
41+
42+
it('includes models when provided', () => {
43+
const models = {
44+
anthropic: { 'claude-sonnet-4-5': { name: 'Claude Sonnet 4.5' } },
45+
openai: { 'gpt-4o': { name: 'GPT-4o' } },
46+
};
47+
const payload = buildDiscoveryPayload(origin, models);
48+
const anthropic = payload.config.provider['ic-anthropic'] as Record<string, unknown>;
49+
const openai = payload.config.provider['ic-openai'] as Record<string, unknown>;
50+
expect(anthropic.models).toEqual(models.anthropic);
51+
expect(openai.models).toEqual(models.openai);
2552
});
2653
});

test/env.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,6 @@ declare module "cloudflare:test" {
22
interface ProvidedEnv extends Env {
33
CF_ACCESS_AUD: string;
44
CF_AIG_TOKEN: string;
5+
MODELS: KVNamespace;
56
}
67
}

test/index.spec.ts

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,27 @@ describe('integration', () => {
2323
'https://opencode.icap.dev',
2424
]);
2525
expect(body.auth.env).toBe('CF_ACCESS_TOKEN');
26-
expect(body.config.provider.anthropic.options.baseURL).toBe('https://opencode.icap.dev/v1/anthropic');
27-
expect(body.config.provider.openai.options.baseURL).toBe('https://opencode.icap.dev/v1/openai');
26+
expect(body.config.enabled_providers).toEqual(['ic-anthropic', 'ic-openai']);
27+
expect(body.config.provider['ic-anthropic'].npm).toBe('@ai-sdk/anthropic');
28+
expect(body.config.provider['ic-anthropic'].options.baseURL).toBe(
29+
'https://opencode.icap.dev/v1/anthropic',
30+
);
31+
expect(body.config.provider['ic-openai'].npm).toBe('@ai-sdk/openai');
32+
expect(body.config.provider['ic-openai'].options.baseURL).toBe(
33+
'https://opencode.icap.dev/v1/openai',
34+
);
35+
});
36+
37+
it('includes models from KV in discovery config', async () => {
38+
const models = { 'claude-sonnet-4-5': { name: 'Claude Sonnet 4.5' } };
39+
await env.MODELS.put('models:anthropic', JSON.stringify(models));
40+
41+
const response = await SELF.fetch('https://opencode.icap.dev/.well-known/opencode');
42+
const body = (await response.json()) as any;
43+
44+
expect(body.config.provider['ic-anthropic'].models).toEqual(models);
45+
46+
await env.MODELS.delete('models:anthropic');
2847
});
2948

3049
it('rejects proxy requests without authorization', async () => {

test/models.spec.ts

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import { env } from 'cloudflare:test';
2+
import { describe, it, expect, beforeEach } from 'vitest';
3+
import { ModelCatalog, type FetchFn } from '../src/models';
4+
5+
const modelsDevResponse = {
6+
anthropic: {
7+
id: 'anthropic',
8+
npm: '@ai-sdk/anthropic',
9+
name: 'Anthropic',
10+
models: {
11+
'claude-sonnet-4-5': {
12+
id: 'claude-sonnet-4-5',
13+
name: 'Claude Sonnet 4.5',
14+
limit: { context: 200000, output: 64000 },
15+
},
16+
},
17+
},
18+
openai: {
19+
id: 'openai',
20+
npm: '@ai-sdk/openai',
21+
name: 'OpenAI',
22+
models: {
23+
'gpt-4o': {
24+
id: 'gpt-4o',
25+
name: 'GPT-4o',
26+
limit: { context: 128000, output: 16384 },
27+
},
28+
},
29+
},
30+
groq: {
31+
id: 'groq',
32+
npm: '@ai-sdk/groq',
33+
name: 'Groq',
34+
models: { 'llama-3': { id: 'llama-3', name: 'Llama 3' } },
35+
},
36+
};
37+
38+
function fakeFetch(status: number, body: unknown): FetchFn {
39+
return async () => new Response(JSON.stringify(body), { status });
40+
}
41+
42+
describe('ModelCatalog', () => {
43+
beforeEach(async () => {
44+
await env.MODELS.delete('models:anthropic');
45+
await env.MODELS.delete('models:openai');
46+
});
47+
48+
describe('refresh', () => {
49+
it('fetches models from models.dev and stores them in KV', async () => {
50+
const catalog = new ModelCatalog(env.MODELS, fakeFetch(200, modelsDevResponse));
51+
52+
await catalog.refresh();
53+
54+
const anthropicRaw = await env.MODELS.get('models:anthropic');
55+
const openaiRaw = await env.MODELS.get('models:openai');
56+
expect(JSON.parse(anthropicRaw!)).toEqual(modelsDevResponse.anthropic.models);
57+
expect(JSON.parse(openaiRaw!)).toEqual(modelsDevResponse.openai.models);
58+
});
59+
60+
it('does not store models for unsupported providers', async () => {
61+
const catalog = new ModelCatalog(env.MODELS, fakeFetch(200, modelsDevResponse));
62+
63+
await catalog.refresh();
64+
65+
const groqRaw = await env.MODELS.get('models:groq');
66+
expect(groqRaw).toBeNull();
67+
});
68+
69+
it('throws on non-200 responses', async () => {
70+
const catalog = new ModelCatalog(env.MODELS, fakeFetch(500, { error: 'server error' }));
71+
72+
await expect(catalog.refresh()).rejects.toThrow('models.dev returned 500');
73+
});
74+
75+
it('handles a provider missing from the response', async () => {
76+
const partial = { anthropic: modelsDevResponse.anthropic };
77+
const catalog = new ModelCatalog(env.MODELS, fakeFetch(200, partial));
78+
79+
await catalog.refresh();
80+
81+
const anthropicRaw = await env.MODELS.get('models:anthropic');
82+
const openaiRaw = await env.MODELS.get('models:openai');
83+
expect(JSON.parse(anthropicRaw!)).toEqual(modelsDevResponse.anthropic.models);
84+
expect(openaiRaw).toBeNull();
85+
});
86+
});
87+
88+
describe('getModels', () => {
89+
it('returns models from KV', async () => {
90+
await env.MODELS.put('models:anthropic', JSON.stringify(modelsDevResponse.anthropic.models));
91+
const catalog = new ModelCatalog(env.MODELS);
92+
93+
const models = await catalog.getModels('anthropic');
94+
95+
expect(models).toEqual(modelsDevResponse.anthropic.models);
96+
});
97+
98+
it('returns an empty object when KV has no data', async () => {
99+
const catalog = new ModelCatalog(env.MODELS);
100+
101+
const models = await catalog.getModels('anthropic');
102+
103+
expect(models).toEqual({});
104+
});
105+
});
106+
});

vitest.config.mts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export default defineWorkersConfig({
1111
CF_ACCESS_AUD: "test-access-aud",
1212
CF_AIG_TOKEN: "test-aig-token",
1313
},
14+
kvNamespaces: ["MODELS"],
1415
},
1516
},
1617
},

worker-configuration.d.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
/* eslint-disable */
2-
// Generated by Wrangler by running `wrangler types` (hash: 592ea5fe1962a513e697fc8b06eb4519)
3-
// Runtime types generated with workerd@1.20260421.1 2026-04-21 nodejs_compat
2+
// Generated by Wrangler by running `wrangler types` (hash: bb57a9ace762d20fc8a33632362b5d42)
3+
// Runtime types generated with workerd@1.20260421.1 2026-03-10 nodejs_compat
44
declare namespace Cloudflare {
55
interface GlobalProps {
66
mainModule: typeof import("./src/index");
77
}
88
interface Env {
9+
MODELS: KVNamespace;
910
CF_ACCESS_TEAM_DOMAIN: "initialcapacity.cloudflareaccess.com";
1011
CF_ACCOUNT_ID: "8a4e4c15971c03154164931e43651ae4";
1112
CF_GATEWAY_NAME: "ai-gateway";

0 commit comments

Comments
 (0)