@agimon-ai/model-proxy-mcp 0.2.3 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4041 +0,0 @@
1
- import fs from "node:fs";
2
- import path from "node:path";
3
- import { fileURLToPath } from "node:url";
4
- import { randomUUID } from "node:crypto";
5
- import fs$1 from "node:fs/promises";
6
- import os from "node:os";
7
- import { parse, stringify } from "yaml";
8
- import { ZodError, z } from "zod";
9
- import { serve } from "@hono/node-server";
10
- import { Hono } from "hono";
11
- import { ulid } from "ulidx";
12
- import Database from "better-sqlite3";
13
- import { Server } from "@modelcontextprotocol/sdk/server/index.js";
14
- import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
15
- import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
16
-
17
- //#region src/constants/defaults.ts
18
- const DEFAULT_SETTINGS_DIR = path.join(os.homedir(), ".model-proxy");
19
- const DEFAULT_MODEL_PROVIDER_PATH = path.join(DEFAULT_SETTINGS_DIR, "model-provider.yaml");
20
- const DEFAULT_MODEL_LIST_PATH = path.join(DEFAULT_SETTINGS_DIR, "model-list.yaml");
21
- const DEFAULT_SCOPE_SETTINGS_DIR = path.join(DEFAULT_SETTINGS_DIR, "scopes");
22
- const DEFAULT_HISTORY_DB_PATH = path.join(DEFAULT_SETTINGS_DIR, "history.sqlite");
23
- const DEFAULT_AUTH_FILE_PATH = path.join(os.homedir(), ".codex", "auth.json");
24
- const DEFAULT_HTTP_PORT = 43191;
25
- const DEFAULT_SERVICE_NAME = "model-proxy-mcp-http";
26
- const DEFAULT_HISTORY_RETENTION_LIMIT = 1e3;
27
- const DEFAULT_PROVIDER_SETTINGS = {
28
- "chatgpt-codex": {
29
- type: "chatgpt-codex",
30
- endpoint: "https://chatgpt.com/backend-api/codex/responses",
31
- authTokenEnvVar: null,
32
- apiTimeoutMs: null
33
- },
34
- "zai-anthropic-compat": {
35
- type: "anthropic-compatible",
36
- endpoint: "https://api.z.ai/api/anthropic/v1/messages",
37
- authTokenEnvVar: "ZAI_ANTHROPIC_AUTH_TOKEN",
38
- apiTimeoutMs: 3e6
39
- },
40
- "google-gemini-direct": {
41
- type: "gemini-direct",
42
- endpoint: "https://generativelanguage.googleapis.com",
43
- authTokenEnvVar: "GEMINI_API_KEY",
44
- apiTimeoutMs: 3e5,
45
- authMode: "auto",
46
- apiKeyEnvVar: "GEMINI_API_KEY",
47
- project: null,
48
- location: "global",
49
- apiVersion: "v1beta"
50
- }
51
- };
52
- const DEFAULT_MODEL_LIST = [
53
- {
54
- id: "chatgpt-codex-gpt-5.3-codex",
55
- label: "ChatGPT Codex GPT-5.3 Codex",
56
- provider: "chatgpt-codex",
57
- model: "gpt-5.3-codex",
58
- reasoningEffort: "medium",
59
- enabled: true
60
- },
61
- {
62
- id: "chatgpt-codex-gpt-5.3-codex-spark",
63
- label: "ChatGPT Codex GPT-5.3 Codex Spark",
64
- provider: "chatgpt-codex",
65
- model: "gpt-5.3-codex-spark",
66
- reasoningEffort: "low",
67
- enabled: true
68
- },
69
- {
70
- id: "chatgpt-codex-gpt-5.4",
71
- label: "ChatGPT Codex GPT-5.4",
72
- provider: "chatgpt-codex",
73
- model: "gpt-5.4",
74
- reasoningEffort: "medium",
75
- enabled: true
76
- },
77
- {
78
- id: "chatgpt-codex-gpt-5.2",
79
- label: "ChatGPT Codex GPT-5.2",
80
- provider: "chatgpt-codex",
81
- model: "gpt-5.2",
82
- reasoningEffort: "medium",
83
- enabled: true
84
- },
85
- {
86
- id: "zai-anthropic-compat-glm-4.7",
87
- label: "Z.ai GLM-4.7",
88
- provider: "zai-anthropic-compat",
89
- model: "GLM-4.7",
90
- reasoningEffort: "high",
91
- enabled: true
92
- },
93
- {
94
- id: "zai-anthropic-compat-glm-5",
95
- label: "Z.ai GLM-5",
96
- provider: "zai-anthropic-compat",
97
- model: "glm-5",
98
- reasoningEffort: "high",
99
- enabled: true
100
- },
101
- {
102
- id: "zai-anthropic-compat-glm-4.5-air",
103
- label: "Z.ai GLM-4.5-Air",
104
- provider: "zai-anthropic-compat",
105
- model: "GLM-4.5-Air",
106
- reasoningEffort: "medium",
107
- enabled: true
108
- },
109
- {
110
- id: "google-gemini-direct-gemini-2.5-flash",
111
- label: "Google Gemini 2.5 Flash",
112
- provider: "google-gemini-direct",
113
- model: "gemini-2.5-flash",
114
- reasoningEffort: "medium",
115
- enabled: true
116
- },
117
- {
118
- id: "google-gemini-direct-gemini-3-flash-preview",
119
- label: "Google Gemini 3 Flash Preview",
120
- provider: "google-gemini-direct",
121
- model: "gemini-3-flash-preview",
122
- reasoningEffort: "medium",
123
- enabled: true
124
- },
125
- {
126
- id: "google-gemini-direct-gemini-3.1-pro-preview",
127
- label: "Google Gemini 3.1 Pro Preview",
128
- provider: "google-gemini-direct",
129
- model: "gemini-3.1-pro-preview",
130
- reasoningEffort: "high",
131
- enabled: true
132
- }
133
- ];
134
- const DEFAULT_SCOPE_SETTINGS = { models: {
135
- default: {
136
- main: {
137
- provider: "chatgpt-codex",
138
- model: "gpt-5.4",
139
- reasoningEffort: "medium"
140
- },
141
- fallbacks: [{
142
- provider: "chatgpt-codex",
143
- model: "gpt-5.4",
144
- reasoningEffort: "low"
145
- }]
146
- },
147
- sonnet: {
148
- main: {
149
- provider: "chatgpt-codex",
150
- model: "gpt-5.3-codex",
151
- reasoningEffort: "medium"
152
- },
153
- fallbacks: [{
154
- provider: "chatgpt-codex",
155
- model: "gpt-5.4",
156
- reasoningEffort: "medium"
157
- }]
158
- },
159
- opus: {
160
- main: {
161
- provider: "chatgpt-codex",
162
- model: "gpt-5.4",
163
- reasoningEffort: "medium"
164
- },
165
- fallbacks: [{
166
- provider: "chatgpt-codex",
167
- model: "gpt-5.4",
168
- reasoningEffort: "low"
169
- }]
170
- },
171
- haiku: {
172
- main: {
173
- provider: "chatgpt-codex",
174
- model: "gpt-5.3-codex-spark",
175
- reasoningEffort: "low"
176
- },
177
- fallbacks: [{
178
- provider: "chatgpt-codex",
179
- model: "gpt-5.4",
180
- reasoningEffort: "medium"
181
- }]
182
- },
183
- subagent: {
184
- main: {
185
- provider: "chatgpt-codex",
186
- model: "gpt-5.3-codex-spark",
187
- reasoningEffort: "low"
188
- },
189
- fallbacks: [{
190
- provider: "chatgpt-codex",
191
- model: "gpt-5.4",
192
- reasoningEffort: "medium"
193
- }]
194
- }
195
- } };
196
-
197
- //#endregion
198
- //#region src/services/logger.ts
199
- const consoleLogger = {
200
- info: (msg, data) => console.error(msg, data ?? ""),
201
- error: (msg, error) => console.error(msg, error ?? ""),
202
- debug: () => void 0,
203
- warn: (msg, data) => console.error(msg, data ?? "")
204
- };
205
-
206
- //#endregion
207
- //#region src/services/ProfileStore.ts
208
- const DEFAULT_SCOPE$2 = "default";
209
- const DEFAULT_ZAI_PROVIDER_ID = "zai-anthropic-compat";
210
- const LEGACY_ZAI_AUTH_ENV = "ANTHROPIC_AUTH_TOKEN";
211
- const DEFAULT_ZAI_AUTH_ENV = "ZAI_ANTHROPIC_AUTH_TOKEN";
212
- const SETTINGS_EXTENSION = ".yaml";
213
- const DEFAULT_REASONING_EFFORT = "medium";
214
- const MODEL_SLOTS$1 = [
215
- "default",
216
- "sonnet",
217
- "opus",
218
- "haiku",
219
- "subagent"
220
- ];
221
- const LOG_PREFIX = "[model-proxy-mcp]";
222
- const FILE_NOT_FOUND_ERROR_CODE = "ENOENT";
223
- const YAML_INDENT = 2;
224
- const FILE_ENCODING = "utf8";
225
- const PROVIDER_TYPES = [
226
- "chatgpt-codex",
227
- "anthropic-compatible",
228
- "gemini-direct"
229
- ];
230
- const PROFILE_STORE_ERROR_CODES = {
231
- profileNotFound: "PROFILE_NOT_FOUND",
232
- settingsReadFailed: "SETTINGS_READ_FAILED",
233
- settingsWriteFailed: "SETTINGS_WRITE_FAILED"
234
- };
235
- const reasoningEffortSchema$1 = z.enum([
236
- "minimal",
237
- "low",
238
- "medium",
239
- "high"
240
- ]);
241
- const providerRuntimeConfigSchema = z.object({
242
- type: z.enum(PROVIDER_TYPES),
243
- endpoint: z.url(),
244
- authTokenEnvVar: z.string().min(1).nullable().optional(),
245
- apiTimeoutMs: z.number().int().positive().nullable().optional(),
246
- authMode: z.enum([
247
- "auto",
248
- "api-key",
249
- "oauth"
250
- ]).nullable().optional(),
251
- apiKeyEnvVar: z.string().min(1).nullable().optional(),
252
- project: z.string().min(1).nullable().optional(),
253
- location: z.string().min(1).nullable().optional(),
254
- apiVersion: z.string().min(1).nullable().optional()
255
- });
256
- const providerRegistrySchema = z.object({ providers: z.record(z.string().min(1), providerRuntimeConfigSchema) });
257
- const modelConfigSchema = z.object({
258
- id: z.string().min(1),
259
- label: z.string().min(1),
260
- provider: z.string().min(1),
261
- model: z.string().min(1),
262
- reasoningEffort: reasoningEffortSchema$1.default(DEFAULT_REASONING_EFFORT),
263
- enabled: z.boolean().default(true)
264
- });
265
- const selectionSchema$1 = z.object({
266
- provider: z.string().min(1),
267
- model: z.string().min(1),
268
- reasoningEffort: reasoningEffortSchema$1.default(DEFAULT_REASONING_EFFORT),
269
- thinkingDisabled: z.boolean().optional()
270
- });
271
- const slotConfigSchema$1 = z.object({
272
- main: selectionSchema$1,
273
- fallbacks: z.array(selectionSchema$1).default([])
274
- });
275
- const scopeSettingsSchema = z.object({ models: z.object({
276
- default: slotConfigSchema$1.optional(),
277
- sonnet: slotConfigSchema$1.optional(),
278
- opus: slotConfigSchema$1.optional(),
279
- haiku: slotConfigSchema$1.optional(),
280
- subagent: slotConfigSchema$1.optional()
281
- }).default({}) });
282
- const modelListSchema = z.array(modelConfigSchema);
283
- const adminConfigUpdateSchema$1 = z.object({ models: z.object({
284
- default: slotConfigSchema$1.nullable().optional(),
285
- sonnet: slotConfigSchema$1.nullable().optional(),
286
- opus: slotConfigSchema$1.nullable().optional(),
287
- haiku: slotConfigSchema$1.nullable().optional(),
288
- subagent: slotConfigSchema$1.nullable().optional()
289
- }).optional() });
290
- var ProfileStoreError = class extends Error {
291
- constructor(message, code, options) {
292
- super(message, options);
293
- this.code = code;
294
- this.name = "ProfileStoreError";
295
- }
296
- };
297
- var ProfileStore = class {
298
- constructor(providerConfigPath = process.env.MODEL_PROXY_MCP_PROVIDER_PATH || DEFAULT_MODEL_PROVIDER_PATH, modelListPath = process.env.MODEL_PROXY_MCP_MODEL_LIST_PATH || path.join(path.dirname(process.env.MODEL_PROXY_MCP_PROVIDER_PATH || DEFAULT_MODEL_PROVIDER_PATH), path.basename(DEFAULT_MODEL_LIST_PATH)), scopeSettingsDir = process.env.MODEL_PROXY_MCP_SCOPE_DIR || path.join(path.dirname(process.env.MODEL_PROXY_MCP_PROVIDER_PATH || DEFAULT_MODEL_PROVIDER_PATH), path.basename(DEFAULT_SCOPE_SETTINGS_DIR)), logger = consoleLogger) {
299
- this.providerConfigPath = providerConfigPath;
300
- this.modelListPath = modelListPath;
301
- this.scopeSettingsDir = scopeSettingsDir;
302
- this.logger = logger;
303
- }
304
- getConfigPath(scope = DEFAULT_SCOPE$2) {
305
- return this.getScopeConfigPath(scope);
306
- }
307
- async ensureConfig(scope = DEFAULT_SCOPE$2, seedConfigPath) {
308
- return this.getConfig(scope, seedConfigPath);
309
- }
310
- async listScopes() {
311
- try {
312
- const entries = await fs$1.readdir(this.scopeSettingsDir, { withFileTypes: true });
313
- const scopes = new Set([DEFAULT_SCOPE$2]);
314
- for (const entry of entries) {
315
- if (!entry.isFile() || !entry.name.endsWith(SETTINGS_EXTENSION)) continue;
316
- const scopeName = entry.name.slice(0, -5);
317
- if (scopeName) scopes.add(scopeName);
318
- }
319
- return Array.from(scopes).sort();
320
- } catch (error) {
321
- if (this.isFileNotFoundError(error)) return [DEFAULT_SCOPE$2];
322
- this.logger.error(`${LOG_PREFIX} Failed to list scopes`, {
323
- scopeSettingsDir: this.scopeSettingsDir,
324
- cause: error
325
- });
326
- throw new ProfileStoreError(`Failed to list scopes in ${this.scopeSettingsDir}`, PROFILE_STORE_ERROR_CODES.settingsReadFailed, { cause: error });
327
- }
328
- }
329
- async getConfig(scope = DEFAULT_SCOPE$2, seedConfigPath) {
330
- return this.toProxyConfig(await this.getSettings(scope, seedConfigPath));
331
- }
332
- async getAdminConfig(scope = DEFAULT_SCOPE$2) {
333
- const settings = await this.getSettings(scope);
334
- const profiles = this.toProfiles(settings);
335
- const slots = Object.fromEntries(MODEL_SLOTS$1.map((slot) => [slot, this.resolveSlotConfig(settings, profiles, slot)]));
336
- return {
337
- scope,
338
- providerConfigPath: this.providerConfigPath,
339
- modelListPath: this.modelListPath,
340
- scopeConfigPath: this.getScopeConfigPath(scope),
341
- providers: settings.providers,
342
- models: profiles,
343
- scopeModels: settings.scope.models,
344
- slots
345
- };
346
- }
347
- async listProfiles(scope = DEFAULT_SCOPE$2) {
348
- return (await this.getAdminConfig(scope)).models;
349
- }
350
- async getActiveProfile(scope = DEFAULT_SCOPE$2, slot = DEFAULT_SCOPE$2) {
351
- const config = await this.getAdminConfig(scope);
352
- const resolvedSlot = config.slots[slot];
353
- return config.models.find((profile) => profile.id === resolvedSlot.profileId && profile.enabled) ?? null;
354
- }
355
- async getResolvedSlotConfig(scope = DEFAULT_SCOPE$2, slot = DEFAULT_SCOPE$2) {
356
- return (await this.getAdminConfig(scope)).slots[slot];
357
- }
358
- async setActiveProfile(profileId, scope = DEFAULT_SCOPE$2, slot = DEFAULT_SCOPE$2) {
359
- const settings = await this.getSettings(scope);
360
- const profile = this.toProfiles(settings).find((item) => item.id === profileId && item.enabled);
361
- if (!profile) throw new ProfileStoreError(`Profile not found or disabled: ${profileId}`, PROFILE_STORE_ERROR_CODES.profileNotFound, { cause: {
362
- profileId,
363
- scope,
364
- slot
365
- } });
366
- return this.updateConfig({ models: { [slot]: {
367
- main: {
368
- provider: profile.provider,
369
- model: profile.model,
370
- reasoningEffort: profile.reasoningEffort,
371
- thinkingDisabled: settings.scope.models[slot]?.main.thinkingDisabled ?? false
372
- },
373
- fallbacks: settings.scope.models[slot]?.fallbacks ?? []
374
- } } }, scope);
375
- }
376
- async upsertProfile(profile, scope = DEFAULT_SCOPE$2) {
377
- const nextProfile = modelConfigSchema.parse({
378
- id: profile.id,
379
- label: profile.label,
380
- provider: profile.provider,
381
- model: profile.model,
382
- reasoningEffort: profile.reasoningEffort,
383
- enabled: profile.enabled
384
- });
385
- const settings = await this.getSettings(scope);
386
- const models = settings.models.filter((item) => item.id !== nextProfile.id);
387
- models.push(nextProfile);
388
- const nextSettings = this.normalizeSettings({
389
- providers: settings.providers,
390
- models,
391
- scope: settings.scope
392
- });
393
- await this.saveSettings(nextSettings, scope);
394
- return this.toProxyConfig(nextSettings);
395
- }
396
- async updateConfig(update, scope = DEFAULT_SCOPE$2) {
397
- const parsedUpdate = adminConfigUpdateSchema$1.parse(update);
398
- const settings = await this.getSettings(scope);
399
- const nextScopeModels = { ...settings.scope.models };
400
- for (const slot of MODEL_SLOTS$1) {
401
- const selection = parsedUpdate.models?.[slot];
402
- if (selection === void 0) continue;
403
- if (selection === null) {
404
- delete nextScopeModels[slot];
405
- continue;
406
- }
407
- nextScopeModels[slot] = selection;
408
- }
409
- const nextSettings = this.normalizeSettings({
410
- providers: settings.providers,
411
- models: settings.models,
412
- scope: { models: nextScopeModels }
413
- });
414
- await this.saveSettings(nextSettings, scope);
415
- return this.toProxyConfig(nextSettings);
416
- }
417
- getScopeConfigPath(scope) {
418
- return path.join(this.scopeSettingsDir, `${this.sanitizeScope(scope)}${SETTINGS_EXTENSION}`);
419
- }
420
- sanitizeScope(scope) {
421
- return scope.replace(/[^a-zA-Z0-9._-]/g, "-").replace(/--+/g, "-").replace(/^-|-$/g, "") || DEFAULT_SCOPE$2;
422
- }
423
- async getSettings(scope, seedConfigPath) {
424
- const settings = await this.readSettings(scope, seedConfigPath);
425
- const normalized = this.normalizeSettings(settings);
426
- if (JSON.stringify(normalized) !== JSON.stringify(settings)) await this.saveSettings(normalized, scope);
427
- return normalized;
428
- }
429
- async readSettings(scope, seedConfigPath) {
430
- const scopeConfigPath = this.getScopeConfigPath(scope);
431
- const [providerResult, modelListResult, scopeResult] = await Promise.allSettled([
432
- fs$1.readFile(this.providerConfigPath, FILE_ENCODING),
433
- fs$1.readFile(this.modelListPath, FILE_ENCODING),
434
- fs$1.readFile(scopeConfigPath, FILE_ENCODING)
435
- ]);
436
- try {
437
- const providerRaw = providerResult.status === "fulfilled" ? providerResult.value : null;
438
- const modelListRaw = modelListResult.status === "fulfilled" ? modelListResult.value : null;
439
- const scopeRaw = scopeResult.status === "fulfilled" ? scopeResult.value : null;
440
- const providerMissing = providerResult.status === "rejected" && this.isFileNotFoundError(providerResult.reason);
441
- const modelListMissing = modelListResult.status === "rejected" && this.isFileNotFoundError(modelListResult.reason);
442
- const scopeMissing = scopeResult.status === "rejected" && this.isFileNotFoundError(scopeResult.reason);
443
- if (!providerRaw && providerResult.status === "rejected" && !providerMissing) throw providerResult.reason;
444
- if (!modelListRaw && modelListResult.status === "rejected" && !modelListMissing) throw modelListResult.reason;
445
- if (!scopeRaw && scopeResult.status === "rejected" && !scopeMissing) throw scopeResult.reason;
446
- const scopeSettings = scopeRaw ? scopeSettingsSchema.parse(parse(scopeRaw)) : await this.loadScopeSeedSettings(scope, seedConfigPath);
447
- const settings = this.normalizeSettings({
448
- providers: providerRaw ? providerRegistrySchema.parse(parse(providerRaw)).providers : structuredClone(DEFAULT_PROVIDER_SETTINGS),
449
- models: modelListRaw ? modelListSchema.parse(parse(modelListRaw)) : structuredClone(DEFAULT_MODEL_LIST),
450
- scope: scopeSettings
451
- });
452
- if (providerMissing || modelListMissing || scopeMissing) await this.saveSettings(settings, scope);
453
- return settings;
454
- } catch (error) {
455
- this.logger.error(`${LOG_PREFIX} Failed to read settings`, {
456
- scope,
457
- providerConfigPath: this.providerConfigPath,
458
- modelListPath: this.modelListPath,
459
- scopeConfigPath,
460
- code: PROFILE_STORE_ERROR_CODES.settingsReadFailed,
461
- cause: error
462
- });
463
- throw new ProfileStoreError(`Failed to read settings from ${this.providerConfigPath}, ${this.modelListPath}, and ${scopeConfigPath}`, PROFILE_STORE_ERROR_CODES.settingsReadFailed, { cause: error });
464
- }
465
- }
466
- isFileNotFoundError(error) {
467
- return error?.code === FILE_NOT_FOUND_ERROR_CODE;
468
- }
469
- async loadScopeSeedSettings(scope, seedConfigPath) {
470
- const candidatePaths = [seedConfigPath, this.getDefaultScopeSeedPath(scope)].filter((candidate) => Boolean(candidate));
471
- for (const candidatePath of candidatePaths) try {
472
- const raw = await fs$1.readFile(candidatePath, FILE_ENCODING);
473
- return scopeSettingsSchema.parse(parse(raw));
474
- } catch (error) {
475
- if (this.isFileNotFoundError(error)) continue;
476
- this.logger.error(`${LOG_PREFIX} Failed to read scope seed settings`, {
477
- scope,
478
- seedConfigPath: candidatePath,
479
- cause: error
480
- });
481
- throw error;
482
- }
483
- return structuredClone(DEFAULT_SCOPE_SETTINGS);
484
- }
485
- getDefaultScopeSeedPath(scope) {
486
- const defaultScopePath = this.getScopeConfigPath(DEFAULT_SCOPE$2);
487
- return scope === DEFAULT_SCOPE$2 ? null : defaultScopePath;
488
- }
489
- normalizeSettings(settings) {
490
- const providers = this.normalizeProviders(Object.keys(settings.providers).length > 0 ? settings.providers : structuredClone(DEFAULT_PROVIDER_SETTINGS));
491
- const models = settings.models.map((model) => modelConfigSchema.parse(model));
492
- const defaultSelection = this.normalizeSlotSelection(settings.scope.models.default?.main ?? null, models, providers, this.getDefaultSelection(models, providers));
493
- const normalizedScopeModels = { default: defaultSelection ? {
494
- main: defaultSelection,
495
- fallbacks: this.normalizeFallbacks(settings.scope.models.default?.fallbacks ?? [], defaultSelection, models, providers)
496
- } : void 0 };
497
- for (const slot of MODEL_SLOTS$1) {
498
- if (slot === DEFAULT_SCOPE$2) continue;
499
- const fallback = defaultSelection ?? this.getDefaultSelection(models, providers);
500
- const selection = this.normalizeSlotSelection(settings.scope.models[slot]?.main ?? null, models, providers, fallback);
501
- normalizedScopeModels[slot] = selection ? {
502
- main: selection,
503
- fallbacks: this.normalizeFallbacks(settings.scope.models[slot]?.fallbacks ?? [], selection, models, providers)
504
- } : void 0;
505
- }
506
- return {
507
- providers,
508
- models,
509
- scope: { models: normalizedScopeModels }
510
- };
511
- }
512
- normalizeProviders(providers) {
513
- const normalizedProviders = { ...providers };
514
- const zaiProvider = normalizedProviders[DEFAULT_ZAI_PROVIDER_ID];
515
- if (zaiProvider?.type === "anthropic-compatible" && zaiProvider.authTokenEnvVar === LEGACY_ZAI_AUTH_ENV) normalizedProviders[DEFAULT_ZAI_PROVIDER_ID] = {
516
- ...zaiProvider,
517
- authTokenEnvVar: DEFAULT_ZAI_AUTH_ENV
518
- };
519
- return normalizedProviders;
520
- }
521
- getDefaultSelection(models, providers) {
522
- const fallbackModel = models.find((model) => model.enabled && providers[model.provider]);
523
- if (!fallbackModel) return null;
524
- return {
525
- provider: fallbackModel.provider,
526
- model: fallbackModel.model,
527
- reasoningEffort: fallbackModel.reasoningEffort,
528
- thinkingDisabled: false
529
- };
530
- }
531
- normalizeSlotSelection(selection, models, providers, fallback) {
532
- if (!selection) return fallback ? { ...fallback } : null;
533
- const provider = providers[selection.provider] ? selection.provider : fallback?.provider ?? null;
534
- if (!provider) return null;
535
- if (provider !== selection.provider) return fallback && providers[fallback.provider] ? { ...fallback } : null;
536
- const catalogEntry = models.find((model) => model.enabled && model.provider === provider && model.model === selection.model);
537
- return {
538
- provider,
539
- model: catalogEntry?.model ?? selection.model,
540
- reasoningEffort: selection.reasoningEffort ?? catalogEntry?.reasoningEffort ?? DEFAULT_REASONING_EFFORT,
541
- thinkingDisabled: selection.thinkingDisabled ?? false
542
- };
543
- }
544
- normalizeFallbacks(fallbacks, main, models, providers) {
545
- const normalized = [];
546
- const seen = new Set([`${main.provider}:${main.model}:${main.reasoningEffort}:${main.thinkingDisabled ? "off" : "on"}`]);
547
- for (const fallback of fallbacks) {
548
- const nextFallback = this.normalizeSlotSelection(fallback, models, providers, null);
549
- if (!nextFallback) continue;
550
- const key = `${nextFallback.provider}:${nextFallback.model}:${nextFallback.reasoningEffort}:${nextFallback.thinkingDisabled ? "off" : "on"}`;
551
- if (seen.has(key)) continue;
552
- seen.add(key);
553
- normalized.push(nextFallback);
554
- }
555
- return normalized;
556
- }
557
- toProxyConfig(settings) {
558
- const profiles = this.toProfiles(settings);
559
- const slotProfileIds = Object.fromEntries(MODEL_SLOTS$1.map((slot) => {
560
- return [slot, this.resolveSlotConfig(settings, profiles, slot).profileId];
561
- }));
562
- return {
563
- activeProfileId: slotProfileIds.default ?? null,
564
- slotProfileIds,
565
- providers: settings.providers,
566
- profiles,
567
- scope: settings.scope
568
- };
569
- }
570
- resolveSlotConfig(settings, profiles, slot) {
571
- const slotConfig = settings.scope.models[slot] ?? settings.scope.models.default;
572
- const selection = slotConfig?.main ?? null;
573
- const profile = selection ? profiles.find((item) => item.provider === selection.provider && item.model === selection.model && item.enabled) : void 0;
574
- const provider = selection?.provider ? settings.providers[selection.provider] : void 0;
575
- return {
576
- slot,
577
- profileId: profile?.id ?? null,
578
- label: profile?.label ?? null,
579
- provider: selection?.provider ?? null,
580
- providerType: provider?.type ?? null,
581
- endpoint: provider?.endpoint ?? null,
582
- model: selection?.model ?? null,
583
- reasoningEffort: selection?.reasoningEffort ?? profile?.reasoningEffort ?? DEFAULT_REASONING_EFFORT,
584
- thinkingDisabled: selection?.thinkingDisabled ?? false,
585
- fallbacks: slotConfig?.fallbacks ?? []
586
- };
587
- }
588
- toProfiles(settings) {
589
- return settings.models.map((model) => ({
590
- id: model.id,
591
- label: model.label,
592
- provider: model.provider,
593
- providerType: settings.providers[model.provider]?.type ?? null,
594
- model: model.model,
595
- endpoint: settings.providers[model.provider]?.endpoint ?? null,
596
- reasoningEffort: model.reasoningEffort,
597
- enabled: model.enabled
598
- }));
599
- }
600
- async saveSettings(settings, scope = DEFAULT_SCOPE$2) {
601
- const scopeConfigPath = this.getScopeConfigPath(scope);
602
- try {
603
- const directories = new Set([
604
- path.dirname(this.providerConfigPath),
605
- path.dirname(this.modelListPath),
606
- path.dirname(scopeConfigPath)
607
- ]);
608
- await Promise.all(Array.from(directories, (directory) => fs$1.mkdir(directory, { recursive: true })));
609
- await Promise.all([
610
- fs$1.writeFile(this.providerConfigPath, stringify(providerRegistrySchema.parse({ providers: settings.providers }), { indent: YAML_INDENT }), FILE_ENCODING),
611
- fs$1.writeFile(this.modelListPath, stringify(modelListSchema.parse(settings.models), { indent: YAML_INDENT }), FILE_ENCODING),
612
- fs$1.writeFile(scopeConfigPath, stringify(scopeSettingsSchema.parse(settings.scope), { indent: YAML_INDENT }), FILE_ENCODING)
613
- ]);
614
- } catch (error) {
615
- this.logger.error(`${LOG_PREFIX} Failed to write settings`, {
616
- scope,
617
- providerConfigPath: this.providerConfigPath,
618
- modelListPath: this.modelListPath,
619
- scopeConfigPath,
620
- code: PROFILE_STORE_ERROR_CODES.settingsWriteFailed,
621
- cause: error
622
- });
623
- throw new ProfileStoreError(`Failed to write settings to ${this.providerConfigPath}, ${this.modelListPath}, and ${scopeConfigPath}`, PROFILE_STORE_ERROR_CODES.settingsWriteFailed, { cause: error });
624
- }
625
- }
626
- };
627
-
628
- //#endregion
629
- //#region src/adapters/codex/ClaudeToOpenAITransformer.ts
630
- /**
631
- * Claude to OpenAI Request Transformer
632
- *
633
- * Converts Anthropic Claude Messages API requests to OpenAI Responses API format.
634
- * Handles message structure, system prompts, streaming, and other parameters.
635
- */
636
- const __filename = fileURLToPath(import.meta.url);
637
- const __dirname = path.dirname(__filename);
638
- var ClaudeToOpenAITransformer = class {
639
- config;
640
- codexAuth;
641
- codexInstructions;
642
- constructor(config, codexAuth) {
643
- this.config = config;
644
- this.codexAuth = codexAuth;
645
- this.codexInstructions = this.loadCodexInstructions();
646
- }
647
- /**
648
- * Load the complete Codex CLI system prompt from codex.md
649
- */
650
- loadCodexInstructions() {
651
- const codexMdPath = path.join(__dirname, "codex.md");
652
- try {
653
- return fs.readFileSync(codexMdPath, "utf-8");
654
- } catch (error) {
655
- console.warn(`Warning: Could not load codex.md from ${codexMdPath}`);
656
- return "";
657
- }
658
- }
659
- async transform(_url, requestBody) {
660
- try {
661
- const claudeRequest = JSON.parse(requestBody);
662
- this.config.logger?.debug("[ClaudeToOpenAI] ===== ORIGINAL CLAUDE REQUEST =====");
663
- this.config.logger?.debug("[ClaudeToOpenAI] Original body", { body: JSON.stringify(claudeRequest, null, 2) });
664
- const conversationId = randomUUID();
665
- const sessionId = conversationId;
666
- const sessionReasoningEffort = this.config.sessionReasoningEffort;
667
- const isHaikuModel = claudeRequest.model && claudeRequest.model.toLowerCase().includes("haiku");
668
- const reasoningEffort = sessionReasoningEffort || (isHaikuModel ? "minimal" : "medium");
669
- this.config.logger?.debug("[ClaudeToOpenAI] Model detection and reasoning effort", {
670
- originalModel: claudeRequest.model,
671
- isHaikuModel,
672
- sessionReasoningEffort: sessionReasoningEffort || "none",
673
- finalReasoningEffort: reasoningEffort,
674
- source: sessionReasoningEffort ? "session override" : "model-based"
675
- });
676
- const responsesRequest = {
677
- model: this.config.toModel || "gpt-5",
678
- stream: true,
679
- store: false,
680
- tool_choice: "auto",
681
- parallel_tool_calls: false,
682
- prompt_cache_key: conversationId
683
- };
684
- if (!this.config.thinkingDisabled) {
685
- responsesRequest.reasoning = {
686
- effort: reasoningEffort,
687
- summary: "auto"
688
- };
689
- responsesRequest.include = ["reasoning.encrypted_content"];
690
- }
691
- this.config.logger?.debug("[ClaudeToOpenAI] Thinking mode", { thinkingDisabled: this.config.thinkingDisabled ?? false });
692
- responsesRequest.instructions = this.adaptInstructionsForChatGPT(this.codexInstructions);
693
- const input = [];
694
- let claudeSystemPrompt = "";
695
- if (claudeRequest.system) {
696
- const systemMessages = this.extractSystemMessages(claudeRequest.system);
697
- if (systemMessages && Array.isArray(systemMessages) && systemMessages.length > 0) claudeSystemPrompt = systemMessages.map((msg) => msg.content).join("\n\n");
698
- }
699
- claudeSystemPrompt = this.removeClaudeCodeInstructions(claudeSystemPrompt);
700
- if (claudeSystemPrompt) input.push({
701
- type: "message",
702
- role: "user",
703
- content: [{
704
- type: "input_text",
705
- text: claudeSystemPrompt
706
- }]
707
- });
708
- if (claudeRequest.messages && Array.isArray(claudeRequest.messages)) for (const msg of claudeRequest.messages) {
709
- const converted = this.convertMessageToInput(msg);
710
- if (Array.isArray(converted)) input.push(...converted);
711
- else if (converted) input.push(converted);
712
- }
713
- responsesRequest.input = input;
714
- if (claudeRequest.tools && Array.isArray(claudeRequest.tools)) {
715
- this.config.logger?.debug("[ClaudeToOpenAI] Original Claude tools", { tools: JSON.stringify(claudeRequest.tools, null, 2) });
716
- const convertedTools = this.convertTools(claudeRequest.tools);
717
- this.config.logger?.debug("[ClaudeToOpenAI] Converted tools", { tools: JSON.stringify(convertedTools, null, 2) });
718
- if (convertedTools.length > 0) {
719
- responsesRequest.tools = convertedTools;
720
- this.config.logger?.debug("[ClaudeToOpenAI] Added tools to responsesRequest", { toolCount: convertedTools.length });
721
- this.config.logger?.debug("[ClaudeToOpenAI] Verify responsesRequest.tools exists", {
722
- hasTools: !!responsesRequest.tools,
723
- toolsLength: responsesRequest.tools?.length,
724
- keys: Object.keys(responsesRequest)
725
- });
726
- } else this.config.logger?.warn("[ClaudeToOpenAI] No valid tools after conversion, omitting tools field");
727
- } else this.config.logger?.debug("[ClaudeToOpenAI] No tools in Claude request", {
728
- hasTools: !!claudeRequest.tools,
729
- isArray: Array.isArray(claudeRequest.tools)
730
- });
731
- const targetUrl = this.config.toEndpoint || "https://chatgpt.com/backend-api/codex/responses";
732
- const headers = {
733
- version: "0.46.0",
734
- "openai-beta": "responses=experimental",
735
- conversation_id: conversationId,
736
- session_id: sessionId,
737
- accept: "text/event-stream",
738
- "content-type": "application/json",
739
- "user-agent": "codex_cli_rs/0.46.0 (Mac OS 15.6.0; arm64) iTerm.app/3.6.2",
740
- originator: "codex_cli_rs"
741
- };
742
- if (this.codexAuth) {
743
- const accessToken = await this.codexAuth.getAccessToken();
744
- this.config.logger?.debug("[ClaudeToOpenAI] Raw access token", { token: accessToken?.substring(0, 30) + "..." });
745
- if (accessToken) if (accessToken.startsWith("Bearer ")) {
746
- headers.authorization = accessToken;
747
- this.config.logger?.debug("[ClaudeToOpenAI] Token already has Bearer prefix, using as-is");
748
- } else {
749
- headers.authorization = `Bearer ${accessToken}`;
750
- this.config.logger?.debug("[ClaudeToOpenAI] Added Bearer prefix to token");
751
- }
752
- const accountId = await this.codexAuth.getAccountId();
753
- if (accountId) headers["chatgpt-account-id"] = accountId;
754
- } else if (this.config.toApiKey) headers.authorization = `Bearer ${this.config.toApiKey}`;
755
- this.config.logger?.debug("[ClaudeToOpenAI] ===== REQUEST DETAILS =====");
756
- this.config.logger?.debug("[ClaudeToOpenAI] Target URL", { targetUrl });
757
- this.config.logger?.debug("[ClaudeToOpenAI] Headers", { headers: {
758
- ...headers,
759
- authorization: headers.authorization ? `${headers.authorization.substring(0, 30)}...` : void 0
760
- } });
761
- this.config.logger?.debug("[ClaudeToOpenAI] Body", { body: JSON.stringify(responsesRequest, null, 2) });
762
- this.config.logger?.debug("[ClaudeToOpenAI] ===============================");
763
- this.config.logger?.debug("[ClaudeToOpenAI] ===== FINAL TRANSFORMED REQUEST =====");
764
- this.config.logger?.debug("[ClaudeToOpenAI] Pre-final check - responsesRequest.tools", {
765
- hasTools: !!responsesRequest.tools,
766
- toolsLength: responsesRequest.tools?.length,
767
- keys: Object.keys(responsesRequest)
768
- });
769
- this.config.logger?.debug("[ClaudeToOpenAI] Final body", { body: JSON.stringify(responsesRequest, null, 2) });
770
- this.config.logger?.debug("[ClaudeToOpenAI] ===== END FINAL TRANSFORMED REQUEST =====");
771
- return {
772
- url: targetUrl,
773
- body: JSON.stringify(responsesRequest),
774
- headers
775
- };
776
- } catch (error) {
777
- throw new Error(`Failed to transform Claude request to OpenAI: ${error}`);
778
- }
779
- }
780
- /**
781
- * Adapt Codex instructions for ChatGPT by replacing Claude-specific model information
782
- */
783
- adaptInstructionsForChatGPT(instructions) {
784
- let adapted = instructions;
785
- adapted = adapted.replace(/You are powered by the model named Sonnet 4\.5\. The exact model ID is claude-sonnet-4-5-\d+\./g, "You are powered by ChatGPT (GPT-5 reasoning model).");
786
- adapted = adapted.replace(/Assistant knowledge cutoff is January 2025/g, "Assistant knowledge cutoff is October 2023");
787
- adapted = adapted.replace(/\bClaude\b/g, "ChatGPT");
788
- adapted = adapted.replace(/\bAnthropic\b/g, "OpenAI");
789
- return adapted;
790
- }
791
- /**
792
- * Remove Claude Code-specific instructions from system messages
793
- * These should not be sent to ChatGPT as user messages
794
- */
795
- removeClaudeCodeInstructions(text) {
796
- const claudeCodePatterns = [/You are Claude Code, Anthropic's official CLI for Claude\.[\s\S]*?claude_code_docs_map\.md/, /You are Claude Code[\s\S]*?using Claude Code\n/];
797
- let filtered = text;
798
- for (const pattern of claudeCodePatterns) filtered = filtered.replace(pattern, "");
799
- filtered = filtered.replace(/\n{3,}/g, "\n\n").trim();
800
- return filtered;
801
- }
802
- /**
803
- * Extract and convert system messages from Claude format
804
- */
805
- extractSystemMessages(system) {
806
- const messages = [];
807
- if (typeof system === "string") messages.push({
808
- role: "system",
809
- content: system
810
- });
811
- else if (Array.isArray(system)) {
812
- for (const item of system) if (typeof item === "string") messages.push({
813
- role: "system",
814
- content: item
815
- });
816
- else if (item.type === "text" && item.text) messages.push({
817
- role: "system",
818
- content: item.text
819
- });
820
- } else if (system.type === "text" && system.text) messages.push({
821
- role: "system",
822
- content: system.text
823
- });
824
- return messages;
825
- }
826
- /**
827
- * Convert a single Claude message to Responses API input format
828
- * Returns a single message or array of messages (for tool results)
829
- */
830
- convertMessageToInput(msg) {
831
- if (!msg.role || !msg.content) return null;
832
- const textType = msg.role === "assistant" ? "output_text" : "input_text";
833
- if (Array.isArray(msg.content)) {
834
- const toolResults = [];
835
- const toolCalls = [];
836
- const textContent = [];
837
- for (const block of msg.content) if (block.type === "tool_result") {
838
- const resultText = typeof block.content === "string" ? block.content : JSON.stringify(block.content);
839
- toolResults.push({
840
- type: "function_call_output",
841
- call_id: block.tool_use_id,
842
- output: resultText
843
- });
844
- } else if (block.type === "text") textContent.push({
845
- type: textType,
846
- text: block.text || ""
847
- });
848
- else if (block.type === "image") if (block.source && block.source.type === "base64" && block.source.data) {
849
- const mediaType = block.source.media_type || "image/jpeg";
850
- const imageUrl = `data:${mediaType};base64,${block.source.data}`;
851
- textContent.push({
852
- type: "input_image",
853
- image_url: imageUrl
854
- });
855
- this.config.logger?.debug("[ClaudeToOpenAI] Converted image block", {
856
- mediaType,
857
- dataLength: block.source.data.length
858
- });
859
- } else this.config.logger?.warn("[ClaudeToOpenAI] Unsupported image format", { source: block.source });
860
- else if (block.type === "tool_use") toolCalls.push({
861
- type: "function_call",
862
- call_id: block.id,
863
- name: block.name,
864
- arguments: JSON.stringify(block.input || {})
865
- });
866
- const result = [];
867
- if (textContent.length > 0) result.push({
868
- type: "message",
869
- role: msg.role,
870
- content: textContent
871
- });
872
- if (toolCalls.length > 0) result.push(...toolCalls);
873
- if (toolResults.length > 0) result.push(...toolResults);
874
- if (result.length > 1) return result;
875
- else if (result.length === 1) return result[0];
876
- return null;
877
- } else if (typeof msg.content === "string") return {
878
- type: "message",
879
- role: msg.role,
880
- content: [{
881
- type: textType,
882
- text: msg.content
883
- }]
884
- };
885
- return null;
886
- }
887
- /**
888
- * Convert Claude tools to ChatGPT Responses API format
889
- *
890
- * The Responses API uses a flat structure with type at the top level:
891
- * { type: "function", name: "...", description: "...", parameters: {...} }
892
- */
893
- convertTools(tools) {
894
- if (!tools || !Array.isArray(tools)) return [];
895
- return tools.filter((tool) => {
896
- if (!tool || typeof tool !== "object") return false;
897
- if (!tool.name) return false;
898
- return true;
899
- }).map((tool) => {
900
- return {
901
- type: "function",
902
- name: tool.name,
903
- description: tool.description || "",
904
- parameters: tool.input_schema || tool.parameters || {}
905
- };
906
- });
907
- }
908
- };
909
-
910
- //#endregion
911
- //#region src/adapters/codex/CodexAuth.ts
912
- var CodexAuth = class CodexAuth {
913
- static TOKEN_REFRESH_URL = "https://auth.openai.com/oauth/token";
914
- static CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
915
- constructor(logger = consoleLogger, authFilePath = path.join(process.env.HOME || "", ".codex", "auth.json")) {
916
- this.logger = logger;
917
- this.authFilePath = authFilePath;
918
- }
919
- getAuthFilePath() {
920
- return this.authFilePath;
921
- }
922
- async getAuthStatus() {
923
- const authData = this.readAuthFile();
924
- return {
925
- configured: Boolean(authData?.tokens?.refresh_token),
926
- accountId: authData?.tokens?.account_id ?? null,
927
- authFilePath: this.authFilePath,
928
- lastRefresh: authData?.last_refresh ?? null
929
- };
930
- }
931
- async getAccountId() {
932
- return this.readAuthFile()?.tokens?.account_id ?? null;
933
- }
934
- async getAccessToken() {
935
- const authData = this.readAuthFile();
936
- if (!authData?.tokens) {
937
- this.logger.warn("[CodexAuth] No tokens found in auth file");
938
- return null;
939
- }
940
- let accessToken = authData.tokens.access_token;
941
- if (accessToken.startsWith("Bearer ")) accessToken = accessToken.slice(7);
942
- if (!this.isTokenExpired(accessToken)) return accessToken;
943
- const refreshed = await this.refreshAccessToken(authData.tokens.refresh_token);
944
- if (!refreshed) return null;
945
- this.saveTokens(refreshed);
946
- return refreshed.access_token;
947
- }
948
- readAuthFile() {
949
- try {
950
- if (!fs.existsSync(this.authFilePath)) return null;
951
- return JSON.parse(fs.readFileSync(this.authFilePath, "utf8"));
952
- } catch (error) {
953
- this.logger.error("[CodexAuth] Failed to read auth file", error);
954
- return null;
955
- }
956
- }
957
- isTokenExpired(token) {
958
- const payload = this.decodeJWT(token);
959
- if (!payload?.exp) return true;
960
- return payload.exp * 1e3 - Date.now() < 300 * 1e3;
961
- }
962
- decodeJWT(token) {
963
- try {
964
- const [, payload] = token.split(".");
965
- if (!payload) return null;
966
- return JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
967
- } catch (error) {
968
- this.logger.error("[CodexAuth] Failed to decode JWT", error);
969
- return null;
970
- }
971
- }
972
- async refreshAccessToken(refreshToken) {
973
- try {
974
- const response = await fetch(CodexAuth.TOKEN_REFRESH_URL, {
975
- method: "POST",
976
- headers: { "content-type": "application/x-www-form-urlencoded" },
977
- body: new URLSearchParams({
978
- grant_type: "refresh_token",
979
- refresh_token: refreshToken,
980
- client_id: CodexAuth.CLIENT_ID
981
- })
982
- });
983
- if (!response.ok) {
984
- this.logger.error("[CodexAuth] Token refresh failed", await response.text());
985
- return null;
986
- }
987
- const data = await response.json();
988
- return {
989
- id_token: data.id_token ?? "",
990
- access_token: (data.access_token ?? "").replace(/^Bearer\s+/i, ""),
991
- refresh_token: data.refresh_token || refreshToken,
992
- account_id: data.account_id ?? ""
993
- };
994
- } catch (error) {
995
- this.logger.error("[CodexAuth] Failed to refresh token", error);
996
- return null;
997
- }
998
- }
999
- saveTokens(tokens) {
1000
- try {
1001
- const authData = this.readAuthFile();
1002
- if (!authData) return;
1003
- authData.tokens = tokens;
1004
- authData.last_refresh = (/* @__PURE__ */ new Date()).toISOString();
1005
- fs.writeFileSync(this.authFilePath, JSON.stringify(authData, null, 2), "utf8");
1006
- } catch (error) {
1007
- this.logger.error("[CodexAuth] Failed to save tokens", error);
1008
- }
1009
- }
1010
- };
1011
-
1012
- //#endregion
1013
- //#region src/adapters/codex/OpenAIToClaudeTransformer.ts
1014
- /**
1015
- * OpenAI to Claude Response Transformer
1016
- *
1017
- * Converts OpenAI Chat Completions API streaming responses (SSE format)
1018
- * to Anthropic Claude Messages API streaming format.
1019
- *
1020
- * OpenAI SSE events: data: {"choices":[{"delta":{"content":"text"}}]}
1021
- * Claude SSE events: event: content_block_delta\ndata: {"delta":{"type":"text","text":"text"}}
1022
- */
1023
- var OpenAIToClaudeTransformer = class {
1024
- logger;
1025
- thinkingDisabled;
1026
- constructor(logger, thinkingDisabled = false) {
1027
- this.logger = logger;
1028
- this.thinkingDisabled = thinkingDisabled;
1029
- }
1030
- transform(responseBody) {
1031
- try {
1032
- if (!responseBody || responseBody.trim() === "") return this.createEmptyClaudeResponse();
1033
- if (!responseBody.includes("data:")) return this.convertNonStreamingResponse(responseBody);
1034
- return this.convertStreamingResponse(responseBody);
1035
- } catch (error) {
1036
- this.logger?.error("[OpenAIToClaude] ERROR in transform", error);
1037
- return this.createEmptyClaudeResponse();
1038
- }
1039
- }
1040
- /**
1041
- * Create a valid empty Claude streaming response to prevent parsing errors
1042
- */
1043
- createEmptyClaudeResponse() {
1044
- const messageId = `msg_${ulid()}`;
1045
- const events = [];
1046
- events.push("event: message_start");
1047
- events.push(`data: ${JSON.stringify({
1048
- type: "message_start",
1049
- message: {
1050
- id: messageId,
1051
- type: "message",
1052
- role: "assistant",
1053
- content: [],
1054
- model: "gpt-5",
1055
- stop_reason: null,
1056
- stop_sequence: null,
1057
- usage: {
1058
- input_tokens: 0,
1059
- output_tokens: 0
1060
- }
1061
- }
1062
- })}`);
1063
- events.push("");
1064
- events.push("event: message_stop");
1065
- events.push("data: {\"type\":\"message_stop\"}");
1066
- events.push("");
1067
- return events.join("\n");
1068
- }
1069
- convertStreamingResponse(sseText) {
1070
- const parsed = this.parseOpenAIStream(sseText);
1071
- const claudeStream = this.createClaudeStreamFromParsed(parsed);
1072
- const fallbackMessage = parsed.errorMessage || "Empty streaming response from provider";
1073
- return this.ensureValidClaudeStream(claudeStream, fallbackMessage);
1074
- }
1075
- parseOpenAIStream(sseText) {
1076
- const parsed = {
1077
- textSegments: [],
1078
- thinkingSegments: [],
1079
- toolCalls: /* @__PURE__ */ new Map(),
1080
- model: "gpt-5",
1081
- inputTokens: 0,
1082
- outputTokens: 0,
1083
- cachedTokens: 0,
1084
- reasoningTokens: 0,
1085
- reasoningEffort: void 0,
1086
- stopReason: void 0,
1087
- errorMessage: void 0
1088
- };
1089
- const lines = sseText.split("\n");
1090
- let currentEvent = "";
1091
- const isResponsesApi = /event:\s*response\./i.test(sseText) || /"type"\s*:\s*"response\./i.test(sseText) || /"response"\s*:\s*\{/i.test(sseText);
1092
- for (const rawLine of lines) {
1093
- const line = rawLine.trim();
1094
- if (!line) continue;
1095
- if (line.startsWith("event:")) {
1096
- currentEvent = line.slice(6).trim();
1097
- continue;
1098
- }
1099
- if (!line.startsWith("data:")) continue;
1100
- const dataContent = line.slice(5).trim();
1101
- if (!dataContent || dataContent === "[DONE]") continue;
1102
- let data;
1103
- try {
1104
- data = JSON.parse(dataContent);
1105
- } catch {
1106
- continue;
1107
- }
1108
- if (data?.error) {
1109
- parsed.errorMessage = data.error?.message || data.error?.error || (typeof data.error === "string" ? data.error : "Unexpected API error");
1110
- break;
1111
- }
1112
- if (isResponsesApi || currentEvent.startsWith("response.")) this.handleResponsesEvent(currentEvent, data, parsed);
1113
- else this.handleChatCompletionChunk(data, parsed);
1114
- }
1115
- return parsed;
1116
- }
1117
- handleResponsesEvent(eventName, data, parsed) {
1118
- const eventType = typeof data?.type === "string" ? data.type : eventName;
1119
- if (data?.model && typeof data.model === "string") parsed.model = data.model;
1120
- if (data?.usage) {
1121
- parsed.inputTokens = data.usage.input_tokens ?? data.usage.prompt_tokens ?? parsed.inputTokens;
1122
- parsed.outputTokens = data.usage.output_tokens ?? data.usage.completion_tokens ?? parsed.outputTokens;
1123
- }
1124
- switch (eventType) {
1125
- case "response.created":
1126
- if (data?.response?.model) parsed.model = data.response.model;
1127
- break;
1128
- case "response.reasoning.delta":
1129
- case "response.reasoning_summary_text.delta":
1130
- case "response.function_call_arguments.delta":
1131
- case "response.function_call_arguments.done":
1132
- case "response.in_progress":
1133
- case "response.output_item.added":
1134
- case "response.output_item.done":
1135
- case "response.content_part.added":
1136
- case "response.content_part.done":
1137
- case "response.reasoning_summary_part.added":
1138
- case "response.reasoning_summary_part.done":
1139
- case "response.reasoning_summary_text.done": break;
1140
- case "response.output_text.delta":
1141
- case "response.delta":
1142
- if (data?.delta?.tool_calls) {}
1143
- break;
1144
- case "response.output_text.done": break;
1145
- case "response.completed":
1146
- if (data?.response?.model) parsed.model = data.response.model;
1147
- if (data?.response?.usage) {
1148
- parsed.inputTokens = data.response.usage.input_tokens ?? data.response.usage.prompt_tokens ?? parsed.inputTokens;
1149
- parsed.outputTokens = data.response.usage.output_tokens ?? data.response.usage.completion_tokens ?? parsed.outputTokens;
1150
- if (data.response.usage.input_tokens_details?.cached_tokens) parsed.cachedTokens = data.response.usage.input_tokens_details.cached_tokens;
1151
- if (data.response.usage.output_tokens_details?.reasoning_tokens) parsed.reasoningTokens = data.response.usage.output_tokens_details.reasoning_tokens;
1152
- }
1153
- if (data?.response?.reasoning?.effort) parsed.reasoningEffort = data.response.reasoning.effort;
1154
- if (Array.isArray(data?.response?.tool_calls)) this.collectToolCalls(data.response.tool_calls, parsed.toolCalls);
1155
- if (Array.isArray(data?.response?.output)) {
1156
- for (const output of data.response.output) if (output?.type === "reasoning") {
1157
- if (Array.isArray(output?.summary)) {
1158
- for (const summaryItem of output.summary) if (summaryItem?.type === "summary_text" && summaryItem?.text) parsed.thinkingSegments.push(summaryItem.text);
1159
- }
1160
- } else if (output?.type === "message") {
1161
- if (Array.isArray(output?.content)) {
1162
- for (const contentItem of output.content) if ((contentItem?.type === "output_text" || contentItem?.type === "text") && contentItem?.text) parsed.textSegments.push(contentItem.text);
1163
- }
1164
- } else if (output?.type === "function_call") {
1165
- const toolCallData = {
1166
- index: parsed.toolCalls.size,
1167
- id: output.id || `tool_${ulid()}`,
1168
- function: {
1169
- name: output.name,
1170
- arguments: output.arguments
1171
- }
1172
- };
1173
- this.collectToolCalls([toolCallData], parsed.toolCalls);
1174
- }
1175
- }
1176
- if (data?.response?.output_text) this.collectTextFromNode(data.response.output_text, parsed.textSegments);
1177
- parsed.stopReason = this.mapResponseStatusToStopReason(data?.response?.status);
1178
- break;
1179
- case "response.error":
1180
- parsed.errorMessage = data?.error?.message || data?.message || "Unexpected API error";
1181
- break;
1182
- default:
1183
- if (data?.delta) {
1184
- this.collectTextFromNode(data.delta, parsed.textSegments);
1185
- if (data.delta.tool_calls) this.collectToolCalls(data.delta.tool_calls, parsed.toolCalls);
1186
- }
1187
- break;
1188
- }
1189
- }
1190
- handleChatCompletionChunk(chunk, parsed) {
1191
- if (!chunk) return;
1192
- if (chunk.model && typeof chunk.model === "string") parsed.model = chunk.model;
1193
- if (chunk.usage) {
1194
- parsed.inputTokens = chunk.usage.prompt_tokens ?? parsed.inputTokens;
1195
- parsed.outputTokens = chunk.usage.completion_tokens ?? parsed.outputTokens;
1196
- }
1197
- if (!Array.isArray(chunk.choices)) return;
1198
- for (const choice of chunk.choices) {
1199
- if (choice?.delta) {
1200
- this.collectTextFromNode(choice.delta, parsed.textSegments);
1201
- if (choice.delta.tool_calls) this.collectToolCalls(choice.delta.tool_calls, parsed.toolCalls);
1202
- }
1203
- if (choice?.message?.content) this.collectTextFromNode(choice.message.content, parsed.textSegments);
1204
- if (choice?.finish_reason) parsed.stopReason = this.mapFinishReason(choice.finish_reason);
1205
- }
1206
- }
1207
- collectTextFromNode(node, collector) {
1208
- if (node == null) return;
1209
- if (typeof node === "string") {
1210
- if (node.length > 0) collector.push(node);
1211
- return;
1212
- }
1213
- if (Array.isArray(node)) {
1214
- for (const item of node) this.collectTextFromNode(item, collector);
1215
- return;
1216
- }
1217
- if (typeof node !== "object") return;
1218
- if (typeof node.text === "string") collector.push(node.text);
1219
- if (typeof node.output_text === "string") collector.push(node.output_text);
1220
- if (typeof node.value === "string") collector.push(node.value);
1221
- if (typeof node.delta === "string") collector.push(node.delta);
1222
- else if (node.delta) this.collectTextFromNode(node.delta, collector);
1223
- if (node.token && typeof node.token.text === "string") collector.push(node.token.text);
1224
- for (const key of [
1225
- "content",
1226
- "output",
1227
- "output_text",
1228
- "message",
1229
- "choices",
1230
- "segments"
1231
- ]) if (node[key] !== void 0) if (key === "choices" && Array.isArray(node[key])) for (const choice of node[key]) {
1232
- if (choice?.message?.content) this.collectTextFromNode(choice.message.content, collector);
1233
- if (choice?.delta) this.collectTextFromNode(choice.delta, collector);
1234
- }
1235
- else this.collectTextFromNode(node[key], collector);
1236
- }
1237
- collectToolCalls(rawToolCalls, toolCallMap) {
1238
- if (!rawToolCalls) return;
1239
- const callsArray = Array.isArray(rawToolCalls) ? rawToolCalls : [rawToolCalls];
1240
- for (const call of callsArray) {
1241
- if (!call) continue;
1242
- const index = typeof call.index === "number" ? call.index : toolCallMap.size;
1243
- const existing = toolCallMap.get(index) || {
1244
- id: "",
1245
- name: "",
1246
- argumentChunks: []
1247
- };
1248
- if (typeof call.id === "string" && !existing.id) existing.id = call.id;
1249
- const functionName = call.function?.name;
1250
- if (typeof functionName === "string" && functionName.length > 0) existing.name = functionName;
1251
- const functionArgs = call.function?.arguments;
1252
- if (typeof functionArgs === "string" && functionArgs.length > 0) existing.argumentChunks.push(functionArgs);
1253
- toolCallMap.set(index, existing);
1254
- }
1255
- }
1256
- createClaudeStreamFromParsed(parsed) {
1257
- if (parsed.errorMessage) return this.createClaudeErrorStream(parsed.errorMessage);
1258
- const thinkingSegments = this.thinkingDisabled ? [] : this.mergeAndChunkSegments(parsed.thinkingSegments);
1259
- const textSegments = this.mergeAndChunkSegments(parsed.textSegments);
1260
- const toolCalls = Array.from(parsed.toolCalls.entries()).sort((a, b) => a[0] - b[0]).map(([index, value]) => ({
1261
- index,
1262
- id: value.id,
1263
- name: value.name,
1264
- arguments: value.argumentChunks.join("")
1265
- })).filter((call) => call.name);
1266
- if (thinkingSegments.length === 0 && textSegments.length === 0 && toolCalls.length === 0) return this.createClaudeErrorStream("Empty streaming response from provider");
1267
- const messageId = `msg_${ulid()}`;
1268
- const model = parsed.model || "gpt-5";
1269
- const events = [];
1270
- events.push("event: message_start");
1271
- events.push(`data: ${JSON.stringify({
1272
- type: "message_start",
1273
- message: {
1274
- id: messageId,
1275
- type: "message",
1276
- role: "assistant",
1277
- content: [],
1278
- model,
1279
- stop_reason: null,
1280
- stop_sequence: null,
1281
- usage: {
1282
- input_tokens: parsed.inputTokens ?? 0,
1283
- output_tokens: 0
1284
- }
1285
- }
1286
- })}`);
1287
- events.push("");
1288
- let blockIndex = 0;
1289
- if (thinkingSegments.length > 0) {
1290
- events.push("event: content_block_start");
1291
- events.push(`data: ${JSON.stringify({
1292
- type: "content_block_start",
1293
- index: blockIndex,
1294
- content_block: {
1295
- type: "thinking",
1296
- thinking: ""
1297
- }
1298
- })}`);
1299
- events.push("");
1300
- for (const chunk of thinkingSegments) {
1301
- if (!chunk) continue;
1302
- events.push("event: content_block_delta");
1303
- events.push(`data: ${JSON.stringify({
1304
- type: "content_block_delta",
1305
- index: blockIndex,
1306
- delta: {
1307
- type: "thinking_delta",
1308
- thinking: chunk
1309
- }
1310
- })}`);
1311
- events.push("");
1312
- }
1313
- events.push("event: content_block_stop");
1314
- events.push(`data: ${JSON.stringify({
1315
- type: "content_block_stop",
1316
- index: blockIndex
1317
- })}`);
1318
- events.push("");
1319
- blockIndex += 1;
1320
- }
1321
- if (textSegments.length > 0) {
1322
- events.push("event: content_block_start");
1323
- events.push(`data: ${JSON.stringify({
1324
- type: "content_block_start",
1325
- index: blockIndex,
1326
- content_block: {
1327
- type: "text",
1328
- text: ""
1329
- }
1330
- })}`);
1331
- events.push("");
1332
- for (const chunk of textSegments) {
1333
- if (!chunk) continue;
1334
- events.push("event: content_block_delta");
1335
- events.push(`data: ${JSON.stringify({
1336
- type: "content_block_delta",
1337
- index: blockIndex,
1338
- delta: {
1339
- type: "text_delta",
1340
- text: chunk
1341
- }
1342
- })}`);
1343
- events.push("");
1344
- }
1345
- events.push("event: content_block_stop");
1346
- events.push(`data: ${JSON.stringify({
1347
- type: "content_block_stop",
1348
- index: blockIndex
1349
- })}`);
1350
- events.push("");
1351
- blockIndex += 1;
1352
- }
1353
- for (const toolCall of toolCalls) {
1354
- const toolIndex = blockIndex + toolCall.index;
1355
- const toolId = toolCall.id || `tool_${ulid()}`;
1356
- events.push("event: content_block_start");
1357
- events.push(`data: ${JSON.stringify({
1358
- type: "content_block_start",
1359
- index: toolIndex,
1360
- content_block: {
1361
- type: "tool_use",
1362
- id: toolId,
1363
- name: toolCall.name,
1364
- input: {}
1365
- }
1366
- })}`);
1367
- events.push("");
1368
- if (toolCall.arguments) {
1369
- events.push("event: content_block_delta");
1370
- events.push(`data: ${JSON.stringify({
1371
- type: "content_block_delta",
1372
- index: toolIndex,
1373
- delta: {
1374
- type: "input_json_delta",
1375
- partial_json: toolCall.arguments
1376
- }
1377
- })}`);
1378
- events.push("");
1379
- }
1380
- events.push("event: content_block_stop");
1381
- events.push(`data: ${JSON.stringify({
1382
- type: "content_block_stop",
1383
- index: toolIndex
1384
- })}`);
1385
- events.push("");
1386
- }
1387
- const stopReason = parsed.stopReason || "end_turn";
1388
- const usageDetails = { output_tokens: parsed.outputTokens ?? 0 };
1389
- if (parsed.cachedTokens || parsed.reasoningTokens || parsed.reasoningEffort) {
1390
- usageDetails.metadata = {};
1391
- if (parsed.cachedTokens) usageDetails.metadata.cached_tokens = parsed.cachedTokens;
1392
- if (parsed.reasoningTokens) usageDetails.metadata.reasoning_tokens = parsed.reasoningTokens;
1393
- if (parsed.reasoningEffort) usageDetails.metadata.reasoning_effort = parsed.reasoningEffort;
1394
- }
1395
- events.push("event: message_delta");
1396
- events.push(`data: ${JSON.stringify({
1397
- type: "message_delta",
1398
- delta: {
1399
- stop_reason: stopReason,
1400
- stop_sequence: null
1401
- },
1402
- usage: usageDetails
1403
- })}`);
1404
- events.push("");
1405
- events.push("event: message_stop");
1406
- events.push("data: {\"type\":\"message_stop\"}");
1407
- events.push("");
1408
- return events.join("\n");
1409
- }
1410
- mergeAndChunkSegments(segments, chunkSize = 2e3) {
1411
- if (!segments || segments.length === 0) return [];
1412
- const combined = segments.join("");
1413
- if (!combined) return [];
1414
- const result = [];
1415
- for (let i = 0; i < combined.length; i += chunkSize) result.push(combined.slice(i, i + chunkSize));
1416
- return result;
1417
- }
1418
- mapResponseStatusToStopReason(status) {
1419
- if (!status) return "end_turn";
1420
- return {
1421
- completed: "end_turn",
1422
- completed_with_error: "error",
1423
- completed_with_streaming_error: "error",
1424
- cancelled: "error",
1425
- errored: "error"
1426
- }[status] || "end_turn";
1427
- }
1428
- /**
1429
- * Convert non-streaming OpenAI response to Claude format
1430
- */
1431
- convertNonStreamingResponse(responseBody) {
1432
- try {
1433
- const openAIResponse = JSON.parse(responseBody);
1434
- if (openAIResponse?.error) {
1435
- const errorMessage = openAIResponse.error?.message || openAIResponse.error?.error || (typeof openAIResponse.error === "string" ? openAIResponse.error : "Unexpected API error");
1436
- return this.createClaudeErrorResponse(errorMessage);
1437
- }
1438
- const choice = openAIResponse.choices?.[0];
1439
- if (!choice) return responseBody;
1440
- const claudeResponse = {
1441
- id: `msg_${ulid()}`,
1442
- type: "message",
1443
- role: "assistant",
1444
- content: [{
1445
- type: "text",
1446
- text: choice.message?.content || ""
1447
- }],
1448
- model: openAIResponse.model || "gpt-4-turbo",
1449
- stop_reason: this.mapFinishReason(choice.finish_reason),
1450
- stop_sequence: null,
1451
- usage: {
1452
- input_tokens: openAIResponse.usage?.prompt_tokens || 0,
1453
- output_tokens: openAIResponse.usage?.completion_tokens || 0
1454
- }
1455
- };
1456
- return JSON.stringify(claudeResponse);
1457
- } catch (error) {
1458
- this.logger?.error("Failed to transform OpenAI response to Claude format", error);
1459
- const fallbackMessage = typeof responseBody === "string" && responseBody.trim() ? responseBody.trim() : "Unexpected API error";
1460
- return this.createClaudeErrorResponse(fallbackMessage);
1461
- }
1462
- }
1463
- /**
1464
- * Map OpenAI finish_reason to Claude stop_reason
1465
- */
1466
- mapFinishReason(finishReason) {
1467
- if (!finishReason) return null;
1468
- return {
1469
- stop: "end_turn",
1470
- length: "max_tokens",
1471
- function_call: "tool_use",
1472
- tool_calls: "tool_use",
1473
- content_filter: "stop_sequence"
1474
- }[finishReason] || "end_turn";
1475
- }
1476
- createClaudeErrorResponse(message) {
1477
- const messageId = `msg_${ulid()}`;
1478
- const text = message || "Unexpected API error";
1479
- return JSON.stringify({
1480
- id: messageId,
1481
- type: "message",
1482
- role: "assistant",
1483
- content: [{
1484
- type: "text",
1485
- text
1486
- }],
1487
- model: "gpt-5",
1488
- stop_reason: "error",
1489
- stop_sequence: null,
1490
- usage: {
1491
- input_tokens: 0,
1492
- output_tokens: 0
1493
- }
1494
- });
1495
- }
1496
- createClaudeErrorStream(message) {
1497
- const messageId = `msg_${ulid()}`;
1498
- const text = message || "Unexpected API error";
1499
- const events = [];
1500
- events.push("event: message_start");
1501
- events.push(`data: ${JSON.stringify({
1502
- type: "message_start",
1503
- message: {
1504
- id: messageId,
1505
- type: "message",
1506
- role: "assistant",
1507
- content: [],
1508
- model: "gpt-5",
1509
- stop_reason: null,
1510
- stop_sequence: null,
1511
- usage: {
1512
- input_tokens: 0,
1513
- output_tokens: 0
1514
- }
1515
- }
1516
- })}`);
1517
- events.push("");
1518
- events.push("event: content_block_start");
1519
- events.push(`data: ${JSON.stringify({
1520
- type: "content_block_start",
1521
- index: 0,
1522
- content_block: {
1523
- type: "text",
1524
- text: ""
1525
- }
1526
- })}`);
1527
- events.push("");
1528
- events.push("event: content_block_delta");
1529
- events.push(`data: ${JSON.stringify({
1530
- type: "content_block_delta",
1531
- index: 0,
1532
- delta: {
1533
- type: "text_delta",
1534
- text
1535
- }
1536
- })}`);
1537
- events.push("");
1538
- events.push("event: content_block_stop");
1539
- events.push(`data: ${JSON.stringify({
1540
- type: "content_block_stop",
1541
- index: 0
1542
- })}`);
1543
- events.push("");
1544
- events.push("event: message_delta");
1545
- events.push(`data: ${JSON.stringify({
1546
- type: "message_delta",
1547
- delta: {
1548
- stop_reason: "error",
1549
- stop_sequence: null
1550
- },
1551
- usage: { output_tokens: 0 }
1552
- })}`);
1553
- events.push("");
1554
- events.push("event: message_stop");
1555
- events.push("data: {\"type\":\"message_stop\"}");
1556
- events.push("");
1557
- return events.join("\n");
1558
- }
1559
- ensureValidClaudeStream(stream, errorMessage) {
1560
- if (!stream || !stream.includes("event: message_start")) return this.createClaudeErrorStream(errorMessage);
1561
- return stream;
1562
- }
1563
- };
1564
-
1565
- //#endregion
1566
- //#region src/adapters/gemini/types.ts
1567
- const GEMINI_API_KEY_ENV_FALLBACK = "GEMINI_API_KEY";
1568
- const GEMINI_CLI_HOME_ENV = "GEMINI_CLI_HOME";
1569
- const GEMINI_DEFAULT_API_VERSION = "v1beta";
1570
- const GEMINI_DEFAULT_AUTH_MODE = "auto";
1571
- const GEMINI_PERSONAL_OAUTH_AUTH_TYPE = "oauth-personal";
1572
- const GEMINI_CODE_ASSIST_ENDPOINT = "https://cloudcode-pa.googleapis.com";
1573
- const GEMINI_CODE_ASSIST_API_VERSION = "v1internal";
1574
- const GEMINI_SETTINGS_FILE = "settings.json";
1575
- const GEMINI_OAUTH_FILE = "oauth_creds.json";
1576
- const GEMINI_STREAM_QUERY_PARAM = "alt=sse";
1577
- const GEMINI_USER_ROLE = "user";
1578
- const GEMINI_MODEL_ROLE = "model";
1579
- const GEMINI_MODEL_PREFIX = "models/";
1580
-
1581
- //#endregion
1582
- //#region src/adapters/gemini/ClaudeToGeminiTransformer.ts
1583
- const DEFAULT_MAX_OUTPUT_TOKENS = 4096;
1584
- const MINIMAL_REASONING_MAX_OUTPUT_TOKENS = 2048;
1585
- const TOOL_MODE_AUTO = "AUTO";
1586
- const TOOL_MODE_NONE = "NONE";
1587
- const GEMINI_SUPPORTED_SCHEMA_KEYS = new Set([
1588
- "type",
1589
- "format",
1590
- "description",
1591
- "nullable",
1592
- "enum",
1593
- "items",
1594
- "maxItems",
1595
- "minItems",
1596
- "properties",
1597
- "required",
1598
- "propertyOrdering",
1599
- "maxProperties",
1600
- "minProperties",
1601
- "minimum",
1602
- "maximum",
1603
- "minLength",
1604
- "maxLength",
1605
- "pattern",
1606
- "example",
1607
- "anyOf",
1608
- "title"
1609
- ]);
1610
- var ClaudeToGeminiTransformer = class {
1611
- transform(request, resolvedModel, reasoningEffort, thinkingDisabled = false) {
1612
- const contents = this.toGeminiContents(request.messages);
1613
- const systemInstruction = this.toSystemInstruction(request.system);
1614
- const tools = this.toGeminiTools(request.tools);
1615
- return {
1616
- modelPath: this.toGeminiModelPath(resolvedModel),
1617
- body: {
1618
- contents,
1619
- system_instruction: systemInstruction,
1620
- tools,
1621
- tool_config: { function_calling_config: { mode: tools.length > 0 ? TOOL_MODE_AUTO : TOOL_MODE_NONE } },
1622
- generationConfig: { maxOutputTokens: this.resolveMaxOutputTokens(request.max_tokens, reasoningEffort, thinkingDisabled) }
1623
- }
1624
- };
1625
- }
1626
- toGeminiModelPath(model) {
1627
- return model.startsWith(GEMINI_MODEL_PREFIX) ? model : `${GEMINI_MODEL_PREFIX}${model}`;
1628
- }
1629
- toSystemInstruction(system) {
1630
- const texts = this.extractTextParts(system);
1631
- if (texts.length === 0) return;
1632
- return { parts: texts.map((text) => ({ text })) };
1633
- }
1634
- toGeminiContents(messages) {
1635
- const contents = [];
1636
- for (const message of messages) {
1637
- const parts = this.toGeminiParts(message.content);
1638
- if (parts.length === 0) continue;
1639
- contents.push({
1640
- role: message.role === "assistant" ? GEMINI_MODEL_ROLE : GEMINI_USER_ROLE,
1641
- parts
1642
- });
1643
- }
1644
- return contents;
1645
- }
1646
- toGeminiParts(content) {
1647
- if (typeof content === "string") return content.trim() ? [{ text: content }] : [];
1648
- if (!Array.isArray(content)) return [];
1649
- const parts = [];
1650
- for (const block of content) {
1651
- if (block.type === "text" && block.text) parts.push({ text: block.text });
1652
- if (block.type === "tool_use") parts.push({ functionCall: {
1653
- name: block.name,
1654
- args: block.input
1655
- } });
1656
- if (block.type === "tool_result") parts.push({ functionResponse: {
1657
- name: block.tool_use_id,
1658
- response: { content: typeof block.content === "string" ? block.content : JSON.stringify(block.content ?? {}) }
1659
- } });
1660
- }
1661
- return parts;
1662
- }
1663
- toGeminiTools(tools) {
1664
- if (!tools || tools.length === 0) return [];
1665
- return [{ function_declarations: tools.map((tool) => ({
1666
- name: tool.name,
1667
- description: tool.description,
1668
- parameters: this.toGeminiParameters(tool.input_schema)
1669
- })) }];
1670
- }
1671
- toGeminiParameters(schema) {
1672
- if (!schema) return;
1673
- return this.sanitizeGeminiSchema(schema);
1674
- }
1675
- sanitizeGeminiSchema(value) {
1676
- if (Array.isArray(value)) return value.map((item) => this.sanitizeGeminiSchema(item));
1677
- if (!value || typeof value !== "object") return value;
1678
- const sanitizedEntries = Object.entries(value).flatMap(([key, nestedValue]) => {
1679
- if (!GEMINI_SUPPORTED_SCHEMA_KEYS.has(key)) return [];
1680
- if (key === "properties" && nestedValue && typeof nestedValue === "object" && !Array.isArray(nestedValue)) {
1681
- const propertyEntries = Object.entries(nestedValue).map(([propertyName, propertySchema]) => [propertyName, this.sanitizeGeminiSchema(propertySchema)]);
1682
- return [[key, Object.fromEntries(propertyEntries)]];
1683
- }
1684
- return [[key, this.sanitizeGeminiSchema(nestedValue)]];
1685
- });
1686
- return Object.fromEntries(sanitizedEntries);
1687
- }
1688
- extractTextParts(value) {
1689
- if (typeof value === "string") return value.trim() ? [value] : [];
1690
- if (!Array.isArray(value)) return [];
1691
- return value.map((item) => {
1692
- if (typeof item === "string") return item;
1693
- if (item && typeof item === "object" && "text" in item && typeof item.text === "string") return item.text;
1694
- return null;
1695
- }).filter((item) => Boolean(item?.trim()));
1696
- }
1697
- resolveMaxOutputTokens(maxTokens, reasoningEffort, thinkingDisabled) {
1698
- if (typeof maxTokens === "number" && maxTokens > 0) return maxTokens;
1699
- if (thinkingDisabled) return MINIMAL_REASONING_MAX_OUTPUT_TOKENS;
1700
- return reasoningEffort === "minimal" ? MINIMAL_REASONING_MAX_OUTPUT_TOKENS : DEFAULT_MAX_OUTPUT_TOKENS;
1701
- }
1702
- };
1703
-
1704
- //#endregion
1705
- //#region src/adapters/gemini/GeminiAuth.ts
1706
- const GEMINI_CONFIG_DIRECTORY = ".gemini";
1707
- const AUTHORIZATION_HEADER$1 = "Authorization";
1708
- const API_KEY_HEADER = "x-goog-api-key";
1709
- const BEARER_PREFIX = "Bearer ";
1710
- const GEMINI_AUTH_CONFIG_ERROR = "GEMINI_AUTH_CONFIG_ERROR";
1711
- const OAUTH_REFRESH_ENDPOINT = "https://oauth2.googleapis.com/token";
1712
- const OAUTH_REFRESH_GRANT_TYPE = "refresh_token";
1713
- const OAUTH_CLIENT_ID = "681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com";
1714
- const OAUTH_CLIENT_SECRET = "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";
1715
- const OAUTH_EXPIRY_SKEW_MS = 6e4;
1716
- var GeminiAuthError = class extends Error {
1717
- constructor(message, code) {
1718
- super(message);
1719
- this.code = code;
1720
- this.name = "GeminiAuthError";
1721
- }
1722
- };
1723
- var GeminiAuth = class {
1724
- constructor(logger = consoleLogger, env = process.env) {
1725
- this.logger = logger;
1726
- this.env = env;
1727
- }
1728
- async resolveHeaders(provider) {
1729
- const authMode = provider.authMode ?? GEMINI_DEFAULT_AUTH_MODE;
1730
- if (authMode === "api-key") return this.resolveApiKeyHeaders(provider);
1731
- if (authMode === "oauth") return this.resolveOAuthHeaders();
1732
- return await this.tryResolveApiKeyHeaders(provider) ?? this.resolveOAuthHeaders();
1733
- }
1734
- getGeminiDirectory() {
1735
- const homeDirectory = this.env[GEMINI_CLI_HOME_ENV] || os.homedir();
1736
- return path.join(homeDirectory, GEMINI_CONFIG_DIRECTORY);
1737
- }
1738
- getSettingsPath() {
1739
- return path.join(this.getGeminiDirectory(), GEMINI_SETTINGS_FILE);
1740
- }
1741
- getOAuthPath() {
1742
- return path.join(this.getGeminiDirectory(), GEMINI_OAUTH_FILE);
1743
- }
1744
- async tryResolveApiKeyHeaders(provider) {
1745
- try {
1746
- return await this.resolveApiKeyHeaders(provider);
1747
- } catch {
1748
- return null;
1749
- }
1750
- }
1751
- async resolveApiKeyHeaders(provider) {
1752
- const apiKeyEnvVar = provider.apiKeyEnvVar ?? provider.authTokenEnvVar ?? GEMINI_API_KEY_ENV_FALLBACK;
1753
- const apiKey = this.env[apiKeyEnvVar]?.trim();
1754
- if (!apiKey) throw new GeminiAuthError(`Missing Gemini API key. Set ${apiKeyEnvVar}.`, GEMINI_AUTH_CONFIG_ERROR);
1755
- return {
1756
- headers: { [API_KEY_HEADER]: apiKey },
1757
- authMode: "api-key",
1758
- authSource: "env"
1759
- };
1760
- }
1761
- async resolveOAuthHeaders() {
1762
- const settings = await this.readSettings();
1763
- const credentials = await this.readOAuthCredentials();
1764
- const resolvedCredentials = await this.refreshOAuthCredentialsIfNeeded(credentials);
1765
- const selectedType = settings?.security?.auth?.selectedType ?? null;
1766
- const resolvedAccessToken = resolvedCredentials.access_token?.trim();
1767
- if (!resolvedAccessToken) throw new GeminiAuthError(`Missing Gemini OAuth credentials. Expected ${this.getOAuthPath()} or ${GEMINI_API_KEY_ENV_FALLBACK}.`, GEMINI_AUTH_CONFIG_ERROR);
1768
- return {
1769
- headers: { [AUTHORIZATION_HEADER$1]: `${BEARER_PREFIX}${resolvedAccessToken}` },
1770
- authMode: "oauth",
1771
- authSource: "gemini-home",
1772
- authType: selectedType
1773
- };
1774
- }
1775
- async readOAuthCredentials() {
1776
- try {
1777
- const raw = await fs$1.readFile(this.getOAuthPath(), "utf8");
1778
- return JSON.parse(raw);
1779
- } catch (error) {
1780
- this.logger.warn("[GeminiAuth] Failed to read oauth credentials", {
1781
- oauthPath: this.getOAuthPath(),
1782
- cause: error
1783
- });
1784
- throw new GeminiAuthError(`Unable to read Gemini OAuth credentials from ${this.getOAuthPath()}.`, GEMINI_AUTH_CONFIG_ERROR);
1785
- }
1786
- }
1787
- async readSettings() {
1788
- try {
1789
- const raw = await fs$1.readFile(this.getSettingsPath(), "utf8");
1790
- return JSON.parse(raw);
1791
- } catch (error) {
1792
- if (error?.code === "ENOENT") return null;
1793
- this.logger.warn("[GeminiAuth] Failed to read settings", {
1794
- settingsPath: this.getSettingsPath(),
1795
- cause: error
1796
- });
1797
- return null;
1798
- }
1799
- }
1800
- async refreshOAuthCredentialsIfNeeded(credentials) {
1801
- if (!this.shouldRefreshCredentials(credentials)) return credentials;
1802
- const refreshToken = credentials.refresh_token?.trim();
1803
- if (!refreshToken) return credentials;
1804
- const body = new URLSearchParams({
1805
- client_id: OAUTH_CLIENT_ID,
1806
- client_secret: OAUTH_CLIENT_SECRET,
1807
- grant_type: OAUTH_REFRESH_GRANT_TYPE,
1808
- refresh_token: refreshToken
1809
- });
1810
- const response = await fetch(OAUTH_REFRESH_ENDPOINT, {
1811
- method: "POST",
1812
- headers: { "content-type": "application/x-www-form-urlencoded" },
1813
- body
1814
- });
1815
- if (!response.ok) {
1816
- this.logger.warn("[GeminiAuth] Failed to refresh oauth credentials", {
1817
- status: response.status,
1818
- oauthPath: this.getOAuthPath()
1819
- });
1820
- return credentials;
1821
- }
1822
- const payload = await response.json();
1823
- const nextCredentials = {
1824
- ...credentials,
1825
- access_token: payload.access_token ?? credentials.access_token,
1826
- token_type: payload.token_type ?? credentials.token_type,
1827
- scope: payload.scope ?? credentials.scope,
1828
- expiry_date: typeof payload.expires_in === "number" ? Date.now() + payload.expires_in * 1e3 : credentials.expiry_date
1829
- };
1830
- await fs$1.writeFile(this.getOAuthPath(), `${JSON.stringify(nextCredentials, null, 2)}\n`, "utf8");
1831
- return nextCredentials;
1832
- }
1833
- shouldRefreshCredentials(credentials) {
1834
- const accessToken = credentials.access_token?.trim();
1835
- const expiryDate = credentials.expiry_date;
1836
- if (!accessToken) return true;
1837
- if (typeof expiryDate !== "number") return false;
1838
- return expiryDate <= Date.now() + OAUTH_EXPIRY_SKEW_MS;
1839
- }
1840
- };
1841
-
1842
- //#endregion
1843
- //#region src/adapters/gemini/GeminiToClaudeTransformer.ts
1844
- const SSE_EVENT_PREFIX = "event: ";
1845
- const SSE_DATA_PREFIX = "data: ";
1846
- const MESSAGE_START_EVENT = "message_start";
1847
- const CONTENT_BLOCK_START_EVENT = "content_block_start";
1848
- const CONTENT_BLOCK_DELTA_EVENT = "content_block_delta";
1849
- const CONTENT_BLOCK_STOP_EVENT = "content_block_stop";
1850
- const MESSAGE_DELTA_EVENT = "message_delta";
1851
- const MESSAGE_STOP_EVENT = "message_stop";
1852
- const CONTENT_BLOCK_TYPE = "text";
1853
- const TEXT_DELTA_TYPE = "text_delta";
1854
- const STOP_REASON_END_TURN = "end_turn";
1855
- const STOP_REASON_TOOL_USE = "tool_use";
1856
- const DONE_SENTINEL = "[DONE]";
1857
- var GeminiToClaudeTransformer = class {
1858
- transformBuffered(responseBody, model) {
1859
- const parsed = JSON.parse(responseBody);
1860
- return this.toClaudeStream([parsed], model);
1861
- }
1862
- transformStreaming(responseBody, model) {
1863
- const payloads = [];
1864
- for (const chunk of responseBody.split("\n\n")) {
1865
- const dataLine = chunk.split("\n").find((line) => line.startsWith(SSE_DATA_PREFIX))?.slice(6).trim();
1866
- if (!dataLine || dataLine === DONE_SENTINEL) continue;
1867
- payloads.push(JSON.parse(dataLine));
1868
- }
1869
- return this.toClaudeStream(payloads, model);
1870
- }
1871
- toClaudeStream(payloads, model) {
1872
- const text = payloads.flatMap((payload) => payload.candidates ?? []).flatMap((candidate) => candidate.content?.parts ?? []).map((part) => "text" in part && typeof part.text === "string" ? part.text : "").join("");
1873
- const finishReason = payloads.map((payload) => payload.candidates?.[0]?.finishReason).find((value) => typeof value === "string");
1874
- const usage = payloads.find((payload) => payload.usageMetadata)?.usageMetadata;
1875
- const messageId = `msg_${ulid()}`;
1876
- const events = [];
1877
- events.push(`${SSE_EVENT_PREFIX}${MESSAGE_START_EVENT}`);
1878
- events.push(`${SSE_DATA_PREFIX}${JSON.stringify({
1879
- type: MESSAGE_START_EVENT,
1880
- message: {
1881
- id: messageId,
1882
- type: "message",
1883
- role: "assistant",
1884
- content: [],
1885
- model,
1886
- stop_reason: null,
1887
- stop_sequence: null,
1888
- usage: {
1889
- input_tokens: usage?.promptTokenCount ?? 0,
1890
- output_tokens: 0
1891
- }
1892
- }
1893
- })}`);
1894
- events.push("");
1895
- events.push(`${SSE_EVENT_PREFIX}${CONTENT_BLOCK_START_EVENT}`);
1896
- events.push(`${SSE_DATA_PREFIX}${JSON.stringify({
1897
- type: CONTENT_BLOCK_START_EVENT,
1898
- index: 0,
1899
- content_block: {
1900
- type: CONTENT_BLOCK_TYPE,
1901
- text: ""
1902
- }
1903
- })}`);
1904
- events.push("");
1905
- if (text) {
1906
- events.push(`${SSE_EVENT_PREFIX}${CONTENT_BLOCK_DELTA_EVENT}`);
1907
- events.push(`${SSE_DATA_PREFIX}${JSON.stringify({
1908
- type: CONTENT_BLOCK_DELTA_EVENT,
1909
- index: 0,
1910
- delta: {
1911
- type: TEXT_DELTA_TYPE,
1912
- text
1913
- }
1914
- })}`);
1915
- events.push("");
1916
- }
1917
- events.push(`${SSE_EVENT_PREFIX}${CONTENT_BLOCK_STOP_EVENT}`);
1918
- events.push(`${SSE_DATA_PREFIX}${JSON.stringify({
1919
- type: CONTENT_BLOCK_STOP_EVENT,
1920
- index: 0
1921
- })}`);
1922
- events.push("");
1923
- events.push(`${SSE_EVENT_PREFIX}${MESSAGE_DELTA_EVENT}`);
1924
- events.push(`${SSE_DATA_PREFIX}${JSON.stringify({
1925
- type: MESSAGE_DELTA_EVENT,
1926
- delta: {
1927
- stop_reason: this.mapStopReason(finishReason),
1928
- stop_sequence: null
1929
- },
1930
- usage: { output_tokens: usage?.candidatesTokenCount ?? 0 }
1931
- })}`);
1932
- events.push("");
1933
- events.push(`${SSE_EVENT_PREFIX}${MESSAGE_STOP_EVENT}`);
1934
- events.push(`${SSE_DATA_PREFIX}${JSON.stringify({ type: MESSAGE_STOP_EVENT })}`);
1935
- events.push("");
1936
- return events.join("\n");
1937
- }
1938
- mapStopReason(finishReason) {
1939
- if (!finishReason) return STOP_REASON_END_TURN;
1940
- return finishReason === "STOP" ? STOP_REASON_END_TURN : STOP_REASON_TOOL_USE;
1941
- }
1942
- };
1943
-
1944
- //#endregion
1945
- //#region src/services/ConversationHistoryService.ts
1946
- const DEFAULT_HISTORY_PAGE_SIZE$1 = 50;
1947
- const MIN_HISTORY_PAGE_SIZE = 1;
1948
- const MAX_HISTORY_PAGE_SIZE = 200;
1949
- const HISTORY_TABLE = "conversation_history";
1950
- const HISTORY_SCOPE_SEQUENCE_INDEX = "conversation_history_scope_sequence_idx";
1951
- const HISTORY_SCOPE_REQUEST_INDEX = "conversation_history_scope_request_idx";
1952
- const HISTORY_ERROR_CODES = {
1953
- appendFailed: "HISTORY_APPEND_FAILED",
1954
- listFailed: "HISTORY_LIST_FAILED",
1955
- clearFailed: "HISTORY_CLEAR_FAILED",
1956
- statsFailed: "HISTORY_STATS_FAILED",
1957
- initFailed: "HISTORY_INIT_FAILED"
1958
- };
1959
- const historyCursorSchema = z.string().transform((value) => Number(value)).refine((value) => Number.isInteger(value) && value > 0, "Cursor must be a positive integer");
1960
- /**
1961
- * Error raised for conversation history persistence failures.
1962
- */
1963
- var ConversationHistoryServiceError = class extends Error {
1964
- constructor(message, code, options) {
1965
- super(message, options);
1966
- this.code = code;
1967
- this.name = "ConversationHistoryServiceError";
1968
- }
1969
- };
1970
- /**
1971
- * Persists scoped conversation history using SQLite and enforces bounded retention.
1972
- *
1973
- * Environment variables:
1974
- * - `MODEL_PROXY_MCP_DB_PATH`
1975
- */
1976
- var ConversationHistoryService = class {
1977
- sqlite = null;
1978
- /**
1979
- * Creates a SQLite-backed history store.
1980
- *
1981
- * `dbPath` defaults to `MODEL_PROXY_MCP_DB_PATH` or `~/.model-proxy/history.sqlite`.
1982
- * `retentionLimit` is enforced per scope after each insert batch.
1983
- * `logger` receives structured operational failures.
1984
- */
1985
- constructor(dbPath = process.env.MODEL_PROXY_MCP_DB_PATH || DEFAULT_HISTORY_DB_PATH, retentionLimit = DEFAULT_HISTORY_RETENTION_LIMIT, logger = consoleLogger) {
1986
- this.dbPath = dbPath;
1987
- this.retentionLimit = retentionLimit;
1988
- this.logger = logger;
1989
- }
1990
- /**
1991
- * Eagerly initializes the SQLite database and schema.
1992
- */
1993
- async ensureInitialized() {
1994
- await this.getDb();
1995
- }
1996
- /**
1997
- * Inserts normalized history entries and prunes overflow per scope.
1998
- */
1999
- async appendEntries(entries) {
2000
- if (entries.length === 0) return;
2001
- try {
2002
- const db = await this.getDb();
2003
- const insert = db.prepare(`
2004
- INSERT INTO conversation_history (
2005
- id, scope, request_id, direction, role, message_type, model, slot, payload_json, created_at
2006
- ) VALUES (
2007
- @id, @scope, @request_id, @direction, @role, @message_type, @model, @slot, @payload_json, @created_at
2008
- )
2009
- `);
2010
- const transaction = db.transaction((scopeEntries) => {
2011
- for (const entry of scopeEntries) insert.run({
2012
- id: entry.id ?? ulid(),
2013
- scope: entry.scope,
2014
- request_id: entry.requestId,
2015
- direction: entry.direction,
2016
- role: entry.role,
2017
- message_type: entry.messageType,
2018
- model: entry.model,
2019
- slot: entry.slot,
2020
- payload_json: entry.payloadJson,
2021
- created_at: entry.createdAt ?? (/* @__PURE__ */ new Date()).toISOString()
2022
- });
2023
- });
2024
- const entriesByScope = /* @__PURE__ */ new Map();
2025
- for (const entry of entries) {
2026
- const bucket = entriesByScope.get(entry.scope) ?? [];
2027
- bucket.push(entry);
2028
- entriesByScope.set(entry.scope, bucket);
2029
- }
2030
- for (const scopeEntries of entriesByScope.values()) transaction(scopeEntries);
2031
- for (const scope of entriesByScope.keys()) await this.pruneScope(scope);
2032
- } catch (error) {
2033
- this.logger.error("[model-proxy-mcp] Failed to append history", {
2034
- code: HISTORY_ERROR_CODES.appendFailed,
2035
- dbPath: this.dbPath,
2036
- cause: error
2037
- });
2038
- throw new ConversationHistoryServiceError("Failed to append conversation history", HISTORY_ERROR_CODES.appendFailed, { cause: error });
2039
- }
2040
- }
2041
- /**
2042
- * Lists conversation history for a scope ordered from newest to oldest.
2043
- */
2044
- async listHistory(scope, limit = DEFAULT_HISTORY_PAGE_SIZE$1, cursor) {
2045
- try {
2046
- const db = await this.getDb();
2047
- const parsedCursor = cursor ? historyCursorSchema.parse(cursor) : void 0;
2048
- const boundedLimit = Math.max(MIN_HISTORY_PAGE_SIZE, Math.min(limit, MAX_HISTORY_PAGE_SIZE));
2049
- const rows = parsedCursor ? db.prepare(`
2050
- SELECT sequence, id, scope, request_id, direction, role, message_type, model, slot, payload_json, created_at
2051
- FROM ${HISTORY_TABLE}
2052
- WHERE scope = ? AND sequence < ?
2053
- ORDER BY sequence DESC
2054
- LIMIT ?
2055
- `).all(scope, parsedCursor, boundedLimit + 1) : db.prepare(`
2056
- SELECT sequence, id, scope, request_id, direction, role, message_type, model, slot, payload_json, created_at
2057
- FROM ${HISTORY_TABLE}
2058
- WHERE scope = ?
2059
- ORDER BY sequence DESC
2060
- LIMIT ?
2061
- `).all(scope, boundedLimit + 1);
2062
- const total = Number(db.prepare(`SELECT COUNT(*) as count FROM ${HISTORY_TABLE} WHERE scope = ?`).get(scope).count);
2063
- const nextRow = rows.length > boundedLimit ? rows.pop() : void 0;
2064
- return {
2065
- items: rows.map((row) => this.toEntry(row)),
2066
- nextCursor: nextRow ? String(nextRow.sequence) : null,
2067
- total
2068
- };
2069
- } catch (error) {
2070
- this.logger.error("[model-proxy-mcp] Failed to list history", {
2071
- code: HISTORY_ERROR_CODES.listFailed,
2072
- scope,
2073
- cause: error
2074
- });
2075
- throw new ConversationHistoryServiceError("Failed to list conversation history", HISTORY_ERROR_CODES.listFailed, { cause: error });
2076
- }
2077
- }
2078
- /**
2079
- * Clears all history rows for a single scope and returns the deleted count.
2080
- */
2081
- async clearScope(scope) {
2082
- try {
2083
- return (await this.getDb()).prepare(`DELETE FROM ${HISTORY_TABLE} WHERE scope = ?`).run(scope).changes;
2084
- } catch (error) {
2085
- this.logger.error("[model-proxy-mcp] Failed to clear history", {
2086
- code: HISTORY_ERROR_CODES.clearFailed,
2087
- scope,
2088
- cause: error
2089
- });
2090
- throw new ConversationHistoryServiceError("Failed to clear conversation history", HISTORY_ERROR_CODES.clearFailed, { cause: error });
2091
- }
2092
- }
2093
- /**
2094
- * Returns retention stats for a scope.
2095
- */
2096
- async getStats(scope) {
2097
- try {
2098
- const row = (await this.getDb()).prepare(`
2099
- SELECT COUNT(*) as count, MIN(created_at) as oldest, MAX(created_at) as newest
2100
- FROM ${HISTORY_TABLE}
2101
- WHERE scope = ?
2102
- `).get(scope);
2103
- return {
2104
- scope,
2105
- retentionLimit: this.retentionLimit,
2106
- totalMessages: Number(row.count),
2107
- oldestCreatedAt: row.oldest,
2108
- newestCreatedAt: row.newest
2109
- };
2110
- } catch (error) {
2111
- this.logger.error("[model-proxy-mcp] Failed to read history stats", {
2112
- code: HISTORY_ERROR_CODES.statsFailed,
2113
- scope,
2114
- cause: error
2115
- });
2116
- throw new ConversationHistoryServiceError("Failed to read history stats", HISTORY_ERROR_CODES.statsFailed, { cause: error });
2117
- }
2118
- }
2119
- /**
2120
- * Lists known scopes discovered from stored conversation history.
2121
- */
2122
- async listScopes() {
2123
- try {
2124
- return (await this.getDb()).prepare(`SELECT DISTINCT scope FROM ${HISTORY_TABLE} ORDER BY scope ASC`).all().map((row) => row.scope);
2125
- } catch (error) {
2126
- this.logger.error("[model-proxy-mcp] Failed to list history scopes", {
2127
- code: HISTORY_ERROR_CODES.listFailed,
2128
- cause: error
2129
- });
2130
- return [];
2131
- }
2132
- }
2133
- /**
2134
- * Removes the oldest rows for a scope when it exceeds retention.
2135
- */
2136
- async pruneScope(scope) {
2137
- const db = await this.getDb();
2138
- const row = db.prepare(`SELECT COUNT(*) as count FROM ${HISTORY_TABLE} WHERE scope = ?`).get(scope);
2139
- const overflow = Number(row.count) - this.retentionLimit;
2140
- if (overflow <= 0) return;
2141
- db.prepare(`
2142
- DELETE FROM ${HISTORY_TABLE}
2143
- WHERE sequence IN (
2144
- SELECT sequence
2145
- FROM ${HISTORY_TABLE}
2146
- WHERE scope = ?
2147
- ORDER BY sequence ASC
2148
- LIMIT ?
2149
- )
2150
- `).run(scope, overflow);
2151
- }
2152
- /**
2153
- * Lazily initializes the SQLite connection and schema.
2154
- */
2155
- async getDb() {
2156
- if (this.sqlite) return this.sqlite;
2157
- try {
2158
- await fs$1.mkdir(path.dirname(this.dbPath), { recursive: true });
2159
- this.sqlite = new Database(this.dbPath);
2160
- this.sqlite.pragma("journal_mode = WAL");
2161
- this.sqlite.exec(`
2162
- CREATE TABLE IF NOT EXISTS ${HISTORY_TABLE} (
2163
- sequence INTEGER PRIMARY KEY AUTOINCREMENT,
2164
- id TEXT NOT NULL UNIQUE,
2165
- scope TEXT NOT NULL,
2166
- request_id TEXT NOT NULL,
2167
- direction TEXT NOT NULL,
2168
- role TEXT,
2169
- message_type TEXT NOT NULL,
2170
- model TEXT,
2171
- slot TEXT NOT NULL,
2172
- payload_json TEXT NOT NULL,
2173
- created_at TEXT NOT NULL
2174
- )
2175
- `);
2176
- this.sqlite.exec(`CREATE INDEX IF NOT EXISTS ${HISTORY_SCOPE_SEQUENCE_INDEX} ON ${HISTORY_TABLE} (scope, sequence DESC)`);
2177
- this.sqlite.exec(`CREATE INDEX IF NOT EXISTS ${HISTORY_SCOPE_REQUEST_INDEX} ON ${HISTORY_TABLE} (scope, request_id)`);
2178
- return this.sqlite;
2179
- } catch (error) {
2180
- this.logger.error("[model-proxy-mcp] Failed to initialize history database", {
2181
- code: HISTORY_ERROR_CODES.initFailed,
2182
- dbPath: this.dbPath,
2183
- cause: error
2184
- });
2185
- throw new ConversationHistoryServiceError("Failed to initialize history database", HISTORY_ERROR_CODES.initFailed, { cause: error });
2186
- }
2187
- }
2188
- /**
2189
- * Maps a SQLite row into the public history DTO.
2190
- */
2191
- toEntry(row) {
2192
- return {
2193
- id: row.id,
2194
- scope: row.scope,
2195
- requestId: row.request_id,
2196
- direction: row.direction,
2197
- role: row.role,
2198
- messageType: row.message_type,
2199
- model: row.model,
2200
- slot: row.slot,
2201
- payloadJson: row.payload_json,
2202
- createdAt: row.created_at
2203
- };
2204
- }
2205
- };
2206
-
2207
- //#endregion
2208
- //#region src/services/GatewayService.ts
2209
- const MODEL_SLOT_BY_NAME = {
2210
- "ccproxy-default": "default",
2211
- "ccproxy-sonnet": "sonnet",
2212
- "ccproxy-opus": "opus",
2213
- "ccproxy-haiku": "haiku",
2214
- "ccproxy-subagent": "subagent"
2215
- };
2216
- const DEFAULT_SCOPE$1 = "default";
2217
- const DEFAULT_SLOT$1 = "default";
2218
- const DEFAULT_HISTORY_PAGE_SIZE = 50;
2219
- const MODEL_SLOTS = [
2220
- "default",
2221
- "sonnet",
2222
- "opus",
2223
- "haiku",
2224
- "subagent"
2225
- ];
2226
- const CLAUDE_MESSAGES_ENDPOINT = "https://api.anthropic.com/v1/messages";
2227
- const DEFAULT_ANTHROPIC_VERSION = "2023-06-01";
2228
- const ANTHROPIC_COMPATIBLE_PROVIDER = "anthropic-compatible";
2229
- const CHATGPT_CODEX_PROVIDER = "chatgpt-codex";
2230
- const GEMINI_DIRECT_PROVIDER = "gemini-direct";
2231
- const DEFAULT_UPSTREAM_AUTH_ENV = "ANTHROPIC_AUTH_TOKEN";
2232
- const FALLBACK_UPSTREAM_AUTH_ENV = "MODEL_PROXY_MCP_UPSTREAM_AUTH_TOKEN";
2233
- const FALLBACK_UPSTREAM_TIMEOUT_ENV = "MODEL_PROXY_MCP_UPSTREAM_TIMEOUT_MS";
2234
- const CONFIGURED_UPSTREAM_AUTH_ENV_NAME = "MODEL_PROXY_MCP_UPSTREAM_AUTH_ENV";
2235
- const DEFAULT_API_TIMEOUT_ENV = "API_TIMEOUT_MS";
2236
- const ERROR_RESPONSE_CONTENT_TYPE = "application/json; charset=utf-8";
2237
- const JSON_CONTENT_TYPE = "application/json";
2238
- const SSE_CONTENT_TYPE = "text/event-stream; charset=utf-8";
2239
- const RESPONSE_CONTENT_TYPE_HEADER = "content-type";
2240
- const RESPONSE_CACHE_CONTROL_HEADER = "cache-control";
2241
- const RESPONSE_CONNECTION_HEADER = "connection";
2242
- const AUTHORIZATION_HEADER = "authorization";
2243
- const X_API_KEY_HEADER = "x-api-key";
2244
- const ACCEPT_HEADER = "accept";
2245
- const ANTHROPIC_VERSION_HEADER = "anthropic-version";
2246
- const ANTHROPIC_BETA_HEADER = "anthropic-beta";
2247
- const HEADER_NO_CACHE = "no-cache";
2248
- const HEADER_KEEP_ALIVE = "keep-alive";
2249
- const API_ERROR_TYPE = "api_error";
2250
- const RAW_REQUEST_MESSAGE_TYPE = "raw-request";
2251
- const RESPONSE_STREAM_MESSAGE_TYPE = "response-stream";
2252
- const ERROR_MESSAGE_TYPE = "error";
2253
- const REQUEST_DIRECTION = "request";
2254
- const RESPONSE_DIRECTION = "response";
2255
- const ERROR_DIRECTION = "error";
2256
- const SYSTEM_ROLE = "system";
2257
- const ASSISTANT_ROLE = "assistant";
2258
- const MESSAGE_MESSAGE_TYPE = "message";
2259
- const RESPONSE_ITEM_MESSAGE_TYPE = "response-item";
2260
- const MODEL_NOT_FOUND = "MODEL_NOT_FOUND";
2261
- const REQUEST_FORWARD_FAILED = "REQUEST_FORWARD_FAILED";
2262
- const REQUEST_VALIDATION_FAILED = "REQUEST_VALIDATION_FAILED";
2263
- const REQUEST_PAYLOAD_PREVIEW_LIMIT = 240;
2264
- const HTTP_STATUS_OK = 200;
2265
- const GEMINI_GENERATE_CONTENT_METHOD = "generateContent";
2266
- const GEMINI_STREAM_GENERATE_CONTENT_METHOD = "streamGenerateContent";
2267
- const CODE_ASSIST_LOAD_METHOD = "loadCodeAssist";
2268
- const CODE_ASSIST_IDE_TYPE = "IDE_UNSPECIFIED";
2269
- const CODE_ASSIST_PLATFORM = "PLATFORM_UNSPECIFIED";
2270
- const CODE_ASSIST_PLUGIN_TYPE = "GEMINI";
2271
- const CLAUDE_REQUEST_SCHEMA = z.object({
2272
- model: z.string().optional(),
2273
- system: z.unknown().optional(),
2274
- messages: z.array(z.unknown()).default([]),
2275
- tools: z.array(z.unknown()).optional(),
2276
- max_tokens: z.number().int().positive().optional(),
2277
- stream: z.boolean().optional()
2278
- });
2279
- /**
2280
- * Base error type for gateway operations with HTTP status and domain code metadata.
2281
- */
2282
- var GatewayServiceError = class extends Error {
2283
- constructor(message, code, status, options) {
2284
- super(message, options);
2285
- this.code = code;
2286
- this.status = status;
2287
- this.name = new.target.name;
2288
- }
2289
- };
2290
- /**
2291
- * Raised when an inbound Claude request payload is invalid.
2292
- */
2293
- var GatewayValidationError = class extends GatewayServiceError {};
2294
- /**
2295
- * Raised when the active proxy configuration cannot satisfy a request.
2296
- */
2297
- var GatewayConfigError = class extends GatewayServiceError {};
2298
- /**
2299
- * Raised when upstream authentication is missing or invalid.
2300
- */
2301
- var GatewayAuthError = class extends GatewayServiceError {};
2302
- /**
2303
- * Raised when an upstream request fails in a way that might be recoverable by trying fallbacks.
2304
- */
2305
- var GatewayUpstreamError = class extends GatewayServiceError {
2306
- constructor(message, status, upstreamBody, attemptLabel, options) {
2307
- super(message, REQUEST_FORWARD_FAILED, status, options);
2308
- this.upstreamBody = upstreamBody;
2309
- this.attemptLabel = attemptLabel;
2310
- }
2311
- };
2312
- const HTTP_STATUS_INTERNAL_SERVER_ERROR = 500;
2313
- const RECOVERABLE_UPSTREAM_STATUSES = new Set([
2314
- 408,
2315
- 429,
2316
- 500,
2317
- 502,
2318
- 503,
2319
- 504
2320
- ]);
2321
- /**
2322
- * Coordinates settings-backed model selection, request forwarding, and scoped history persistence.
2323
- */
2324
- var GatewayService = class {
2325
- constructor(profileStore = new ProfileStore(), codexAuth = new CodexAuth(consoleLogger, DEFAULT_AUTH_FILE_PATH), logger = consoleLogger, fetchImpl = fetch, historyService = new ConversationHistoryService()) {
2326
- this.profileStore = profileStore;
2327
- this.codexAuth = codexAuth;
2328
- this.logger = logger;
2329
- this.fetchImpl = fetchImpl;
2330
- this.historyService = historyService;
2331
- }
2332
- /**
2333
- * Lists all configured profiles for the given scope.
2334
- */
2335
- async listProfiles(scope = DEFAULT_SCOPE$1) {
2336
- return this.profileStore.listProfiles(scope);
2337
- }
2338
- /**
2339
- * Eagerly seeds the scoped settings file so startup always has a backing config.
2340
- */
2341
- async ensureConfig(scope = DEFAULT_SCOPE$1) {
2342
- await this.historyService.ensureInitialized();
2343
- return this.profileStore.ensureConfig(scope);
2344
- }
2345
- /**
2346
- * Returns the resolved admin config used by the live HTTP server and UI.
2347
- */
2348
- async getAdminConfig(scope = DEFAULT_SCOPE$1) {
2349
- return this.profileStore.getAdminConfig(scope);
2350
- }
2351
- /**
2352
- * Updates the resolved admin config.
2353
- */
2354
- async updateAdminConfig(update, scope = DEFAULT_SCOPE$1) {
2355
- await this.profileStore.updateConfig(update, scope);
2356
- return this.profileStore.getAdminConfig(scope);
2357
- }
2358
- /**
2359
- * Returns the currently selected profile for a scope/slot pair.
2360
- */
2361
- async getActiveProfile(scope = DEFAULT_SCOPE$1, slot = DEFAULT_SLOT$1) {
2362
- return this.profileStore.getActiveProfile(scope, slot);
2363
- }
2364
- /**
2365
- * Creates or updates a profile in the scoped settings file.
2366
- */
2367
- async upsertProfile(profile, scope = DEFAULT_SCOPE$1) {
2368
- return this.profileStore.upsertProfile(profile, scope);
2369
- }
2370
- /**
2371
- * Selects the active profile by profile id for the requested slot.
2372
- */
2373
- async setActiveProfile(profileId, scope = DEFAULT_SCOPE$1, slot = DEFAULT_SLOT$1) {
2374
- return this.profileStore.setActiveProfile(profileId, scope, slot);
2375
- }
2376
- /**
2377
- * Returns the active profile currently used for routing requests.
2378
- */
2379
- async getCurrentModel(scope = DEFAULT_SCOPE$1, slot = DEFAULT_SLOT$1) {
2380
- return this.profileStore.getActiveProfile(scope, slot);
2381
- }
2382
- /**
2383
- * Switches the selected model by model name.
2384
- */
2385
- async switchModel(model, scope = DEFAULT_SCOPE$1, slot = DEFAULT_SLOT$1) {
2386
- const profiles = await this.profileStore.listProfiles(scope);
2387
- const current = await this.profileStore.getResolvedSlotConfig(scope, slot);
2388
- const profile = profiles.find((item) => item.model === model && item.provider === current.provider && item.enabled) ?? profiles.find((item) => item.model === model && item.enabled);
2389
- if (!profile) throw new GatewayConfigError(`Model not found or disabled: ${model}`, MODEL_NOT_FOUND, 400, { cause: {
2390
- model,
2391
- scope,
2392
- slot
2393
- } });
2394
- return this.profileStore.updateConfig({ models: { [slot]: {
2395
- main: {
2396
- provider: profile.provider,
2397
- model: profile.model,
2398
- reasoningEffort: profile.reasoningEffort
2399
- },
2400
- fallbacks: (await this.profileStore.getAdminConfig(scope)).scopeModels[slot]?.fallbacks ?? []
2401
- } } }, scope);
2402
- }
2403
- /**
2404
- * Lists persisted history for a scope.
2405
- */
2406
- async listHistory(scope = DEFAULT_SCOPE$1, limit = DEFAULT_HISTORY_PAGE_SIZE, cursor) {
2407
- return this.historyService.listHistory(scope, limit, cursor);
2408
- }
2409
- /**
2410
- * Clears persisted history for a scope.
2411
- */
2412
- async clearHistory(scope = DEFAULT_SCOPE$1) {
2413
- return { deleted: await this.historyService.clearScope(scope) };
2414
- }
2415
- /**
2416
- * Lists known scopes from config files and stored history.
2417
- */
2418
- async listScopes() {
2419
- const [configScopes, historyScopes] = await Promise.all([this.profileStore.listScopes(), this.historyService.listScopes()]);
2420
- return Array.from(new Set([
2421
- ...configScopes,
2422
- ...historyScopes,
2423
- DEFAULT_SCOPE$1
2424
- ])).sort();
2425
- }
2426
- /**
2427
- * Returns persisted history stats for a scope.
2428
- */
2429
- async getHistoryStats(scope = DEFAULT_SCOPE$1) {
2430
- return this.historyService.getStats(scope);
2431
- }
2432
- /**
2433
- * Returns the current gateway status, auth state, and active slot routing.
2434
- */
2435
- async getStatus(scope = DEFAULT_SCOPE$1, port, pid) {
2436
- const config = await this.profileStore.getConfig(scope);
2437
- const activeSlot = await this.profileStore.getResolvedSlotConfig(scope, DEFAULT_SLOT$1);
2438
- const authStatus = await this.codexAuth.getAuthStatus();
2439
- const slotModels = Object.fromEntries(await Promise.all(MODEL_SLOTS.map(async (slot) => {
2440
- const resolved = await this.profileStore.getResolvedSlotConfig(scope, slot);
2441
- return [slot, {
2442
- profileId: resolved.profileId,
2443
- provider: resolved.provider,
2444
- model: resolved.model,
2445
- reasoningEffort: resolved.reasoningEffort,
2446
- thinkingDisabled: resolved.thinkingDisabled ?? false
2447
- }];
2448
- })));
2449
- return {
2450
- running: true,
2451
- port,
2452
- pid,
2453
- scope,
2454
- activeProfileId: config.activeProfileId,
2455
- activeModel: activeSlot.model ?? void 0,
2456
- activeReasoningEffort: activeSlot.reasoningEffort,
2457
- slotModels,
2458
- auth: authStatus,
2459
- profiles: config.profiles
2460
- };
2461
- }
2462
- /**
2463
- * Returns the OpenAI-compatible model listing served by the proxy.
2464
- */
2465
- async getModels(scope = DEFAULT_SCOPE$1) {
2466
- return {
2467
- object: "list",
2468
- data: (await this.profileStore.listProfiles(scope)).map((profile) => ({
2469
- id: profile.model,
2470
- object: "model",
2471
- created: 0,
2472
- owned_by: `${profile.provider}:${profile.id}`,
2473
- metadata: {
2474
- profileId: profile.id,
2475
- reasoningEffort: profile.reasoningEffort
2476
- }
2477
- }))
2478
- };
2479
- }
2480
- /**
2481
- * Forwards a Claude-compatible request to the configured upstream Codex endpoint.
2482
- */
2483
- async forwardClaudeRequest(requestBody, scope = DEFAULT_SCOPE$1, requestHeaders = new Headers()) {
2484
- const requestId = ulid();
2485
- let slot = DEFAULT_SLOT$1;
2486
- let resolved = null;
2487
- try {
2488
- const parsed = this.parseClaudeRequest(requestBody);
2489
- const adminConfig = await this.profileStore.getAdminConfig(scope);
2490
- slot = this.resolveSlot(parsed.model, adminConfig);
2491
- resolved = this.resolveRequestedModel(parsed.model, adminConfig, slot);
2492
- if (!resolved.model) {
2493
- const message$1 = `No active model configured for scope '${scope}' and slot '${slot}'`;
2494
- await this.recordError(scope, slot, resolved, requestId, message$1, requestBody);
2495
- return this.createErrorResponse(400, message$1);
2496
- }
2497
- const attemptTargets = this.buildAttemptTargets(adminConfig, resolved, slot);
2498
- if (attemptTargets.length === 0) {
2499
- const message$1 = `No provider configured for scope '${scope}' and slot '${slot}'`;
2500
- await this.recordError(scope, slot, resolved, requestId, message$1, requestBody);
2501
- return this.createErrorResponse(400, message$1);
2502
- }
2503
- await this.recordRequest(scope, slot, attemptTargets[0].resolved, requestId, parsed, requestBody);
2504
- let lastRecoverableFailure = null;
2505
- for (const target of attemptTargets) {
2506
- const result = await this.forwardSingleAttempt(target, parsed, requestBody, scope, slot, requestHeaders);
2507
- if (result.ok) {
2508
- await this.recordResponse(scope, slot, target.resolved, requestId, result.success.upstreamText, result.success.claudeBody);
2509
- return result.success.response;
2510
- }
2511
- const { error, payloadForHistory } = result.failure;
2512
- if (!this.isRecoverableUpstreamError(error)) {
2513
- this.logger.error("[model-proxy-mcp] Non-recoverable model attempt failed", {
2514
- scope,
2515
- slot,
2516
- model: target.resolved.model,
2517
- code: error.code,
2518
- status: error.status,
2519
- message: error.message
2520
- });
2521
- await this.recordError(scope, slot, target.resolved, requestId, error.message, payloadForHistory);
2522
- return this.createErrorResponse(error.status, error.message);
2523
- }
2524
- lastRecoverableFailure = result.failure;
2525
- this.logger.warn("[model-proxy-mcp] Recoverable model attempt failed; trying fallback", {
2526
- scope,
2527
- slot,
2528
- model: target.resolved.model,
2529
- code: error.code,
2530
- status: error.status,
2531
- message: error.message
2532
- });
2533
- await this.recordError(scope, slot, target.resolved, requestId, `Attempt failed for ${target.label}: ${error.message}`, payloadForHistory);
2534
- }
2535
- if (lastRecoverableFailure) {
2536
- await this.recordError(scope, slot, resolved, requestId, lastRecoverableFailure.error.message, lastRecoverableFailure.payloadForHistory);
2537
- return this.createErrorResponse(lastRecoverableFailure.error.status, lastRecoverableFailure.error.message);
2538
- }
2539
- const message = "No model attempt could be executed";
2540
- await this.recordError(scope, slot, resolved, requestId, message, requestBody);
2541
- return this.createErrorResponse(HTTP_STATUS_INTERNAL_SERVER_ERROR, message);
2542
- } catch (error) {
2543
- const gatewayError = error instanceof GatewayServiceError ? error : new GatewayServiceError("Failed to process Claude proxy request", REQUEST_FORWARD_FAILED, 500, { cause: error });
2544
- this.logger.error("[model-proxy-mcp] Request forwarding failed", {
2545
- scope,
2546
- slot,
2547
- code: gatewayError.code,
2548
- message: gatewayError.message,
2549
- cause: gatewayError.cause
2550
- });
2551
- await this.recordError(scope, slot, resolved, requestId, gatewayError.message, requestBody);
2552
- return this.createErrorResponse(gatewayError.status, gatewayError.message);
2553
- }
2554
- }
2555
- buildAttemptTargets(adminConfig, primary, slot) {
2556
- const targets = [];
2557
- const dedupe = /* @__PURE__ */ new Set();
2558
- const candidates = [primary, ...primary.fallbacks.map((fallback) => ({
2559
- ...primary,
2560
- ...fallback,
2561
- slot
2562
- }))];
2563
- for (const candidate of candidates) {
2564
- const providerId = candidate.provider;
2565
- const modelId = candidate.model;
2566
- if (!providerId || !modelId) continue;
2567
- const provider = adminConfig.providers[providerId];
2568
- if (!provider) continue;
2569
- const key = `${providerId}:${modelId}:${candidate.reasoningEffort}:${candidate.thinkingDisabled ? "off" : "on"}`;
2570
- if (dedupe.has(key)) continue;
2571
- dedupe.add(key);
2572
- targets.push({
2573
- resolved: {
2574
- ...candidate,
2575
- slot,
2576
- provider: providerId,
2577
- model: modelId,
2578
- providerType: provider.type,
2579
- endpoint: provider.endpoint,
2580
- reasoningEffort: candidate.reasoningEffort,
2581
- thinkingDisabled: candidate.thinkingDisabled ?? false,
2582
- fallbacks: []
2583
- },
2584
- provider,
2585
- label: `${providerId}/${modelId}`
2586
- });
2587
- }
2588
- return targets;
2589
- }
2590
- async forwardSingleAttempt(target, parsedRequest, requestBody, scope, slot, requestHeaders) {
2591
- try {
2592
- if (target.resolved.providerType === ANTHROPIC_COMPATIBLE_PROVIDER) return {
2593
- ok: true,
2594
- success: await this.forwardAnthropicCompatibleRequest(requestBody, scope, slot, target.resolved, target.provider, requestHeaders)
2595
- };
2596
- if (target.resolved.providerType === GEMINI_DIRECT_PROVIDER) return {
2597
- ok: true,
2598
- success: await this.forwardGeminiDirectRequest(parsedRequest, scope, slot, target.resolved, target.provider, requestHeaders)
2599
- };
2600
- return {
2601
- ok: true,
2602
- success: await this.forwardCodexRequest(requestBody, scope, slot, target.resolved)
2603
- };
2604
- } catch (error) {
2605
- return {
2606
- ok: false,
2607
- failure: {
2608
- error: error instanceof GatewayServiceError ? error : new GatewayServiceError("Failed to process Claude proxy request", REQUEST_FORWARD_FAILED, 500, { cause: error }),
2609
- payloadForHistory: error instanceof GatewayUpstreamError ? error.upstreamBody : requestBody
2610
- }
2611
- };
2612
- }
2613
- }
2614
- isRecoverableUpstreamError(error) {
2615
- return error instanceof GatewayUpstreamError && RECOVERABLE_UPSTREAM_STATUSES.has(error.status);
2616
- }
2617
- async forwardCodexRequest(requestBody, scope, slot, resolved) {
2618
- const resolvedModel = resolved.model;
2619
- if (!resolvedModel) throw new GatewayConfigError(`No active model configured for slot '${slot}'`, MODEL_NOT_FOUND, 400);
2620
- if (!await this.codexAuth.getAccessToken()) throw new GatewayAuthError("Missing Codex auth. Sign in with the official Codex CLI first.", "AUTH_MISSING", 401);
2621
- const transformer = new ClaudeToOpenAITransformer({
2622
- toProvider: CHATGPT_CODEX_PROVIDER,
2623
- toModel: resolvedModel,
2624
- toEndpoint: resolved.endpoint ?? CLAUDE_MESSAGES_ENDPOINT,
2625
- sessionReasoningEffort: resolved.reasoningEffort,
2626
- thinkingDisabled: resolved.thinkingDisabled ?? false,
2627
- logger: this.logger
2628
- }, this.codexAuth);
2629
- const responseTransformer = new OpenAIToClaudeTransformer(this.logger, resolved.thinkingDisabled ?? false);
2630
- const transformed = await transformer.transform(CLAUDE_MESSAGES_ENDPOINT, requestBody);
2631
- const upstreamResponse = await this.fetchImpl(transformed.url, {
2632
- method: "POST",
2633
- headers: transformed.headers,
2634
- body: transformed.body
2635
- });
2636
- const upstreamText = await upstreamResponse.text();
2637
- if (!upstreamResponse.ok) {
2638
- this.logger.error("[model-proxy-mcp] Upstream request failed", {
2639
- scope,
2640
- slot,
2641
- profileId: resolved.profileId,
2642
- model: resolved.model,
2643
- status: upstreamResponse.status,
2644
- body: upstreamText
2645
- });
2646
- throw new GatewayUpstreamError(upstreamText || "Codex upstream request failed", upstreamResponse.status, upstreamText, `${resolved.provider}/${resolved.model}`);
2647
- }
2648
- const body = responseTransformer.transform(upstreamText);
2649
- return {
2650
- response: {
2651
- status: HTTP_STATUS_OK,
2652
- body,
2653
- headers: this.createSuccessHeaders(scope, slot)
2654
- },
2655
- upstreamText,
2656
- claudeBody: body
2657
- };
2658
- }
2659
- /**
2660
- * Forwards a Claude-compatible request to the Gemini GenerateContent REST API and translates the result back to Claude SSE.
2661
- */
2662
- async forwardGeminiDirectRequest(parsedRequest, scope, slot, resolved, provider, requestHeaders) {
2663
- try {
2664
- const resolvedModel = resolved.model;
2665
- if (!resolvedModel) throw new GatewayConfigError(`No active Gemini model configured for scope '${scope}' and slot '${slot}'`, MODEL_NOT_FOUND, 400);
2666
- const authHeaders = await new GeminiAuth(this.logger).resolveHeaders(provider);
2667
- const requestTransformer = new ClaudeToGeminiTransformer();
2668
- const responseTransformer = new GeminiToClaudeTransformer();
2669
- const geminiRequest = {
2670
- model: parsedRequest.model,
2671
- system: parsedRequest.system,
2672
- messages: parsedRequest.messages,
2673
- tools: parsedRequest.tools,
2674
- max_tokens: parsedRequest.max_tokens
2675
- };
2676
- const transformed = requestTransformer.transform(geminiRequest, resolvedModel, resolved.reasoningEffort, resolved.thinkingDisabled ?? false);
2677
- if (authHeaders.authType === GEMINI_PERSONAL_OAUTH_AUTH_TYPE) return await this.forwardGeminiCodeAssistRequest(transformed.body, parsedRequest, scope, slot, resolved, authHeaders, responseTransformer);
2678
- const method = parsedRequest.stream === false ? GEMINI_GENERATE_CONTENT_METHOD : GEMINI_STREAM_GENERATE_CONTENT_METHOD;
2679
- const url = this.createGeminiRequestUrl(provider, transformed.modelPath, method, parsedRequest.stream !== false);
2680
- const headers = new Headers(authHeaders.headers);
2681
- headers.set(RESPONSE_CONTENT_TYPE_HEADER, JSON_CONTENT_TYPE);
2682
- headers.set(ACCEPT_HEADER, requestHeaders.get(ACCEPT_HEADER) ?? SSE_CONTENT_TYPE);
2683
- const timeoutMs = provider.apiTimeoutMs ?? this.resolveUpstreamTimeoutMs();
2684
- const upstreamResponse = await this.fetchImpl(url, {
2685
- method: "POST",
2686
- headers,
2687
- body: JSON.stringify(transformed.body),
2688
- signal: timeoutMs ? AbortSignal.timeout(timeoutMs) : void 0
2689
- });
2690
- const upstreamText = await upstreamResponse.text();
2691
- if (!upstreamResponse.ok) {
2692
- this.logger.error("[model-proxy-mcp] Gemini upstream request failed", {
2693
- scope,
2694
- slot,
2695
- profileId: resolved.profileId,
2696
- model: resolved.model,
2697
- status: upstreamResponse.status,
2698
- body: upstreamText,
2699
- authMode: authHeaders.authMode,
2700
- authSource: authHeaders.authSource
2701
- });
2702
- throw new GatewayUpstreamError(upstreamText || "Gemini upstream request failed", upstreamResponse.status, upstreamText, `${resolved.provider}/${resolved.model}`);
2703
- }
2704
- const body = parsedRequest.stream === false ? responseTransformer.transformBuffered(upstreamText, resolvedModel) : responseTransformer.transformStreaming(upstreamText, resolvedModel);
2705
- return {
2706
- response: {
2707
- status: HTTP_STATUS_OK,
2708
- body,
2709
- headers: this.createSuccessHeaders(scope, slot)
2710
- },
2711
- upstreamText,
2712
- claudeBody: body
2713
- };
2714
- } catch (error) {
2715
- if (error instanceof GeminiAuthError) throw new GatewayAuthError(error.message, error.code, 401, { cause: error });
2716
- throw error;
2717
- }
2718
- }
2719
- async forwardGeminiCodeAssistRequest(transformedBody, parsedRequest, scope, slot, resolved, authHeaders, responseTransformer) {
2720
- const projectId = await this.resolveCodeAssistProjectId(authHeaders);
2721
- const url = this.createCodeAssistUrl(parsedRequest.stream === false ? GEMINI_GENERATE_CONTENT_METHOD : GEMINI_STREAM_GENERATE_CONTENT_METHOD, parsedRequest.stream !== false);
2722
- const headers = new Headers(authHeaders.headers);
2723
- headers.set(RESPONSE_CONTENT_TYPE_HEADER, JSON_CONTENT_TYPE);
2724
- headers.set(ACCEPT_HEADER, SSE_CONTENT_TYPE);
2725
- const requestBody = {
2726
- model: resolved.model,
2727
- project: projectId,
2728
- user_prompt_id: ulid(),
2729
- request: this.toCodeAssistGenerateContentRequest(transformedBody)
2730
- };
2731
- const upstreamResponse = await this.fetchImpl(url, {
2732
- method: "POST",
2733
- headers,
2734
- body: JSON.stringify(requestBody)
2735
- });
2736
- const upstreamText = await upstreamResponse.text();
2737
- if (!upstreamResponse.ok) {
2738
- this.logger.error("[model-proxy-mcp] Gemini Code Assist request failed", {
2739
- scope,
2740
- slot,
2741
- profileId: resolved.profileId,
2742
- model: resolved.model,
2743
- status: upstreamResponse.status,
2744
- body: upstreamText,
2745
- authMode: authHeaders.authMode,
2746
- authSource: authHeaders.authSource,
2747
- authType: authHeaders.authType
2748
- });
2749
- throw new GatewayUpstreamError(upstreamText || "Gemini Code Assist request failed", upstreamResponse.status, upstreamText, `${resolved.provider}/${resolved.model}`);
2750
- }
2751
- const normalizedResponse = parsedRequest.stream === false ? this.normalizeCodeAssistBufferedResponse(upstreamText) : this.normalizeCodeAssistStreamingResponse(upstreamText);
2752
- const body = parsedRequest.stream === false ? responseTransformer.transformBuffered(normalizedResponse, resolved.model ?? "gemini") : responseTransformer.transformStreaming(normalizedResponse, resolved.model ?? "gemini");
2753
- return {
2754
- response: {
2755
- status: HTTP_STATUS_OK,
2756
- body,
2757
- headers: this.createSuccessHeaders(scope, slot)
2758
- },
2759
- upstreamText,
2760
- claudeBody: body
2761
- };
2762
- }
2763
- /**
2764
- * Builds response headers shared by successful SSE responses.
2765
- */
2766
- createSuccessHeaders(scope, slot) {
2767
- return {
2768
- [RESPONSE_CONTENT_TYPE_HEADER]: SSE_CONTENT_TYPE,
2769
- [RESPONSE_CACHE_CONTROL_HEADER]: HEADER_NO_CACHE,
2770
- [RESPONSE_CONNECTION_HEADER]: HEADER_KEEP_ALIVE,
2771
- "x-model-proxy-service": DEFAULT_SERVICE_NAME,
2772
- "x-model-proxy-scope": scope,
2773
- "x-model-proxy-slot": slot
2774
- };
2775
- }
2776
- /**
2777
- * Builds the target Gemini REST endpoint for buffered or streaming requests.
2778
- */
2779
- createGeminiRequestUrl(provider, modelPath, method, streaming) {
2780
- const baseUrl = provider.endpoint.replace(/\/$/, "");
2781
- const apiVersion = provider.apiVersion ?? GEMINI_DEFAULT_API_VERSION;
2782
- const url = new URL(`${baseUrl}/${apiVersion}/${modelPath}:${method}`);
2783
- if (streaming) {
2784
- const [key, value] = GEMINI_STREAM_QUERY_PARAM.split("=");
2785
- if (key && value) url.searchParams.set(key, value);
2786
- }
2787
- return url.toString();
2788
- }
2789
- createCodeAssistUrl(method, streaming = false) {
2790
- const url = new URL(`${GEMINI_CODE_ASSIST_ENDPOINT}/${GEMINI_CODE_ASSIST_API_VERSION}:${method}`);
2791
- if (streaming) {
2792
- const [key, value] = GEMINI_STREAM_QUERY_PARAM.split("=");
2793
- if (key && value) url.searchParams.set(key, value);
2794
- }
2795
- return url.toString();
2796
- }
2797
- async resolveCodeAssistProjectId(authHeaders) {
2798
- const headers = new Headers(authHeaders.headers);
2799
- headers.set(RESPONSE_CONTENT_TYPE_HEADER, JSON_CONTENT_TYPE);
2800
- const configuredProject = process.env.GOOGLE_CLOUD_PROJECT || process.env.GOOGLE_CLOUD_PROJECT_ID || void 0;
2801
- const response = await this.fetchImpl(this.createCodeAssistUrl(CODE_ASSIST_LOAD_METHOD), {
2802
- method: "POST",
2803
- headers,
2804
- body: JSON.stringify({
2805
- cloudaicompanionProject: configuredProject,
2806
- metadata: {
2807
- ideType: CODE_ASSIST_IDE_TYPE,
2808
- platform: CODE_ASSIST_PLATFORM,
2809
- pluginType: CODE_ASSIST_PLUGIN_TYPE,
2810
- duetProject: configuredProject
2811
- }
2812
- })
2813
- });
2814
- const responseText = await response.text();
2815
- if (!response.ok) throw new GatewayUpstreamError(responseText || "Failed to load Gemini Code Assist project context", response.status, responseText, "google-gemini-direct/loadCodeAssist");
2816
- const projectId = JSON.parse(responseText).cloudaicompanionProject || configuredProject;
2817
- if (!projectId) throw new GatewayAuthError("Gemini Code Assist did not return a usable project ID.", "GEMINI_PROJECT_MISSING", 401);
2818
- return projectId;
2819
- }
2820
- toCodeAssistGenerateContentRequest(body) {
2821
- return {
2822
- contents: body.contents,
2823
- systemInstruction: body.system_instruction,
2824
- tools: body.tools,
2825
- toolConfig: body.tool_config,
2826
- generationConfig: body.generationConfig,
2827
- session_id: ulid()
2828
- };
2829
- }
2830
- normalizeCodeAssistBufferedResponse(responseBody) {
2831
- const parsed = JSON.parse(responseBody);
2832
- return JSON.stringify(this.extractCodeAssistResponse(parsed));
2833
- }
2834
- normalizeCodeAssistStreamingResponse(responseBody) {
2835
- const chunks = [];
2836
- for (const event of responseBody.split("\n\n")) {
2837
- const dataLine = event.split("\n").find((line) => line.startsWith("data:"))?.slice(5).trim();
2838
- if (!dataLine || dataLine === "[DONE]") continue;
2839
- const parsed = JSON.parse(dataLine);
2840
- chunks.push(`data: ${JSON.stringify(this.extractCodeAssistResponse(parsed))}`);
2841
- chunks.push("");
2842
- }
2843
- return chunks.join("\n");
2844
- }
2845
- extractCodeAssistResponse(payload) {
2846
- return {
2847
- candidates: payload.response?.candidates ?? [],
2848
- usageMetadata: payload.response?.usageMetadata,
2849
- modelVersion: payload.response?.modelVersion
2850
- };
2851
- }
2852
- /**
2853
- * Resolves the logical slot from a Claude model name or configured model mapping.
2854
- */
2855
- resolveSlot(model, adminConfig) {
2856
- if (!model) return DEFAULT_SLOT$1;
2857
- const mappedSlot = MODEL_SLOT_BY_NAME[model];
2858
- if (mappedSlot) return mappedSlot;
2859
- for (const slot of MODEL_SLOTS) if (adminConfig.slots[slot].model === model) return slot;
2860
- return DEFAULT_SLOT$1;
2861
- }
2862
- /**
2863
- * Reconciles a request-specified concrete model with the resolved slot configuration.
2864
- */
2865
- resolveRequestedModel(model, adminConfig, slot) {
2866
- const resolved = adminConfig.slots[slot];
2867
- if (!model || MODEL_SLOT_BY_NAME[model]) return resolved;
2868
- const profile = adminConfig.models.find((item) => item.model === model && item.provider === resolved.provider && item.enabled) ?? adminConfig.models.find((item) => item.model === model && item.enabled);
2869
- if (!profile && resolved.model !== model) throw new GatewayConfigError(`Model not found or disabled: ${model}`, MODEL_NOT_FOUND, 400, { cause: {
2870
- model,
2871
- slot,
2872
- scope: adminConfig.scope
2873
- } });
2874
- return {
2875
- ...resolved,
2876
- profileId: profile?.id ?? null,
2877
- label: profile?.label ?? null,
2878
- provider: profile?.provider ?? resolved.provider,
2879
- providerType: profile?.providerType ?? resolved.providerType,
2880
- endpoint: profile?.endpoint ?? resolved.endpoint,
2881
- model: profile?.model ?? model,
2882
- reasoningEffort: profile?.reasoningEffort ?? resolved.reasoningEffort,
2883
- thinkingDisabled: resolved.thinkingDisabled ?? false
2884
- };
2885
- }
2886
- /**
2887
- * Forwards a Claude-compatible request to an Anthropic-compatible upstream such as Z.ai.
2888
- */
2889
- async forwardAnthropicCompatibleRequest(requestBody, scope, slot, resolved, provider, requestHeaders) {
2890
- const token = this.getAnthropicCompatibleAuthToken(provider.authTokenEnvVar);
2891
- if (!token) throw new GatewayAuthError(`Missing upstream auth token for ${ANTHROPIC_COMPATIBLE_PROVIDER}. Set ${provider.authTokenEnvVar || DEFAULT_UPSTREAM_AUTH_ENV}.`, "UPSTREAM_AUTH_MISSING", 401);
2892
- const payload = this.sanitizeClaudePayloadForAnthropic({
2893
- ...CLAUDE_REQUEST_SCHEMA.parse(JSON.parse(requestBody)),
2894
- model: resolved.model
2895
- });
2896
- const headers = new Headers();
2897
- headers.set(ACCEPT_HEADER, requestHeaders.get(ACCEPT_HEADER) ?? SSE_CONTENT_TYPE);
2898
- headers.set(RESPONSE_CONTENT_TYPE_HEADER, JSON_CONTENT_TYPE);
2899
- headers.set(ANTHROPIC_VERSION_HEADER, requestHeaders.get(ANTHROPIC_VERSION_HEADER) ?? DEFAULT_ANTHROPIC_VERSION);
2900
- headers.set(X_API_KEY_HEADER, token);
2901
- headers.set(AUTHORIZATION_HEADER, `Bearer ${token}`);
2902
- const anthropicBeta = requestHeaders.get(ANTHROPIC_BETA_HEADER);
2903
- if (anthropicBeta) headers.set(ANTHROPIC_BETA_HEADER, anthropicBeta);
2904
- const timeoutMs = provider.apiTimeoutMs ?? this.resolveUpstreamTimeoutMs();
2905
- const upstreamResponse = await this.fetchImpl(provider.endpoint, {
2906
- method: "POST",
2907
- headers,
2908
- body: JSON.stringify(payload),
2909
- signal: timeoutMs ? AbortSignal.timeout(timeoutMs) : void 0
2910
- });
2911
- const upstreamText = await upstreamResponse.text();
2912
- if (!upstreamResponse.ok) {
2913
- this.logger.error("[model-proxy-mcp] Anthropic-compatible upstream request failed", {
2914
- scope,
2915
- slot,
2916
- profileId: resolved.profileId,
2917
- model: resolved.model,
2918
- status: upstreamResponse.status,
2919
- body: upstreamText
2920
- });
2921
- throw new GatewayUpstreamError(upstreamText || "Anthropic-compatible upstream request failed", upstreamResponse.status, upstreamText, `${resolved.provider}/${resolved.model}`);
2922
- }
2923
- return {
2924
- response: {
2925
- status: upstreamResponse.status,
2926
- body: upstreamText,
2927
- headers: {
2928
- [RESPONSE_CONTENT_TYPE_HEADER]: upstreamResponse.headers.get(RESPONSE_CONTENT_TYPE_HEADER) ?? SSE_CONTENT_TYPE,
2929
- [RESPONSE_CACHE_CONTROL_HEADER]: upstreamResponse.headers.get(RESPONSE_CACHE_CONTROL_HEADER) ?? HEADER_NO_CACHE,
2930
- [RESPONSE_CONNECTION_HEADER]: upstreamResponse.headers.get(RESPONSE_CONNECTION_HEADER) ?? HEADER_KEEP_ALIVE,
2931
- "x-model-proxy-service": DEFAULT_SERVICE_NAME,
2932
- "x-model-proxy-scope": scope,
2933
- "x-model-proxy-slot": slot
2934
- }
2935
- },
2936
- upstreamText,
2937
- claudeBody: upstreamText
2938
- };
2939
- }
2940
- /**
2941
- * Resolves the environment-provided auth token for Anthropic-compatible upstreams.
2942
- */
2943
- getAnthropicCompatibleAuthToken(configuredEnvVar) {
2944
- const envVarName = configuredEnvVar || process.env[CONFIGURED_UPSTREAM_AUTH_ENV_NAME] || DEFAULT_UPSTREAM_AUTH_ENV;
2945
- return process.env[envVarName] || process.env[FALLBACK_UPSTREAM_AUTH_ENV] || null;
2946
- }
2947
- /**
2948
- * Resolves the request timeout for upstream Anthropic-compatible calls.
2949
- */
2950
- resolveUpstreamTimeoutMs() {
2951
- const rawTimeout = process.env[DEFAULT_API_TIMEOUT_ENV] || process.env[FALLBACK_UPSTREAM_TIMEOUT_ENV];
2952
- if (!rawTimeout) return null;
2953
- const timeoutMs = Number.parseInt(rawTimeout, 10);
2954
- return Number.isInteger(timeoutMs) && timeoutMs > 0 ? timeoutMs : null;
2955
- }
2956
- createErrorResponse(status, message) {
2957
- return {
2958
- status,
2959
- body: JSON.stringify({
2960
- type: "error",
2961
- error: {
2962
- type: API_ERROR_TYPE,
2963
- message
2964
- }
2965
- }),
2966
- headers: { "content-type": ERROR_RESPONSE_CONTENT_TYPE }
2967
- };
2968
- }
2969
- /**
2970
- * Parses and validates an inbound Claude request.
2971
- */
2972
- parseClaudeRequest(requestBody) {
2973
- try {
2974
- return CLAUDE_REQUEST_SCHEMA.parse(JSON.parse(requestBody));
2975
- } catch (error) {
2976
- if (error instanceof SyntaxError || error instanceof ZodError) throw new GatewayValidationError("Invalid Claude request payload", REQUEST_VALIDATION_FAILED, 400, { cause: error });
2977
- throw error;
2978
- }
2979
- }
2980
- /**
2981
- * Persists normalized inbound request messages for a proxied call.
2982
- */
2983
- async recordRequest(scope, slot, resolved, requestId, parsedRequest, rawBody) {
2984
- const entries = [];
2985
- const systemEntries = this.normalizeSystemPayloads(parsedRequest.system);
2986
- for (const payload of systemEntries) entries.push({
2987
- scope,
2988
- requestId,
2989
- direction: REQUEST_DIRECTION,
2990
- role: SYSTEM_ROLE,
2991
- messageType: SYSTEM_ROLE,
2992
- model: resolved.model,
2993
- slot,
2994
- payloadJson: JSON.stringify(payload)
2995
- });
2996
- const messages = Array.isArray(parsedRequest.messages) ? parsedRequest.messages : [];
2997
- for (const message of messages) {
2998
- const role = this.extractRole(message);
2999
- entries.push({
3000
- scope,
3001
- requestId,
3002
- direction: REQUEST_DIRECTION,
3003
- role,
3004
- messageType: MESSAGE_MESSAGE_TYPE,
3005
- model: resolved.model,
3006
- slot,
3007
- payloadJson: JSON.stringify(message)
3008
- });
3009
- }
3010
- if (entries.length === 0) entries.push({
3011
- scope,
3012
- requestId,
3013
- direction: REQUEST_DIRECTION,
3014
- role: null,
3015
- messageType: RAW_REQUEST_MESSAGE_TYPE,
3016
- model: resolved.model,
3017
- slot,
3018
- payloadJson: rawBody
3019
- });
3020
- await this.historyService.appendEntries(entries);
3021
- }
3022
- /**
3023
- * Persists normalized upstream response items or a stream fallback.
3024
- */
3025
- async recordResponse(scope, slot, resolved, requestId, upstreamText, claudeBody) {
3026
- const parsedEntries = this.normalizeResponsePayloads(scope, requestId, slot, resolved.model, upstreamText);
3027
- if (parsedEntries.length > 0) {
3028
- await this.historyService.appendEntries(parsedEntries);
3029
- return;
3030
- }
3031
- await this.historyService.appendEntries([{
3032
- scope,
3033
- requestId,
3034
- direction: RESPONSE_DIRECTION,
3035
- role: ASSISTANT_ROLE,
3036
- messageType: RESPONSE_STREAM_MESSAGE_TYPE,
3037
- model: resolved.model,
3038
- slot,
3039
- payloadJson: JSON.stringify({
3040
- upstreamText,
3041
- claudeBody
3042
- })
3043
- }]);
3044
- }
3045
- /**
3046
- * Persists a redacted error record for the request lifecycle.
3047
- */
3048
- async recordError(scope, slot, resolved, requestId, message, payload) {
3049
- await this.historyService.appendEntries([{
3050
- scope,
3051
- requestId,
3052
- direction: ERROR_DIRECTION,
3053
- role: null,
3054
- messageType: ERROR_MESSAGE_TYPE,
3055
- model: resolved?.model ?? null,
3056
- slot,
3057
- payloadJson: JSON.stringify({
3058
- message,
3059
- payloadPreview: this.summarizePayload(payload)
3060
- })
3061
- }]);
3062
- }
3063
- /**
3064
- * Normalizes Claude system payloads into storable entries.
3065
- */
3066
- normalizeSystemPayloads(system) {
3067
- if (!system) return [];
3068
- if (typeof system === "string") return [{
3069
- type: "text",
3070
- text: system
3071
- }];
3072
- if (Array.isArray(system)) return system;
3073
- return [system];
3074
- }
3075
- /**
3076
- * Removes proxy-private metadata before sending Claude payloads to strict Anthropic-compatible upstreams.
3077
- */
3078
- sanitizeClaudePayloadForAnthropic(payload) {
3079
- return this.stripPrivateChatGptMetadata(payload);
3080
- }
3081
- stripPrivateChatGptMetadata(value) {
3082
- if (Array.isArray(value)) return value.map((item) => this.stripPrivateChatGptMetadata(item));
3083
- if (!value || typeof value !== "object") return value;
3084
- return Object.fromEntries(Object.entries(value).filter(([key]) => !key.startsWith("_chatgpt_")).map(([key, nestedValue]) => [key, this.stripPrivateChatGptMetadata(nestedValue)]));
3085
- }
3086
- /**
3087
- * Extracts a message role string when present.
3088
- */
3089
- extractRole(message) {
3090
- if (!message || typeof message !== "object") return null;
3091
- const candidate = message.role;
3092
- return typeof candidate === "string" ? candidate : null;
3093
- }
3094
- /**
3095
- * Extracts response output items from upstream SSE payloads for storage.
3096
- */
3097
- normalizeResponsePayloads(scope, requestId, slot, model, upstreamText) {
3098
- const entries = [];
3099
- for (const event of upstreamText.split("\n\n")) {
3100
- const dataLine = event.split("\n").find((line) => line.startsWith("data:"))?.slice(5).trim();
3101
- if (!dataLine || dataLine === "[DONE]") continue;
3102
- let payload;
3103
- try {
3104
- payload = JSON.parse(dataLine);
3105
- } catch {
3106
- continue;
3107
- }
3108
- const response = this.extractCompletedResponse(payload);
3109
- if (!response?.output || !Array.isArray(response.output)) continue;
3110
- for (const output of response.output) entries.push({
3111
- scope,
3112
- requestId,
3113
- direction: RESPONSE_DIRECTION,
3114
- role: ASSISTANT_ROLE,
3115
- messageType: typeof output.type === "string" ? output.type : RESPONSE_ITEM_MESSAGE_TYPE,
3116
- model: typeof response.model === "string" ? response.model : model,
3117
- slot,
3118
- payloadJson: JSON.stringify(output)
3119
- });
3120
- }
3121
- return entries;
3122
- }
3123
- /**
3124
- * Extracts the completed response envelope from an upstream SSE event payload.
3125
- */
3126
- extractCompletedResponse(payload) {
3127
- if (!payload || typeof payload !== "object") return null;
3128
- const candidate = payload;
3129
- if (candidate.type === "response.completed" && candidate.response && typeof candidate.response === "object") return candidate.response;
3130
- if (candidate.response && typeof candidate.response === "object") return candidate.response;
3131
- return {
3132
- model: typeof candidate.model === "string" ? candidate.model : void 0,
3133
- output: Array.isArray(candidate.output) ? candidate.output : void 0
3134
- };
3135
- }
3136
- /**
3137
- * Redacts large request bodies before they are stored in error history.
3138
- */
3139
- summarizePayload(payload) {
3140
- const compact = payload.replace(/\s+/g, " ").trim();
3141
- return compact.length <= REQUEST_PAYLOAD_PREVIEW_LIMIT ? compact : `${compact.slice(0, REQUEST_PAYLOAD_PREVIEW_LIMIT)}...`;
3142
- }
3143
- };
3144
-
3145
- //#endregion
3146
- //#region src/server/adminPage.ts
3147
- const ADMIN_PAGE_HTML = `<!DOCTYPE html>
3148
- <html lang="en">
3149
- <head>
3150
- <meta charset="utf-8" />
3151
- <meta name="viewport" content="width=device-width, initial-scale=1" />
3152
- <title>Model Proxy Admin</title>
3153
- <style>
3154
- :root {
3155
- color-scheme: light;
3156
- --bg: #f3efe6;
3157
- --panel: rgba(255, 252, 246, 0.92);
3158
- --panel-border: #d7cdb7;
3159
- --text: #1f1b16;
3160
- --muted: #6b6255;
3161
- --accent: #b24a2f;
3162
- --accent-2: #1b6b73;
3163
- --danger: #8f2d21;
3164
- }
3165
- * { box-sizing: border-box; }
3166
- body {
3167
- margin: 0;
3168
- font-family: "Iowan Old Style", "Palatino Linotype", serif;
3169
- color: var(--text);
3170
- background:
3171
- radial-gradient(circle at top left, rgba(178, 74, 47, 0.14), transparent 28%),
3172
- radial-gradient(circle at top right, rgba(27, 107, 115, 0.12), transparent 24%),
3173
- linear-gradient(180deg, #f8f4eb 0%, var(--bg) 100%);
3174
- }
3175
- main {
3176
- max-width: 1280px;
3177
- margin: 0 auto;
3178
- padding: 32px 20px 48px;
3179
- }
3180
- h1, h2 {
3181
- margin: 0 0 12px;
3182
- font-weight: 700;
3183
- }
3184
- p { color: var(--muted); margin-top: 0; }
3185
- .hero {
3186
- display: grid;
3187
- gap: 16px;
3188
- margin-bottom: 24px;
3189
- }
3190
- .panel {
3191
- background: var(--panel);
3192
- border: 1px solid var(--panel-border);
3193
- border-radius: 18px;
3194
- padding: 18px;
3195
- box-shadow: 0 14px 40px rgba(80, 58, 24, 0.08);
3196
- }
3197
- .row {
3198
- display: grid;
3199
- gap: 12px;
3200
- }
3201
- .row.cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
3202
- .row.cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
3203
- .toolbar {
3204
- display: flex;
3205
- flex-wrap: wrap;
3206
- gap: 10px;
3207
- align-items: end;
3208
- }
3209
- label {
3210
- display: grid;
3211
- gap: 6px;
3212
- font-size: 13px;
3213
- color: var(--muted);
3214
- }
3215
- input, select, button, textarea {
3216
- font: inherit;
3217
- border-radius: 10px;
3218
- border: 1px solid #c9bea7;
3219
- padding: 10px 12px;
3220
- background: #fffdf8;
3221
- color: var(--text);
3222
- }
3223
- button {
3224
- background: var(--text);
3225
- color: #fff7ea;
3226
- border-color: var(--text);
3227
- cursor: pointer;
3228
- }
3229
- button.secondary {
3230
- background: #fff7ea;
3231
- color: var(--text);
3232
- }
3233
- button.danger {
3234
- background: var(--danger);
3235
- border-color: var(--danger);
3236
- }
3237
- table {
3238
- width: 100%;
3239
- border-collapse: collapse;
3240
- }
3241
- th, td {
3242
- padding: 10px 8px;
3243
- text-align: left;
3244
- border-bottom: 1px solid rgba(107, 98, 85, 0.16);
3245
- vertical-align: top;
3246
- }
3247
- th {
3248
- font-size: 12px;
3249
- text-transform: uppercase;
3250
- letter-spacing: 0.08em;
3251
- color: var(--muted);
3252
- }
3253
- code, pre {
3254
- font-family: "SFMono-Regular", "Menlo", monospace;
3255
- font-size: 12px;
3256
- }
3257
- pre {
3258
- margin: 0;
3259
- white-space: pre-wrap;
3260
- word-break: break-word;
3261
- max-height: 180px;
3262
- overflow: auto;
3263
- }
3264
- .status {
3265
- min-height: 20px;
3266
- color: var(--accent-2);
3267
- }
3268
- .muted { color: var(--muted); }
3269
- .slot-grid {
3270
- display: grid;
3271
- gap: 12px;
3272
- }
3273
- .slot-card {
3274
- padding: 14px;
3275
- border-radius: 14px;
3276
- border: 1px solid rgba(107, 98, 85, 0.16);
3277
- background: rgba(255, 255, 255, 0.7);
3278
- }
3279
- .slot-card h3 {
3280
- margin: 0 0 10px;
3281
- font-size: 18px;
3282
- }
3283
- .pill {
3284
- display: inline-flex;
3285
- padding: 4px 8px;
3286
- border-radius: 999px;
3287
- background: rgba(27, 107, 115, 0.1);
3288
- color: var(--accent-2);
3289
- font-size: 12px;
3290
- }
3291
- .paths {
3292
- display: grid;
3293
- gap: 8px;
3294
- }
3295
- @media (max-width: 900px) {
3296
- .row.cols-2, .row.cols-3 { grid-template-columns: 1fr; }
3297
- }
3298
- </style>
3299
- </head>
3300
- <body>
3301
- <main>
3302
- <section class="hero">
3303
- <div>
3304
- <h1>Model Proxy Admin</h1>
3305
- <p>Scoped slot routing, provider-aware model selection, and conversation history.</p>
3306
- </div>
3307
- <div class="panel toolbar">
3308
- <label>
3309
- Scope
3310
- <select id="scope-input"></select>
3311
- </label>
3312
- <button id="load-button" class="secondary">Load scope</button>
3313
- <span id="status" class="status"></span>
3314
- </div>
3315
- </section>
3316
-
3317
- <section class="panel">
3318
- <h2>Runtime Config</h2>
3319
- <div class="paths muted">
3320
- <div>Providers: <code id="provider-config-path"></code></div>
3321
- <div>Model catalog: <code id="model-list-path"></code></div>
3322
- <div>Scope config: <code id="scope-config-path"></code></div>
3323
- </div>
3324
- <div id="slot-grid" class="slot-grid" style="margin-top:16px;"></div>
3325
- <div class="toolbar" style="margin-top:16px;">
3326
- <button id="save-config">Save config</button>
3327
- </div>
3328
- </section>
3329
-
3330
- <section class="panel" style="margin-top:20px;">
3331
- <h2>Model Catalog</h2>
3332
- <div id="catalog" class="row"></div>
3333
- </section>
3334
-
3335
- <section class="panel" style="margin-top:20px;">
3336
- <div class="toolbar" style="justify-content:space-between;">
3337
- <div>
3338
- <h2 style="margin-bottom:4px;">Conversation History</h2>
3339
- <p id="history-stats"></p>
3340
- </div>
3341
- <div class="toolbar">
3342
- <label>
3343
- Limit
3344
- <select id="history-limit">
3345
- <option>25</option>
3346
- <option selected>50</option>
3347
- <option>100</option>
3348
- </select>
3349
- </label>
3350
- <button id="refresh-history" class="secondary">Refresh history</button>
3351
- <button id="clear-history" class="danger">Clear history</button>
3352
- </div>
3353
- </div>
3354
- <div style="overflow:auto;">
3355
- <table>
3356
- <thead>
3357
- <tr>
3358
- <th>Created</th>
3359
- <th>Direction</th>
3360
- <th>Role</th>
3361
- <th>Slot</th>
3362
- <th>Model</th>
3363
- <th>Payload</th>
3364
- </tr>
3365
- </thead>
3366
- <tbody id="history-body"></tbody>
3367
- </table>
3368
- </div>
3369
- <div class="toolbar" style="margin-top:16px;">
3370
- <button id="more-history" class="secondary">Load more</button>
3371
- </div>
3372
- </section>
3373
- </main>
3374
-
3375
- <script>
3376
- const reasoningOptions = ['minimal', 'low', 'medium', 'high'];
3377
- const slots = ['default', 'sonnet', 'opus', 'haiku', 'subagent'];
3378
- let currentConfig = null;
3379
- let nextCursor = null;
3380
-
3381
- const scopeInput = document.getElementById('scope-input');
3382
- const statusEl = document.getElementById('status');
3383
- const slotGridEl = document.getElementById('slot-grid');
3384
- const catalogEl = document.getElementById('catalog');
3385
- const providerConfigPathEl = document.getElementById('provider-config-path');
3386
- const modelListPathEl = document.getElementById('model-list-path');
3387
- const scopeConfigPathEl = document.getElementById('scope-config-path');
3388
- const historyBodyEl = document.getElementById('history-body');
3389
- const historyStatsEl = document.getElementById('history-stats');
3390
- const historyLimitEl = document.getElementById('history-limit');
3391
-
3392
- function setStatus(message, isError = false) {
3393
- statusEl.textContent = message;
3394
- statusEl.style.color = isError ? 'var(--danger)' : 'var(--accent-2)';
3395
- }
3396
-
3397
- function renderScopeOptions(scopes) {
3398
- const currentScope = scopeInput.value || 'default';
3399
- const scopeList = Array.from(new Set(['default'].concat(scopes || []))).sort();
3400
- scopeInput.innerHTML = scopeList
3401
- .map((scope) => '<option value="' + scope + '">' + scope + '</option>')
3402
- .join('');
3403
- scopeInput.value = scopeList.includes(currentScope) ? currentScope : 'default';
3404
- }
3405
-
3406
- function providerOptions(providers, selected) {
3407
- return Object.entries(providers)
3408
- .sort((left, right) => left[0].localeCompare(right[0]))
3409
- .map(([id, config]) => {
3410
- const isSelected = id === selected ? ' selected' : '';
3411
- return '<option value="' + id + '"' + isSelected + '>' + id + ' (' + config.type + ')</option>';
3412
- })
3413
- .join('');
3414
- }
3415
-
3416
- function modelOptions(models, provider, selected) {
3417
- return models
3418
- .filter((model) => model.enabled && model.provider === provider)
3419
- .map((model) => {
3420
- const isSelected = model.model === selected ? ' selected' : '';
3421
- return '<option value="' + model.model + '"' + isSelected + '>' + model.label + ' (' + model.model + ')</option>';
3422
- })
3423
- .join('');
3424
- }
3425
-
3426
- function reasoningSelect(value, slot) {
3427
- return (
3428
- '<select data-slot-reasoning="' +
3429
- slot +
3430
- '">' +
3431
- reasoningOptions
3432
- .map((option) => {
3433
- const selected = option === value ? ' selected' : '';
3434
- return '<option value="' + option + '"' + selected + '>' + option + '</option>';
3435
- })
3436
- .join('') +
3437
- '</select>'
3438
- );
3439
- }
3440
-
3441
- function refreshSlotModelOptions(slot) {
3442
- if (!currentConfig) {
3443
- return;
3444
- }
3445
-
3446
- const providerSelect = document.querySelector('[data-slot-provider="' + slot + '"]');
3447
- const modelSelect = document.querySelector('[data-slot-model="' + slot + '"]');
3448
- if (!providerSelect || !modelSelect) {
3449
- return;
3450
- }
3451
-
3452
- const resolved = currentConfig.slots[slot];
3453
- const provider = providerSelect.value;
3454
- modelSelect.innerHTML = modelOptions(currentConfig.models, provider, resolved.model);
3455
-
3456
- if (!modelSelect.value) {
3457
- const fallbackModel = currentConfig.models.find((model) => model.enabled && model.provider === provider);
3458
- if (fallbackModel) {
3459
- modelSelect.value = fallbackModel.model;
3460
- }
3461
- }
3462
-
3463
- const endpointEl = document.querySelector('[data-slot-endpoint="' + slot + '"]');
3464
- if (endpointEl) {
3465
- endpointEl.textContent = currentConfig.providers[provider]?.endpoint || 'No provider config';
3466
- }
3467
- }
3468
-
3469
- function renderConfig(config) {
3470
- currentConfig = config;
3471
- providerConfigPathEl.textContent = config.providerConfigPath;
3472
- modelListPathEl.textContent = config.modelListPath;
3473
- scopeConfigPathEl.textContent = config.scopeConfigPath;
3474
-
3475
- slotGridEl.innerHTML = slots.map((slot) => {
3476
- const resolved = config.slots[slot];
3477
- return (
3478
- '<div class="slot-card">' +
3479
- '<div class="toolbar" style="justify-content:space-between;">' +
3480
- '<h3>' + slot + '</h3>' +
3481
- '<span class="pill">' +
3482
- (resolved.providerType ?? 'unconfigured') +
3483
- (resolved.fallbacks.length > 0 ? ' · +' + resolved.fallbacks.length + ' fallback' + (resolved.fallbacks.length > 1 ? 's' : '') : '') +
3484
- '</span>' +
3485
- '</div>' +
3486
- '<div class="row cols-3">' +
3487
- '<label>Provider<select data-slot-provider="' + slot + '">' +
3488
- providerOptions(config.providers, resolved.provider || '') +
3489
- '</select></label>' +
3490
- '<label>Model<select data-slot-model="' + slot + '"></select></label>' +
3491
- '<label>Thinking' + reasoningSelect(resolved.reasoningEffort, slot) + '</label>' +
3492
- '</div>' +
3493
- '<div class="muted" style="margin-top:10px;">Endpoint: <code data-slot-endpoint="' + slot + '"></code></div>' +
3494
- '</div>'
3495
- );
3496
- }).join('');
3497
-
3498
- for (const slot of slots) {
3499
- refreshSlotModelOptions(slot);
3500
- const providerSelect = document.querySelector('[data-slot-provider="' + slot + '"]');
3501
- if (providerSelect) {
3502
- providerSelect.addEventListener('change', () => refreshSlotModelOptions(slot));
3503
- }
3504
- }
3505
-
3506
- catalogEl.innerHTML = config.models
3507
- .map(
3508
- (model) =>
3509
- '<div class="panel" style="padding:12px;">' +
3510
- '<strong>' + model.label + '</strong>' +
3511
- '<div class="muted"><code>' + model.provider + '</code> · <code>' + model.model + '</code> · default thinking: ' + model.reasoningEffort + '</div>' +
3512
- '</div>',
3513
- )
3514
- .join('');
3515
- }
3516
-
3517
- function payloadPreview(payloadJson) {
3518
- try {
3519
- return JSON.stringify(JSON.parse(payloadJson), null, 2);
3520
- } catch {
3521
- return payloadJson;
3522
- }
3523
- }
3524
-
3525
- async function loadConfig() {
3526
- const scope = encodeURIComponent(scopeInput.value || 'default');
3527
- const response = await fetch('/admin/config?scope=' + scope);
3528
- if (!response.ok) {
3529
- throw new Error(await response.text());
3530
- }
3531
- renderConfig(await response.json());
3532
- }
3533
-
3534
- async function loadScopes() {
3535
- const response = await fetch('/admin/scopes');
3536
- if (!response.ok) {
3537
- throw new Error(await response.text());
3538
- }
3539
- const payload = await response.json();
3540
- renderScopeOptions(payload.scopes || []);
3541
- }
3542
-
3543
- async function saveConfig() {
3544
- const models = {};
3545
- for (const slot of slots) {
3546
- const currentSlot = currentConfig.scopeModels[slot];
3547
- models[slot] = {
3548
- main: {
3549
- provider: document.querySelector('[data-slot-provider="' + slot + '"]')?.value,
3550
- model: document.querySelector('[data-slot-model="' + slot + '"]')?.value,
3551
- reasoningEffort: document.querySelector('[data-slot-reasoning="' + slot + '"]')?.value,
3552
- thinkingDisabled:
3553
- currentSlot?.main?.thinkingDisabled ?? currentConfig.slots[slot]?.thinkingDisabled ?? false,
3554
- },
3555
- fallbacks: currentSlot?.fallbacks ?? [],
3556
- };
3557
- }
3558
-
3559
- const response = await fetch('/admin/config?scope=' + encodeURIComponent(scopeInput.value || 'default'), {
3560
- method: 'PUT',
3561
- headers: { 'content-type': 'application/json' },
3562
- body: JSON.stringify({ models }),
3563
- });
3564
- if (!response.ok) {
3565
- throw new Error(await response.text());
3566
- }
3567
- renderConfig(await response.json());
3568
- }
3569
-
3570
- async function loadHistory(append = false) {
3571
- const scope = encodeURIComponent(scopeInput.value || 'default');
3572
- const cursorQuery = append && nextCursor ? '&cursor=' + encodeURIComponent(nextCursor) : '';
3573
- const limit = encodeURIComponent(historyLimitEl.value);
3574
- const response = await fetch('/admin/history?scope=' + scope + '&limit=' + limit + cursorQuery);
3575
- if (!response.ok) {
3576
- throw new Error(await response.text());
3577
- }
3578
- const payload = await response.json();
3579
- nextCursor = payload.nextCursor;
3580
- const rows = payload.items
3581
- .map(
3582
- (item) =>
3583
- '<tr>' +
3584
- '<td>' + item.createdAt + '</td>' +
3585
- '<td>' + item.direction + '</td>' +
3586
- '<td>' + (item.role ?? '') + '</td>' +
3587
- '<td>' + item.slot + '</td>' +
3588
- '<td>' + (item.model ?? '') + '</td>' +
3589
- '<td><pre>' + payloadPreview(item.payloadJson) + '</pre></td>' +
3590
- '</tr>',
3591
- )
3592
- .join('');
3593
- historyBodyEl.innerHTML = append ? historyBodyEl.innerHTML + rows : rows;
3594
- document.getElementById('more-history').disabled = !nextCursor;
3595
-
3596
- const statsResponse = await fetch('/admin/history/stats?scope=' + scope);
3597
- const stats = await statsResponse.json();
3598
- historyStatsEl.textContent = stats.totalMessages + ' stored messages. Retention limit ' + stats.retentionLimit + '.';
3599
- }
3600
-
3601
- async function clearHistory() {
3602
- const scope = encodeURIComponent(scopeInput.value || 'default');
3603
- const response = await fetch('/admin/history?scope=' + scope, { method: 'DELETE' });
3604
- if (!response.ok) {
3605
- throw new Error(await response.text());
3606
- }
3607
- nextCursor = null;
3608
- historyBodyEl.innerHTML = '';
3609
- await loadHistory();
3610
- }
3611
-
3612
- document.getElementById('load-button').addEventListener('click', async () => {
3613
- try {
3614
- setStatus('Loading...');
3615
- nextCursor = null;
3616
- await loadConfig();
3617
- await loadHistory();
3618
- setStatus('Scope loaded.');
3619
- } catch (error) {
3620
- setStatus(error.message, true);
3621
- }
3622
- });
3623
-
3624
- document.getElementById('save-config').addEventListener('click', async () => {
3625
- try {
3626
- setStatus('Saving config...');
3627
- await saveConfig();
3628
- setStatus('Config saved.');
3629
- } catch (error) {
3630
- setStatus(error.message, true);
3631
- }
3632
- });
3633
-
3634
- document.getElementById('refresh-history').addEventListener('click', async () => {
3635
- try {
3636
- setStatus('Refreshing history...');
3637
- nextCursor = null;
3638
- await loadHistory();
3639
- setStatus('History refreshed.');
3640
- } catch (error) {
3641
- setStatus(error.message, true);
3642
- }
3643
- });
3644
-
3645
- document.getElementById('clear-history').addEventListener('click', async () => {
3646
- try {
3647
- setStatus('Clearing history...');
3648
- await clearHistory();
3649
- setStatus('History cleared.');
3650
- } catch (error) {
3651
- setStatus(error.message, true);
3652
- }
3653
- });
3654
-
3655
- document.getElementById('more-history').addEventListener('click', async () => {
3656
- try {
3657
- setStatus('Loading more history...');
3658
- await loadHistory(true);
3659
- setStatus('More history loaded.');
3660
- } catch (error) {
3661
- setStatus(error.message, true);
3662
- }
3663
- });
3664
-
3665
- document.addEventListener('DOMContentLoaded', async () => {
3666
- try {
3667
- await loadScopes();
3668
- await loadConfig();
3669
- await loadHistory();
3670
- setStatus('Ready.');
3671
- } catch (error) {
3672
- setStatus(error.message, true);
3673
- }
3674
- });
3675
- <\/script>
3676
- </body>
3677
- </html>
3678
- `;
3679
-
3680
- //#endregion
3681
- //#region src/server/http.ts
3682
- const reasoningEffortSchema = z.enum([
3683
- "minimal",
3684
- "low",
3685
- "medium",
3686
- "high"
3687
- ]);
3688
- const modelSlotSchema$1 = z.enum([
3689
- "default",
3690
- "sonnet",
3691
- "opus",
3692
- "haiku",
3693
- "subagent"
3694
- ]);
3695
- const selectionSchema = z.object({
3696
- provider: z.string().min(1),
3697
- model: z.string().min(1),
3698
- reasoningEffort: reasoningEffortSchema,
3699
- thinkingDisabled: z.boolean().optional()
3700
- });
3701
- const slotConfigSchema = z.object({
3702
- main: selectionSchema,
3703
- fallbacks: z.array(selectionSchema).default([])
3704
- });
3705
- const adminConfigUpdateSchema = z.object({ models: z.object({
3706
- default: slotConfigSchema.nullable().optional(),
3707
- sonnet: slotConfigSchema.nullable().optional(),
3708
- opus: slotConfigSchema.nullable().optional(),
3709
- haiku: slotConfigSchema.nullable().optional(),
3710
- subagent: slotConfigSchema.nullable().optional()
3711
- }).optional() });
3712
- const profileSchema = z.object({
3713
- id: z.string().min(1),
3714
- label: z.string().min(1),
3715
- provider: z.string().min(1),
3716
- model: z.string().min(1),
3717
- endpoint: z.url().nullable(),
3718
- reasoningEffort: reasoningEffortSchema.default("medium"),
3719
- enabled: z.boolean().default(true),
3720
- providerType: z.enum([
3721
- "chatgpt-codex",
3722
- "anthropic-compatible",
3723
- "gemini-direct"
3724
- ]).nullable().optional()
3725
- });
3726
- function createHttpServer(gatewayService = new GatewayService()) {
3727
- const app = new Hono();
3728
- const getScope = (scope) => scope || "default";
3729
- app.get("/health", (c) => c.json({
3730
- status: "healthy",
3731
- service: DEFAULT_SERVICE_NAME
3732
- }));
3733
- app.get("/status", async (c) => {
3734
- const status = await gatewayService.getStatus();
3735
- return c.json(status);
3736
- });
3737
- app.get("/v1/models", async (c) => c.json(await gatewayService.getModels()));
3738
- app.get("/scopes/:scope/status", async (c) => c.json(await gatewayService.getStatus(getScope(c.req.param("scope")))));
3739
- app.get("/scopes/:scope/v1/models", async (c) => c.json(await gatewayService.getModels(getScope(c.req.param("scope")))));
3740
- app.post("/v1/messages", async (c) => {
3741
- const body = await c.req.text();
3742
- const response = await gatewayService.forwardClaudeRequest(body, "default", c.req.raw.headers);
3743
- return new Response(response.body, {
3744
- status: response.status,
3745
- headers: response.headers
3746
- });
3747
- });
3748
- app.post("/scopes/:scope/v1/messages", async (c) => {
3749
- const body = await c.req.text();
3750
- const response = await gatewayService.forwardClaudeRequest(body, getScope(c.req.param("scope")), c.req.raw.headers);
3751
- return new Response(response.body, {
3752
- status: response.status,
3753
- headers: response.headers
3754
- });
3755
- });
3756
- app.get("/admin", () => new Response(ADMIN_PAGE_HTML, { headers: { "content-type": "text/html; charset=utf-8" } }));
3757
- app.get("/admin/scopes", async (c) => c.json({ scopes: await gatewayService.listScopes() }));
3758
- app.get("/admin/config", async (c) => {
3759
- const scope = getScope(c.req.query("scope"));
3760
- return c.json(await gatewayService.getAdminConfig(scope));
3761
- });
3762
- app.get("/admin/profiles", async (c) => {
3763
- const scope = getScope(c.req.query("scope"));
3764
- return c.json({ profiles: await gatewayService.listProfiles(scope) });
3765
- });
3766
- app.get("/admin/current-model", async (c) => {
3767
- const scope = getScope(c.req.query("scope"));
3768
- const slot = c.req.query("slot") || "default";
3769
- return c.json({ profile: await gatewayService.getCurrentModel(scope, slot) });
3770
- });
3771
- app.get("/admin/history", async (c) => {
3772
- const scope = getScope(c.req.query("scope"));
3773
- const limit = z.coerce.number().int().positive().max(200).default(50).parse(c.req.query("limit") ?? 50);
3774
- const cursor = c.req.query("cursor") || void 0;
3775
- return c.json(await gatewayService.listHistory(scope, limit, cursor));
3776
- });
3777
- app.get("/admin/history/stats", async (c) => {
3778
- const scope = getScope(c.req.query("scope"));
3779
- return c.json(await gatewayService.getHistoryStats(scope));
3780
- });
3781
- app.put("/admin/profiles/:id", async (c) => {
3782
- const payload = profileSchema.parse(await c.req.json());
3783
- const scope = getScope(c.req.query("scope"));
3784
- const result = await gatewayService.upsertProfile({
3785
- ...payload,
3786
- id: c.req.param("id"),
3787
- providerType: payload.providerType ?? null
3788
- }, scope);
3789
- return c.json(result);
3790
- });
3791
- app.put("/admin/active-profile", async (c) => {
3792
- const scope = getScope(c.req.query("scope"));
3793
- const payload = z.object({
3794
- profileId: z.string().min(1),
3795
- slot: modelSlotSchema$1.default("default")
3796
- }).parse(await c.req.json());
3797
- const result = await gatewayService.setActiveProfile(payload.profileId, scope, payload.slot);
3798
- return c.json(result);
3799
- });
3800
- app.put("/admin/current-model", async (c) => {
3801
- const scope = getScope(c.req.query("scope"));
3802
- const payload = z.object({
3803
- model: z.string().min(1),
3804
- slot: modelSlotSchema$1.default("default")
3805
- }).parse(await c.req.json());
3806
- const result = await gatewayService.switchModel(payload.model, scope, payload.slot);
3807
- return c.json(result);
3808
- });
3809
- app.put("/admin/config", async (c) => {
3810
- const scope = getScope(c.req.query("scope"));
3811
- const payload = adminConfigUpdateSchema.parse(await c.req.json());
3812
- return c.json(await gatewayService.updateAdminConfig(payload, scope));
3813
- });
3814
- app.delete("/admin/history", async (c) => {
3815
- const scope = getScope(c.req.query("scope"));
3816
- return c.json(await gatewayService.clearHistory(scope));
3817
- });
3818
- return app;
3819
- }
3820
-
3821
- //#endregion
3822
- //#region src/server/index.ts
3823
- const DEFAULT_SCOPE = "default";
3824
- const DEFAULT_SLOT = "default";
3825
- const MODEL_PROXY_SCOPE_ENV = "MODEL_PROXY_MCP_SCOPE";
3826
- const MODEL_PROXY_SLOT_ENV = "MODEL_PROXY_MCP_SLOT";
3827
- const modelSlotSchema = z.enum([
3828
- "default",
3829
- "sonnet",
3830
- "opus",
3831
- "haiku",
3832
- "subagent"
3833
- ]);
3834
- function createServer(gatewayService = new GatewayService(), environment = process.env) {
3835
- const resolveScope = (args) => {
3836
- const scope = args?.scope;
3837
- return typeof scope === "string" && scope.trim() ? scope : environment[MODEL_PROXY_SCOPE_ENV] || DEFAULT_SCOPE;
3838
- };
3839
- const resolveSlot = (args) => {
3840
- const slot = typeof args?.slot === "string" ? args.slot : environment[MODEL_PROXY_SLOT_ENV] || DEFAULT_SLOT;
3841
- return modelSlotSchema.parse(slot);
3842
- };
3843
- const server = new Server({
3844
- name: "model-proxy-mcp",
3845
- version: "0.1.0"
3846
- }, { capabilities: { tools: {} } });
3847
- server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [
3848
- {
3849
- name: "get_proxy_status",
3850
- description: "Get the HTTP proxy status, auth status, and active profile.",
3851
- inputSchema: {
3852
- type: "object",
3853
- properties: { scope: { type: "string" } },
3854
- additionalProperties: false
3855
- }
3856
- },
3857
- {
3858
- name: "list_profiles",
3859
- description: "List all configured model proxy profiles.",
3860
- inputSchema: {
3861
- type: "object",
3862
- properties: { scope: { type: "string" } },
3863
- additionalProperties: false
3864
- }
3865
- },
3866
- {
3867
- name: "get_active_profile",
3868
- description: "Get the active model proxy profile.",
3869
- inputSchema: {
3870
- type: "object",
3871
- properties: {
3872
- scope: { type: "string" },
3873
- slot: {
3874
- type: "string",
3875
- enum: modelSlotSchema.options
3876
- }
3877
- },
3878
- additionalProperties: false
3879
- }
3880
- },
3881
- {
3882
- name: "get_current_model",
3883
- description: "Get the currently active model and profile settings.",
3884
- inputSchema: {
3885
- type: "object",
3886
- properties: {
3887
- scope: { type: "string" },
3888
- slot: {
3889
- type: "string",
3890
- enum: modelSlotSchema.options
3891
- }
3892
- },
3893
- additionalProperties: false
3894
- }
3895
- },
3896
- {
3897
- name: "set_model_target",
3898
- description: "Set the scoped slot main target directly by provider, model, and reasoning effort.",
3899
- inputSchema: {
3900
- type: "object",
3901
- properties: {
3902
- provider: { type: "string" },
3903
- model: { type: "string" },
3904
- reasoningEffort: {
3905
- type: "string",
3906
- enum: [
3907
- "minimal",
3908
- "low",
3909
- "medium",
3910
- "high"
3911
- ]
3912
- },
3913
- scope: { type: "string" },
3914
- slot: {
3915
- type: "string",
3916
- enum: modelSlotSchema.options
3917
- },
3918
- thinkingDisabled: { type: "boolean" }
3919
- },
3920
- required: [
3921
- "provider",
3922
- "model",
3923
- "reasoningEffort"
3924
- ],
3925
- additionalProperties: false
3926
- }
3927
- },
3928
- {
3929
- name: "upsert_profile",
3930
- description: "Create or update a Codex model profile.",
3931
- inputSchema: {
3932
- type: "object",
3933
- properties: {
3934
- id: { type: "string" },
3935
- label: { type: "string" },
3936
- provider: { type: "string" },
3937
- model: { type: "string" },
3938
- endpoint: { type: "string" },
3939
- reasoningEffort: {
3940
- type: "string",
3941
- enum: [
3942
- "minimal",
3943
- "low",
3944
- "medium",
3945
- "high"
3946
- ]
3947
- },
3948
- enabled: { type: "boolean" },
3949
- scope: { type: "string" }
3950
- },
3951
- required: [
3952
- "id",
3953
- "label",
3954
- "model",
3955
- "endpoint",
3956
- "reasoningEffort",
3957
- "enabled"
3958
- ],
3959
- additionalProperties: false
3960
- }
3961
- }
3962
- ] }));
3963
- server.setRequestHandler(CallToolRequestSchema, async (request) => {
3964
- const { name, arguments: args } = request.params;
3965
- try {
3966
- switch (name) {
3967
- case "get_proxy_status": return textResult(JSON.stringify(await gatewayService.getStatus(resolveScope(args)), null, 2));
3968
- case "list_profiles": return textResult(JSON.stringify(await gatewayService.listProfiles(resolveScope(args)), null, 2));
3969
- case "get_active_profile": return textResult(JSON.stringify(await gatewayService.getActiveProfile(resolveScope(args), resolveSlot(args)), null, 2));
3970
- case "get_current_model": return textResult(JSON.stringify(await gatewayService.getCurrentModel(resolveScope(args), resolveSlot(args)), null, 2));
3971
- case "set_model_target": {
3972
- const scope = resolveScope(args);
3973
- const slot = resolveSlot(args);
3974
- const existingConfig = await gatewayService.getAdminConfig(scope);
3975
- return textResult(JSON.stringify(await gatewayService.updateAdminConfig({ models: { [slot]: {
3976
- main: {
3977
- provider: String(args?.provider),
3978
- model: String(args?.model),
3979
- reasoningEffort: z.enum([
3980
- "minimal",
3981
- "low",
3982
- "medium",
3983
- "high"
3984
- ]).parse(String(args?.reasoningEffort)),
3985
- thinkingDisabled: args?.thinkingDisabled === void 0 ? false : Boolean(args.thinkingDisabled)
3986
- },
3987
- fallbacks: existingConfig.scopeModels[slot]?.fallbacks ?? []
3988
- } } }, scope), null, 2));
3989
- }
3990
- case "upsert_profile": return textResult(JSON.stringify(await gatewayService.upsertProfile({
3991
- id: String(args?.id),
3992
- label: String(args?.label),
3993
- provider: String(args?.provider || "chatgpt-codex"),
3994
- providerType: null,
3995
- model: String(args?.model),
3996
- endpoint: String(args?.endpoint),
3997
- reasoningEffort: String(args?.reasoningEffort),
3998
- enabled: Boolean(args?.enabled)
3999
- }, resolveScope(args)), null, 2));
4000
- default: throw new Error(`Unknown tool: ${name}`);
4001
- }
4002
- } catch (error) {
4003
- return {
4004
- content: [{
4005
- type: "text",
4006
- text: `Error executing tool ${name}: ${error instanceof Error ? error.message : String(error)}`
4007
- }],
4008
- isError: true
4009
- };
4010
- }
4011
- });
4012
- return server;
4013
- }
4014
- function textResult(text) {
4015
- return { content: [{
4016
- type: "text",
4017
- text
4018
- }] };
4019
- }
4020
-
4021
- //#endregion
4022
- //#region src/transports/stdio.ts
4023
- var StdioTransportHandler = class {
4024
- transport = null;
4025
- constructor(server) {
4026
- this.server = server;
4027
- }
4028
- async start() {
4029
- this.transport = new StdioServerTransport();
4030
- await this.server.connect(this.transport);
4031
- }
4032
- async stop() {
4033
- if (this.transport) {
4034
- await this.transport.close();
4035
- this.transport = null;
4036
- }
4037
- }
4038
- };
4039
-
4040
- //#endregion
4041
- export { ConversationHistoryService as a, DEFAULT_HTTP_PORT as c, GatewayService as i, DEFAULT_SERVICE_NAME as l, createServer as n, ProfileStore as o, createHttpServer as r, consoleLogger as s, StdioTransportHandler as t };