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.
- package/README.md +14 -7
- package/bench/README.md +88 -88
- package/bench/swebench.mjs +242 -242
- package/docs/architecture.md +163 -139
- package/docs/architecture.svg +73 -73
- package/docs/cloudflare.md +81 -0
- package/docs/connectors.md +49 -48
- package/docs/integrations.md +62 -59
- package/package.json +65 -64
- package/src/client/catalog.json +1 -0
- package/src/client/cli.ts +1262 -1253
- package/src/client/models-dev.ts +106 -52
- package/src/selfcheck.ts +26 -0
- package/src/server/config.ts +65 -58
- package/src/server/providers/openai-compat.ts +78 -76
- package/src/server/providers/registry.ts +32 -31
- package/src/server/router.ts +33 -29
- package/src/shared/types.ts +21 -20
package/src/client/models-dev.ts
CHANGED
|
@@ -1,52 +1,106 @@
|
|
|
1
|
-
// models.dev catalog — model metadata (context limits, pricing, capabilities).
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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));
|
package/src/server/config.ts
CHANGED
|
@@ -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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
/** The
|
|
43
|
-
export function
|
|
44
|
-
const
|
|
45
|
-
if (
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
//
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
const
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
+
}
|
package/src/server/router.ts
CHANGED
|
@@ -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
|
-
//
|
|
15
|
-
if (m.startsWith("
|
|
16
|
-
if (m.
|
|
17
|
-
//
|
|
18
|
-
if (m.
|
|
19
|
-
|
|
20
|
-
if (
|
|
21
|
-
|
|
22
|
-
if (m.
|
|
23
|
-
|
|
24
|
-
if (
|
|
25
|
-
if (m.startsWith("
|
|
26
|
-
if (m.startsWith("
|
|
27
|
-
|
|
28
|
-
return "
|
|
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
|
+
}
|