@heventure/model-provider-x 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -24,7 +24,7 @@ Or run the published package directly:
24
24
  npx @heventure/model-provider-x --help
25
25
  ```
26
26
 
27
- ## OpenCode Setup
27
+ ## Unified Setup
28
28
 
29
29
  Run the TUI wizard:
30
30
 
@@ -32,7 +32,17 @@ Run the TUI wizard:
32
32
  node dist/cli/index.js
33
33
  ```
34
34
 
35
- Or print a config fragment without writing files:
35
+ The wizard asks for:
36
+
37
+ 1. Provider.
38
+ 2. Models.
39
+ 3. Agent platform: OpenCode, Codex, or Claude Code.
40
+ 4. Direct or proxy mode, with a recommendation based on provider API support.
41
+ 5. Any platform-specific install target.
42
+
43
+ ## OpenCode Setup
44
+
45
+ Print a config fragment without writing files:
36
46
 
37
47
  ```bash
38
48
  node dist/cli/index.js \
@@ -43,6 +53,21 @@ node dist/cli/index.js \
43
53
  --print
44
54
  ```
45
55
 
56
+ OpenCode provider entries include `options.setCacheKey=true` by default.
57
+ This lets OpenCode pass a stable cache key through the OpenAI-compatible provider path so relays or local providers that support prompt caching can route repeated context to the same cache.
58
+
59
+ When targeting OpenCode, choose the direct API type interactively or pass `--opencode-api`:
60
+
61
+ ```bash
62
+ node dist/cli/index.js setup --target opencode --provider lmstudio --direct --opencode-api responses
63
+ ```
64
+
65
+ Supported values are:
66
+
67
+ - `chat`: writes `npm: "@ai-sdk/openai-compatible"` for `/v1/chat/completions`.
68
+ - `responses`: writes `npm: "@ai-sdk/openai"` for `/v1/responses`.
69
+ - `messages`: writes `npm: "@ai-sdk/anthropic"` for Anthropic-compatible `/v1/messages`.
70
+
46
71
  ## Claude Code Setup
47
72
 
48
73
  Create or update a provider profile and write Claude Code user settings:
@@ -61,6 +86,9 @@ node dist/cli/index.js proxy up --profile unsloth
61
86
  ```
62
87
 
63
88
  The Claude Code setup writes gateway environment values to `~/.claude/settings.json`.
89
+ Claude Code appends `/v1/messages` itself, so direct Anthropic-compatible provider URLs are written without a trailing `/v1`.
90
+ For example, entering `http://localhost:1234/v1` for LM Studio writes `ANTHROPIC_BASE_URL=http://localhost:1234`.
91
+ The setup uses `ANTHROPIC_API_KEY` and removes stale `ANTHROPIC_AUTH_TOKEN` values to avoid Claude Code auth conflicts.
64
92
  Upstream provider keys are stored in `~/.config/model-provider-x/config.jsonc`, not in Claude Code settings.
65
93
 
66
94
  ## Codex Setup
@@ -82,9 +110,9 @@ The Codex setup writes a Responses-compatible provider to `~/.codex/config.toml`
82
110
  It also configures command-backed authentication so Codex can fetch the local proxy token automatically.
83
111
  The proxy currently supports non-streaming `/v1/responses` requests and forwards them to upstream OpenAI-compatible `/v1/chat/completions`.
84
112
 
85
- ## Unified Setup Flow
113
+ ## Setup Modes
86
114
 
87
- Use the unified setup wizard to install a provider into OpenCode, Codex, or Claude Code:
115
+ You can also run the unified setup wizard through the explicit setup command:
88
116
 
