@heventure/model-provider-x 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +48 -1
- package/dist/cli/args.js +8 -0
- package/dist/cli/commands.js +20 -10
- package/dist/cli/index.js +252 -61
- package/dist/core/provider.js +40 -0
- package/dist/core/proxy-process.js +102 -0
- package/dist/proxy/responses.js +107 -0
- package/dist/proxy/server.js +72 -1
- package/dist/targets/codex.js +84 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -8,6 +8,7 @@ It currently supports:
|
|
|
8
8
|
- Claude Code setup through a local Anthropic Messages-compatible proxy.
|
|
9
9
|
- OpenAI-compatible `/v1/models` discovery.
|
|
10
10
|
- Anthropic Messages API to OpenAI Chat Completions API conversion.
|
|
11
|
+
- OpenAI Responses API to OpenAI Chat Completions API conversion for Codex.
|
|
11
12
|
- Non-streaming and streaming SSE proxy responses.
|
|
12
13
|
|
|
13
14
|
## Install
|
|
@@ -56,19 +57,65 @@ node dist/cli/index.js setup --target claude-code \
|
|
|
56
57
|
Then start the local proxy:
|
|
57
58
|
|
|
58
59
|
```bash
|
|
59
|
-
node dist/cli/index.js proxy --profile unsloth
|
|
60
|
+
node dist/cli/index.js proxy up --profile unsloth
|
|
60
61
|
```
|
|
61
62
|
|
|
62
63
|
The Claude Code setup writes gateway environment values to `~/.claude/settings.json`.
|
|
63
64
|
Upstream provider keys are stored in `~/.config/model-provider-x/config.jsonc`, not in Claude Code settings.
|
|
64
65
|
|
|
66
|
+
## Codex Setup
|
|
67
|
+
|
|
68
|
+
Create or update a provider profile and write Codex user config:
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
node dist/cli/index.js setup --target codex \
|
|
72
|
+
--provider lmstudio
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Then start the local proxy:
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
node dist/cli/index.js proxy up --profile lmstudio
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
The Codex setup writes a Responses-compatible provider to `~/.codex/config.toml`.
|
|
82
|
+
It also configures command-backed authentication so Codex can fetch the local proxy token automatically.
|
|
83
|
+
The proxy currently supports non-streaming `/v1/responses` requests and forwards them to upstream OpenAI-compatible `/v1/chat/completions`.
|
|
84
|
+
|
|
85
|
+
## Unified Setup Flow
|
|
86
|
+
|
|
87
|
+
Use the unified setup wizard to install a provider into OpenCode, Codex, or Claude Code:
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
node dist/cli/index.js setup --provider lmstudio
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
The wizard asks whether to write the agent config through the local compatibility proxy before choosing the target agent.
|
|
94
|
+
Proxy mode gives the broadest compatibility:
|
|
95
|
+
|
|
96
|
+
- `/v1/responses` for Codex.
|
|
97
|
+
- `/v1/chat/completions` and `/v1/completions` passthrough for OpenAI-compatible clients.
|
|
98
|
+
- `/v1/messages` for Claude Code.
|
|
99
|
+
|
|
100
|
+
You can force either mode non-interactively:
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
node dist/cli/index.js setup --target codex --provider lmstudio --proxy
|
|
104
|
+
node dist/cli/index.js setup --target opencode --provider lmstudio --direct
|
|
105
|
+
```
|
|
106
|
+
|
|
65
107
|
## Commands
|
|
66
108
|
|
|
67
109
|
```bash
|
|
68
110
|
npx @heventure/model-provider-x --help
|
|
69
111
|
npx @heventure/model-provider-x setup --target claude-code
|
|
112
|
+
npx @heventure/model-provider-x setup --target codex
|
|
70
113
|
npx @heventure/model-provider-x proxy --profile <id>
|
|
114
|
+
npx @heventure/model-provider-x proxy up --profile <id>
|
|
115
|
+
npx @heventure/model-provider-x proxy status
|
|
116
|
+
npx @heventure/model-provider-x proxy down
|
|
71
117
|
npx @heventure/model-provider-x config print --profile <id>
|
|
118
|
+
npx @heventure/model-provider-x config api-key --profile <id>
|
|
72
119
|
```
|
|
73
120
|
|
|
74
121
|
## Development
|
package/dist/cli/args.js
CHANGED
|
@@ -35,6 +35,12 @@ export function parseCliArgs(argv) {
|
|
|
35
35
|
case "--provider":
|
|
36
36
|
options.providerPreset = next();
|
|
37
37
|
break;
|
|
38
|
+
case "--proxy":
|
|
39
|
+
options.proxy = true;
|
|
40
|
+
break;
|
|
41
|
+
case "--direct":
|
|
42
|
+
options.proxy = false;
|
|
43
|
+
break;
|
|
38
44
|
case "--print":
|
|
39
45
|
options.print = true;
|
|
40
46
|
break;
|
|
@@ -93,6 +99,8 @@ Options:
|
|
|
93
99
|
--name <name> Provider display name.
|
|
94
100
|
--id <id> Provider id used under provider.<id>.
|
|
95
101
|
--provider <id> Use a built-in provider preset, for example lmstudio or openai.
|
|
102
|
+
--proxy Write agent config through the local compatibility proxy.
|
|
103
|
+
--direct Write agent config directly to the upstream provider.
|
|
96
104
|
--models <list> Comma-separated model ids. Skips interactive model selection.
|
|
97
105
|
--config <path> OpenCode config path to write.
|
|
98
106
|
--print Print generated JSON and do not write config.
|
package/dist/cli/commands.js
CHANGED
|
@@ -21,13 +21,18 @@ export function parseCommand(argv) {
|
|
|
21
21
|
export function commandUsage() {
|
|
22
22
|
return `${usage()}
|
|
23
23
|
Commands:
|
|
24
|
-
model-provider-x setup --target <opencode|claude-code> [options]
|
|
24
|
+
model-provider-x setup --target <opencode|claude-code|codex> [options]
|
|
25
25
|
model-provider-x proxy --profile <id> [--host 127.0.0.1] [--port 4141]
|
|
26
|
+
model-provider-x proxy up --profile <id> [--host 127.0.0.1] [--port 4141]
|
|
27
|
+
model-provider-x proxy down
|
|
28
|
+
model-provider-x proxy status
|
|
29
|
+
model-provider-x proxy token
|
|
26
30
|
model-provider-x config print --profile <id>
|
|
31
|
+
model-provider-x config api-key --profile <id>
|
|
27
32
|
`;
|
|
28
33
|
}
|
|
29
34
|
function parseSetupCommand(argv) {
|
|
30
|
-
let target
|
|
35
|
+
let target;
|
|
31
36
|
let profileId;
|
|
32
37
|
let port;
|
|
33
38
|
let host;
|
|
@@ -66,13 +71,18 @@ function parseSetupCommand(argv) {
|
|
|
66
71
|
return { command: "setup", target, profileId, port, host, defaultModel, options: parseCliArgs(providerArgs) };
|
|
67
72
|
}
|
|
68
73
|
function parseProxyCommand(argv) {
|
|
74
|
+
let action = "run";
|
|
75
|
+
const args = [...argv];
|
|
76
|
+
if (args[0] === "up" || args[0] === "down" || args[0] === "status" || args[0] === "token") {
|
|
77
|
+
action = args.shift();
|
|
78
|
+
}
|
|
69
79
|
let profileId;
|
|
70
80
|
let host;
|
|
71
81
|
let port;
|
|
72
|
-
for (let index = 0; index <
|
|
73
|
-
const arg =
|
|
82
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
83
|
+
const arg = args[index];
|
|
74
84
|
const next = () => {
|
|
75
|
-
const value =
|
|
85
|
+
const value = args[index + 1];
|
|
76
86
|
if (!value || value.startsWith("--")) {
|
|
77
87
|
throw new Error(`${arg} requires a value`);
|
|
78
88
|
}
|
|
@@ -96,14 +106,14 @@ function parseProxyCommand(argv) {
|
|
|
96
106
|
throw new Error(`Unknown proxy argument: ${arg}`);
|
|
97
107
|
}
|
|
98
108
|
}
|
|
99
|
-
if (!profileId) {
|
|
109
|
+
if ((action === "run" || action === "up") && !profileId) {
|
|
100
110
|
throw new Error("--profile is required");
|
|
101
111
|
}
|
|
102
|
-
return { command: "proxy", profileId, host, port };
|
|
112
|
+
return { command: "proxy", action, profileId, host, port };
|
|
103
113
|
}
|
|
104
114
|
function parseConfigCommand(argv) {
|
|
105
115
|
const [subcommand, ...rest] = argv;
|
|
106
|
-
if (subcommand !== "print") {
|
|
116
|
+
if (subcommand !== "print" && subcommand !== "api-key") {
|
|
107
117
|
throw new Error(`Unknown config command: ${subcommand ?? ""}`.trim());
|
|
108
118
|
}
|
|
109
119
|
let profileId;
|
|
@@ -122,10 +132,10 @@ function parseConfigCommand(argv) {
|
|
|
122
132
|
if (!profileId) {
|
|
123
133
|
throw new Error("--profile is required");
|
|
124
134
|
}
|
|
125
|
-
return { command: "config-print", profileId };
|
|
135
|
+
return { command: subcommand === "api-key" ? "config-api-key" : "config-print", profileId };
|
|
126
136
|
}
|
|
127
137
|
function parseTarget(value) {
|
|
128
|
-
if (value === "opencode" || value === "claude-code") {
|
|
138
|
+
if (value === "opencode" || value === "claude-code" || value === "codex") {
|
|
129
139
|
return value;
|
|
130
140
|
}
|
|
131
141
|
throw new Error(`Unknown setup target: ${value}`);
|
package/dist/cli/index.js
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { createInterface } from "node:readline/promises";
|
|
3
3
|
import { stdin as input, stdout as output } from "node:process";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { readProxyStatus, startProxyProcess, stopProxyProcess } from "../core/proxy-process.js";
|
|
4
6
|
import { getDefaultToolConfigPath, readToolConfig, upsertProviderProfile } from "../core/tool-config.js";
|
|
5
7
|
import { discoverOpenCodeConfigs, getDefaultConfigPath, writeProviderToConfig } from "../core/config.js";
|
|
6
|
-
import { buildProviderConfig, validateAndFetchModels } from "../core/provider.js";
|
|
8
|
+
import { buildProviderConfig, detectProviderCapabilities, recommendProxyMode, validateAndFetchModels } from "../core/provider.js";
|
|
7
9
|
import { startProxyServer } from "../proxy/server.js";
|
|
8
10
|
import { getDefaultClaudeSettingsPath, writeClaudeCodeSettings } from "../targets/claude-code.js";
|
|
11
|
+
import { getDefaultCodexConfigPath, writeCodexConfig } from "../targets/codex.js";
|
|
9
12
|
import { HelpRequested, parseModelSelection } from "./args.js";
|
|
10
13
|
import { commandUsage, parseCommand } from "./commands.js";
|
|
11
14
|
import { createModelChoices } from "./model-choices.js";
|
|
@@ -43,22 +46,16 @@ export async function runCommand(command) {
|
|
|
43
46
|
output.write(`${JSON.stringify(profile, null, 2)}\n`);
|
|
44
47
|
return;
|
|
45
48
|
}
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
await server.close();
|
|
57
|
-
resolve();
|
|
58
|
-
};
|
|
59
|
-
process.once("SIGINT", stop);
|
|
60
|
-
process.once("SIGTERM", stop);
|
|
61
|
-
});
|
|
49
|
+
if (command.command === "config-api-key") {
|
|
50
|
+
const config = await readToolConfig();
|
|
51
|
+
const profile = config.profiles[command.profileId];
|
|
52
|
+
if (!profile?.apiKey) {
|
|
53
|
+
throw new Error(`No API key stored for provider profile: ${command.profileId}`);
|
|
54
|
+
}
|
|
55
|
+
output.write(`${profile.apiKey}\n`);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
await runProxyCommand(command);
|
|
62
59
|
}
|
|
63
60
|
export async function runCli(options) {
|
|
64
61
|
const rl = createInterface({ input, output });
|
|
@@ -107,59 +104,211 @@ export async function runCli(options) {
|
|
|
107
104
|
}
|
|
108
105
|
}
|
|
109
106
|
async function runSetup(command) {
|
|
110
|
-
if (command.target === "opencode") {
|
|
111
|
-
await runCli(command.options);
|
|
112
|
-
return;
|
|
113
|
-
}
|
|
114
|
-
await runClaudeCodeSetup(command);
|
|
115
|
-
}
|
|
116
|
-
async function runClaudeCodeSetup(command) {
|
|
117
107
|
const rl = createInterface({ input, output });
|
|
118
108
|
try {
|
|
119
109
|
output.write(canUseTui() ? renderIntro() : "model-provider-x\n\n");
|
|
120
|
-
const
|
|
121
|
-
|
|
122
|
-
const
|
|
123
|
-
const
|
|
124
|
-
const
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
? await multiSelectChoices("Select Claude Code gateway models", createModelChoices(fetched.models))
|
|
130
|
-
: parseModelSelection(await rl.question(formatModelPrompt(fetched.models)), fetched.models));
|
|
131
|
-
const toolConfigPath = getDefaultToolConfigPath();
|
|
132
|
-
const config = await upsertProviderProfile(toolConfigPath, {
|
|
133
|
-
id: providerId,
|
|
134
|
-
name: providerName,
|
|
135
|
-
baseURL: fetched.baseURL,
|
|
136
|
-
apiKey: apiKey.trim() || undefined,
|
|
137
|
-
models: selectedModels
|
|
138
|
-
}, {
|
|
139
|
-
host: command.host,
|
|
140
|
-
port: command.port
|
|
141
|
-
});
|
|
142
|
-
const proxyBaseURL = `http://${config.proxy.host}:${config.proxy.port}`;
|
|
143
|
-
const result = await writeClaudeCodeSettings({
|
|
144
|
-
targetPath: getDefaultClaudeSettingsPath(),
|
|
145
|
-
proxy: {
|
|
146
|
-
baseURL: proxyBaseURL,
|
|
147
|
-
authToken: config.proxy.authToken,
|
|
148
|
-
enableModelDiscovery: true,
|
|
149
|
-
defaultModel: command.defaultModel
|
|
150
|
-
}
|
|
151
|
-
});
|
|
152
|
-
output.write(`Saved profile ${providerId} to ${toolConfigPath}\n`);
|
|
153
|
-
output.write(`Updated Claude Code settings: ${result.targetPath}\n`);
|
|
154
|
-
if (result.backupPath) {
|
|
155
|
-
output.write(`Backup: ${result.backupPath}\n`);
|
|
110
|
+
const providerInput = await collectProviderInput(rl, command);
|
|
111
|
+
output.write("Detecting provider capabilities...\n");
|
|
112
|
+
const capabilities = await detectProviderCapabilities({ baseURL: providerInput.baseURL, apiKey: providerInput.apiKey });
|
|
113
|
+
const target = await resolveSetupTarget(rl, command.target);
|
|
114
|
+
const useProxy = await resolveProxyMode(rl, command.options, capabilities, target);
|
|
115
|
+
const selection = await collectProviderSelection(rl, command, target, providerInput);
|
|
116
|
+
if (target === "opencode") {
|
|
117
|
+
await writeOpenCodeSetup(rl, command, selection, useProxy);
|
|
118
|
+
return;
|
|
156
119
|
}
|
|
157
|
-
|
|
120
|
+
if (target === "codex") {
|
|
121
|
+
await writeCodexSetup(command, selection, useProxy);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
await writeClaudeCodeSetup(command, selection, useProxy);
|
|
158
125
|
}
|
|
159
126
|
finally {
|
|
160
127
|
rl.close();
|
|
161
128
|
}
|
|
162
129
|
}
|
|
130
|
+
async function collectProviderInput(rl, command) {
|
|
131
|
+
const providerDefaults = await resolveProviderDefaults(rl, command.options);
|
|
132
|
+
const providerName = await requiredOption(rl, command.options.providerName ?? providerDefaults.name, "Provider name");
|
|
133
|
+
const providerId = await requiredOption(rl, command.profileId ?? command.options.providerId ?? providerDefaults.id ?? slugify(providerName), "Provider id");
|
|
134
|
+
const baseURL = await requiredOption(rl, command.options.baseURL ?? providerDefaults.baseURL, "API base URL");
|
|
135
|
+
const apiKey = await resolveApiKey(rl, command.options.apiKey, providerDefaults.preset, "Upstream API key");
|
|
136
|
+
return { providerId, providerName, baseURL, apiKey };
|
|
137
|
+
}
|
|
138
|
+
async function collectProviderSelection(rl, command, target, providerInput) {
|
|
139
|
+
output.write("Fetching models...\n");
|
|
140
|
+
const fetched = await validateAndFetchModels({ baseURL: providerInput.baseURL, apiKey: providerInput.apiKey });
|
|
141
|
+
const selectedModels = command.options.models ??
|
|
142
|
+
(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
|
+
: parseModelSelection(await rl.question(formatModelPrompt(fetched.models)), fetched.models));
|
|
149
|
+
const defaultModel = command.defaultModel ?? selectedModels[0];
|
|
150
|
+
if (!defaultModel) {
|
|
151
|
+
throw new Error("Select at least one model");
|
|
152
|
+
}
|
|
153
|
+
const toolConfigPath = getDefaultToolConfigPath();
|
|
154
|
+
const config = await upsertProviderProfile(toolConfigPath, {
|
|
155
|
+
id: providerInput.providerId,
|
|
156
|
+
name: providerInput.providerName,
|
|
157
|
+
baseURL: fetched.baseURL,
|
|
158
|
+
apiKey: providerInput.apiKey.trim() || undefined,
|
|
159
|
+
models: selectedModels
|
|
160
|
+
}, {
|
|
161
|
+
host: command.host,
|
|
162
|
+
port: command.port
|
|
163
|
+
});
|
|
164
|
+
return {
|
|
165
|
+
providerId: providerInput.providerId,
|
|
166
|
+
providerName: providerInput.providerName,
|
|
167
|
+
upstreamBaseURL: fetched.baseURL,
|
|
168
|
+
apiKey: providerInput.apiKey,
|
|
169
|
+
selectedModels,
|
|
170
|
+
defaultModel,
|
|
171
|
+
config,
|
|
172
|
+
toolConfigPath
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
async function writeOpenCodeSetup(rl, command, selection, useProxy) {
|
|
176
|
+
const baseURL = useProxy ? `http://${selection.config.proxy.host}:${selection.config.proxy.port}/v1` : selection.upstreamBaseURL;
|
|
177
|
+
const apiKey = useProxy ? selection.config.proxy.authToken : selection.apiKey;
|
|
178
|
+
const fragment = buildProviderConfig({
|
|
179
|
+
providerId: selection.providerId,
|
|
180
|
+
providerName: selection.providerName,
|
|
181
|
+
baseURL,
|
|
182
|
+
apiKey,
|
|
183
|
+
models: selection.selectedModels
|
|
184
|
+
});
|
|
185
|
+
const provider = fragment.provider[selection.providerId];
|
|
186
|
+
const targetPath = command.options.configPath ?? (await chooseConfigPath(rl, selection.providerId, command.options.yes));
|
|
187
|
+
if (!targetPath) {
|
|
188
|
+
output.write(`${JSON.stringify(fragment, null, 2)}\n`);
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
const result = await writeProviderToConfig({ targetPath, providerId: selection.providerId, provider });
|
|
192
|
+
output.write(`Saved profile ${selection.providerId} to ${selection.toolConfigPath}\n`);
|
|
193
|
+
output.write(`Updated ${result.targetPath}\n`);
|
|
194
|
+
if (result.backupPath) {
|
|
195
|
+
output.write(`Backup: ${result.backupPath}\n`);
|
|
196
|
+
}
|
|
197
|
+
if (useProxy) {
|
|
198
|
+
const proxy = await startProxyProcess({
|
|
199
|
+
profileId: selection.providerId,
|
|
200
|
+
config: selection.config,
|
|
201
|
+
entrypoint: fileURLToPath(import.meta.url),
|
|
202
|
+
replace: true
|
|
203
|
+
});
|
|
204
|
+
output.write(`Started proxy: ${proxy.baseURL}\n`);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
async function writeClaudeCodeSetup(command, selection, useProxy) {
|
|
208
|
+
const proxyBaseURL = useProxy ? `http://${selection.config.proxy.host}:${selection.config.proxy.port}` : selection.upstreamBaseURL;
|
|
209
|
+
const result = await writeClaudeCodeSettings({
|
|
210
|
+
targetPath: getDefaultClaudeSettingsPath(),
|
|
211
|
+
proxy: {
|
|
212
|
+
baseURL: proxyBaseURL,
|
|
213
|
+
authToken: useProxy ? selection.config.proxy.authToken : selection.apiKey,
|
|
214
|
+
enableModelDiscovery: useProxy,
|
|
215
|
+
defaultModel: command.defaultModel
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
output.write(`Saved profile ${selection.providerId} to ${selection.toolConfigPath}\n`);
|
|
219
|
+
output.write(`Updated Claude Code settings: ${result.targetPath}\n`);
|
|
220
|
+
if (result.backupPath) {
|
|
221
|
+
output.write(`Backup: ${result.backupPath}\n`);
|
|
222
|
+
}
|
|
223
|
+
if (useProxy) {
|
|
224
|
+
const proxy = await startProxyProcess({
|
|
225
|
+
profileId: selection.providerId,
|
|
226
|
+
config: selection.config,
|
|
227
|
+
entrypoint: fileURLToPath(import.meta.url),
|
|
228
|
+
replace: true
|
|
229
|
+
});
|
|
230
|
+
output.write(`Started proxy: ${proxy.baseURL}\n`);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
async function writeCodexSetup(_command, selection, useProxy) {
|
|
234
|
+
const proxyBaseURL = useProxy
|
|
235
|
+
? `http://${selection.config.proxy.host}:${selection.config.proxy.port}/v1`
|
|
236
|
+
: selection.upstreamBaseURL;
|
|
237
|
+
const result = await writeCodexConfig({
|
|
238
|
+
targetPath: getDefaultCodexConfigPath(),
|
|
239
|
+
providerId: selection.providerId,
|
|
240
|
+
providerName: selection.providerName,
|
|
241
|
+
baseURL: proxyBaseURL,
|
|
242
|
+
authCommand: "model-provider-x",
|
|
243
|
+
authArgs: useProxy ? ["proxy", "token"] : ["config", "api-key", "--profile", selection.providerId],
|
|
244
|
+
model: selection.defaultModel
|
|
245
|
+
});
|
|
246
|
+
output.write(`Saved profile ${selection.providerId} to ${selection.toolConfigPath}\n`);
|
|
247
|
+
output.write(`Updated Codex config: ${result.targetPath}\n`);
|
|
248
|
+
if (result.backupPath) {
|
|
249
|
+
output.write(`Backup: ${result.backupPath}\n`);
|
|
250
|
+
}
|
|
251
|
+
if (useProxy) {
|
|
252
|
+
const proxy = await startProxyProcess({
|
|
253
|
+
profileId: selection.providerId,
|
|
254
|
+
config: selection.config,
|
|
255
|
+
entrypoint: fileURLToPath(import.meta.url),
|
|
256
|
+
replace: true
|
|
257
|
+
});
|
|
258
|
+
output.write(`Started proxy: ${proxy.baseURL}\n`);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
async function runProxyCommand(command) {
|
|
262
|
+
if (command.action === "up") {
|
|
263
|
+
const config = await readToolConfig();
|
|
264
|
+
const state = await startProxyProcess({
|
|
265
|
+
profileId: command.profileId,
|
|
266
|
+
config,
|
|
267
|
+
host: command.host,
|
|
268
|
+
port: command.port,
|
|
269
|
+
entrypoint: fileURLToPath(import.meta.url)
|
|
270
|
+
});
|
|
271
|
+
output.write(`Proxy running at ${state.baseURL} for profile ${state.profileId}\n`);
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
if (command.action === "down") {
|
|
275
|
+
const status = await stopProxyProcess();
|
|
276
|
+
output.write(status.state ? `Stopped proxy for profile ${status.state.profileId}\n` : "Proxy is not running\n");
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
if (command.action === "status") {
|
|
280
|
+
const status = await readProxyStatus();
|
|
281
|
+
if (!status.state) {
|
|
282
|
+
output.write("Proxy is not running\n");
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
output.write(status.running
|
|
286
|
+
? `Proxy is running at ${status.state.baseURL} for profile ${status.state.profileId} (pid ${status.state.pid})\n`
|
|
287
|
+
: `Proxy state exists but process ${status.state.pid} is not running\n`);
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
if (command.action === "token") {
|
|
291
|
+
const config = await readToolConfig();
|
|
292
|
+
output.write(`${config.proxy.authToken}\n`);
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
const config = await readToolConfig();
|
|
296
|
+
const server = await startProxyServer({
|
|
297
|
+
profileId: command.profileId,
|
|
298
|
+
config,
|
|
299
|
+
host: command.host,
|
|
300
|
+
port: command.port
|
|
301
|
+
});
|
|
302
|
+
output.write(`model-provider-x proxy listening at ${server.baseURL}\n`);
|
|
303
|
+
await new Promise((resolve) => {
|
|
304
|
+
const stop = async () => {
|
|
305
|
+
await server.close();
|
|
306
|
+
resolve();
|
|
307
|
+
};
|
|
308
|
+
process.once("SIGINT", stop);
|
|
309
|
+
process.once("SIGTERM", stop);
|
|
310
|
+
});
|
|
311
|
+
}
|
|
163
312
|
async function requiredOption(rl, value, label) {
|
|
164
313
|
const answer = value ?? (await rl.question(`${label}: `));
|
|
165
314
|
if (!answer.trim()) {
|
|
@@ -186,6 +335,48 @@ async function resolveProviderDefaults(rl, options) {
|
|
|
186
335
|
preset
|
|
187
336
|
};
|
|
188
337
|
}
|
|
338
|
+
async function resolveProxyMode(rl, options, capabilities, target) {
|
|
339
|
+
if (options.proxy !== undefined) {
|
|
340
|
+
return options.proxy;
|
|
341
|
+
}
|
|
342
|
+
const recommendedProxy = recommendProxyMode(capabilities, target);
|
|
343
|
+
if (canUseTui()) {
|
|
344
|
+
return selectChoice("Connection mode", [
|
|
345
|
+
{
|
|
346
|
+
label: "Direct provider config",
|
|
347
|
+
value: false,
|
|
348
|
+
hint: recommendedProxy ? "target API not detected" : "recommended"
|
|
349
|
+
},
|
|
350
|
+
{
|
|
351
|
+
label: "Use compatibility proxy",
|
|
352
|
+
value: true,
|
|
353
|
+
hint: recommendedProxy ? "recommended" : "maximum compatibility"
|
|
354
|
+
}
|
|
355
|
+
]);
|
|
356
|
+
}
|
|
357
|
+
const prompt = recommendedProxy ? "Use compatibility proxy? [Y/n] " : "Use direct provider config? [Y/n] ";
|
|
358
|
+
const answer = await rl.question(prompt);
|
|
359
|
+
const accepted = !answer.trim() || answer.trim().toLowerCase() === "y";
|
|
360
|
+
return recommendedProxy ? accepted : !accepted;
|
|
361
|
+
}
|
|
362
|
+
async function resolveSetupTarget(rl, target) {
|
|
363
|
+
if (target) {
|
|
364
|
+
return target;
|
|
365
|
+
}
|
|
366
|
+
if (canUseTui()) {
|
|
367
|
+
return selectChoice("Choose agent platform", [
|
|
368
|
+
{ label: "OpenCode", value: "opencode" },
|
|
369
|
+
{ label: "Codex", value: "codex" },
|
|
370
|
+
{ label: "Claude Code", value: "claude-code" }
|
|
371
|
+
]);
|
|
372
|
+
}
|
|
373
|
+
const answer = await rl.question("Agent platform [opencode/codex/claude-code]: ");
|
|
374
|
+
const value = answer.trim() || "opencode";
|
|
375
|
+
if (value === "opencode" || value === "codex" || value === "claude-code") {
|
|
376
|
+
return value;
|
|
377
|
+
}
|
|
378
|
+
throw new Error(`Unknown setup target: ${value}`);
|
|
379
|
+
}
|
|
189
380
|
async function resolveApiKey(rl, apiKey, preset, label = "API key") {
|
|
190
381
|
if (apiKey !== undefined) {
|
|
191
382
|
return apiKey;
|
package/dist/core/provider.js
CHANGED
|
@@ -37,6 +37,37 @@ export async function validateAndFetchModels(input, fetchImpl = globalThis.fetch
|
|
|
37
37
|
}
|
|
38
38
|
return { baseURL, models };
|
|
39
39
|
}
|
|
40
|
+
export async function detectProviderCapabilities(input, fetchImpl = globalThis.fetch) {
|
|
41
|
+
const baseURL = normalizeBaseUrl(input.baseURL);
|
|
42
|
+
const apiKey = input.apiKey?.trim() ?? "";
|
|
43
|
+
const headers = apiKey ? { Authorization: `Bearer ${apiKey}` } : {};
|
|
44
|
+
const apis = new Set();
|
|
45
|
+
const models = await probe(`${baseURL}/models`, { method: "GET", headers }, fetchImpl);
|
|
46
|
+
if (models.reachable) {
|
|
47
|
+
apis.add("openai-compatible");
|
|
48
|
+
}
|
|
49
|
+
const responses = await probe(`${baseURL}/responses`, { method: "POST", headers: { ...headers, "content-type": "application/json" }, body: "{}" }, fetchImpl);
|
|
50
|
+
if (responses.reachable) {
|
|
51
|
+
apis.add("openai-responses");
|
|
52
|
+
}
|
|
53
|
+
const messages = await probe(`${baseURL}/messages`, { method: "POST", headers: { ...headers, "content-type": "application/json" }, body: "{}" }, fetchImpl);
|
|
54
|
+
if (messages.reachable) {
|
|
55
|
+
apis.add("anthropic-messages");
|
|
56
|
+
}
|
|
57
|
+
return { baseURL, apis: [...apis] };
|
|
58
|
+
}
|
|
59
|
+
export function targetRequiredApi(target) {
|
|
60
|
+
if (target === "codex") {
|
|
61
|
+
return "openai-responses";
|
|
62
|
+
}
|
|
63
|
+
if (target === "claude-code") {
|
|
64
|
+
return "anthropic-messages";
|
|
65
|
+
}
|
|
66
|
+
return "openai-compatible";
|
|
67
|
+
}
|
|
68
|
+
export function recommendProxyMode(capabilities, target) {
|
|
69
|
+
return !capabilities.apis.includes(targetRequiredApi(target));
|
|
70
|
+
}
|
|
40
71
|
export function buildProviderConfig(input) {
|
|
41
72
|
const baseURL = normalizeBaseUrl(input.baseURL);
|
|
42
73
|
const apiKey = input.apiKey?.trim();
|
|
@@ -70,3 +101,12 @@ function isOpenCodeCompatibleModel(model) {
|
|
|
70
101
|
}
|
|
71
102
|
return ["llm", "chat", "completion", "text-generation"].includes(model.type.trim().toLowerCase());
|
|
72
103
|
}
|
|
104
|
+
async function probe(input, init, fetchImpl) {
|
|
105
|
+
try {
|
|
106
|
+
const response = await fetchImpl(input, init);
|
|
107
|
+
return { reachable: response.ok || response.status === 400 || response.status === 401 || response.status === 422 };
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return { reachable: false };
|
|
111
|
+
}
|
|
112
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
export function getDefaultProxyStatePath(homeDir = homedir()) {
|
|
6
|
+
return join(homeDir, ".config", "model-provider-x", "proxy.json");
|
|
7
|
+
}
|
|
8
|
+
export async function readProxyStatus(path = getDefaultProxyStatePath()) {
|
|
9
|
+
const state = await readProxyState(path);
|
|
10
|
+
if (!state) {
|
|
11
|
+
return { running: false };
|
|
12
|
+
}
|
|
13
|
+
return { running: isProcessRunning(state.pid), state };
|
|
14
|
+
}
|
|
15
|
+
export async function startProxyProcess(input) {
|
|
16
|
+
const profile = input.config.profiles[input.profileId];
|
|
17
|
+
if (!profile) {
|
|
18
|
+
throw new Error(`Unknown provider profile: ${input.profileId}`);
|
|
19
|
+
}
|
|
20
|
+
const statePath = input.statePath ?? getDefaultProxyStatePath();
|
|
21
|
+
const current = await readProxyStatus(statePath);
|
|
22
|
+
if (current.running && current.state?.profileId === input.profileId) {
|
|
23
|
+
return current.state;
|
|
24
|
+
}
|
|
25
|
+
if (current.running && current.state) {
|
|
26
|
+
if (input.replace) {
|
|
27
|
+
await stopProxyProcess(statePath);
|
|
28
|
+
}
|
|
29
|
+
else {
|
|
30
|
+
throw new Error(`Proxy is already running for profile ${current.state.profileId}`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
const host = input.host ?? input.config.proxy.host;
|
|
34
|
+
const port = input.port ?? input.config.proxy.port;
|
|
35
|
+
const entrypoint = input.entrypoint ?? process.argv[1];
|
|
36
|
+
const child = spawn(input.nodePath ?? process.execPath, [entrypoint, "proxy", "--profile", input.profileId, "--host", host, "--port", String(port)], {
|
|
37
|
+
detached: true,
|
|
38
|
+
stdio: "ignore",
|
|
39
|
+
env: { ...process.env, MODEL_PROVIDER_X_API_KEY: input.config.proxy.authToken }
|
|
40
|
+
});
|
|
41
|
+
child.unref();
|
|
42
|
+
const state = {
|
|
43
|
+
pid: child.pid ?? 0,
|
|
44
|
+
profileId: input.profileId,
|
|
45
|
+
baseURL: `http://${host}:${port}`,
|
|
46
|
+
authToken: input.config.proxy.authToken,
|
|
47
|
+
startedAt: new Date().toISOString()
|
|
48
|
+
};
|
|
49
|
+
await writeProxyState(statePath, state);
|
|
50
|
+
return state;
|
|
51
|
+
}
|
|
52
|
+
export async function stopProxyProcess(path = getDefaultProxyStatePath()) {
|
|
53
|
+
const state = await readProxyState(path);
|
|
54
|
+
if (!state) {
|
|
55
|
+
return { running: false };
|
|
56
|
+
}
|
|
57
|
+
if (isProcessRunning(state.pid)) {
|
|
58
|
+
process.kill(state.pid, "SIGTERM");
|
|
59
|
+
}
|
|
60
|
+
await rm(path, { force: true });
|
|
61
|
+
return { running: false, state };
|
|
62
|
+
}
|
|
63
|
+
async function readProxyState(path) {
|
|
64
|
+
if (!(await fileExists(path))) {
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
const parsed = JSON.parse(await readFile(path, "utf8"));
|
|
68
|
+
if (typeof parsed.pid !== "number" || typeof parsed.profileId !== "string") {
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
71
|
+
return {
|
|
72
|
+
pid: parsed.pid,
|
|
73
|
+
profileId: parsed.profileId,
|
|
74
|
+
baseURL: String(parsed.baseURL ?? ""),
|
|
75
|
+
authToken: String(parsed.authToken ?? ""),
|
|
76
|
+
startedAt: String(parsed.startedAt ?? "")
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
async function writeProxyState(path, state) {
|
|
80
|
+
await mkdir(dirname(path), { recursive: true });
|
|
81
|
+
await writeFile(path, `${JSON.stringify(state, null, 2)}\n`, "utf8");
|
|
82
|
+
}
|
|
83
|
+
function isProcessRunning(pid) {
|
|
84
|
+
if (!pid) {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
try {
|
|
88
|
+
process.kill(pid, 0);
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
async function fileExists(path) {
|
|
96
|
+
try {
|
|
97
|
+
return (await stat(path)).isFile();
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
export function responsesToChatCompletionRequest(request) {
|
|
2
|
+
if (request.stream) {
|
|
3
|
+
throw new Error("Responses streaming is not supported yet");
|
|
4
|
+
}
|
|
5
|
+
const messages = [];
|
|
6
|
+
if (request.instructions) {
|
|
7
|
+
messages.push({ role: "system", content: request.instructions });
|
|
8
|
+
}
|
|
9
|
+
if (typeof request.input === "string") {
|
|
10
|
+
messages.push({ role: "user", content: request.input });
|
|
11
|
+
}
|
|
12
|
+
else {
|
|
13
|
+
for (const item of request.input) {
|
|
14
|
+
messages.push(responsesInputItemToChatMessage(item));
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
return pruneUndefined({
|
|
18
|
+
model: request.model,
|
|
19
|
+
messages,
|
|
20
|
+
max_tokens: request.max_output_tokens,
|
|
21
|
+
temperature: request.temperature,
|
|
22
|
+
top_p: request.top_p,
|
|
23
|
+
tools: request.tools?.map((tool) => ({
|
|
24
|
+
type: "function",
|
|
25
|
+
function: {
|
|
26
|
+
name: tool.name,
|
|
27
|
+
description: tool.description,
|
|
28
|
+
parameters: tool.parameters ?? {}
|
|
29
|
+
}
|
|
30
|
+
}))
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
export function chatCompletionToResponses(response) {
|
|
34
|
+
const choice = response.choices[0];
|
|
35
|
+
const message = choice?.message;
|
|
36
|
+
const output = [];
|
|
37
|
+
if (message?.content) {
|
|
38
|
+
output.push({
|
|
39
|
+
id: `msg_${response.id}`,
|
|
40
|
+
type: "message",
|
|
41
|
+
status: "completed",
|
|
42
|
+
role: "assistant",
|
|
43
|
+
content: [{ type: "output_text", text: message.content, annotations: [] }]
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
for (const toolCall of message?.tool_calls ?? []) {
|
|
47
|
+
output.push({
|
|
48
|
+
id: toolCall.id,
|
|
49
|
+
type: "function_call",
|
|
50
|
+
status: "completed",
|
|
51
|
+
call_id: toolCall.id,
|
|
52
|
+
name: toolCall.function.name,
|
|
53
|
+
arguments: toolCall.function.arguments
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
return {
|
|
57
|
+
id: `resp_${response.id}`,
|
|
58
|
+
object: "response",
|
|
59
|
+
status: "completed",
|
|
60
|
+
model: response.model,
|
|
61
|
+
output,
|
|
62
|
+
output_text: outputText(output),
|
|
63
|
+
usage: {
|
|
64
|
+
input_tokens: response.usage?.prompt_tokens ?? 0,
|
|
65
|
+
output_tokens: response.usage?.completion_tokens ?? 0
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
function responsesInputItemToChatMessage(item) {
|
|
70
|
+
if (item.type === "function_call_output") {
|
|
71
|
+
return { role: "tool", tool_call_id: item.call_id, content: item.output };
|
|
72
|
+
}
|
|
73
|
+
const content = responsesContentToText(item.content);
|
|
74
|
+
if (item.role === "assistant") {
|
|
75
|
+
return { role: "assistant", content };
|
|
76
|
+
}
|
|
77
|
+
return {
|
|
78
|
+
role: item.role === "user" ? "user" : "system",
|
|
79
|
+
content
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
function responsesContentToText(content) {
|
|
83
|
+
if (typeof content === "string") {
|
|
84
|
+
return content;
|
|
85
|
+
}
|
|
86
|
+
return content
|
|
87
|
+
.map((part) => {
|
|
88
|
+
if (part.type === "input_text" || part.type === "output_text" || part.type === "text") {
|
|
89
|
+
return part.text;
|
|
90
|
+
}
|
|
91
|
+
throw new Error(`Unsupported Responses content part type: ${part.type ?? "unknown"}`);
|
|
92
|
+
})
|
|
93
|
+
.join("\n");
|
|
94
|
+
}
|
|
95
|
+
function outputText(output) {
|
|
96
|
+
return output
|
|
97
|
+
.flatMap((item) => (Array.isArray(item.content) ? item.content : []))
|
|
98
|
+
.filter((part) => isRecord(part) && part.type === "output_text" && typeof part.text === "string")
|
|
99
|
+
.map((part) => part.text)
|
|
100
|
+
.join("");
|
|
101
|
+
}
|
|
102
|
+
function isRecord(value) {
|
|
103
|
+
return typeof value === "object" && value !== null;
|
|
104
|
+
}
|
|
105
|
+
function pruneUndefined(value) {
|
|
106
|
+
return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined));
|
|
107
|
+
}
|
package/dist/proxy/server.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createServer } from "node:http";
|
|
2
2
|
import { anthropicMessageToChatRequest, chatCompletionToAnthropicMessage, createAnthropicStreamEvents, formatSseEvent } from "./anthropic-chat.js";
|
|
3
|
+
import { chatCompletionToResponses, responsesToChatCompletionRequest } from "./responses.js";
|
|
3
4
|
import { parseOpenAiSseStream } from "./sse.js";
|
|
4
5
|
export async function startProxyServer(input) {
|
|
5
6
|
const profile = input.config.profiles[input.profileId];
|
|
@@ -30,6 +31,14 @@ export async function startProxyServer(input) {
|
|
|
30
31
|
await handleMessages(request, response, profile, fetchImpl);
|
|
31
32
|
return;
|
|
32
33
|
}
|
|
34
|
+
if (request.method === "POST" && (request.url === "/v1/chat/completions" || request.url === "/v1/completions")) {
|
|
35
|
+
await passthroughOpenAi(request, response, profile, fetchImpl, request.url);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
if (request.method === "POST" && request.url === "/v1/responses") {
|
|
39
|
+
await handleResponses(request, response, profile, fetchImpl);
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
33
42
|
writeJson(response, 404, { error: { type: "not_found_error", message: "Not found" } });
|
|
34
43
|
}
|
|
35
44
|
catch (error) {
|
|
@@ -45,6 +54,65 @@ export async function startProxyServer(input) {
|
|
|
45
54
|
close: () => new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())))
|
|
46
55
|
};
|
|
47
56
|
}
|
|
57
|
+
async function passthroughOpenAi(request, response, profile, fetchImpl, path) {
|
|
58
|
+
const body = await readRaw(request);
|
|
59
|
+
const upstream = await fetchImpl(`${profile.baseURL.replace(/\/+$/, "")}${path.replace(/^\/v1/, "")}`, {
|
|
60
|
+
method: "POST",
|
|
61
|
+
headers: {
|
|
62
|
+
"content-type": request.headers["content-type"] ?? "application/json",
|
|
63
|
+
...(profile.apiKey ? { Authorization: `Bearer ${profile.apiKey}` } : {})
|
|
64
|
+
},
|
|
65
|
+
body: new Uint8Array(body)
|
|
66
|
+
});
|
|
67
|
+
if (!upstream.ok) {
|
|
68
|
+
const message = upstream.text ? await upstream.text() : upstream.statusText;
|
|
69
|
+
writeJson(response, upstream.status ?? 502, { error: { type: "upstream_error", message } });
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
if (isStreamingOpenAiRequest(body) && upstream.body) {
|
|
73
|
+
response.writeHead(200, {
|
|
74
|
+
"content-type": "text/event-stream; charset=utf-8"
|
|
75
|
+
});
|
|
76
|
+
for await (const chunk of upstream.body) {
|
|
77
|
+
response.write(Buffer.from(chunk));
|
|
78
|
+
}
|
|
79
|
+
response.end();
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
if (!upstream.json) {
|
|
83
|
+
throw new Error("Upstream response did not include JSON");
|
|
84
|
+
}
|
|
85
|
+
writeJson(response, 200, await upstream.json());
|
|
86
|
+
}
|
|
87
|
+
function isStreamingOpenAiRequest(body) {
|
|
88
|
+
try {
|
|
89
|
+
return Boolean(JSON.parse(body.toString("utf8")).stream);
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
async function handleResponses(request, response, profile, fetchImpl) {
|
|
96
|
+
const body = (await readJson(request));
|
|
97
|
+
const chatRequest = responsesToChatCompletionRequest(body);
|
|
98
|
+
const upstream = await fetchImpl(`${profile.baseURL.replace(/\/+$/, "")}/chat/completions`, {
|
|
99
|
+
method: "POST",
|
|
100
|
+
headers: {
|
|
101
|
+
"content-type": "application/json",
|
|
102
|
+
...(profile.apiKey ? { Authorization: `Bearer ${profile.apiKey}` } : {})
|
|
103
|
+
},
|
|
104
|
+
body: JSON.stringify(chatRequest)
|
|
105
|
+
});
|
|
106
|
+
if (!upstream.ok) {
|
|
107
|
+
const message = upstream.text ? await upstream.text() : upstream.statusText;
|
|
108
|
+
writeJson(response, upstream.status ?? 502, { error: { type: "upstream_error", message } });
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
if (!upstream.json) {
|
|
112
|
+
throw new Error("Upstream response did not include JSON");
|
|
113
|
+
}
|
|
114
|
+
writeJson(response, 200, chatCompletionToResponses((await upstream.json())));
|
|
115
|
+
}
|
|
48
116
|
async function handleMessages(request, response, profile, fetchImpl) {
|
|
49
117
|
const body = (await readJson(request));
|
|
50
118
|
const chatRequest = anthropicMessageToChatRequest(body);
|
|
@@ -88,11 +156,14 @@ function isAuthorized(request, authToken) {
|
|
|
88
156
|
return authorization === `Bearer ${authToken}` || apiKey === authToken;
|
|
89
157
|
}
|
|
90
158
|
async function readJson(request) {
|
|
159
|
+
return JSON.parse((await readRaw(request)).toString("utf8") || "{}");
|
|
160
|
+
}
|
|
161
|
+
async function readRaw(request) {
|
|
91
162
|
const chunks = [];
|
|
92
163
|
for await (const chunk of request) {
|
|
93
164
|
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
94
165
|
}
|
|
95
|
-
return
|
|
166
|
+
return Buffer.concat(chunks);
|
|
96
167
|
}
|
|
97
168
|
function writeJson(response, status, body) {
|
|
98
169
|
response.writeHead(status, { "content-type": "application/json; charset=utf-8" });
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { copyFile, mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
export function getDefaultCodexConfigPath(homeDir = homedir()) {
|
|
5
|
+
return join(homeDir, ".codex", "config.toml");
|
|
6
|
+
}
|
|
7
|
+
export function mergeCodexConfig(text, provider) {
|
|
8
|
+
let next = text.trimEnd();
|
|
9
|
+
next = upsertTopLevelString(next, "model", provider.model);
|
|
10
|
+
next = upsertTopLevelString(next, "model_provider", provider.providerId);
|
|
11
|
+
next = removeTable(next, `model_providers.${quoteTomlKey(provider.providerId)}`);
|
|
12
|
+
const providerBlock = [
|
|
13
|
+
`[model_providers.${quoteTomlKey(provider.providerId)}]`,
|
|
14
|
+
`name = ${quoteTomlString(provider.providerName)}`,
|
|
15
|
+
`base_url = ${quoteTomlString(provider.baseURL)}`,
|
|
16
|
+
'wire_api = "responses"',
|
|
17
|
+
"",
|
|
18
|
+
`[model_providers.${quoteTomlKey(provider.providerId)}.auth]`,
|
|
19
|
+
`command = ${quoteTomlString(provider.authCommand)}`,
|
|
20
|
+
`args = [${provider.authArgs.map(quoteTomlString).join(", ")}]`,
|
|
21
|
+
"refresh_interval_ms = 0"
|
|
22
|
+
].join("\n");
|
|
23
|
+
return `${next.trimEnd()}\n\n${providerBlock}\n`;
|
|
24
|
+
}
|
|
25
|
+
export async function writeCodexConfig(input) {
|
|
26
|
+
const targetPath = input.targetPath ?? getDefaultCodexConfigPath();
|
|
27
|
+
await mkdir(dirname(targetPath), { recursive: true });
|
|
28
|
+
const exists = await fileExists(targetPath);
|
|
29
|
+
const current = exists ? await readFile(targetPath, "utf8") : "";
|
|
30
|
+
let backupPath;
|
|
31
|
+
if (exists) {
|
|
32
|
+
backupPath = `${targetPath}.${timestamp()}.bak`;
|
|
33
|
+
await copyFile(targetPath, backupPath);
|
|
34
|
+
}
|
|
35
|
+
await writeFile(targetPath, mergeCodexConfig(current, input), "utf8");
|
|
36
|
+
return { targetPath, backupPath };
|
|
37
|
+
}
|
|
38
|
+
function upsertTopLevelString(text, key, value) {
|
|
39
|
+
const line = `${key} = ${quoteTomlString(value)}`;
|
|
40
|
+
const pattern = new RegExp(`^${escapeRegExp(key)}\\s*=.*$`, "m");
|
|
41
|
+
if (pattern.test(text)) {
|
|
42
|
+
return text.replace(pattern, line);
|
|
43
|
+
}
|
|
44
|
+
return text ? `${line}\n${text}` : line;
|
|
45
|
+
}
|
|
46
|
+
function removeTable(text, tableName) {
|
|
47
|
+
const lines = text.split(/\r?\n/);
|
|
48
|
+
const header = `[${tableName}]`;
|
|
49
|
+
const kept = [];
|
|
50
|
+
let removing = false;
|
|
51
|
+
for (const line of lines) {
|
|
52
|
+
const trimmed = line.trim();
|
|
53
|
+
if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
|
|
54
|
+
removing = trimmed === header || trimmed.startsWith(`[${tableName}.`);
|
|
55
|
+
if (removing) {
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
if (!removing) {
|
|
60
|
+
kept.push(line);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return kept.join("\n").trimEnd();
|
|
64
|
+
}
|
|
65
|
+
function quoteTomlKey(value) {
|
|
66
|
+
return quoteTomlString(value);
|
|
67
|
+
}
|
|
68
|
+
function quoteTomlString(value) {
|
|
69
|
+
return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
70
|
+
}
|
|
71
|
+
function escapeRegExp(value) {
|
|
72
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
73
|
+
}
|
|
74
|
+
async function fileExists(path) {
|
|
75
|
+
try {
|
|
76
|
+
return (await stat(path)).isFile();
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
function timestamp() {
|
|
83
|
+
return new Date().toISOString().replace(/[:.]/g, "-");
|
|
84
|
+
}
|
package/package.json
CHANGED