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,64 @@
1
+ /**
2
+ * Concern: reasoning_effort ↔ provider-native thinking config.
3
+ * Central source of truth for level↔budget maps (web-standard values).
4
+ * Provider-specific application lives in thinkingUnified.ts; this file is maps-only.
5
+ *
6
+ * Implemented for aigetwey so
7
+ * aigetwey normalizes thinking identically.
8
+ */
9
+
10
+ /** Discrete effort levels, ordered low→high. */
11
+ export const EFFORT_LEVELS = ["minimal", "low", "medium", "high", "xhigh", "max"] as const;
12
+
13
+ /** Web-standard level → budget_tokens (Anthropic/Gemini docs). */
14
+ export const LEVEL_TO_BUDGET: Record<string, number> = {
15
+ none: 0,
16
+ minimal: 512,
17
+ low: 1024,
18
+ medium: 8192,
19
+ high: 24576,
20
+ xhigh: 32768,
21
+ max: 128000,
22
+ };
23
+
24
+ /**
25
+ * Returns budget_tokens for an effort level, or undefined if unknown.
26
+ * 0 means "no thinking"; undefined means "effort not recognized".
27
+ */
28
+ export function effortToBudget(effort: string | undefined | null): number | undefined {
29
+ if (!effort) return undefined;
30
+ return LEVEL_TO_BUDGET[String(effort).toLowerCase()];
31
+ }
32
+
33
+ /**
34
+ * OpenAI reasoning_effort → Gemini thinkingLevel (gemini-3 enum: minimal|low|medium|high).
35
+ * Gemini 3 cannot fully disable thinking; "none"/"off" map to "minimal".
36
+ */
37
+ export function effortToThinkingLevel(effort: string): string {
38
+ const e = String(effort).toLowerCase().trim();
39
+ if (e === "none" || e === "off") return "minimal";
40
+ if (e === "xhigh" || e === "max") return "high";
41
+ return e;
42
+ }
43
+
44
+ /**
45
+ * Numeric budget → nearest discrete level (reverse map via thresholds).
46
+ * Returns null when budget <= 0 (no reasoning).
47
+ */
48
+ export function budgetToLevel(budget: number): string | null {
49
+ const b = Number(budget);
50
+ if (!b || b <= 0) return null;
51
+ if (b <= 768) return "minimal";
52
+ if (b <= 4096) return "low";
53
+ if (b <= 16384) return "medium";
54
+ if (b <= 28672) return "high";
55
+ return "xhigh";
56
+ }
57
+
58
+ /** Gemini thinkingBudget (numeric) → OpenAI reasoning_effort (antigravity reverse map). */
59
+ export function budgetToEffort(budget: number): string | null {
60
+ if (!budget || budget <= 0) return null;
61
+ if (budget <= 2048) return "low";
62
+ if (budget <= 16384) return "medium";
63
+ return "high";
64
+ }
@@ -0,0 +1,319 @@
1
+ /**
2
+ * Unified thinking normalization: extract client intent → apply provider-native format.
3
+ * Config-driven: thinking format/limits come from capabilities.ts, never hardcoded
4
+ * per-model here.
5
+ *
6
+ * aigetwey's own implementation. The one
7
+ * adaptation for aigetwey: there is no provider registry carrying a thinkingFormat,
8
+ * so resolveFormat relies on the capabilities table + the wire format alone, and
9
+ * FORMAT_TO_NATIVE also maps aigetwey's "anthropic" wire format. See
10
+ *
11
+ */
12
+ import { getCapabilitiesForModel, type Caps } from "../providers/capabilities.js";
13
+ import { LEVEL_TO_BUDGET, budgetToLevel, effortToBudget } from "./thinking.js";
14
+
15
+ export interface ThinkingConfig {
16
+ mode: "none" | "auto" | "budget" | "level";
17
+ budget?: number;
18
+ level?: string;
19
+ }
20
+
21
+ type Body = Record<string, any>;
22
+
23
+ // Map a target wire-format to its native thinking format (when capability has none).
24
+ const FORMAT_TO_NATIVE: Record<string, string> = {
25
+ openai: "openai",
26
+ "openai-responses": "openai",
27
+ "openai-response": "openai",
28
+ codex: "openai",
29
+ claude: "claude-budget",
30
+ anthropic: "claude-budget",
31
+ gemini: "gemini-budget",
32
+ "gemini-cli": "gemini-budget",
33
+ vertex: "gemini-budget",
34
+ antigravity: "gemini-budget",
35
+ kiro: "kiro",
36
+ };
37
+
38
+ /**
39
+ * Parse model-name suffix "model(value)" → { cleanModel, override }.
40
+ * value: level name (high) | number (8192) | auto | none. null override when absent.
41
+ */
42
+ export function parseSuffix(model: string): { cleanModel: string; override: ThinkingConfig | null } {
43
+ if (typeof model !== "string") return { cleanModel: model, override: null };
44
+ const m = model.match(/^(.*)\(([^()]+)\)\s*$/);
45
+ if (!m) return { cleanModel: model, override: null };
46
+ const cleanModel = m[1]!.trim();
47
+ const raw = m[2]!.trim().toLowerCase();
48
+ if (raw === "none" || raw === "off") return { cleanModel, override: { mode: "none" } };
49
+ if (raw === "auto") return { cleanModel, override: { mode: "auto" } };
50
+ if (/^\d+$/.test(raw)) return { cleanModel, override: { mode: "budget", budget: Number(raw) } };
51
+ if (LEVEL_TO_BUDGET[raw] !== undefined) return { cleanModel, override: { mode: "level", level: raw } };
52
+ return { cleanModel, override: null };
53
+ }
54
+
55
+ /**
56
+ * Extract unified thinking intent from a request body (post-translation, mixed shapes).
57
+ * Returns { mode, budget?, level? } or null when no thinking intent present.
58
+ */
59
+ export function extractThinking(body: Body | null | undefined): ThinkingConfig | null {
60
+ if (!body || typeof body !== "object") return null;
61
+
62
+ // Claude output_config.effort (explicit) — priority over adaptive thinking
63
+ const oc = body.output_config?.effort;
64
+ if (typeof oc === "string" && oc) {
65
+ const e = oc.toLowerCase();
66
+ if (e === "none" || e === "off") return { mode: "none" };
67
+ if (e === "auto") return { mode: "auto" };
68
+ return { mode: "level", level: e };
69
+ }
70
+
71
+ // Claude shape
72
+ const t = body.thinking;
73
+ if (t && typeof t === "object") {
74
+ if (t.type === "disabled") return { mode: "none" };
75
+ if (t.type === "adaptive" || t.type === "enabled") {
76
+ const budget = Number(t.budget_tokens);
77
+ if (Number.isFinite(budget) && budget > 0) return { mode: "budget", budget };
78
+ return { mode: "auto" };
79
+ }
80
+ }
81
+
82
+ // OpenAI chat / Responses shape
83
+ const effort = body.reasoning_effort ?? (typeof body.reasoning === "object" ? body.reasoning?.effort : null);
84
+ if (typeof effort === "string" && effort) {
85
+ const e = effort.toLowerCase();
86
+ if (e === "none" || e === "off") return { mode: "none" };
87
+ if (e === "auto") return { mode: "auto" };
88
+ return { mode: "level", level: e };
89
+ }
90
+
91
+ // Gemini shape (top-level, generationConfig, or request envelope)
92
+ const tc = body.thinkingConfig || body.generationConfig?.thinkingConfig || body.request?.generationConfig?.thinkingConfig;
93
+ if (tc && typeof tc === "object") {
94
+ if (typeof tc.thinkingLevel === "string") return { mode: "level", level: tc.thinkingLevel.toLowerCase() };
95
+ const tb = Number(tc.thinkingBudget);
96
+ if (Number.isFinite(tb)) {
97
+ if (tb === 0) return { mode: "none" };
98
+ if (tb < 0) return { mode: "auto" };
99
+ return { mode: "budget", budget: tb };
100
+ }
101
+ }
102
+
103
+ // Qwen shape
104
+ if (body.enable_thinking === false) return { mode: "none" };
105
+ if (body.enable_thinking === true) {
106
+ const tb = Number(body.thinking_budget);
107
+ if (Number.isFinite(tb) && tb > 0) return { mode: "budget", budget: tb };
108
+ return { mode: "auto" };
109
+ }
110
+
111
+ return null;
112
+ }
113
+
114
+ /**
115
+ * Capture thinking intent from a body. Alias of extractThinking, named for clarity
116
+ * at the call-site where intent is snapshotted before format translation.
117
+ */
118
+ export const captureThinking = extractThinking;
119
+
120
+ // Resolve thinking format: capability > derive(targetFormat).
121
+ function resolveFormat(targetFormat: string, model: string, provider: string | null): string {
122
+ const caps = getCapabilitiesForModel(provider, model);
123
+ if (caps.thinkingFormat) return caps.thinkingFormat;
124
+ return FORMAT_TO_NATIVE[targetFormat] || "openai";
125
+ }
126
+
127
+ // Convert unified config to a budget number (for budget-based formats).
128
+ function toBudget(cfg: ThinkingConfig, range: Caps["thinkingRange"]): number | undefined {
129
+ let budget: number | undefined;
130
+ if (cfg.mode === "budget") budget = cfg.budget;
131
+ else if (cfg.mode === "level") budget = effortToBudget(cfg.level);
132
+ else if (cfg.mode === "auto") return -1;
133
+ if (!Number.isFinite(budget)) return undefined;
134
+ if (range) {
135
+ if (range.min != null && budget! < range.min) budget = range.min;
136
+ if (range.max != null && budget! > range.max) budget = range.max;
137
+ }
138
+ return budget;
139
+ }
140
+
141
+ // Convert unified config to a discrete level string.
142
+ function toLevel(cfg: ThinkingConfig): string | null {
143
+ if (cfg.mode === "level") return cfg.level ?? null;
144
+ if (cfg.mode === "budget") return budgetToLevel(cfg.budget ?? 0) || "medium";
145
+ if (cfg.mode === "auto") return "auto";
146
+ return null;
147
+ }
148
+
149
+ // Gemini nests thinkingConfig under generationConfig.
150
+ function setGeminiThinking(body: Body, tc: Record<string, unknown>): void {
151
+ const gc = body.request?.generationConfig
152
+ ? body.request.generationConfig
153
+ : body.generationConfig && typeof body.generationConfig === "object"
154
+ ? body.generationConfig
155
+ : (body.generationConfig = {});
156
+ gc.thinkingConfig = tc;
157
+ }
158
+
159
+ // Strip every known thinking field from a body (used before re-applying / when unsupported).
160
+ function stripAll(body: Body): void {
161
+ delete body.thinking;
162
+ delete body.reasoning_effort;
163
+ delete body.reasoning;
164
+ delete body.thinkingConfig;
165
+ delete body.enable_thinking;
166
+ delete body.thinking_budget;
167
+ delete body.output_config;
168
+ if (body.generationConfig) delete body.generationConfig.thinkingConfig;
169
+ if (body.request?.generationConfig) delete body.request.generationConfig.thinkingConfig;
170
+ }
171
+
172
+ // Apply unified thinking config to body in the resolved provider-native format.
173
+ function applyFormat(fmt: string, body: Body, cfg: ThinkingConfig, caps: Caps): void {
174
+ const none = cfg.mode === "none";
175
+ const canDisable = caps.thinkingCanDisable !== false;
176
+ // Model cannot disable thinking → clamp "none" to minimal effort instead.
177
+ const eff: ThinkingConfig = none && !canDisable ? { mode: "level", level: "minimal" } : cfg;
178
+
179
+ switch (fmt) {
180
+ case "openai": {
181
+ if (none && canDisable) {
182
+ body.reasoning_effort = "none";
183
+ break;
184
+ }
185
+ const level = toLevel(eff);
186
+ if (level) body.reasoning_effort = level === "xhigh" || level === "max" ? "high" : level;
187
+ break;
188
+ }
189
+ case "claude-adaptive": {
190
+ if (none && canDisable) {
191
+ body.thinking = { type: "disabled" };
192
+ break;
193
+ }
194
+ const level = toLevel(eff);
195
+ body.output_config = { effort: level === "xhigh" ? "high" : level };
196
+ break;
197
+ }
198
+ case "claude-budget": {
199
+ if (none && canDisable) {
200
+ body.thinking = { type: "disabled" };
201
+ break;
202
+ }
203
+ const budget = toBudget(eff, caps.thinkingRange);
204
+ body.thinking = budget === -1 ? { type: "enabled" } : { type: "enabled", budget_tokens: budget || 8192 };
205
+ break;
206
+ }
207
+ case "gemini-level": {
208
+ const level = none ? "minimal" : toLevel(eff) || "high";
209
+ setGeminiThinking(body, { thinkingLevel: level, includeThoughts: level !== "minimal" });
210
+ break;
211
+ }
212
+ case "gemini-budget": {
213
+ if (none && canDisable) {
214
+ setGeminiThinking(body, { thinkingBudget: 0, includeThoughts: false });
215
+ break;
216
+ }
217
+ const budget = toBudget(eff, caps.thinkingRange);
218
+ setGeminiThinking(body, { thinkingBudget: budget ?? -1, includeThoughts: true });
219
+ break;
220
+ }
221
+ case "zai": {
222
+ // Z.ai ignores thinking.disabled → must use enable_thinking:false to turn off.
223
+ if (none && canDisable) {
224
+ body.enable_thinking = false;
225
+ delete body.thinking;
226
+ break;
227
+ }
228
+ body.thinking = { type: "enabled" };
229
+ break;
230
+ }
231
+ case "qwen": {
232
+ if (none && canDisable) {
233
+ body.enable_thinking = false;
234
+ break;
235
+ }
236
+ body.enable_thinking = true;
237
+ const budget = toBudget(eff, caps.thinkingRange);
238
+ if (Number.isFinite(budget) && budget! > 0) body.thinking_budget = budget;
239
+ break;
240
+ }
241
+ case "deepseek": {
242
+ if (none && canDisable) {
243
+ body.thinking = { type: "disabled" };
244
+ break;
245
+ }
246
+ body.thinking = { type: "enabled" };
247
+ // DeepSeek: low/medium→high, xhigh/max→max.
248
+ const level = toLevel(eff);
249
+ body.reasoning_effort = level === "xhigh" || level === "max" ? "max" : "high";
250
+ break;
251
+ }
252
+ case "kimi": {
253
+ if (none && canDisable) {
254
+ body.thinking = { type: "disabled" };
255
+ break;
256
+ }
257
+ const level = toLevel(eff);
258
+ if (level) body.reasoning_effort = level === "max" ? "high" : level;
259
+ break;
260
+ }
261
+ case "minimax": {
262
+ // M3 adaptive; M2.x cannot disable (handled via canDisable clamp).
263
+ body.thinking = { type: none && canDisable ? "disabled" : "adaptive" };
264
+ break;
265
+ }
266
+ case "hunyuan": {
267
+ if (none && canDisable) {
268
+ body.thinking = { type: "disabled" };
269
+ break;
270
+ }
271
+ const budget = toBudget(eff, caps.thinkingRange);
272
+ body.thinking = budget === -1 ? { type: "enabled" } : { type: "enabled", budget_tokens: budget || 8192 };
273
+ break;
274
+ }
275
+ case "step": {
276
+ if (none && canDisable) break;
277
+ const level = toLevel(eff);
278
+ if (level) body.reasoning_effort = level === "xhigh" || level === "max" ? "high" : level;
279
+ break;
280
+ }
281
+ case "kiro":
282
+ // Kiro thinking handled via system-tag injection elsewhere; no body field here.
283
+ break;
284
+ default:
285
+ break;
286
+ }
287
+ }
288
+
289
+ /**
290
+ * Public entry: normalize thinking for the resolved target format.
291
+ * Mutates and returns body. No-op when model has no reasoning capability.
292
+ * `intent` is a pre-captured config (from captureThinking on the original body);
293
+ * falls back to extracting from the current body when omitted.
294
+ */
295
+ export function applyThinking(
296
+ targetFormat: string,
297
+ model: string,
298
+ body: Body,
299
+ provider: string | null = null,
300
+ intent: ThinkingConfig | null | undefined = undefined,
301
+ ): Body {
302
+ if (!body || typeof body !== "object") return body;
303
+
304
+ const { cleanModel, override } = parseSuffix(model);
305
+ const cfg = override || intent || extractThinking(body);
306
+ const caps = getCapabilitiesForModel(provider, cleanModel);
307
+
308
+ // Model cannot reason → strip any stray thinking fields.
309
+ if (!caps.reasoning) {
310
+ stripAll(body);
311
+ return body;
312
+ }
313
+ if (!cfg) return body;
314
+
315
+ const fmt = resolveFormat(targetFormat, cleanModel, provider);
316
+ stripAll(body);
317
+ applyFormat(fmt, body, cfg, caps);
318
+ return body;
319
+ }
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Upstream provider client. Translates a canonical request into the provider's
3
+ * native format, calls it, and returns either a parsed canonical response
4
+ * (non-stream) or the raw byte stream (stream — consumed in Phase 3).
5
+ */
6
+ import { request } from "undici";
7
+ import type { Provider } from "../config.js";
8
+ import type { CanonicalRequest, CanonicalResponse } from "../core/canonical.js";
9
+ import { adapterFor } from "../adapters/index.js";
10
+ import { applyThinking, type ThinkingConfig } from "../translator/thinkingUnified.js";
11
+
12
+ export interface UpstreamError extends Error {
13
+ status?: number;
14
+ body?: string;
15
+ /** true if trying a different key/provider might succeed */
16
+ retryable?: boolean;
17
+ }
18
+
19
+ /**
20
+ * Retryable = an availability problem another key/provider could clear: rate
21
+ * limits (429), server errors (5xx), network/timeout (no status). Non-retryable
22
+ * = the request itself is bad (400/401/403/404/422) — falling back just wastes
23
+ * time and spams other providers.
24
+ */
25
+ function classifyRetryable(status: number | undefined): boolean {
26
+ if (status === undefined) return true; // network error / timeout / abort
27
+ if (status === 429) return true;
28
+ if (status >= 500) return true;
29
+ return false;
30
+ }
31
+
32
+ function buildHeaders(provider: Provider, key: string | undefined): Record<string, string> {
33
+ const headers: Record<string, string> = {
34
+ "content-type": "application/json",
35
+ ...(provider.headers ?? {}),
36
+ };
37
+ if (provider.format === "anthropic") {
38
+ if (key) headers["x-api-key"] = key;
39
+ headers["anthropic-version"] ??= "2023-06-01";
40
+ } else if (provider.format === "gemini") {
41
+ if (key) headers["x-goog-api-key"] = key;
42
+ } else {
43
+ if (key) headers["authorization"] = `Bearer ${key}`;
44
+ }
45
+ return headers;
46
+ }
47
+
48
+ /**
49
+ * OpenAI/Anthropic use a fixed path on base_url; Gemini puts the model and
50
+ * stream mode in the path (:generateContent | :streamGenerateContent?alt=sse).
51
+ */
52
+ function buildUrl(provider: Provider, model: string, stream: boolean): string {
53
+ const base = provider.base_url.replace(/\/$/, "");
54
+ if (provider.format === "gemini") {
55
+ const method = stream ? "streamGenerateContent?alt=sse" : "generateContent";
56
+ return `${base}/models/${encodeURIComponent(model)}:${method}`;
57
+ }
58
+ return base + (provider.format === "anthropic" ? "/messages" : "/chat/completions");
59
+ }
60
+
61
+ function buildBody(
62
+ provider: Provider,
63
+ req: CanonicalRequest,
64
+ model: string,
65
+ stream: boolean,
66
+ thinkingIntent?: ThinkingConfig | null,
67
+ ): unknown {
68
+ const adapter = adapterFor(provider.format);
69
+ const upstreamReq: CanonicalRequest = { ...req, model, stream };
70
+ const out = adapter.requestFromCanonical(upstreamReq) as Record<string, unknown>;
71
+ // Normalize thinking into THIS provider's native format, keyed by the upstream
72
+ // model's capabilities. No-op for non-reasoning models. Runs per-attempt so each
73
+ // provider in a fallback chain gets the right shape.
74
+ applyThinking(provider.format, model, out, provider.id, thinkingIntent);
75
+ return out;
76
+ }
77
+
78
+ export interface NonStreamResult {
79
+ stream: false;
80
+ response: CanonicalResponse;
81
+ }
82
+ export interface StreamResult {
83
+ stream: true;
84
+ body: AsyncIterable<Uint8Array>;
85
+ }
86
+
87
+ export async function callUpstream(
88
+ provider: Provider,
89
+ req: CanonicalRequest,
90
+ model: string,
91
+ opts: { stream: boolean; key?: string; signal?: AbortSignal; thinkingIntent?: ThinkingConfig | null },
92
+ ): Promise<NonStreamResult | StreamResult> {
93
+ const url = buildUrl(provider, model, opts.stream);
94
+ const headers = buildHeaders(provider, opts.key);
95
+ const body = buildBody(provider, req, model, opts.stream, opts.thinkingIntent);
96
+
97
+ let res;
98
+ try {
99
+ res = await request(url, {
100
+ method: "POST",
101
+ headers,
102
+ body: JSON.stringify(body),
103
+ signal: opts.signal,
104
+ // providers can be slow to first byte on long generations
105
+ headersTimeout: 600_000,
106
+ bodyTimeout: 600_000,
107
+ });
108
+ } catch (e) {
109
+ const err = new Error(`upstream ${provider.id} request failed: ${(e as Error).message}`) as UpstreamError;
110
+ err.retryable = true;
111
+ throw err;
112
+ }
113
+
114
+ if (res.statusCode >= 400) {
115
+ const text = await res.body.text();
116
+ const err = new Error(`upstream ${provider.id} returned ${res.statusCode}`) as UpstreamError;
117
+ err.status = res.statusCode;
118
+ err.body = text;
119
+ err.retryable = classifyRetryable(res.statusCode);
120
+ throw err;
121
+ }
122
+
123
+ if (opts.stream) return { stream: true, body: res.body };
124
+
125
+ const json = await res.body.json();
126
+ const adapter = adapterFor(provider.format);
127
+ return { stream: false, response: adapter.responseToCanonical(json) };
128
+ }
129
+
130
+ export interface PingResult {
131
+ reachable: boolean;
132
+ status?: number;
133
+ ok: boolean; // 2xx — endpoint + key both good
134
+ error?: string;
135
+ }
136
+
137
+ /**
138
+ * Lightweight connectivity check: GET {base}/models with the provider's auth.
139
+ * Any HTTP status means the host is reachable; 2xx means the key is accepted.
140
+ * Never throws — returns a structured result for the dashboard.
141
+ */
142
+ export async function pingProvider(provider: Provider, key: string | undefined): Promise<PingResult> {
143
+ const base = provider.base_url.replace(/\/$/, "");
144
+ const url = `${base}/models`;
145
+ const headers = buildHeaders(provider, key);
146
+ try {
147
+ const res = await request(url, { method: "GET", headers, headersTimeout: 10_000, bodyTimeout: 10_000 });
148
+ await res.body.dump();
149
+ return { reachable: true, status: res.statusCode, ok: res.statusCode >= 200 && res.statusCode < 300 };
150
+ } catch (e) {
151
+ return { reachable: false, ok: false, error: (e as Error).message };
152
+ }
153
+ }
154
+
155
+ export { buildHeaders, buildUrl };
package/tsconfig.json ADDED
@@ -0,0 +1,20 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "NodeNext",
5
+ "moduleResolution": "NodeNext",
6
+ "lib": ["ES2022"],
7
+ "outDir": "dist",
8
+ "rootDir": "src",
9
+ "strict": true,
10
+ "noUncheckedIndexedAccess": true,
11
+ "esModuleInterop": true,
12
+ "skipLibCheck": true,
13
+ "forceConsistentCasingInFileNames": true,
14
+ "declaration": false,
15
+ "sourceMap": true,
16
+ "resolveJsonModule": true
17
+ },
18
+ "include": ["src/**/*.ts"],
19
+ "exclude": ["node_modules", "dist", "tests"]
20
+ }