89
117
  ```bash
90
118
  node dist/cli/index.js setup --provider lmstudio
package/dist/cli/args.js CHANGED
@@ -35,6 +35,9 @@ export function parseCliArgs(argv) {
35
35
  case "--provider":
36
36
  options.providerPreset = next();
37
37
  break;
38
+ case "--opencode-api":
39
+ options.opencodeApiType = parseOpenCodeApiType(next());
40
+ break;
38
41
  case "--proxy":
39
42
  options.proxy = true;
40
43
  break;
@@ -92,6 +95,7 @@ export function usage() {
92
95
 
93
96
  Usage:
94
97
  model-provider-x [options]
98
+ model-provider-x setup [options]
95
99
 
96
100
  Options:
97
101
  --base-url <url> OpenAI-compatible API base URL, for example http://localhost:8888/v1
@@ -99,10 +103,11 @@ Options:
99
103
  --name <name> Provider display name.
100
104
  --id <id> Provider id used under provider.<id>.
101
105
  --provider <id> Use a built-in provider preset, for example lmstudio or openai.
106
+ --opencode-api <api> OpenCode API type: chat, responses, or messages.
102
107
  --proxy Write agent config through the local compatibility proxy.
103
108
  --direct Write agent config directly to the upstream provider.
104
109
  --models <list> Comma-separated model ids. Skips interactive model selection.
105
- --config <path> OpenCode config path to write.
110
+ --config <path> OpenCode config path to write when targeting OpenCode.
106
111
  --print Print generated JSON and do not write config.
107
112
  --yes, -y Accept defaults in non-interactive prompts.
108
113
  --help, -h Show this help.
@@ -110,6 +115,18 @@ Options:
110
115
  }
111
116
  export class HelpRequested extends Error {
112
117
  }
118
+ function parseOpenCodeApiType(value) {
119
+ if (value === "chat" || value === "chat-completions" || value === "openai-compatible") {
120
+ return "chat";
121
+ }
122
+ if (value === "responses" || value === "openai-responses") {
123
+ return "responses";
124
+ }
125
+ if (value === "messages" || value === "anthropic-messages") {
126
+ return "messages";
127
+ }
128
+ throw new Error(`Unknown OpenCode API type: ${value}`);
129
+ }
113
130
  function addModelByOneBasedIndex(selected, models, index) {
114
131
  const model = models[index - 1];
115
132
  if (!model) {
@@ -2,7 +2,7 @@ import { HelpRequested, parseCliArgs, usage } from "./args.js";
2
2
  export function parseCommand(argv) {
3
3
  const [command, ...rest] = argv;
4
4
  if (!command || command.startsWith("--")) {
5
- return { command: "opencode", options: parseCliArgs(argv) };
5
+ return { command: "setup", target: undefined, options: parseCliArgs(argv) };
6
6
  }
7
7
  if (command === "setup") {
8
8
  return parseSetupCommand(rest);
package/dist/cli/index.js CHANGED
@@ -7,7 +7,7 @@ import { getDefaultToolConfigPath, readToolConfig, upsertProviderProfile } from
7
7
  import { discoverOpenCodeConfigs, getDefaultConfigPath, writeProviderToConfig } from "../core/config.js";
8
8
  import { buildProviderConfig, detectProviderCapabilities, recommendProxyMode, validateAndFetchModels } from "../core/provider.js";
9
9
  import { startProxyServer } from "../proxy/server.js";
10
- import { getDefaultClaudeSettingsPath, writeClaudeCodeSettings } from "../targets/claude-code.js";
10
+ import { defaultClaudeModelMapping, getDefaultClaudeSettingsPath, writeClaudeCodeSettings } from "../targets/claude-code.js";
11
11
  import { getDefaultCodexConfigPath, writeCodexConfig } from "../targets/codex.js";
12
12
  import { HelpRequested, parseModelSelection } from "./args.js";
13
13
  import { commandUsage, parseCommand } from "./commands.js";
@@ -34,6 +34,10 @@ export async function runCommand(command) {
34
34
  return;
35
35
  }
36
36
  if (command.command === "setup") {
37
+ if (command.options.print) {
38
+ await runCli(command.options);
39
+ return;
40
+ }
37
41
  await runSetup(command);
38
42
  return;
39
43
  }
@@ -77,7 +81,8 @@ export async function runCli(options) {
77
81
  providerName,
78
82
  baseURL: fetched.baseURL,
79
83
  apiKey,
80
- models: selectedModels
84
+ models: selectedModels,
85
+ opencodeApiType: options.opencodeApiType ?? "chat"
81
86
  });
82
87
  const provider = fragment.provider[providerId];
83
88
  const json = JSON.stringify(fragment, null, 2);
@@ -110,18 +115,18 @@ async function runSetup(command) {
110
115
  const providerInput = await collectProviderInput(rl, command);
111
116
  output.write("Detecting provider capabilities...\n");
112
117
  const capabilities = await detectProviderCapabilities({ baseURL: providerInput.baseURL, apiKey: providerInput.apiKey });
118
+ const selection = await collectProviderSelection(rl, command, providerInput);
113
119
  const target = await resolveSetupTarget(rl, command.target);
114
120
  const useProxy = await resolveProxyMode(rl, command.options, capabilities, target);
115
- const selection = await collectProviderSelection(rl, command, target, providerInput);
116
121
  if (target === "opencode") {
117
- await writeOpenCodeSetup(rl, command, selection, useProxy);
122
+ await writeOpenCodeSetup(rl, command, selection, useProxy, capabilities);
118
123
  return;
119
124
  }
120
125
  if (target === "codex") {
121
126
  await writeCodexSetup(command, selection, useProxy);
122
127
  return;
123
128
  }
124
- await writeClaudeCodeSetup(command, selection, useProxy);
129
+ await writeClaudeCodeSetup(rl, command, selection, useProxy);
125
130
  }
126
131
  finally {
127
132
  rl.close();
@@ -135,18 +140,14 @@ async function collectProviderInput(rl, command) {
135
140
  const apiKey = await resolveApiKey(rl, command.options.apiKey, providerDefaults.preset, "Upstream API key");
136
141
  return { providerId, providerName, baseURL, apiKey };
137
142
  }
138
- async function collectProviderSelection(rl, command, target, providerInput) {
143
+ async function collectProviderSelection(rl, command, providerInput) {
139
144
  output.write("Fetching models...\n");
140
145
  const fetched = await validateAndFetchModels({ baseURL: providerInput.baseURL, apiKey: providerInput.apiKey });
141
146
  const selectedModels = command.options.models ??
142
147
  (canUseTui()
143
- ? await multiSelectChoices(target === "codex"
144
- ? "Select Codex Responses gateway models"
145
- : target === "claude-code"
146
- ? "Select Claude Code gateway models"
147
- : "Select models", createModelChoices(fetched.models))
148
+ ? await multiSelectChoices("Select models", createModelChoices(fetched.models))
148
149
  : parseModelSelection(await rl.question(formatModelPrompt(fetched.models)), fetched.models));
149
- const defaultModel = command.defaultModel ?? selectedModels[0];
150
+ const defaultModel = await resolveDefaultModel(rl, command, selectedModels);
150
151
  if (!defaultModel) {
151
152
  throw new Error("Select at least one model");
152
153
  }
@@ -172,15 +173,17 @@ async function collectProviderSelection(rl, command, target, providerInput) {
172
173
  toolConfigPath
173
174
  };
174
175
  }
175
- async function writeOpenCodeSetup(rl, command, selection, useProxy) {
176
+ async function writeOpenCodeSetup(rl, command, selection, useProxy, capabilities) {
176
177
  const baseURL = useProxy ? `http://${selection.config.proxy.host}:${selection.config.proxy.port}/v1` : selection.upstreamBaseURL;
