aigetwey 1.0.1

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.
Files changed (216) hide show
  1. package/CHANGELOG.md +84 -0
  2. package/LICENSE +21 -0
  3. package/README.md +302 -0
  4. package/assets/logo.svg +8 -0
  5. package/assets/screenshot.png +0 -0
  6. package/assets/wordmark.svg +9 -0
  7. package/config.example.yaml +56 -0
  8. package/dashboard/.env.example +12 -0
  9. package/dashboard/next-env.d.ts +6 -0
  10. package/dashboard/next.config.ts +12 -0
  11. package/dashboard/package-lock.json +1771 -0
  12. package/dashboard/package.json +29 -0
  13. package/dashboard/postcss.config.mjs +5 -0
  14. package/dashboard/src/app/(console)/combos/page.tsx +10 -0
  15. package/dashboard/src/app/(console)/config/page.tsx +5 -0
  16. package/dashboard/src/app/(console)/console/page.tsx +92 -0
  17. package/dashboard/src/app/(console)/endpoint/page.tsx +5 -0
  18. package/dashboard/src/app/(console)/layout.tsx +17 -0
  19. package/dashboard/src/app/(console)/page.tsx +8 -0
  20. package/dashboard/src/app/(console)/providers/[id]/page.tsx +6 -0
  21. package/dashboard/src/app/(console)/providers/page.tsx +5 -0
  22. package/dashboard/src/app/(console)/quota/page.tsx +5 -0
  23. package/dashboard/src/app/(console)/tools/[id]/page.tsx +6 -0
  24. package/dashboard/src/app/(console)/tools/page.tsx +5 -0
  25. package/dashboard/src/app/(console)/usage/page.tsx +24 -0
  26. package/dashboard/src/app/api/cli-detect/[tool]/route.ts +253 -0
  27. package/dashboard/src/app/api/gw/[...path]/route.ts +89 -0
  28. package/dashboard/src/app/api/login/route.ts +30 -0
  29. package/dashboard/src/app/api/logout/route.ts +9 -0
  30. package/dashboard/src/app/api/password/route.ts +34 -0
  31. package/dashboard/src/app/globals.css +340 -0
  32. package/dashboard/src/app/icon.svg +8 -0
  33. package/dashboard/src/app/layout.tsx +28 -0
  34. package/dashboard/src/app/login/page.tsx +60 -0
  35. package/dashboard/src/components/AreaChart.tsx +115 -0
  36. package/dashboard/src/components/Badge.tsx +32 -0
  37. package/dashboard/src/components/Button.tsx +60 -0
  38. package/dashboard/src/components/CapacityBadges.tsx +40 -0
  39. package/dashboard/src/components/Checkbox.tsx +40 -0
  40. package/dashboard/src/components/CliToolConfig.tsx +63 -0
  41. package/dashboard/src/components/ConfigEditor.tsx +199 -0
  42. package/dashboard/src/components/ConfirmModal.tsx +36 -0
  43. package/dashboard/src/components/CooldownTimer.tsx +42 -0
  44. package/dashboard/src/components/EndpointView.tsx +439 -0
  45. package/dashboard/src/components/Icon.tsx +25 -0
  46. package/dashboard/src/components/KeyReveal.tsx +78 -0
  47. package/dashboard/src/components/Lamp.tsx +8 -0
  48. package/dashboard/src/components/LogTable.tsx +223 -0
  49. package/dashboard/src/components/LogoutButton.tsx +20 -0
  50. package/dashboard/src/components/ModelPicker.tsx +121 -0
  51. package/dashboard/src/components/ModelSelectModal.tsx +126 -0
  52. package/dashboard/src/components/PasswordEditor.tsx +86 -0
  53. package/dashboard/src/components/PricingEditor.tsx +171 -0
  54. package/dashboard/src/components/ProviderDetail.tsx +566 -0
  55. package/dashboard/src/components/ProviderManager.tsx +311 -0
  56. package/dashboard/src/components/QuotaView.tsx +78 -0
  57. package/dashboard/src/components/Rail.tsx +82 -0
  58. package/dashboard/src/components/RichCard.tsx +46 -0
  59. package/dashboard/src/components/RoutingView.tsx +329 -0
  60. package/dashboard/src/components/ThemeProvider.tsx +36 -0
  61. package/dashboard/src/components/ToastProvider.tsx +58 -0
  62. package/dashboard/src/components/ToolDetail.tsx +475 -0
  63. package/dashboard/src/components/TopBar.tsx +128 -0
  64. package/dashboard/src/components/UsageView.tsx +151 -0
  65. package/dashboard/src/components/ui.tsx +54 -0
  66. package/dashboard/src/lib/capabilities.ts +318 -0
  67. package/dashboard/src/lib/cliTools.ts +120 -0
  68. package/dashboard/src/lib/client.ts +190 -0
  69. package/dashboard/src/lib/gateway.ts +269 -0
  70. package/dashboard/src/lib/session.ts +71 -0
  71. package/dashboard/src/middleware.ts +37 -0
  72. package/dashboard/tsconfig.json +21 -0
  73. package/dist/adapters/anthropic.js +289 -0
  74. package/dist/adapters/anthropic.js.map +1 -0
  75. package/dist/adapters/gemini.js +268 -0
  76. package/dist/adapters/gemini.js.map +1 -0
  77. package/dist/adapters/index.js +8 -0
  78. package/dist/adapters/index.js.map +1 -0
  79. package/dist/adapters/openai.js +13 -0
  80. package/dist/adapters/openai.js.map +1 -0
  81. package/dist/cli/tray/autostart.js +152 -0
  82. package/dist/cli/tray/autostart.js.map +1 -0
  83. package/dist/cli/tray/icon.js +4 -0
  84. package/dist/cli/tray/icon.js.map +1 -0
  85. package/dist/cli/tray/tray.js +141 -0
  86. package/dist/cli/tray/tray.js.map +1 -0
  87. package/dist/cli/tray/trayRuntime.js +91 -0
  88. package/dist/cli/tray/trayRuntime.js.map +1 -0
  89. package/dist/cli.js +361 -0
  90. package/dist/cli.js.map +1 -0
  91. package/dist/config.js +728 -0
  92. package/dist/config.js.map +1 -0
  93. package/dist/core/authStore.js +78 -0
  94. package/dist/core/authStore.js.map +1 -0
  95. package/dist/core/canonical.js +9 -0
  96. package/dist/core/canonical.js.map +1 -0
  97. package/dist/core/console-buffer.js +25 -0
  98. package/dist/core/console-buffer.js.map +1 -0
  99. package/dist/core/fallback.js +62 -0
  100. package/dist/core/fallback.js.map +1 -0
  101. package/dist/core/handler.js +174 -0
  102. package/dist/core/handler.js.map +1 -0
  103. package/dist/core/keypool.js +105 -0
  104. package/dist/core/keypool.js.map +1 -0
  105. package/dist/core/quota.js +165 -0
  106. package/dist/core/quota.js.map +1 -0
  107. package/dist/core/state.js +52 -0
  108. package/dist/core/state.js.map +1 -0
  109. package/dist/db.js +193 -0
  110. package/dist/db.js.map +1 -0
  111. package/dist/headroom/compress.js +44 -0
  112. package/dist/headroom/compress.js.map +1 -0
  113. package/dist/headroom/detect.js +108 -0
  114. package/dist/headroom/detect.js.map +1 -0
  115. package/dist/headroom/process.js +158 -0
  116. package/dist/headroom/process.js.map +1 -0
  117. package/dist/inject/caveman.js +30 -0
  118. package/dist/inject/caveman.js.map +1 -0
  119. package/dist/inject/index.js +24 -0
  120. package/dist/inject/index.js.map +1 -0
  121. package/dist/inject/ponytail.js +19 -0
  122. package/dist/inject/ponytail.js.map +1 -0
  123. package/dist/middleware/auth.js +66 -0
  124. package/dist/middleware/auth.js.map +1 -0
  125. package/dist/providers/capabilities.js +246 -0
  126. package/dist/providers/capabilities.js.map +1 -0
  127. package/dist/providers/free.js +43 -0
  128. package/dist/providers/free.js.map +1 -0
  129. package/dist/providers/pricing.js +224 -0
  130. package/dist/providers/pricing.js.map +1 -0
  131. package/dist/providers/vertex.js +97 -0
  132. package/dist/providers/vertex.js.map +1 -0
  133. package/dist/routes/admin.js +622 -0
  134. package/dist/routes/admin.js.map +1 -0
  135. package/dist/routes/health.js +4 -0
  136. package/dist/routes/health.js.map +1 -0
  137. package/dist/routes/index.js +12 -0
  138. package/dist/routes/index.js.map +1 -0
  139. package/dist/routes/v1.js +75 -0
  140. package/dist/routes/v1.js.map +1 -0
  141. package/dist/rtk/detect.js +50 -0
  142. package/dist/rtk/detect.js.map +1 -0
  143. package/dist/rtk/filters.js +85 -0
  144. package/dist/rtk/filters.js.map +1 -0
  145. package/dist/rtk/index.js +39 -0
  146. package/dist/rtk/index.js.map +1 -0
  147. package/dist/server.js +100 -0
  148. package/dist/server.js.map +1 -0
  149. package/dist/stream/anthropic-stream.js +239 -0
  150. package/dist/stream/anthropic-stream.js.map +1 -0
  151. package/dist/stream/chunk.js +7 -0
  152. package/dist/stream/chunk.js.map +1 -0
  153. package/dist/stream/gemini-stream.js +135 -0
  154. package/dist/stream/gemini-stream.js.map +1 -0
  155. package/dist/stream/index.js +12 -0
  156. package/dist/stream/index.js.map +1 -0
  157. package/dist/stream/openai-stream.js +34 -0
  158. package/dist/stream/openai-stream.js.map +1 -0
  159. package/dist/stream/sse.js +64 -0
  160. package/dist/stream/sse.js.map +1 -0
  161. package/dist/translator/thinking.js +70 -0
  162. package/dist/translator/thinking.js.map +1 -0
  163. package/dist/translator/thinkingUnified.js +322 -0
  164. package/dist/translator/thinkingUnified.js.map +1 -0
  165. package/dist/upstream/client.js +120 -0
  166. package/dist/upstream/client.js.map +1 -0
  167. package/package.json +76 -0
  168. package/run.sh +27 -0
  169. package/src/adapters/anthropic.ts +377 -0
  170. package/src/adapters/gemini.ts +341 -0
  171. package/src/adapters/index.ts +17 -0
  172. package/src/adapters/openai.ts +22 -0
  173. package/src/cli/tray/autostart.ts +133 -0
  174. package/src/cli/tray/icon.ts +4 -0
  175. package/src/cli/tray/tray.ts +156 -0
  176. package/src/cli/tray/trayRuntime.ts +90 -0
  177. package/src/cli.ts +379 -0
  178. package/src/config.ts +777 -0
  179. package/src/core/authStore.ts +86 -0
  180. package/src/core/canonical.ts +93 -0
  181. package/src/core/console-buffer.ts +39 -0
  182. package/src/core/fallback.ts +116 -0
  183. package/src/core/handler.ts +236 -0
  184. package/src/core/keypool.ts +152 -0
  185. package/src/core/quota.ts +214 -0
  186. package/src/core/state.ts +65 -0
  187. package/src/db.ts +280 -0
  188. package/src/headroom/compress.ts +78 -0
  189. package/src/headroom/detect.ts +119 -0
  190. package/src/headroom/process.ts +166 -0
  191. package/src/inject/caveman.ts +35 -0
  192. package/src/inject/index.ts +46 -0
  193. package/src/inject/ponytail.ts +31 -0
  194. package/src/middleware/auth.ts +76 -0
  195. package/src/providers/capabilities.ts +297 -0
  196. package/src/providers/free.ts +53 -0
  197. package/src/providers/pricing.ts +261 -0
  198. package/src/providers/vertex.ts +117 -0
  199. package/src/routes/admin.ts +716 -0
  200. package/src/routes/health.ts +5 -0
  201. package/src/routes/index.ts +24 -0
  202. package/src/routes/v1.ts +87 -0
  203. package/src/rtk/detect.ts +55 -0
  204. package/src/rtk/filters.ts +94 -0
  205. package/src/rtk/index.ts +58 -0
  206. package/src/server.ts +108 -0
  207. package/src/stream/anthropic-stream.ts +310 -0
  208. package/src/stream/chunk.ts +46 -0
  209. package/src/stream/gemini-stream.ts +158 -0
  210. package/src/stream/index.ts +23 -0
  211. package/src/stream/openai-stream.ts +41 -0
  212. package/src/stream/sse.ts +72 -0
  213. package/src/translator/thinking.ts +64 -0
  214. package/src/translator/thinkingUnified.ts +319 -0
  215. package/src/upstream/client.ts +155 -0
  216. package/tsconfig.json +20 -0
