ada-agent 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,52 +1,106 @@
1
- // models.dev catalog — model metadata (context limits, pricing, capabilities). Prefetched once at
2
- // startup and cached for an hour; reads are synchronous from the in-memory cache. Offline-safe:
3
- // if the fetch fails, the cache stays empty and callers fall back to their own tables.
4
-
5
- interface Info {
6
- context?: number;
7
- output?: number;
8
- inputCost?: number; // $ per 1M input tokens
9
- outputCost?: number; // $ per 1M output tokens
10
- reasoning?: boolean;
11
- }
12
-
13
- const cache = new Map<string, Info>();
14
- let fetchedAt = 0;
15
-
16
- /** Fetch and cache the models.dev catalog (no-op if fetched within the last hour). */
17
- export async function prefetch(): Promise<void> {
18
- if (cache.size && Date.now() - fetchedAt < 3_600_000) return;
19
- try {
20
- const res = await fetch("https://models.dev/api.json", { signal: AbortSignal.timeout(10_000) });
21
- if (!res.ok) return;
22
- const data = (await res.json()) as Record<string, { models?: Record<string, { limit?: { context?: number; output?: number }; cost?: { input?: number; output?: number }; reasoning?: boolean }> }>;
23
- cache.clear();
24
- for (const prov of Object.values(data)) {
25
- for (const [id, m] of Object.entries(prov.models ?? {})) {
26
- cache.set(id, { context: m.limit?.context, output: m.limit?.output, inputCost: m.cost?.input, outputCost: m.cost?.output, reasoning: m.reasoning });
27
- }
28
- }
29
- fetchedAt = Date.now();
30
- } catch {
31
- /* offline keep whatever's cached */
32
- }
33
- }
34
-
35
- function lookup(modelId: string): Info | null {
36
- return cache.get(modelId) ?? cache.get(modelId.split("/").pop() ?? "") ?? cache.get(modelId.split(":")[0] ?? "") ?? null;
37
- }
38
-
39
- /** [inputCostPer1M, outputCostPer1M] from models.dev, or null. */
40
- export function priceOf(modelId: string): [number, number] | null {
41
- const i = lookup(modelId);
42
- return i && i.inputCost != null && i.outputCost != null ? [i.inputCost, i.outputCost] : null;
43
- }
44
-
45
- /** Context-window limit (tokens) from models.dev, or null. */
46
- export function contextOf(modelId: string): number | null {
47
- return lookup(modelId)?.context ?? null;
48
- }
49
-
50
- export function catalogSize(): number {
51
- return cache.size;
52
- }
1
+ // models.dev catalog — model metadata (context limits, pricing, capabilities). The cache is seeded
2
+ // at load from a baked, curated subset (catalog.json popular providers, generated by
3
+ // `npm run catalog:refresh`) so pricing/limits work offline; a live prefetch() then overlays the
4
+ // full models.dev catalog. Reads are synchronous from the in-memory cache.
5
+
6
+ import { readFileSync } from "node:fs";
7
+
8
+ interface Info {
9
+ context?: number;
10
+ output?: number;
11
+ inputCost?: number; // $ per 1M input tokens
12
+ outputCost?: number; // $ per 1M output tokens
13
+ reasoning?: boolean;
14
+ }
15
+
16
+ interface CatalogModel {
17
+ name: string;
18
+ context: number | null;
19
+ output: number | null;
20
+ in: number | null;
21
+ out: number | null;
22
+ reasoning?: boolean;
23
+ cacheRead?: number;
24
+ toolCall?: boolean;
25
+ }
26
+ interface Catalog {
27
+ providers: Record<string, { name: string; npm?: string; models: Record<string, CatalogModel> }>;
28
+ }
29
+
30
+ const cache = new Map<string, Info>();
31
+ let fetchedAt = 0;
32
+
33
+ // The baked offline catalog (curated popular providers). Seeds the cache; live prefetch overlays it.
34
+ const CATALOG: Catalog = (() => {
35
+ try {
36
+ return JSON.parse(readFileSync(new URL("./catalog.json", import.meta.url), "utf8")) as Catalog;
37
+ } catch {
38
+ return { providers: {} };
39
+ }
40
+ })();
41
+ for (const prov of Object.values(CATALOG.providers)) {
42
+ for (const [id, m] of Object.entries(prov.models)) {
43
+ cache.set(id, { context: m.context ?? undefined, output: m.output ?? undefined, inputCost: m.in ?? undefined, outputCost: m.out ?? undefined, reasoning: m.reasoning });
44
+ }
45
+ }
46
+
47
+ /** Fetch and cache the models.dev catalog (no-op if fetched within the last hour). */
48
+ export async function prefetch(): Promise<void> {
49
+ if (cache.size && Date.now() - fetchedAt < 3_600_000) return;
50
+ try {
51
+ const res = await fetch("https://models.dev/api.json", { signal: AbortSignal.timeout(10_000) });
52
+ if (!res.ok) return;
53
+ const data = (await res.json()) as Record<string, { models?: Record<string, { limit?: { context?: number; output?: number }; cost?: { input?: number; output?: number }; reasoning?: boolean }> }>;
54
+ cache.clear();
55
+ for (const prov of Object.values(data)) {
56
+ for (const [id, m] of Object.entries(prov.models ?? {})) {
57
+ cache.set(id, { context: m.limit?.context, output: m.limit?.output, inputCost: m.cost?.input, outputCost: m.cost?.output, reasoning: m.reasoning });
58
+ }
59
+ }
60
+ fetchedAt = Date.now();
61
+ } catch {
62
+ /* offline — keep whatever's cached */
63
+ }
64
+ }
65
+
66
+ function lookup(modelId: string): Info | null {
67
+ return cache.get(modelId) ?? cache.get(modelId.split("/").pop() ?? "") ?? cache.get(modelId.split(":")[0] ?? "") ?? null;
68
+ }
69
+
70
+ /** [inputCostPer1M, outputCostPer1M] from models.dev, or null. */
71
+ export function priceOf(modelId: string): [number, number] | null {
72
+ const i = lookup(modelId);
73
+ return i && i.inputCost != null && i.outputCost != null ? [i.inputCost, i.outputCost] : null;
74
+ }
75
+
76
+ /** Context-window limit (tokens) from models.dev, or null. */
77
+ export function contextOf(modelId: string): number | null {
78
+ return lookup(modelId)?.context ?? null;
79
+ }
80
+
81
+ export function catalogSize(): number {
82
+ return cache.size;
83
+ }
84
+
85
+ /** Human-readable listing of the baked offline catalog. No filter → provider summary; a filter
86
+ * (provider id/name substring) → that provider's models with context + price. */
87
+ export function catalogText(filter?: string): string {
88
+ const f = filter?.toLowerCase();
89
+ const out: string[] = [];
90
+ for (const [pid, prov] of Object.entries(CATALOG.providers)) {
91
+ const models = Object.entries(prov.models);
92
+ if (!f) {
93
+ out.push(`${pid.padEnd(24)} ${String(models.length).padStart(3)} models \x1b[2m${prov.name}\x1b[0m`);
94
+ continue;
95
+ }
96
+ if (!pid.toLowerCase().includes(f) && !prov.name.toLowerCase().includes(f)) continue;
97
+ out.push(`\n\x1b[1m${prov.name}\x1b[0m \x1b[2m(${pid})\x1b[0m`);
98
+ for (const [id, m] of models) {
99
+ const price = m.in != null && m.out != null ? `$${m.in}/$${m.out}` : "—";
100
+ const ctx = m.context ? `${Math.round(m.context / 1000)}k` : "—";
101
+ out.push(` ${id.padEnd(40)} ${ctx.padStart(6)} ctx · ${price}/1M${m.reasoning ? " · reasoning" : ""}`);
102
+ }
103
+ }
104
+ if (!out.length) return `no providers match "${filter}". Try /catalog with no argument for the list.`;
105
+ return f ? out.join("\n") : `${out.join("\n")}\n\x1b[2m/catalog <provider> for models · npm run catalog:refresh to update\x1b[0m`;
106
+ }
package/src/selfcheck.ts CHANGED
@@ -255,6 +255,32 @@ async function main(): Promise<void> {
255
255
  assert.equal(permPhrase("write_file", false), "create or modify files on disk", "write phrase");
256
256
  assert.ok(permPhrase("merchant__x", false).includes("connector"), "MCP phrase mentions the connector");
257
257
 
258
+ // --- baked offline catalog seeds pricing/limits (no network) ---
259
+ {
260
+ const { priceOf, contextOf, catalogSize, catalogText } = await import("./client/models-dev.ts");
261
+ assert.ok(catalogSize() > 100, `catalog seeded from catalog.json (${catalogSize()} models)`);
262
+ const op = priceOf("claude-opus-4-8");
263
+ assert.ok(op && op[0] > 0 && op[1] > 0, "priceOf resolves a baked model offline");
264
+ assert.ok((contextOf("claude-opus-4-8") ?? 0) >= 200000, "contextOf resolves a baked model offline");
265
+ assert.ok(/anthropic/.test(catalogText()) && /openai/.test(catalogText()) && /cloudflare/.test(catalogText()), "catalogText lists the popular providers");
266
+ assert.ok(/claude-opus-4-8/.test(catalogText("anthropic")), "catalogText <provider> lists its models");
267
+ }
268
+
269
+ // --- provider routing (incl. the new cloudflare + groq/together disambiguation) ---
270
+ {
271
+ const { route } = await import("./server/router.ts");
272
+ const { PROVIDERS } = await import("./server/config.ts");
273
+ assert.ok("cloudflare" in PROVIDERS, "cloudflare provider is registered");
274
+ assert.equal(route("@cf/moonshotai/kimi-k2.7-code"), "cloudflare", "@cf/ → cloudflare");
275
+ assert.equal(route("groq/llama-3.3-70b"), "groq", "groq/ → groq");
276
+ assert.equal(route("together/x"), "together", "together/ → together");
277
+ assert.equal(route("claude-opus-4-8"), "anthropic", "claude → anthropic");
278
+ assert.equal(route("gpt-5"), "openai", "gpt → openai");
279
+ assert.equal(route("gemini-3-pro"), "google", "gemini → google");
280
+ assert.equal(route("qwen3-coder"), "dashscope", "qwen → dashscope");
281
+ assert.equal(route("anything-else"), "openrouter", "unmatched → openrouter");
282
+ }
283
+
258
284
  // --- background job runs and reports ---
259
285
  const jid = startJob("selfcheck job", async () => "job-done-ok");
260
286
  await new Promise((r) => setTimeout(r, 30));
@@ -1,58 +1,65 @@
1
- // Backend configuration: provider upstreams, keys, client-key auth, port.
2
- // Everything is env-driven. The backend is the only place provider keys live.
3
-
4
- import { getCredential } from "./credentials.ts";
5
- import type { ProviderName } from "../shared/types.ts";
6
-
7
- export interface ProviderDef {
8
- baseURL: string; // OpenAI-compatible base (…/v1) — every provider is proxied as-is
9
- keyEnv: string; // env var holding this provider's key ("" = keyless, e.g. local Ollama)
10
- }
11
-
12
- export const PROVIDERS: Record<ProviderName, ProviderDef> = {
13
- openai: { baseURL: "https://api.openai.com/v1", keyEnv: "OPENAI_API_KEY" },
14
- anthropic: { baseURL: "https://api.anthropic.com/v1", keyEnv: "ANTHROPIC_API_KEY" },
15
- google: { baseURL: "https://generativelanguage.googleapis.com/v1beta/openai", keyEnv: "GEMINI_API_KEY" },
16
- mistral: { baseURL: "https://api.mistral.ai/v1", keyEnv: "MISTRAL_API_KEY" },
17
- openrouter: { baseURL: "https://openrouter.ai/api/v1", keyEnv: "OPENROUTER_API_KEY" },
18
- groq: { baseURL: "https://api.groq.com/openai/v1", keyEnv: "GROQ_API_KEY" },
19
- deepseek: { baseURL: "https://api.deepseek.com", keyEnv: "DEEPSEEK_API_KEY" },
20
- together: { baseURL: "https://api.together.xyz/v1", keyEnv: "TOGETHER_API_KEY" },
21
- xai: { baseURL: "https://api.x.ai/v1", keyEnv: "XAI_API_KEY" },
22
- dashscope: {
23
- baseURL: process.env.DASHSCOPE_BASE_URL ?? "https://dashscope-intl.aliyuncs.com/compatible-mode/v1",
24
- keyEnv: "DASHSCOPE_API_KEY",
25
- },
26
- // GitHub Copilot — OpenAI-compatible chat endpoint. COPILOT_API_KEY must be a Copilot *bearer*
27
- // token (exchanged from a GitHub OAuth token at /copilot_internal/v2/token — that exchange is not
28
- // implemented here; it needs a Copilot subscription). Required headers are added in the adapter.
29
- copilot: { baseURL: process.env.COPILOT_BASE_URL ?? "https://api.githubcopilot.com", keyEnv: "COPILOT_API_KEY" },
30
- ollama: { baseURL: process.env.OLLAMA_BASE_URL ?? "http://localhost:11434/v1", keyEnv: "" },
31
- };
32
-
33
- export const PORT = Number(process.env.ADA_PORT) || 8787;
34
-
35
- /** The ada client keys allowed to use this backend. null = auth disabled (dev mode). */
36
- export function clientKeys(): string[] | null {
37
- const v = process.env.ADA_CLIENT_KEYS;
38
- if (!v) return null;
39
- return v.split(",").map((s) => s.trim()).filter(Boolean);
40
- }
41
-
42
- /** The upstream provider key: env var first, then a stored credential (API key or OAuth token). */
43
- export function providerKey(p: ProviderName): string | undefined {
44
- const env = PROVIDERS[p].keyEnv;
45
- if (env && process.env[env]) return process.env[env];
46
- const cred = getCredential(p);
47
- if (cred) return cred.type === "oauth" ? cred.access : cred.key;
48
- return undefined; // keyless provider (Ollama) or unconfigured
49
- }
50
-
51
- /** A provider is usable if it's keyless, its key env var is set, or a credential is stored. */
52
- export function isConfigured(p: ProviderName): boolean {
53
- return PROVIDERS[p].keyEnv === "" || !!process.env[PROVIDERS[p].keyEnv] || !!getCredential(p);
54
- }
55
-
56
- export function configuredProviders(): ProviderName[] {
57
- return (Object.keys(PROVIDERS) as ProviderName[]).filter(isConfigured);
58
- }
1
+ // Backend configuration: provider upstreams, keys, client-key auth, port.
2
+ // Everything is env-driven. The backend is the only place provider keys live.
3
+
4
+ import { getCredential } from "./credentials.ts";
5
+ import type { ProviderName } from "../shared/types.ts";
6
+
7
+ export interface ProviderDef {
8
+ baseURL: string; // OpenAI-compatible base (…/v1) — every provider is proxied as-is
9
+ keyEnv: string; // env var holding this provider's key ("" = keyless, e.g. local Ollama)
10
+ }
11
+
12
+ export const PROVIDERS: Record<ProviderName, ProviderDef> = {
13
+ openai: { baseURL: "https://api.openai.com/v1", keyEnv: "OPENAI_API_KEY" },
14
+ anthropic: { baseURL: "https://api.anthropic.com/v1", keyEnv: "ANTHROPIC_API_KEY" },
15
+ google: { baseURL: "https://generativelanguage.googleapis.com/v1beta/openai", keyEnv: "GEMINI_API_KEY" },
16
+ mistral: { baseURL: "https://api.mistral.ai/v1", keyEnv: "MISTRAL_API_KEY" },
17
+ openrouter: { baseURL: "https://openrouter.ai/api/v1", keyEnv: "OPENROUTER_API_KEY" },
18
+ groq: { baseURL: "https://api.groq.com/openai/v1", keyEnv: "GROQ_API_KEY" },
19
+ deepseek: { baseURL: "https://api.deepseek.com", keyEnv: "DEEPSEEK_API_KEY" },
20
+ together: { baseURL: "https://api.together.xyz/v1", keyEnv: "TOGETHER_API_KEY" },
21
+ xai: { baseURL: "https://api.x.ai/v1", keyEnv: "XAI_API_KEY" },
22
+ dashscope: {
23
+ baseURL: process.env.DASHSCOPE_BASE_URL ?? "https://dashscope-intl.aliyuncs.com/compatible-mode/v1",
24
+ keyEnv: "DASHSCOPE_API_KEY",
25
+ },
26
+ // GitHub Copilot — OpenAI-compatible chat endpoint. COPILOT_API_KEY must be a Copilot *bearer*
27
+ // token (exchanged from a GitHub OAuth token at /copilot_internal/v2/token — that exchange is not
28
+ // implemented here; it needs a Copilot subscription). Required headers are added in the adapter.
29
+ copilot: { baseURL: process.env.COPILOT_BASE_URL ?? "https://api.githubcopilot.com", keyEnv: "COPILOT_API_KEY" },
30
+ // Cloudflare Workers AI / AI Gateway — OpenAI-compatible. Workers AI: set CLOUDFLARE_ACCOUNT_ID +
31
+ // CLOUDFLARE_API_TOKEN (default URL). AI Gateway: point CLOUDFLARE_BASE_URL at the gateway URL.
32
+ // Model ids are `@cf/<vendor>/<model>` (e.g. @cf/moonshotai/kimi-k2.7-code) — sent through as-is.
33
+ cloudflare: {
34
+ baseURL: process.env.CLOUDFLARE_BASE_URL ?? `https://api.cloudflare.com/client/v4/accounts/${process.env.CLOUDFLARE_ACCOUNT_ID ?? ""}/ai/v1`,
35
+ keyEnv: "CLOUDFLARE_API_TOKEN",
36
+ },
37
+ ollama: { baseURL: process.env.OLLAMA_BASE_URL ?? "http://localhost:11434/v1", keyEnv: "" },
38
+ };
39
+
40
+ export const PORT = Number(process.env.ADA_PORT) || 8787;
41
+
42
+ /** The ada client keys allowed to use this backend. null = auth disabled (dev mode). */
43
+ export function clientKeys(): string[] | null {
44
+ const v = process.env.ADA_CLIENT_KEYS;
45
+ if (!v) return null;
46
+ return v.split(",").map((s) => s.trim()).filter(Boolean);
47
+ }
48
+
49
+ /** The upstream provider key: env var first, then a stored credential (API key or OAuth token). */
50
+ export function providerKey(p: ProviderName): string | undefined {
51
+ const env = PROVIDERS[p].keyEnv;
52
+ if (env && process.env[env]) return process.env[env];
53
+ const cred = getCredential(p);
54
+ if (cred) return cred.type === "oauth" ? cred.access : cred.key;
55
+ return undefined; // keyless provider (Ollama) or unconfigured
56
+ }
57
+
58
+ /** A provider is usable if it's keyless, its key env var is set, or a credential is stored. */
59
+ export function isConfigured(p: ProviderName): boolean {
60
+ return PROVIDERS[p].keyEnv === "" || !!process.env[PROVIDERS[p].keyEnv] || !!getCredential(p);
61
+ }
62
+
63
+ export function configuredProviders(): ProviderName[] {
64
+ return (Object.keys(PROVIDERS) as ProviderName[]).filter(isConfigured);
65
+ }
@@ -1,76 +1,78 @@
1
- // OpenAI-compatible adapter. Covers every provider that speaks the OpenAI Chat
2
- // Completions format: OpenAI, Mistral, Groq, DeepSeek, xAI, OpenRouter, Together, Ollama,
3
- // and Gemini (via Google's OpenAI-compatible endpoint). Because the client also speaks
4
- // that format, this adapter just swaps in the upstream base URL + key and streams the
5
- // response straight back — no translation needed.
6
-
7
- import type { ProviderName } from "../../shared/types.ts";
8
- import { PROVIDERS, providerKey } from "../config.ts";
9
- import { SSE_HEADERS } from "../sse.ts";
10
- import type { Adapter, ChatRequest } from "./adapter.ts";
11
-
12
- function authHeaders(provider: ProviderName): Record<string, string> {
13
- const key = providerKey(provider);
14
- const base: Record<string, string> = key ? { authorization: `Bearer ${key}` } : {};
15
- // GitHub Copilot's endpoint requires these editor-identification headers.
16
- if (provider === "copilot") return { ...base, "Copilot-Integration-Id": "vscode-chat", "Editor-Version": "ada/0.0.1", "Editor-Plugin-Version": "ada/0.0.1" };
17
- return base;
18
- }
19
-
20
- export const openAICompatAdapter: Adapter = {
21
- async chat({ provider, body, res }: ChatRequest): Promise<void> {
22
- const def = PROVIDERS[provider];
23
- // Copilot is addressed as "copilot/<model>" but the endpoint wants the bare model id.
24
- const outBody = provider === "copilot" && typeof body.model === "string" && body.model.startsWith("copilot/") ? { ...body, model: body.model.slice("copilot/".length) } : body;
25
- let upstream: Awaited<ReturnType<typeof fetch>>;
26
- try {
27
- upstream = await fetch(`${def.baseURL}/chat/completions`, {
28
- method: "POST",
29
- headers: { "content-type": "application/json", ...authHeaders(provider) },
30
- body: JSON.stringify(outBody),
31
- });
32
- } catch (e) {
33
- res.writeHead(502, { "content-type": "application/json" });
34
- res.end(
35
- JSON.stringify({
36
- error: { message: `could not reach ${provider} upstream at ${def.baseURL}: ${e instanceof Error ? e.message : String(e)}` },
37
- }),
38
- );
39
- return;
40
- }
41
-
42
- if (!upstream.ok || !upstream.body) {
43
- const text = await upstream.text().catch(() => "");
44
- res.writeHead(upstream.status || 502, { "content-type": "application/json" });
45
- res.end(text || JSON.stringify({ error: { message: `upstream error ${upstream.status}` } }));
46
- return;
47
- }
48
-
49
- if (body.stream) {
50
- res.writeHead(200, SSE_HEADERS);
51
- const reader = upstream.body.getReader();
52
- for (;;) {
53
- const { done, value } = await reader.read();
54
- if (done) break;
55
- if (value) res.write(Buffer.from(value));
56
- }
57
- res.end();
58
- } else {
59
- const text = await upstream.text();
60
- res.writeHead(upstream.status, { "content-type": upstream.headers.get("content-type") ?? "application/json" });
61
- res.end(text);
62
- }
63
- },
64
-
65
- async listModels(provider: ProviderName): Promise<string[]> {
66
- const def = PROVIDERS[provider];
67
- try {
68
- const r = await fetch(`${def.baseURL}/models`, { headers: authHeaders(provider) });
69
- if (!r.ok) return [];
70
- const j = (await r.json()) as { data?: Array<{ id?: unknown }> };
71
- return (j.data ?? []).map((m) => m.id).filter((x): x is string => typeof x === "string");
72
- } catch {
73
- return [];
74
- }
75
- },
76
- };
1
+ // OpenAI-compatible adapter. Covers every provider that speaks the OpenAI Chat
2
+ // Completions format: OpenAI, Mistral, Groq, DeepSeek, xAI, OpenRouter, Together, Ollama,
3
+ // and Gemini (via Google's OpenAI-compatible endpoint). Because the client also speaks
4
+ // that format, this adapter just swaps in the upstream base URL + key and streams the
5
+ // response straight back — no translation needed.
6
+
7
+ import type { ProviderName } from "../../shared/types.ts";
8
+ import { PROVIDERS, providerKey } from "../config.ts";
9
+ import { SSE_HEADERS } from "../sse.ts";
10
+ import type { Adapter, ChatRequest } from "./adapter.ts";
11
+
12
+ function authHeaders(provider: ProviderName): Record<string, string> {
13
+ const key = providerKey(provider);
14
+ const base: Record<string, string> = key ? { authorization: `Bearer ${key}` } : {};
15
+ // GitHub Copilot's endpoint requires these editor-identification headers.
16
+ if (provider === "copilot") return { ...base, "Copilot-Integration-Id": "vscode-chat", "Editor-Version": "ada/0.0.1", "Editor-Plugin-Version": "ada/0.0.1" };
17
+ return base;
18
+ }
19
+
20
+ export const openAICompatAdapter: Adapter = {
21
+ async chat({ provider, body, res }: ChatRequest): Promise<void> {
22
+ const def = PROVIDERS[provider];
23
+ // Strip a leading "<provider>/" the router used only to disambiguate (copilot/groq/together) — the
24
+ // endpoint wants the bare id. (Cloudflare's "@cf/…" ids aren't "cloudflare/…", so they pass through.)
25
+ const prefix = `${provider}/`;
26
+ const outBody = typeof body.model === "string" && body.model.startsWith(prefix) ? { ...body, model: body.model.slice(prefix.length) } : body;
27
+ let upstream: Awaited<ReturnType<typeof fetch>>;
28
+ try {
29
+ upstream = await fetch(`${def.baseURL}/chat/completions`, {
30
+ method: "POST",
31
+ headers: { "content-type": "application/json", ...authHeaders(provider) },
32
+ body: JSON.stringify(outBody),
33
+ });
34
+ } catch (e) {
35
+ res.writeHead(502, { "content-type": "application/json" });
36
+ res.end(
37
+ JSON.stringify({
38
+ error: { message: `could not reach ${provider} upstream at ${def.baseURL}: ${e instanceof Error ? e.message : String(e)}` },
39
+ }),
40
+ );
41
+ return;
42
+ }
43
+
44
+ if (!upstream.ok || !upstream.body) {
45
+ const text = await upstream.text().catch(() => "");
46
+ res.writeHead(upstream.status || 502, { "content-type": "application/json" });
47
+ res.end(text || JSON.stringify({ error: { message: `upstream error ${upstream.status}` } }));
48
+ return;
49
+ }
50
+
51
+ if (body.stream) {
52
+ res.writeHead(200, SSE_HEADERS);
53
+ const reader = upstream.body.getReader();
54
+ for (;;) {
55
+ const { done, value } = await reader.read();
56
+ if (done) break;
57
+ if (value) res.write(Buffer.from(value));
58
+ }
59
+ res.end();
60
+ } else {
61
+ const text = await upstream.text();
62
+ res.writeHead(upstream.status, { "content-type": upstream.headers.get("content-type") ?? "application/json" });
63
+ res.end(text);
64
+ }
65
+ },
66
+
67
+ async listModels(provider: ProviderName): Promise<string[]> {
68
+ const def = PROVIDERS[provider];
69
+ try {
70
+ const r = await fetch(`${def.baseURL}/models`, { headers: authHeaders(provider) });
71
+ if (!r.ok) return [];
72
+ const j = (await r.json()) as { data?: Array<{ id?: unknown }> };
73
+ return (j.data ?? []).map((m) => m.id).filter((x): x is string => typeof x === "string");
74
+ } catch {
75
+ return [];
76
+ }
77
+ },
78
+ };
@@ -1,31 +1,32 @@
1
- // Provider → adapter map. This table is the whole routing story at a glance:
2
- // who shares the OpenAI-compatible adapter, and who has a dedicated one.
3
- //
4
- // Adding support is obvious from here:
5
- // - new model on an existing provider → nothing to change
6
- // - new OpenAI-compatible provider → add it in config.ts + a line below
7
- // - new provider with a divergent format → write an adapter, map it below
8
-
9
- import type { ProviderName } from "../../shared/types.ts";
10
- import type { Adapter } from "./adapter.ts";
11
- import { anthropicAdapter } from "./anthropic.ts";
12
- import { openAICompatAdapter } from "./openai-compat.ts";
13
-
14
- const ADAPTERS: Record<ProviderName, Adapter> = {
15
- anthropic: anthropicAdapter, // native: Anthropic Messages API
16
- openai: openAICompatAdapter,
17
- google: openAICompatAdapter, // via Google's OpenAI-compatible endpoint
18
- mistral: openAICompatAdapter,
19
- openrouter: openAICompatAdapter,
20
- groq: openAICompatAdapter,
21
- deepseek: openAICompatAdapter,
22
- together: openAICompatAdapter,
23
- xai: openAICompatAdapter,
24
- dashscope: openAICompatAdapter, // Alibaba Qwen via DashScope's OpenAI-compatible endpoint
25
- copilot: openAICompatAdapter, // GitHub Copilot's OpenAI-compatible endpoint (+ custom headers in the adapter)
26
- ollama: openAICompatAdapter,
27
- };
28
-
29
- export function adapterFor(provider: ProviderName): Adapter {
30
- return ADAPTERS[provider];
31
- }
1
+ // Provider → adapter map. This table is the whole routing story at a glance:
2
+ // who shares the OpenAI-compatible adapter, and who has a dedicated one.
3
+ //
4
+ // Adding support is obvious from here:
5
+ // - new model on an existing provider → nothing to change
6
+ // - new OpenAI-compatible provider → add it in config.ts + a line below
7
+ // - new provider with a divergent format → write an adapter, map it below
8
+
9
+ import type { ProviderName } from "../../shared/types.ts";
10
+ import type { Adapter } from "./adapter.ts";
11
+ import { anthropicAdapter } from "./anthropic.ts";
12
+ import { openAICompatAdapter } from "./openai-compat.ts";
13
+
14
+ const ADAPTERS: Record<ProviderName, Adapter> = {
15
+ anthropic: anthropicAdapter, // native: Anthropic Messages API
16
+ openai: openAICompatAdapter,
17
+ google: openAICompatAdapter, // via Google's OpenAI-compatible endpoint
18
+ mistral: openAICompatAdapter,
19
+ openrouter: openAICompatAdapter,
20
+ groq: openAICompatAdapter,
21
+ deepseek: openAICompatAdapter,
22
+ together: openAICompatAdapter,
23
+ xai: openAICompatAdapter,
24
+ dashscope: openAICompatAdapter, // Alibaba Qwen via DashScope's OpenAI-compatible endpoint
25
+ copilot: openAICompatAdapter, // GitHub Copilot's OpenAI-compatible endpoint (+ custom headers in the adapter)
26
+ cloudflare: openAICompatAdapter, // Cloudflare Workers AI / AI Gateway (OpenAI-compatible)
27
+ ollama: openAICompatAdapter,
28
+ };
29
+
30
+ export function adapterFor(provider: ProviderName): Adapter {
31
+ return ADAPTERS[provider];
32
+ }
@@ -1,29 +1,33 @@
1
- // Map a model id (and optional explicit provider) to a provider.
2
- // Order matters: explicit wins; then the shape of the id (namespaced / local); then base-name prefixes.
3
-
4
- import type { ProviderName } from "../shared/types.ts";
5
- import { PROVIDERS } from "./config.ts";
6
-
7
- export function route(model: string, explicit?: string): ProviderName {
8
- if (explicit && explicit in PROVIDERS) return explicit as ProviderName;
9
-
10
- const m = model.toLowerCase();
11
-
12
- // "vendor/model" → OpenRouter's namespacing convention. Checked before base-name prefixes
13
- // so e.g. "mistralai/…" routes to OpenRouter, not the Mistral API.
14
- // "copilot/<model>" GitHub Copilot (checked before the OpenRouter "/" rule).
15
- if (m.startsWith("copilot/")) return "copilot";
16
- if (m.includes("/")) return "openrouter";
17
- // "model:tag" a local Ollama model (e.g. gemma4:latest).
18
- if (m.includes(":")) return "ollama";
19
-
20
- if (/^(gpt|o1|o3|o4|chatgpt|text-|davinci)/.test(m)) return "openai";
21
- if (m.startsWith("claude")) return "anthropic";
22
- if (m.startsWith("gemini") || m.startsWith("gemma")) return "google";
23
- if (/^(mistral|codestral|magistral|ministral|devstral|pixtral|open-mi)/.test(m)) return "mistral";
24
- if (m.startsWith("grok")) return "xai";
25
- if (m.startsWith("deepseek")) return "deepseek";
26
- if (m.startsWith("qwen") || m.startsWith("qwq")) return "dashscope";
27
-
28
- return "openrouter"; // default: one key, every model
29
- }
1
+ // Map a model id (and optional explicit provider) to a provider.
2
+ // Order matters: explicit wins; then the shape of the id (namespaced / local); then base-name prefixes.
3
+
4
+ import type { ProviderName } from "../shared/types.ts";
5
+ import { PROVIDERS } from "./config.ts";
6
+
7
+ export function route(model: string, explicit?: string): ProviderName {
8
+ if (explicit && explicit in PROVIDERS) return explicit as ProviderName;
9
+
10
+ const m = model.toLowerCase();
11
+
12
+ // "vendor/model" → OpenRouter's namespacing convention. Checked before base-name prefixes
13
+ // so e.g. "mistralai/…" routes to OpenRouter, not the Mistral API.
14
+ // Prefixed ids that must beat the OpenRouter "/" rule below:
15
+ if (m.startsWith("@cf/")) return "cloudflare"; // Cloudflare Workers AI model ids
16
+ if (m.startsWith("copilot/")) return "copilot";
17
+ // `groq/…` / `together/…` disambiguate shared model names (llama-3.3, gemma2…) that no prefix can.
18
+ if (m.startsWith("groq/")) return "groq";
19
+ if (m.startsWith("together/")) return "together";
20
+ if (m.includes("/")) return "openrouter";
21
+ // "model:tag" a local Ollama model (e.g. gemma4:latest).
22
+ if (m.includes(":")) return "ollama";
23
+
24
+ if (/^(gpt|o1|o3|o4|chatgpt|text-|davinci)/.test(m)) return "openai";
25
+ if (m.startsWith("claude")) return "anthropic";
26
+ if (m.startsWith("gemini") || m.startsWith("gemma")) return "google";
27
+ if (/^(mistral|codestral|magistral|ministral|devstral|pixtral|open-mi)/.test(m)) return "mistral";
28
+ if (m.startsWith("grok")) return "xai";
29
+ if (m.startsWith("deepseek")) return "deepseek";
30
+ if (m.startsWith("qwen") || m.startsWith("qwq")) return "dashscope";
31
+
32
+ return "openrouter"; // default: one key, every model
33
+ }