177
178
  const apiKey = useProxy ? selection.config.proxy.authToken : selection.apiKey;
179
+ const opencodeApiType = await resolveOpenCodeApiType(rl, command, capabilities, useProxy);
178
180
  const fragment = buildProviderConfig({
179
181
  providerId: selection.providerId,
180
182
  providerName: selection.providerName,
181
183
  baseURL,
182
184
  apiKey,
183
- models: selection.selectedModels
185
+ models: selection.selectedModels,
186
+ opencodeApiType
184
187
  });
185
188
  const provider = fragment.provider[selection.providerId];
186
189
  const targetPath = command.options.configPath ?? (await chooseConfigPath(rl, selection.providerId, command.options.yes));
@@ -204,15 +207,18 @@ async function writeOpenCodeSetup(rl, command, selection, useProxy) {
204
207
  output.write(`Started proxy: ${proxy.baseURL}\n`);
205
208
  }
206
209
  }
207
- async function writeClaudeCodeSetup(command, selection, useProxy) {
210
+ async function writeClaudeCodeSetup(rl, command, selection, useProxy) {
208
211
  const proxyBaseURL = useProxy ? `http://${selection.config.proxy.host}:${selection.config.proxy.port}` : selection.upstreamBaseURL;
212
+ const modelMapping = await resolveClaudeModelMapping(rl, command, selection);
209
213
  const result = await writeClaudeCodeSettings({
210
214
  targetPath: getDefaultClaudeSettingsPath(),
211
215
  proxy: {
212
216
  baseURL: proxyBaseURL,
213
217
  authToken: useProxy ? selection.config.proxy.authToken : selection.apiKey,
214
218
  enableModelDiscovery: useProxy,
215
- defaultModel: command.defaultModel
219
+ defaultModel: selection.defaultModel,
220
+ models: selection.selectedModels,
221
+ modelMapping
216
222
  }
217
223
  });
218
224
  output.write(`Saved profile ${selection.providerId} to ${selection.toolConfigPath}\n`);
@@ -359,6 +365,132 @@ async function resolveProxyMode(rl, options, capabilities, target) {
359
365
  const accepted = !answer.trim() || answer.trim().toLowerCase() === "y";
360
366
  return recommendedProxy ? accepted : !accepted;
361
367
  }
368
+ async function resolveOpenCodeApiType(rl, command, capabilities, useProxy) {
369
+ if (command.options.opencodeApiType) {
370
+ return command.options.opencodeApiType;
371
+ }
372
+ const availableApis = useProxy ? new Set(["openai-compatible", "openai-responses", "anthropic-messages"]) : new Set(capabilities.apis);
373
+ const recommended = recommendedOpenCodeApiType(availableApis);
374
+ if (command.options.yes) {
375
+ return recommended;
376
+ }
377
+ const choices = [
378
+ {
379
+ label: "Responses API",
380
+ value: "responses",
381
+ hint: choiceHint("openai-responses", recommended, "responses", availableApis)
382
+ },
383
+ {
384
+ label: "Chat Completions API",
385
+ value: "chat",
386
+ hint: choiceHint("openai-compatible", recommended, "chat", availableApis)
387
+ },
388
+ {
389
+ label: "Anthropic Messages API",
390
+ value: "messages",
391
+ hint: choiceHint("anthropic-messages", recommended, "messages", availableApis)
392
+ }
393
+ ];
394
+ if (canUseTui()) {
395
+ return selectChoice("OpenCode API type", choices);
396
+ }
397
+ output.write("OpenCode API types:\n");
398
+ choices.forEach((choice, index) => {
399
+ output.write(`${index + 1}. ${choice.label}${choice.hint ? ` - ${choice.hint}` : ""}\n`);
400
+ });
401
+ const answer = await rl.question(`OpenCode API type [${recommended}]: `);
402
+ const value = answer.trim();
403
+ if (!value) {
404
+ return recommended;
405
+ }
406
+ if (value === "1" || value === "responses") {
407
+ return "responses";
408
+ }
409
+ if (value === "2" || value === "chat" || value === "chat-completions") {
410
+ return "chat";
411
+ }
412
+ if (value === "3" || value === "messages") {
413
+ return "messages";
414
+ }
415
+ throw new Error(`Unknown OpenCode API type: ${value}`);
416
+ }
417
+ function recommendedOpenCodeApiType(apis) {
418
+ if (apis.has("openai-responses")) {
419
+ return "responses";
420
+ }
421
+ if (apis.has("openai-compatible")) {
422
+ return "chat";
423
+ }
424
+ if (apis.has("anthropic-messages")) {
425
+ return "messages";
426
+ }
427
+ return "chat";
428
+ }
429
+ function choiceHint(api, recommended, value, availableApis) {
430
+ const hints = [];
431
+ if (value === recommended) {
432
+ hints.push("recommended");
433
+ }
434
+ if (!availableApis.has(api)) {
435
+ hints.push("not detected");
436
+ }
437
+ return hints.join(", ");
438
+ }
439
+ async function resolveDefaultModel(rl, command, selectedModels) {
440
+ if (command.defaultModel) {
441
+ if (!selectedModels.includes(command.defaultModel)) {
442
+ throw new Error(`Default model is not in the selected model list: ${command.defaultModel}`);
443
+ }
444
+ return command.defaultModel;
445
+ }
446
+ if (selectedModels.length === 0) {
447
+ throw new Error("Select at least one model");
448
+ }
449
+ if (selectedModels.length === 1 || command.options.yes) {
450
+ return selectedModels[0];
451
+ }
452
+ if (canUseTui()) {
453
+ return selectChoice("Choose default model", selectedModels.map((model) => ({ label: model, value: model })));
454
+ }
455
+ const answer = await rl.question(`Default model [${selectedModels[0]}]: `);
456
+ const model = answer.trim() || selectedModels[0];
457
+ if (!selectedModels.includes(model)) {
458
+ throw new Error(`Default model is not in the selected model list: ${model}`);
459
+ }
460
+ return model;
461
+ }
462
+ async function resolveClaudeModelMapping(rl, command, selection) {
463
+ const defaultMapping = defaultClaudeModelMapping(selection.defaultModel);
464
+ if (selection.selectedModels.length <= 1 || command.options.yes) {
465
+ return defaultMapping;
466
+ }
467
+ const customize = canUseTui()
468
+ ? await selectChoice("Claude Code model mapping", [
469
+ { label: "Use default model for Opus, Sonnet, Haiku", value: false, hint: "recommended" },
470
+ { label: "Customize Opus, Sonnet, Haiku models", value: true }
471
+ ])
472
+ : (await rl.question("Use default model for Opus, Sonnet, Haiku? [Y/n] ")).trim().toLowerCase() === "n";
473
+ if (!customize) {
474
+ return defaultMapping;
475
+ }
476
+ return {
477
+ opus: await selectModelForRole(rl, "Opus model", selection.selectedModels, selection.defaultModel),
478
+ sonnet: await selectModelForRole(rl, "Sonnet model", selection.selectedModels, selection.defaultModel),
479
+ haiku: await selectModelForRole(rl, "Haiku model", selection.selectedModels, selection.defaultModel),
480
+ subagent: await selectModelForRole(rl, "Subagent model", selection.selectedModels, selection.defaultModel)
481
+ };
482
+ }
483
+ async function selectModelForRole(rl, label, models, defaultModel) {
484
+ if (canUseTui()) {
485
+ return selectChoice(label, models.map((model) => ({ label: model, value: model, hint: model === defaultModel ? "default" : undefined })));
486
+ }
487
+ const answer = await rl.question(`${label} [${defaultModel}]: `);
488
+ const model = answer.trim() || defaultModel;
489
+ if (!models.includes(model)) {
490
+ throw new Error(`${label} is not in the selected model list: ${model}`);
491
+ }
492
+ return model;
493
+ }
362
494
  async function resolveSetupTarget(rl, target) {
363
495
  if (target) {
364
496
  return target;
@@ -51,7 +51,7 @@ export async function detectProviderCapabilities(input, fetchImpl = globalThis.f
51
51
  apis.add("openai-responses");
52
52
  }
53
53
  const messages = await probe(`${baseURL}/messages`, { method: "POST", headers: { ...headers, "content-type": "application/json" }, body: "{}" }, fetchImpl);
54
- if (messages.reachable) {
54
+ if (messages.reachable && (await isAnthropicMessagesProbe(messages.response))) {
55
55
  apis.add("anthropic-messages");
56
56
  }
57
57
  return { baseURL, apis: [...apis] };
@@ -71,14 +71,18 @@ export function recommendProxyMode(capabilities, target) {
71
71
  export function buildProviderConfig(input) {
72
72
  const baseURL = normalizeBaseUrl(input.baseURL);
73
73
  const apiKey = input.apiKey?.trim();
74
+ const opencodeApiType = input.opencodeApiType ?? "chat";
74
75
  const provider = {
75
- npm: "@ai-sdk/openai-compatible",
76
+ npm: npmPackageForOpenCodeApiType(opencodeApiType),
76
77
  name: input.providerName.trim(),
77
78
  options: {
78
79
  baseURL
79
80
  },
80
81
  models: Object.fromEntries(input.models.map((model) => [model, { name: model }]))
81
82
  };
83
+ if (opencodeApiType !== "messages") {
84
+ provider.options.setCacheKey = true;
85
+ }
82
86
  if (apiKey) {
83
87
  provider.options.apiKey = apiKey;
84
88
  }
@@ -89,6 +93,15 @@ export function buildProviderConfig(input) {
89
93
  }
90
94
  };
91
95
  }
96
+ export function npmPackageForOpenCodeApiType(apiType) {
97
+ if (apiType === "responses") {
98
+ return "@ai-sdk/openai";
99
+ }
100
+ if (apiType === "messages") {
101
+ return "@ai-sdk/anthropic";
102
+ }
103
+ return "@ai-sdk/openai-compatible";
104
+ }
92
105
  function isModelListResponse(body) {
93
106
  return (typeof body === "object" &&
94
107
  body !== null &&
@@ -104,9 +117,48 @@ function isOpenCodeCompatibleModel(model) {
104
117
  async function probe(input, init, fetchImpl) {
105
118
  try {
106
119
  const response = await fetchImpl(input, init);
107
- return { reachable: response.ok || response.status === 400 || response.status === 401 || response.status === 422 };
120
+ return {
121
+ reachable: response.ok || response.status === 400 || response.status === 401 || response.status === 422,
122
+ response
123
+ };
108
124
  }
109
125
  catch {
110
126
  return { reachable: false };
111
127
  }
112
128
  }
129
+ async function isAnthropicMessagesProbe(response) {
130
+ if (!response) {
131
+ return false;
132
+ }
133
+ const status = response.status ?? (response.ok ? 200 : undefined);
134
+ if (status === 404 || status === undefined) {
135
+ return false;
136
+ }
137
+ if (!response.json) {
138
+ return !response.ok && (status === 400 || status === 401 || status === 422);
139
+ }
140
+ try {
141
+ const body = await response.json();
142
+ if (isAnthropicMessageResponse(body) || isAnthropicErrorResponse(body)) {
143
+ return true;
144
+ }
145
+ return !response.ok && (status === 400 || status === 401 || status === 422);
146
+ }
147
+ catch {
148
+ return !response.ok && (status === 400 || status === 401 || status === 422);
149
+ }
150
+ }
151
+ function isAnthropicMessageResponse(body) {
152
+ return (typeof body === "object" &&
153
+ body !== null &&
154
+ body.type === "message" &&
155
+ typeof body.role === "string" &&
156
+ Array.isArray(body.content));
157
+ }
158
+ function isAnthropicErrorResponse(body) {
159
+ return (typeof body === "object" &&
160
+ body !== null &&
161
+ body.type === "error" &&
162
+ typeof body.error === "object" &&
163
+ body.error !== null);
164
+ }
@@ -114,7 +114,7 @@ async function handleResponses(request, response, profile, fetchImpl) {
114
114
  writeJson(response, 200, chatCompletionToResponses((await upstream.json())));
115
115
  }
116
116
  async function handleMessages(request, response, profile, fetchImpl) {
117
- const body = (await readJson(request));
117
+ const body = normalizeAnthropicModel((await readJson(request)), profile);
118
118
  const chatRequest = anthropicMessageToChatRequest(body);
119
119
  const upstream = await fetchImpl(`${profile.baseURL.replace(/\/+$/, "")}/chat/completions`, {
120
120
  method: "POST",
@@ -150,6 +150,18 @@ async function handleMessages(request, response, profile, fetchImpl) {
150
150
  }
151
151
  writeJson(response, 200, chatCompletionToAnthropicMessage((await upstream.json())));
152
152
  }
153
+ function normalizeAnthropicModel(body, profile) {
154
+ if (!isClaudeModelAlias(body.model) || profile.models.includes(body.model) || profile.models.length === 0) {
155
+ return body;
156
+ }
157
+ return {
158
+ ...body,
159
+ model: profile.models[0]
160
+ };
161
+ }
162
+ function isClaudeModelAlias(model) {
163
+ return /(^claude-|sonnet|opus|haiku)/i.test(model);
164
+ }
153
165
  function isAuthorized(request, authToken) {
154
166
  const authorization = request.headers.authorization;
155
167
  const apiKey = request.headers["x-api-key"];
@@ -1,25 +1,85 @@
1
1
  import { copyFile, mkdir, readFile, stat, writeFile } from "node:fs/promises";
2
2
  import { dirname, join } from "node:path";
3
3
  import { homedir } from "node:os";
4
+ const OPUS_MODEL_OVERRIDE_KEYS = [
5
+ "claude-opus-4-7",
6
+ "claude-opus-4-6",
7
+ "claude-opus-4-5",
8
+ "claude-opus-4-1",
9
+ "claude-opus-4-0",
10
+ "claude-3-opus-latest",
11
+ "claude-3-opus-20240229"
12
+ ];
13
+ const SONNET_MODEL_OVERRIDE_KEYS = [
14
+ "claude-sonnet-4-6",
15
+ "claude-sonnet-4-5",
16
+ "claude-sonnet-4-0",
17
+ "claude-3-7-sonnet-latest",
18
+ "claude-3-7-sonnet-20250219",
19
+ "claude-3-5-sonnet-latest",
20
+ "claude-3-5-sonnet-20241022",
21
+ "claude-3-5-sonnet-20240620"
22
+ ];
23
+ const HAIKU_MODEL_OVERRIDE_KEYS = [
24
+ "claude-haiku-4-5",
25
+ "claude-3-5-haiku-latest",
26
+ "claude-3-5-haiku-20241022",
27
+ "claude-3-haiku-20240307"
28
+ ];
4
29
  export function getDefaultClaudeSettingsPath(homeDir = homedir()) {
5
30
  return join(homeDir, ".claude", "settings.json");
6
31
  }
7
32
  export function mergeClaudeCodeSettings(settings, proxy) {
8
33
  const env = {
9
34
  ...(isRecord(settings.env) ? stringifyRecord(settings.env) : {}),
10
- ANTHROPIC_BASE_URL: proxy.baseURL,
11
- ANTHROPIC_AUTH_TOKEN: proxy.authToken
35
+ ANTHROPIC_BASE_URL: normalizeClaudeCodeBaseURL(proxy.baseURL),
36
+ ANTHROPIC_API_KEY: proxy.authToken
12
37
  };
38
+ delete env.ANTHROPIC_AUTH_TOKEN;
13
39
  if (proxy.enableModelDiscovery) {
14
40
  env.CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY = "1";
15
41
  }
16
42
  if (proxy.defaultModel) {
43
+ const mapping = proxy.modelMapping ?? defaultClaudeModelMapping(proxy.defaultModel);
17
44
  env.ANTHROPIC_MODEL = proxy.defaultModel;
45
+ env.ANTHROPIC_DEFAULT_OPUS_MODEL = mapping.opus;
46
+ env.ANTHROPIC_DEFAULT_SONNET_MODEL = mapping.sonnet;
47
+ env.ANTHROPIC_DEFAULT_HAIKU_MODEL = mapping.haiku;
48
+ env.CLAUDE_CODE_SUBAGENT_MODEL = mapping.subagent;
18
49
  }
19
- return {
50
+ const next = {
20
51
  ...settings,
21
52
  env
22
53
  };
54
+ if (proxy.defaultModel) {
55
+ const defaultModel = proxy.defaultModel;
56
+ const mapping = proxy.modelMapping ?? defaultClaudeModelMapping(defaultModel);
57
+ next.model = defaultModel;
58
+ next.modelOverrides = {
59
+ ...(isRecord(settings.modelOverrides) ? stringifyRecord(settings.modelOverrides) : {}),
60
+ ...modelOverridesForMapping(mapping)
61
+ };
62
+ }
63
+ const availableModels = uniqueStrings([
64
+ ...(Array.isArray(settings.availableModels) ? settings.availableModels.map(String) : []),
65
+ ...(proxy.models ?? []),
66
+ ...(proxy.defaultModel ? [proxy.defaultModel] : [])
67
+ ]);
68
+ if (availableModels.length > 0) {
69
+ next.availableModels = availableModels;
70
+ }
71
+ return next;
72
+ }
73
+ export function defaultClaudeModelMapping(defaultModel) {
74
+ return {
75
+ opus: defaultModel,
76
+ sonnet: defaultModel,
77
+ haiku: defaultModel,
78
+ subagent: defaultModel
79
+ };
80
+ }
81
+ export function normalizeClaudeCodeBaseURL(baseURL) {
82
+ return baseURL.trim().replace(/\/+$/, "").replace(/\/v1$/i, "");
23
83
  }
24
84
  export async function writeClaudeCodeSettings(input) {
25
85
  const targetPath = input.targetPath ?? getDefaultClaudeSettingsPath();
@@ -46,6 +106,16 @@ async function fileExists(path) {
46
106
  function stringifyRecord(record) {
47
107
  return Object.fromEntries(Object.entries(record).map(([key, value]) => [key, String(value)]));
48
108
  }
109
+ function uniqueStrings(values) {
110
+ return [...new Set(values.map((value) => value.trim()).filter(Boolean))];
111
+ }
112
+ function modelOverridesForMapping(mapping) {
113
+ return {
114
+ ...Object.fromEntries(OPUS_MODEL_OVERRIDE_KEYS.map((model) => [model, mapping.opus])),
115
+ ...Object.fromEntries(SONNET_MODEL_OVERRIDE_KEYS.map((model) => [model, mapping.sonnet])),
116
+ ...Object.fromEntries(HAIKU_MODEL_OVERRIDE_KEYS.map((model) => [model, mapping.haiku]))
117
+ };
118
+ }
49
119
  function isRecord(value) {
50
120
  return typeof value === "object" && value !== null && !Array.isArray(value);
51
121
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heventure/model-provider-x",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "TUI configurator and local API proxy for wiring custom model providers into OpenCode and Claude Code.",
5
5
  "private": false,
6
6
  "license": "MIT",