@@ -0,0 +1,151 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState, useCallback } from "react";
4
+ import { AreaChart, type SeriesPoint } from "@/components/AreaChart";
5
+ import { Stat, fmt, Empty } from "@/components/ui";
6
+ import { RichCard } from "@/components/RichCard";
7
+ import type { UsageSummary } from "@/lib/gateway";
8
+
9
+ type Window = { label: string; ms: number; bucketMs: number };
10
+
11
+ // window -> (lookback, chart bucket size). Buckets keep ~24-48 points per range.
12
+ const WINDOWS: Window[] = [
13
+ { label: "24h", ms: 24 * 3600_000, bucketMs: 3600_000 },
14
+ { label: "7d", ms: 7 * 86400_000, bucketMs: 6 * 3600_000 },
15
+ { label: "30d", ms: 30 * 86400_000, bucketMs: 86400_000 },
16
+ ];
17
+
18
+ export function UsageView() {
19
+ const [win, setWin] = useState<Window>(WINDOWS[0]!);
20
+ const [summary, setSummary] = useState<UsageSummary | null>(null);
21
+ const [series, setSeries] = useState<SeriesPoint[]>([]);
22
+ const [error, setError] = useState("");
23
+ const [loading, setLoading] = useState(true);
24
+
25
+ const load = useCallback(async (w: Window) => {
26
+ setLoading(true);
27
+ setError("");
28
+ const since = Date.now() - w.ms;
29
+ const [sumRes, serRes] = await Promise.all([
30
+ fetch(`/api/gw/admin/usage?since=${since}`),
31
+ fetch(`/api/gw/admin/usage/series?since=${since}&bucket=${w.bucketMs}`),
32
+ ]);
33
+ if (!sumRes.ok) {
34
+ setError("could not load usage");
35
+ setLoading(false);
36
+ return;
37
+ }
38
+ setSummary((await sumRes.json()) as UsageSummary);
39
+ const ser = serRes.ok ? ((await serRes.json()) as { series: SeriesPoint[] }) : { series: [] };
40
+ setSeries(ser.series);
41
+ setLoading(false);
42
+ }, []);
43
+
44
+ useEffect(() => {
45
+ void load(win);
46
+ }, [win, load]);
47
+
48
+ const total = summary?.total;
49
+
50
+ return (
51
+ <div>
52
+ <div className="mb-6 flex items-center justify-between gap-3">
53
+ <div>
54
+ <h1 className="text-[22px] font-semibold tracking-tight text-text">Usage</h1>
55
+ <p className="mt-1 text-[13px] text-text-muted">Tokens and cost across providers and models.</p>
56
+ </div>
57
+ <div className="flex items-center gap-1 rounded-full border border-border bg-surface p-1">
58
+ {WINDOWS.map((w) => (
59
+ <button
60
+ key={w.label}
61
+ onClick={() => setWin(w)}
62
+ className={`rounded-full px-3 py-1 text-[12px] font-medium transition-colors ${
63
+ win.label === w.label ? "bg-surface-2 text-text" : "text-text-muted hover:text-text"
64
+ }`}
65
+ >
66
+ {w.label}
67
+ </button>
68
+ ))}
69
+ </div>
70
+ </div>
71
+
72
+ {error ? (
73
+ <Empty>{error}</Empty>
74
+ ) : (
75
+ <>
76
+ <div className="mb-5 grid grid-cols-2 gap-3 sm:grid-cols-4">
77
+ <Stat label="Requests" value={fmt.int(total?.requests ?? 0)} />
78
+ <Stat label="Tokens in" value={fmt.compact(total?.tokens_in ?? 0)} />
79
+ <Stat label="Tokens out" value={fmt.compact(total?.tokens_out ?? 0)} />
80
+ <Stat label="Cost" value={fmt.cost(total?.cost ?? 0)} />
81
+ </div>
82
+
83
+ <div className="mb-5">
84
+ <AreaChart series={series} />
85
+ </div>
86
+
87
+ <div className="grid gap-4 lg:grid-cols-2">
88
+ <RichCard header={<span className="text-[13px] font-semibold text-text">By provider</span>}>
89
+ {summary?.by_provider.length ? (
90
+ <BreakdownTable
91
+ rows={summary.by_provider.map((p) => ({ label: p.provider, ...p }))}
92
+ loading={loading}
93
+ />
94
+ ) : (
95
+ <Empty>No usage in this window.</Empty>
96
+ )}
97
+ </RichCard>
98
+ <RichCard header={<span className="text-[13px] font-semibold text-text">By model</span>}>
99
+ {summary?.by_model.length ? (
100
+ <BreakdownTable
101
+ rows={summary.by_model.map((m) => ({ label: `${m.alias} → ${m.model}`, ...m }))}
102
+ loading={loading}
103
+ />
104
+ ) : (
105
+ <Empty>No usage in this window.</Empty>
106
+ )}
107
+ </RichCard>
108
+ </div>
109
+ </>
110
+ )}
111
+ </div>
112
+ );
113
+ }
114
+
115
+ function BreakdownTable({
116
+ rows,
117
+ loading,
118
+ }: {
119
+ rows: Array<{ label: string; requests: number; tokens_in: number; tokens_out: number; cost: number }>;
120
+ loading: boolean;
121
+ }) {
122
+ return (
123
+ <div className={`table-wrap ${loading ? "opacity-50" : ""}`}>
124
+ <table className="w-full border-collapse">
125
+ <thead>
126
+ <tr className="text-text-subtle">
127
+ {["", "Reqs", "In", "Out", "Cost"].map((h, i) => (
128
+ <th
129
+ key={h + i}
130
+ className={`pb-2 text-[10px] font-medium uppercase tracking-wider ${i === 0 ? "text-left" : "text-right"}`}
131
+ >
132
+ {h}
133
+ </th>
134
+ ))}
135
+ </tr>
136
+ </thead>
137
+ <tbody>
138
+ {rows.map((r) => (
139
+ <tr key={r.label} className="border-t border-border-subtle">
140
+ <td className="py-2 text-[12.5px] text-text">{r.label}</td>
141
+ <td className="py-2 text-right tnum text-[12.5px] text-text-muted">{fmt.int(r.requests)}</td>
142
+ <td className="py-2 text-right tnum text-[12.5px] text-text-muted">{fmt.compact(r.tokens_in)}</td>
143
+ <td className="py-2 text-right tnum text-[12.5px] text-text-muted">{fmt.compact(r.tokens_out)}</td>
144
+ <td className="py-2 text-right tnum text-[12.5px] text-text">{fmt.cost(r.cost)}</td>
145
+ </tr>
146
+ ))}
147
+ </tbody>
148
+ </table>
149
+ </div>
150
+ );
151
+ }
@@ -0,0 +1,54 @@
1
+ /** Small formatting helpers shared across the console. */
2
+ export const fmt = {
3
+ int(n: number): string {
4
+ return n.toLocaleString("en-US");
5
+ },
6
+ /** compact number: 1.2K, 3.4M */
7
+ compact(n: number): string {
8
+ if (Math.abs(n) >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
9
+ if (Math.abs(n) >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
10
+ return String(Math.round(n));
11
+ },
12
+ cost(n: number): string {
13
+ if (n === 0) return "$0";
14
+ return n < 0.01 ? `$${n.toFixed(4)}` : `$${n.toFixed(2)}`;
15
+ },
16
+ time(ts: number): string {
17
+ return new Date(ts).toLocaleString("en-US", { hour12: false });
18
+ },
19
+ /** "3m", "2h", "5d" — coarse relative age */
20
+ ago(ts: number): string {
21
+ const s = Math.max(0, Math.round((Date.now() - ts) / 1000));
22
+ if (s < 60) return `${s}s`;
23
+ if (s < 3600) return `${Math.floor(s / 60)}m`;
24
+ if (s < 86400) return `${Math.floor(s / 3600)}h`;
25
+ return `${Math.floor(s / 86400)}d`;
26
+ },
27
+ /** ms duration -> "Xs", "Xm Ys", "Xh Ym" for quota/cooldown countdowns */
28
+ duration(ms: number): string {
29
+ const s = Math.max(0, Math.round(ms / 1000));
30
+ if (s < 60) return `${s}s`;
31
+ if (s < 3600) return `${Math.floor(s / 60)}m ${s % 60}s`;
32
+ const h = Math.floor(s / 3600);
33
+ const m = Math.floor((s % 3600) / 60);
34
+ if (h < 24) return `${h}h ${m}m`;
35
+ const d = Math.floor(h / 24);
36
+ return `${d}d ${h % 24}h`;
37
+ },
38
+ };
39
+
40
+ /** A labelled stat block — number over a caption. */
41
+ export function Stat({ label, value, sub }: { label: string; value: React.ReactNode; sub?: string }) {
42
+ return (
43
+ <div className="rounded-brand-lg border border-border bg-surface px-4 py-3.5 shadow-soft">
44
+ <div className="text-[11px] font-medium uppercase tracking-wider text-text-subtle">{label}</div>
45
+ <div className="mt-1 tnum text-[22px] font-semibold tracking-tight text-text">{value}</div>
46
+ {sub && <div className="mt-0.5 text-[12px] text-text-muted">{sub}</div>}
47
+ </div>
48
+ );
49
+ }
50
+
51
+ /** Empty-state hint inside a card body. */
52
+ export function Empty({ children }: { children: React.ReactNode }) {
53
+ return <div className="px-1 py-8 text-center text-[13px] text-text-muted">{children}</div>;
54
+ }
@@ -0,0 +1,318 @@
1
+ // Model capabilities — what each model can read/do beyond plain text.
2
+ //
3
+ // Fallback order (first match wins), result merged over DEFAULT_CAPABILITIES:
4
+ // 1. PROVIDER_CAPABILITIES[provider][model] — provider-specific override
5
+ // 2. MODEL_CAPABILITIES[model] — canonical exact id (handles exceptions)
6
+ // 3. PATTERN_CAPABILITIES — glob match, ordered specific -> generic
7
+ // 4. DEFAULT_CAPABILITIES — safe floor (always returned)
8
+ //
9
+ // ── HOW TO ADD / UPDATE A MODEL ──────────────────────────────────────
10
+ // Authoritative data source: https://models.dev/api.json (145 providers, 4000+
11
+ // models, MIT). Each model exposes the exact fields we map below:
12
+ // modalities.input ["text","image","pdf","audio","video"] -> vision / pdf / audioInput / videoInput
13
+ // modalities.output ["text","image","audio"] -> imageOutput / audioOutput
14
+ // reasoning -> reasoning tool_call -> tools
15
+ // limit.context -> contextWindow limit.output -> maxOutput
16
+ // Look up the model id, then:
17
+ // • If a PATTERN below already covers it correctly -> nothing to do.
18
+ // • If it is an exception (pattern would mis-match) -> add an exact entry to
19
+ // MODEL_CAPABILITIES (only the fields that differ from DEFAULT).
20
+ // • If a whole new family -> add an ordered PATTERN (specific before generic).
21
+ // NOTE: models.dev has NO "search" flag (web search is a runtime tool, not a
22
+ // model spec); set `search` from vendor docs (Claude 4.x+, GPT-5.x/4o, Gemini
23
+ // 2.0+, Grok, Perplexity). Verify with: curl -s https://models.dev/api.json
24
+
25
+
26
+ /**
27
+ * Safe floor — every resolved result is merged over this so consumers
28
+ * never need null-checks. Most modern LLMs meet these limits.
29
+ */
30
+ export interface Caps {
31
+ vision: boolean;
32
+ pdf: boolean;
33
+ audioInput: boolean;
34
+ videoInput: boolean;
35
+ imageOutput: boolean;
36
+ audioOutput: boolean;
37
+ search: boolean;
38
+ tools: boolean;
39
+ reasoning: boolean;
40
+ thinkingFormat: string | null;
41
+ thinkingCanDisable: boolean;
42
+ thinkingRange: { min: number; max: number } | null;
43
+ contextWindow: number;
44
+ maxOutput: number;
45
+ }
46
+
47
+ /**
48
+ * Glob (* = wildcard) match, anchored + case-insensitive. aigetwey's
49
+ * pricing.matchPattern so capabilities resolve identically.
50
+ */
51
+ export function matchPattern(pattern: string, model: string): boolean {
52
+ const regex = new RegExp(
53
+ "^" + pattern.split("*").map((s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join(".*") + "$",
54
+ "i",
55
+ );
56
+ return regex.test(model);
57
+ }
58
+
59
+ export const DEFAULT_CAPABILITIES: Caps = {
60
+ // input modalities
61
+ vision: false, // read images
62
+ pdf: false, // read PDF / documents
63
+ audioInput: false, // read audio
64
+ videoInput: false, // read video
65
+ // output modalities
66
+ imageOutput: false, // generate images
67
+ audioOutput: false, // generate audio
68
+ // features
69
+ search: false, // built-in web search tool / grounding
70
+ tools: true, // function / tool calling
71
+ reasoning: false, // thinking / reasoning
72
+ // thinking wire format (only meaningful when reasoning:true). null → derive from transport.format.
73
+ // enum: openai|claude-adaptive|claude-budget|gemini-level|gemini-budget|zai|qwen|deepseek|kimi|minimax|hunyuan|step
74
+ thinkingFormat: null,
75
+ thinkingCanDisable: true, // false → model cannot turn thinking off (clamp to min instead of disable)
76
+ thinkingRange: null, // { min, max } for budget formats; null = no clamp
77
+ // limits (tokens)
78
+ contextWindow: 200000,
79
+ maxOutput: 64000,
80
+ };
81
+
82
+ // User-added model metadata can carry dashboard service kinds instead of the
83
+ // runtime capability names used here. Map those typed model kinds into input /
84
+ // output capabilities so custom vision models are not treated as text-only.
85
+ const SERVICE_KIND_CAPABILITIES: Record<string, Partial<Caps>> = {
86
+ imageToText: { vision: true },
87
+ image: { imageOutput: true },
88
+ stt: { audioInput: true },
89
+ tts: { audioOutput: true },
90
+ embedding: { tools: false },
91
+ };
92
+
93
+ export function capabilitiesFromServiceKind(kind: string): Partial<Caps> | null {
94
+ return SERVICE_KIND_CAPABILITIES[kind] || null;
95
+ }
96
+
97
+ /**
98
+ * Canonical exact-id overrides — used for exceptions that patterns would
99
+ * otherwise mis-match. Only declare deltas vs DEFAULT.
100
+ */
101
+ export const MODEL_CAPABILITIES: Record<string, Partial<Caps>> = {
102
+ // Claude 4.6/4.7 have 1M context + adaptive thinking (override generic claude pattern)
103
+ "claude-opus-4.6": { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 128000 },
104
+ "claude-opus-4.7": { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 128000 },
105
+ "claude-opus-4-6": { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 128000 },
106
+ "claude-sonnet-4.6": { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 64000 },
107
+ "claude-sonnet-4-6": { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 64000 },
108
+
109
+ // Gemini image-gen / OpenAI image / xai image variants
110
+ "gpt-image-1": { imageOutput: true, tools: false },
111
+
112
+ // GLM vision variant (text GLM has no vision)
113
+ "glm-4.6v": { vision: true, reasoning: true, thinkingFormat: "zai", contextWindow: 128000 },
114
+
115
+ // Qwen plain coder/text (no vision) — registry "vision-model" / "coder-model" aliases
116
+ "vision-model": { vision: true, reasoning: true, thinkingFormat: "qwen", contextWindow: 1000000 },
117
+ "coder-model": { reasoning: true, thinkingFormat: "qwen", contextWindow: 1000000 },
118
+ };
119
+
120
+ /**
121
+ * Provider-specific capability overrides. Keyed by provider alias/id.
122
+ */
123
+ export const PROVIDER_CAPABILITIES: Record<string, Record<string, Partial<Caps>>> = {
124
+ // CodeBuddy.cn — authoritative per-model metadata from the gateway's model
125
+ // config (contextWindow=maxInputTokens, maxOutput=maxOutputTokens, vision=
126
+ // supportsImages). Every model reasons via OpenAI-style reasoning_effort
127
+ // (see registry thinkingFormat). `onlyReasoning` models can't turn thinking
128
+ // off → thinkingCanDisable:false (clamped to minimal instead of disabled).
129
+ "codebuddy-cn": {
130
+ "glm-5.2": { reasoning: true, thinkingFormat: "openai", thinkingCanDisable: false, contextWindow: 1000000, maxOutput: 48000 },
131
+ "glm-5.1": { reasoning: true, thinkingFormat: "openai", thinkingCanDisable: false, contextWindow: 200000, maxOutput: 48000 },
132
+ "glm-5.0": { reasoning: true, thinkingFormat: "openai", contextWindow: 200000, maxOutput: 48000 },
133
+ "glm-5.0-turbo": { reasoning: true, thinkingFormat: "openai", thinkingCanDisable: false, contextWindow: 200000, maxOutput: 48000 },
134
+ "glm-5v-turbo": { vision: true, reasoning: true, thinkingFormat: "openai", thinkingCanDisable: false, contextWindow: 200000, maxOutput: 38000 },
135
+ "glm-4.7": { reasoning: true, thinkingFormat: "openai", contextWindow: 200000, maxOutput: 48000 },
136
+ "minimax-m3": { vision: true, reasoning: true, thinkingFormat: "openai", thinkingCanDisable: false, contextWindow: 512000, maxOutput: 48000 },
137
+ "minimax-m2.7": { vision: true, reasoning: true, thinkingFormat: "openai", thinkingCanDisable: false, contextWindow: 200000, maxOutput: 48000 },
138
+ "kimi-k2.7": { vision: true, reasoning: true, thinkingFormat: "openai", thinkingCanDisable: false, contextWindow: 256000, maxOutput: 32000 },
139
+ "kimi-k2.6": { vision: true, reasoning: true, thinkingFormat: "openai", thinkingCanDisable: false, contextWindow: 256000, maxOutput: 32000 },
140
+ "kimi-k2.5": { vision: true, reasoning: true, thinkingFormat: "openai", thinkingCanDisable: false, contextWindow: 164000, maxOutput: 32000 },
141
+ "hy3-preview": { vision: true, reasoning: true, thinkingFormat: "openai", thinkingCanDisable: false, contextWindow: 192000, maxOutput: 64000 },
142
+ "deepseek-v4-pro": { vision: true, reasoning: true, thinkingFormat: "openai", thinkingCanDisable: false, contextWindow: 1000000, maxOutput: 50000 },
143
+ "deepseek-v4-flash": { vision: true, reasoning: true, thinkingFormat: "openai", thinkingCanDisable: false, contextWindow: 1000000, maxOutput: 50000 },
144
+ "deepseek-v3-2-volc": { reasoning: true, thinkingFormat: "openai", thinkingCanDisable: false, contextWindow: 96000, maxOutput: 32000 },
145
+ },
146
+ };
147
+
148
+ /**
149
+ * Pattern fallback — glob (* = wildcard), matched case-insensitively and
150
+ * anchored (^...$) so a pattern must match the full model id. ORDER MATTERS:
151
+ * vision/specific variants first, text-only/generic families last, to avoid
152
+ * a broad family pattern swallowing an exception (e.g. glm-4.6v vs glm-5).
153
+ */
154
+ export const PATTERN_CAPABILITIES: Array<{ pattern: string; caps: Partial<Caps> }> = [
155
+ // ── Claude (4.6+ = adaptive thinking; older/haiku = budget) ──────
156
+ { pattern: "*claude*opus-4.6*", caps: { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive" } },
157
+ { pattern: "*claude*opus-4.7*", caps: { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive" } },
158
+ { pattern: "*claude*opus-4.8*", caps: { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive" } },
159
+ { pattern: "*claude*sonnet-4.6*", caps: { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive" } },
160
+ { pattern: "*claude*sonnet-4.7*", caps: { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive" } },
161
+ { pattern: "*claude*haiku*", caps: { vision: true, reasoning: true, search: true, thinkingFormat: "claude-budget" } },
162
+ { pattern: "*claude*opus*", caps: { vision: true, reasoning: true, search: true, thinkingFormat: "claude-budget" } },
163
+ { pattern: "*claude*sonnet*", caps: { vision: true, reasoning: true, search: true, thinkingFormat: "claude-budget" } },
164
+ { pattern: "*claude*fable*", caps: { vision: true, reasoning: true, search: true, thinkingFormat: "claude-budget", contextWindow: 1000000, maxOutput: 128000 } },
165
+ { pattern: "*claude*mythos*", caps: { vision: true, reasoning: true, search: true, thinkingFormat: "claude-budget", contextWindow: 1000000, maxOutput: 128000 } },
166
+ { pattern: "*claude-3*", caps: { vision: true } },
167
+ { pattern: "*claude*", caps: { vision: true, reasoning: true, search: true, thinkingFormat: "claude-budget" } },
168
+
169
+ // ── Gemini (all 2.0+ multimodal + google_search grounding, 1M ctx) ─
170
+ { pattern: "*gemini*image*", caps: { vision: true, imageOutput: true, contextWindow: 1048576 } },
171
+ { pattern: "*gemini-3*pro*", caps: { vision: true, audioInput: true, videoInput: true, reasoning: true, search: true, thinkingFormat: "gemini-level", thinkingCanDisable: false, contextWindow: 1048576, maxOutput: 65535 } },
172
+ { pattern: "*gemini-3*", caps: { vision: true, audioInput: true, videoInput: true, reasoning: true, search: true, thinkingFormat: "gemini-level", thinkingCanDisable: false, contextWindow: 1048576, maxOutput: 65536 } },
173
+ { pattern: "*gemini-2.5*", caps: { vision: true, audioInput: true, videoInput: true, reasoning: true, search: true, thinkingFormat: "gemini-budget", thinkingRange: { min: 0, max: 24576 }, contextWindow: 1048576, maxOutput: 65536 } },
174
+ { pattern: "*gemini-2*", caps: { vision: true, audioInput: true, videoInput: true, search: true, contextWindow: 1048576, maxOutput: 65536 } },
175
+ { pattern: "*gemini*", caps: { vision: true, search: true, contextWindow: 1048576 } },
176
+ { pattern: "*gemma*", caps: { vision: true, contextWindow: 128000 } },
177
+ { pattern: "*nanobanana*", caps: { vision: true, imageOutput: true } },
178
+
179
+ // ── OpenAI GPT-5.x (vision + thinking + web search) ──────────────
180
+ { pattern: "*gpt-5*image*", caps: { imageOutput: true } },
181
+ { pattern: "*gpt-5*codex*", caps: { reasoning: true, search: true, thinkingFormat: "openai", contextWindow: 400000, maxOutput: 128000 } },
182
+ { pattern: "*gpt-5*", caps: { vision: true, reasoning: true, search: true, thinkingFormat: "openai", contextWindow: 400000, maxOutput: 128000 } },
183
+ { pattern: "*gpt-4o*", caps: { vision: true, search: true, contextWindow: 128000, maxOutput: 16384 } },
184
+ { pattern: "*gpt-4.1*", caps: { vision: true, contextWindow: 1000000, maxOutput: 32768 } },
185
+ { pattern: "*gpt-4-turbo*", caps: { vision: true, contextWindow: 128000 } },
186
+ { pattern: "*gpt-4*", caps: { contextWindow: 128000 } },
187
+ { pattern: "*gpt-3.5*", caps: { contextWindow: 16385, maxOutput: 4096 } },
188
+ { pattern: "*gpt-oss*", caps: { reasoning: true, thinkingFormat: "openai", contextWindow: 128000 } },
189
+
190
+ // ── OpenAI o-series (reasoning, vision) ──────────────────────────
191
+ { pattern: "*o1-mini*", caps: { reasoning: true, thinkingFormat: "openai", contextWindow: 128000 } },
192
+ { pattern: "*o1*", caps: { vision: true, reasoning: true, thinkingFormat: "openai", contextWindow: 200000, maxOutput: 100000 } },
193
+ { pattern: "*o3*", caps: { vision: true, reasoning: true, thinkingFormat: "openai", contextWindow: 200000, maxOutput: 100000 } },
194
+ { pattern: "*o4*", caps: { vision: true, reasoning: true, thinkingFormat: "openai", contextWindow: 200000, maxOutput: 100000 } },
195
+
196
+ // ── Grok (vision + Live Search) ──────────────────────────────────
197
+ { pattern: "*grok*image*", caps: { imageOutput: true } },
198
+ { pattern: "*grok-code*", caps: { reasoning: true, thinkingFormat: "openai", contextWindow: 256000 } },
199
+ { pattern: "*grok-4*", caps: { vision: true, reasoning: true, search: true, thinkingFormat: "openai", contextWindow: 256000 } },
200
+ { pattern: "*grok-3*", caps: { vision: true, reasoning: true, search: true, thinkingFormat: "openai", contextWindow: 131072 } },
201
+ { pattern: "*grok*", caps: { vision: true, reasoning: true, search: true, thinkingFormat: "openai", contextWindow: 256000 } },
202
+
203
+ // ── Qwen (enable_thinking + thinking_budget; QwQ = thinking-only) ─
204
+ { pattern: "*qwen*vl*", caps: { vision: true, reasoning: true, thinkingFormat: "qwen", contextWindow: 262144 } },
205
+ { pattern: "*qwen*max*", caps: { vision: true, reasoning: true, thinkingFormat: "qwen", contextWindow: 1000000, maxOutput: 65536 } },
206
+ { pattern: "*qwen*plus*", caps: { vision: true, reasoning: true, thinkingFormat: "qwen", contextWindow: 1000000, maxOutput: 65536 } },
207
+ { pattern: "*qwen*235b*", caps: { reasoning: true, thinkingFormat: "qwen", contextWindow: 262144 } },
208
+ { pattern: "*qwen*coder*", caps: { reasoning: true, thinkingFormat: "qwen", contextWindow: 1000000 } },
209
+ { pattern: "*qwq*", caps: { reasoning: true, thinkingFormat: "qwen", thinkingCanDisable: false, contextWindow: 131072 } },
210
+ { pattern: "*qwen*", caps: { reasoning: true, thinkingFormat: "qwen", contextWindow: 262144 } },
211
+
212
+ // ── Kimi (enabled→reasoning_effort; K2.7-code cannot disable) ─────
213
+ { pattern: "*kimi*k2.7*code*", caps: { vision: true, reasoning: true, thinkingFormat: "kimi", thinkingCanDisable: false, contextWindow: 262144, maxOutput: 262144 } },
214
+ { pattern: "*kimi*k2*", caps: { vision: true, reasoning: true, thinkingFormat: "kimi", contextWindow: 262144, maxOutput: 262144 } },
215
+ { pattern: "*kimi*", caps: { reasoning: true, thinkingFormat: "kimi", contextWindow: 262144 } },
216
+
217
+ // ── GLM / Z.ai (thinking.enabled; disable via enable_thinking:false) ─
218
+ { pattern: "*glm-5*", caps: { reasoning: true, thinkingFormat: "zai", contextWindow: 200000, maxOutput: 128000 } },
219
+ { pattern: "*glm-4.7*", caps: { reasoning: true, thinkingFormat: "zai", contextWindow: 200000, maxOutput: 128000 } },
220
+ { pattern: "*glm-4*", caps: { reasoning: true, thinkingFormat: "zai", contextWindow: 200000 } },
221
+ { pattern: "*glm*", caps: { reasoning: true, thinkingFormat: "zai", contextWindow: 200000 } },
222
+
223
+ // ── DeepSeek (thinking.enabled + reasoning_effort; r1 = thinking-only) ─
224
+ { pattern: "*deepseek-v4*", caps: { reasoning: true, thinkingFormat: "deepseek", contextWindow: 1000000, maxOutput: 384000 } },
225
+ { pattern: "*reasoner*", caps: { reasoning: true, thinkingFormat: "deepseek", thinkingCanDisable: false, contextWindow: 128000 } },
226
+ { pattern: "*deepseek-r*", caps: { reasoning: true, thinkingFormat: "deepseek", thinkingCanDisable: false, contextWindow: 128000 } },
227
+ { pattern: "*deepseek-chat*", caps: { contextWindow: 128000 } },
228
+ { pattern: "*deepseek*", caps: { reasoning: true, thinkingFormat: "deepseek", contextWindow: 128000 } },
229
+
230
+ // ── MiniMax (M3 = adaptive; M2.x cannot disable) ─────────────────
231
+ { pattern: "*minimax*image*", caps: { imageOutput: true } },
232
+ { pattern: "*minimax-m3*", caps: { vision: true, reasoning: true, thinkingFormat: "minimax", contextWindow: 1048576, maxOutput: 512000 } },
233
+ { pattern: "*minimax-m2.7*", caps: { reasoning: true, thinkingFormat: "minimax", thinkingCanDisable: false, contextWindow: 204800, maxOutput: 131072 } },
234
+ { pattern: "*minimax*", caps: { reasoning: true, thinkingFormat: "minimax", thinkingCanDisable: false, contextWindow: 200000, maxOutput: 131072 } },
235
+
236
+ // ── Xiaomi MiMo (vision, 1M / 262K ctx) ──────────────────────────
237
+ { pattern: "*mimo*v2.5*", caps: { vision: true, contextWindow: 1048576, maxOutput: 131072 } },
238
+ { pattern: "*mimo*omni*", caps: { vision: true, audioInput: true, contextWindow: 262144, maxOutput: 131072 } },
239
+ { pattern: "*mimo*", caps: { vision: true, contextWindow: 262144, maxOutput: 131072 } },
240
+
241
+ // ── Llama (4 = vision/1M; 3.x = text-only/128K) ──────────────────
242
+ { pattern: "*llama-4*", caps: { vision: true, contextWindow: 1000000 } },
243
+ { pattern: "*llama*", caps: { contextWindow: 128000 } },
244
+
245
+ // ── Mistral (Large 3 = vision/256K; codestral text) ──────────────
246
+ { pattern: "*codestral*", caps: { contextWindow: 256000 } },
247
+ { pattern: "*mistral-large*", caps: { vision: true, contextWindow: 256000 } },
248
+ { pattern: "*mistral*", caps: { contextWindow: 128000 } },
249
+
250
+ // ── Cohere (Command A Vision = vision; others text) ──────────────
251
+ { pattern: "*command-a-vision*", caps: { vision: true, contextWindow: 128000 } },
252
+ { pattern: "*command*", caps: { contextWindow: 128000 } },
253
+
254
+ // ── Perplexity (web search native) ───────────────────────────────
255
+ { pattern: "*sonar*", caps: { search: true, contextWindow: 128000 } },
256
+ { pattern: "*pplx*", caps: { search: true, contextWindow: 128000 } },
257
+ { pattern: "*perplexity*", caps: { search: true, contextWindow: 128000 } },
258
+
259
+ // ── Others ───────────────────────────────────────────────────────
260
+ { pattern: "*hunyuan*", caps: { reasoning: true, thinkingFormat: "hunyuan", contextWindow: 262144, maxOutput: 262144 } },
261
+ { pattern: "hy3*", caps: { reasoning: true, thinkingFormat: "hunyuan", contextWindow: 262144, maxOutput: 262144 } },
262
+ { pattern: "*step-*", caps: { reasoning: true, thinkingFormat: "step", contextWindow: 128000 } },
263
+ { pattern: "*nemotron*", caps: { reasoning: true, contextWindow: 128000 } },
264
+ { pattern: "*ling-*", caps: { reasoning: true, contextWindow: 128000 } },
265
+ ];
266
+
267
+ /**
268
+ * Resolve capabilities for a model using the 4-step fallback chain,
269
+ * merged over DEFAULT_CAPABILITIES so the result is always complete.
270
+ *
271
+ * @param {string} provider
272
+ * @param {string} model
273
+ * @returns {object} full capabilities object
274
+ */
275
+ /**
276
+ * models.dev-style modalities for a model ref, derived from its capabilities.
277
+ * Ref may be a `provider/model` (vendor prefix tolerated) or a bare combo alias.
278
+ * Mirrors what opencode/aigetwey store per model in opencode.json.
279
+ */
280
+ export function modalitiesForModel(ref: string): { input: string[]; output: string[] } {
281
+ const slash = ref.indexOf("/");
282
+ const provider = slash > 0 ? ref.slice(0, slash) : null;
283
+ const model = slash > 0 ? ref.slice(slash + 1) : ref;
284
+ const c = getCapabilitiesForModel(provider, model);
285
+ const input = ["text"];
286
+ if (c.vision) input.push("image");
287
+ if (c.pdf) input.push("pdf");
288
+ if (c.audioInput) input.push("audio");
289
+ if (c.videoInput) input.push("video");
290
+ const output = ["text"];
291
+ if (c.imageOutput) output.push("image");
292
+ if (c.audioOutput) output.push("audio");
293
+ return { input, output };
294
+ }
295
+
296
+ export function getCapabilitiesForModel(provider: string | null, model: string): Caps {
297
+ if (!model) return { ...DEFAULT_CAPABILITIES };
298
+
299
+ // 1. Provider-specific override
300
+ if (provider && PROVIDER_CAPABILITIES[provider]?.[model]) {
301
+ return { ...DEFAULT_CAPABILITIES, ...PROVIDER_CAPABILITIES[provider][model] };
302
+ }
303
+
304
+ // 2. Canonical exact (strip vendor prefix: "anthropic/claude-opus-4.7" -> "claude-opus-4.7")
305
+ const baseModel = (model.includes("/") ? model.split("/").pop() : model) ?? model;
306
+ if (MODEL_CAPABILITIES[baseModel]) return { ...DEFAULT_CAPABILITIES, ...MODEL_CAPABILITIES[baseModel] };
307
+ if (MODEL_CAPABILITIES[model]) return { ...DEFAULT_CAPABILITIES, ...MODEL_CAPABILITIES[model] };
308
+
309
+ // 3. Pattern match (first match wins)
310
+ for (const { pattern, caps } of PATTERN_CAPABILITIES) {
311
+ if (matchPattern(pattern, baseModel) || matchPattern(pattern, model)) {
312
+ return { ...DEFAULT_CAPABILITIES, ...caps };
313
+ }
314
+ }
315
+
316
+ // 4. Floor
317
+ return { ...DEFAULT_CAPABILITIES };
318
+ }
@@ -0,0 +1,120 @@
1
+ /**
2
+ * CLI tool setup definitions. Each tool talks to the gateway in either OpenAI or
3
+ * Anthropic wire format; the gateway exposes both on the same port, so setup is
4
+ * just pointing the tool's base_url + key at us. `env` builders take the live
5
+ * gateway base URL + a gateway key and return ready-to-copy environment lines.
6
+ *
7
+ * `slots` are the model names this tool calls. Because our gateway routes by the
8
+ * combo alias (the alias IS the model name), the detail page checks whether a
9
+ * combo with each slot's alias exists, and prompts to create the missing ones.
10
+ */
11
+ export type ToolFormat = "openai" | "anthropic";
12
+
13
+ export interface CliTool {
14
+ id: string;
15
+ name: string;
16
+ /** Material Symbols ligature shown on the tool card + detail header. */
17
+ icon: string;
18
+ format: ToolFormat;
19
+ blurb: string;
20
+ /** true when the dashboard can detect + write this tool's local config file. */
21
+ autoConfig?: boolean;
22
+ /** one-line install command, when the tool ships via a package manager. */
23
+ install?: string;
24
+ /** model names the tool sends — pair each with a combo of the same name. */
25
+ slots: { label: string; alias: string }[];
26
+ /** environment variables to set, given the gateway base + a key. */
27
+ env: (base: string, key: string) => Array<{ name: string; value: string }>;
28
+ /** extra free-form steps shown on the detail page. */
29
+ steps: string[];
30
+ }
31
+
32
+ const KEY = (k: string) => k || "<your-gateway-key>";
33
+
34
+ export const CLI_TOOLS: CliTool[] = [
35
+ {
36
+ id: "claude-code",
37
+ name: "Claude Code",
38
+ icon: "smart_toy",
39
+ format: "anthropic",
40
+ blurb: "Anthropic CLI. Point its base URL + key at the gateway.",
41
+ autoConfig: true,
42
+ install: "npm i -g @anthropic-ai/claude-code",
43
+ slots: [
44
+ { label: "Opus · heavy", alias: "claude-opus-4-1" },
45
+ { label: "Sonnet · default", alias: "claude-sonnet-4-6" },
46
+ { label: "Haiku · fast", alias: "claude-haiku-4-5" },
47
+ ],
48
+ env: (base, key) => [
49
+ { name: "ANTHROPIC_BASE_URL", value: base },
50
+ { name: "ANTHROPIC_API_KEY", value: KEY(key) },
51
+ ],
52
+ steps: [
53
+ "Export the two variables in the shell you run `claude` from.",
54
+ "Create a combo named like each slot above so Claude Code's model ids resolve.",
55
+ "The gateway translates Anthropic ↔ provider format, so any provider works behind it.",
56
+ ],
57
+ },
58
+ {
59
+ id: "codex",
60
+ name: "Codex",
61
+ icon: "code",
62
+ format: "openai",
63
+ blurb: "OpenAI-compatible. Use the /v1 base URL.",
64
+ install: "npm i -g @openai/codex",
65
+ slots: [{ label: "Model", alias: "gpt-5" }],
66
+ env: (base, key) => [
67
+ { name: "OPENAI_BASE_URL", value: `${base}/v1` },
68
+ { name: "OPENAI_API_KEY", value: KEY(key) },
69
+ ],
70
+ steps: ["Set the base URL to the gateway's /v1 path.", "Create a combo named like the slot above, then use it as the model."],
71
+ },
72
+ {
73
+ id: "opencode",
74
+ name: "opencode",
75
+ icon: "code_blocks",
76
+ format: "openai",
77
+ blurb: "OpenAI-compatible provider. Set base_url to /v1.",
78
+ autoConfig: true,
79
+ install: "curl -fsSL https://opencode.ai/install | bash",
80
+ slots: [{ label: "Model", alias: "gpt-5" }],
81
+ env: (base, key) => [
82
+ { name: "OPENAI_BASE_URL", value: `${base}/v1` },
83
+ { name: "OPENAI_API_KEY", value: KEY(key) },
84
+ ],
85
+ steps: ["Add an OpenAI-compatible provider with the gateway /v1 base URL.", "Pick a combo alias as the model."],
86
+ },
87
+ {
88
+ id: "cursor",
89
+ name: "Cursor",
90
+ icon: "edit_square",
91
+ format: "openai",
92
+ blurb: "OpenAI-compatible. Override the base URL in settings.",
93
+ slots: [{ label: "Model", alias: "gpt-5" }],
94
+ env: (base, key) => [
95
+ { name: "Base URL", value: `${base}/v1` },
96
+ { name: "API Key", value: KEY(key) },
97
+ ],
98
+ steps: [
99
+ "Settings → Models → OpenAI API Key → override base URL with the gateway /v1.",
100
+ "Add your combo aliases as custom model names.",
101
+ ],
102
+ },
103
+ {
104
+ id: "cline",
105
+ name: "Cline",
106
+ icon: "extension",
107
+ format: "openai",
108
+ blurb: "OpenAI-compatible VS Code agent.",
109
+ slots: [{ label: "Model", alias: "gpt-5" }],
110
+ env: (base, key) => [
111
+ { name: "Base URL", value: `${base}/v1` },
112
+ { name: "API Key", value: KEY(key) },
113
+ ],
114
+ steps: ["Choose the OpenAI-compatible provider.", "Set the base URL to the gateway /v1 and use a combo alias."],
115
+ },
116
+ ];
117
+
118
+ export function toolById(id: string): CliTool | undefined {
119
+ return CLI_TOOLS.find((t) => t.id === id);
120
+ }