@atmorouter/helper 0.1.0 → 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 CHANGED
@@ -1,54 +1,89 @@
1
1
  # AtmoRouter helper
2
2
 
3
- Configure Claude Code or OpenCode for AtmoRouter, discover model IDs, and check an API key without running paid inference. Review configuration changes before applying them; existing settings are merged and backed up.
3
+ Configure coding agents for AtmoRouter, get IDE setup instructions, discover model IDs, and check a key without running inference. Changes are previewed first; automatic configuration merges existing settings and creates backups.
4
4
 
5
- Requires Node.js 22 or newer. No runtime dependencies. Run the versioned package from the official AtmoRouter website:
5
+ Requires Node.js 22 or newer. The public npm package uses the `yaml` runtime dependency to safely parse and preserve YAML configuration. No npm account or repository access is required.
6
6
 
7
7
  ```sh
8
- npx https://atmorouter.dev/downloads/atmorouter-helper-0.1.0.tgz --help
9
- npx https://atmorouter.dev/downloads/atmorouter-helper-0.1.0.tgz models --search claude
10
- npx https://atmorouter.dev/downloads/atmorouter-helper-0.1.0.tgz configure --target opencode --model YOUR_MODEL_ID
11
- npx https://atmorouter.dev/downloads/atmorouter-helper-0.1.0.tgz configure --target claude --model YOUR_MODEL_ID --write
8
+ npx @atmorouter/helper@0.2.0 --help
9
+ npx @atmorouter/helper@0.2.0 targets
10
+ npx @atmorouter/helper@0.2.0 models --search claude
11
+ npx @atmorouter/helper@0.2.0 configure --target opencode --model YOUR_MODEL_ID
12
+ npx @atmorouter/helper@0.2.0 configure --target claude --model YOUR_MODEL_ID --write
12
13
  ```
13
14
 
14
- With no command, the interactive wizard searches live text models, previews the selected settings, and asks before applying changes. `configure` is preview-only unless `--write` is supplied. Non-interactive writes additionally require `--yes`. Tests use explicit temporary `--config-dir` folders, never the developer's real agent settings.
15
+ The same version is available as `https://atmorouter.dev/downloads/atmorouter-helper-0.2.0.tgz`, with a `.sha256` checksum alongside it. Run it with `npx https://atmorouter.dev/downloads/atmorouter-helper-0.2.0.tgz --help`.
15
16
 
16
- Targets:
17
+ With no command, the interactive wizard searches live text models and previews the selected setup. `configure` is preview-only unless `--write` is supplied. Non-interactive file writes additionally require `--yes`. Tests use temporary `--config-dir` folders, never real agent settings.
17
18
 
18
- | Target | Output |
19
- |---|---|
20
- | `claude` | `~/.claude/settings.json`, changing model/base URL variables and preserving other JSON settings |
21
- | `opencode` | `$XDG_CONFIG_HOME/opencode/opencode.json` or `~/.config/opencode/opencode.json`, using `@ai-sdk/openai-compatible` |
22
- | `openai` | OpenAI-compatible environment snippet; no file writes |
23
- | `anthropic` | Anthropic-compatible environment snippet; no file writes |
19
+ ## Connections
24
20
 
25
- Use `--shell powershell` for PowerShell snippets. `--config-dir PATH` explicitly selects a settings folder. `--offline` skips catalog validation, for previews or isolated tests; it does not establish model availability or agent compatibility. Preview output shows only intended changes, not existing file contents.
21
+ | Target | Mode and output |
22
+ |---|---|
23
+ | `claude` | Automatic: `~/.claude/settings.json`, preserving unrelated JSON settings |
24
+ | `opencode` | Automatic: `$XDG_CONFIG_HOME/opencode/opencode.json` or `~/.config/opencode/opencode.json`, using `@ai-sdk/openai-compatible` |
25
+ | `omp` | Automatic: Oh My Pi `~/.omp/agent/models.yml`, with `api: openai-completions` and an environment variable name for `apiKey` |
26
+ | `continue` | Automatic: `~/.continue/config.yaml`; the secret reference requires a separate manual IDE secret setup |
27
+ | `cursor` | Guided UI: API key and OpenAI base URL override where available; custom keys apply to Chat, not Tab completion |
28
+ | `vscode` | Guided UI: Copilot Chat Manage Language Models → Add Models → Custom Endpoint, replacing the deprecated OpenAI Compatible setting |
29
+ | `cline` | Guided UI: select OpenAI Compatible, set the endpoint, key, and model |
30
+ | `roo` | Guided UI: select OpenAI Compatible; the chosen model must support native tool calling |
31
+ | `codex` | Local bridge instructions; explicit `codex` or `bridge` commands start the bridge |
32
+ | `antigravity` | Unsupported native IDE integration; diagnostic instructions only |
33
+ | `openai`, `anthropic` | Protocol-specific shell environment snippets; no file writes |
34
+
35
+ Guided targets print the steps to perform in your installed client; they do not edit undocumented IDE files. Antigravity does not offer a documented native IDE custom endpoint or authenticated SDK integration path suitable for this helper. It is not advertised as compatible.
36
+
37
+ Use `--shell powershell` for PowerShell snippets. `--config-dir PATH` explicitly selects a settings folder. `--offline` skips live model validation; it does not establish model availability or client compatibility. Preview output shows intended changes, not existing file contents.
26
38
 
27
39
  ## Credentials
28
40
 
29
- The helper never saves or prints an API key. Set `ATMOROUTER_API_KEY` privately through your shell or secret manager. OpenCode reads `{env:ATMOROUTER_API_KEY}`. For Claude Code, set `ANTHROPIC_AUTH_TOKEN` to the same value before launching Claude. Settings that already embed Claude credentials or custom provider credential headers require a manual merge, so an older secret cannot silently override your new environment.
41
+ The helper never saves or prints your API key. Set `ATMOROUTER_API_KEY` privately through your shell or secret manager. OpenCode reads `{env:ATMOROUTER_API_KEY}`; Oh My Pi reads the environment variable named by `apiKey`. Continue uses `${{ secrets.ATMOROUTER_API_KEY }}`; for the IDE, manually add the secret to `~/.continue/.env` without committing that file. For Claude Code, set `ANTHROPIC_AUTH_TOKEN` to the same key before launching Claude. Guided IDE targets require you to enter the key in the client's own credential UI.
30
42
 
31
43
  ```sh
32
44
  export ANTHROPIC_AUTH_TOKEN="$ATMOROUTER_API_KEY"
33
- npx https://atmorouter.dev/downloads/atmorouter-helper-0.1.0.tgz check-key
45
+ npx @atmorouter/helper@0.2.0 check-key
46
+ ```
47
+
48
+ `GET /v1/models` is public and **cannot validate a key**. The optional explicit key check sends an empty JSON body to `/v1/chat/completions`. Only HTTP 400 with `model is required` proves the gateway accepted the credential before rejecting the body. No model is selected, no inference is forwarded, and nothing is billed. The check consumes a rate-limit slot. HTTP 401 rejects the key; 429 and all other responses are inconclusive. Requests have a 15-second timeout and refuse redirects. No custom upstream origin can receive your key.
49
+
50
+ ## Codex local bridge
51
+
52
+ The public gateway implements Chat Completions and Anthropic Messages, not `/v1/responses`. The helper can explicitly start a localhost Responses adapter for Codex and translate a supported subset of requests to Chat Completions:
53
+
54
+ ```sh
55
+ # Prints instructions only; does not start a server or write Codex settings.
56
+ npx @atmorouter/helper@0.2.0 configure --target codex --model YOUR_MODEL_ID
57
+
58
+ # Starts a temporary local bridge and launches your installed Codex CLI.
59
+ npx @atmorouter/helper@0.2.0 codex --model YOUR_MODEL_ID
60
+
61
+ # Or run the bridge separately and follow its printed client instructions.
62
+ npx @atmorouter/helper@0.2.0 bridge --port 43187
34
63
  ```
35
64
 
36
- `GET /v1/models` is public and **cannot validate a key**. The optional explicit key check sends an empty JSON body to `/v1/chat/completions`. Only HTTP 400 with `model is required` is accepted as proof that the gateway accepted the credential before rejecting the body. No model is selected, no inference is forwarded, and nothing is billed. The check consumes a rate-limit slot. HTTP 401 rejects the key; 429 and all other responses are inconclusive. Requests have a 15-second timeout and refuse redirects. There is no custom API origin option that could send a credential to a different host.
65
+ Set `ATMOROUTER_API_KEY` in the process environment before starting the bridge. It listens on loopback only, authenticates local requests, and forwards inference to the official AtmoRouter endpoint. The launcher preserves Codex's permission defaults. It does not weaken approvals or sandbox settings.
66
+
67
+ The bridge supports stateless text and function-tool exchanges, including streaming. The launcher disables hosted web search for this run because the gateway cannot execute that tool. It rejects unsupported built-in tools and stateful Responses features. Reasoning summaries and encrypted reasoning output are unavailable. Image/audio inputs and complete Responses API parity are not promised. Client and model features can still be incompatible. Calls through the bridge are normal paid inference, billed to your AtmoRouter key. The server starts only when you explicitly run `codex` or `bridge`.
37
68
 
38
69
  ## Applying and restoring settings
39
70
 
40
- Existing strict JSON settings are merged, not replaced wholesale. JSONC, invalid JSON, non-object roots, and symlink targets are refused. If an `opencode.jsonc` file exists, use a manual merge instead of creating a competing JSON file. Writes recheck that the file still matches its preview, make a timestamped `.atmorouter-backup-*` copy, and publish the new settings through a temporary file in the same directory. Backups and new files request owner-only permissions (0600 on POSIX); Windows access also depends on directory ACLs.
71
+ Existing JSON and YAML settings are merged, not replaced wholesale. Unsupported formats, malformed documents, non-object roots, symlinks, or ambiguous existing configuration require a manual merge. JSONC is refused. If an `opencode.jsonc` file exists, use a manual merge instead of creating a competing JSON file. Writes recheck that the file matches its preview, create a timestamped `.atmorouter-backup-*` copy, and publish through a temporary file in the same directory. Backups and new files request owner-only permissions (0600 on POSIX); Windows access also depends on directory ACLs.
41
72
 
42
- To roll back, stop the agent, compare its current file with the backup, and restore the backup after preserving any intervening edits. For a newly created settings file, remove only the AtmoRouter entries you added; do not delete unrelated settings.
73
+ To roll back, stop the agent, compare its current file with the backup, and restore the backup after preserving intervening edits. For newly created files, remove only the AtmoRouter entries you added.
43
74
 
44
- The helper does not install agents, execute models, alter shell profiles, create keys, move money, or manage accounts. It does not configure Codex/Responses. A generated config establishes the API connection format; tools, context handling, and agent features remain model/client dependent.
75
+ The helper does not install agents, alter shell profiles, create keys, move money, or manage accounts. Tools, context handling, and agent features depend on the selected model and client.
45
76
 
46
77
  ## Checks
47
78
 
48
79
  ```sh
49
80
  cd packages/helper
81
+ npm ci --registry=https://registry.npmjs.org/ --ignore-scripts
50
82
  node --test
51
83
  npm pack --dry-run
84
+ npm run test:package
52
85
  ```
53
86
 
54
- Source references: [Claude Code gateways](https://code.claude.com/docs/en/llm-gateway), [OpenCode custom providers](https://opencode.ai/docs/providers/). The documented AtmoRouter endpoints are verified against the gateway implementation.
87
+ The packed-install check uses an isolated npm cache and the public registry, without requiring account credentials. The archive contains only the package manifest, README, CLI, and three library modules; tests, scripts, local settings, and keys are excluded.
88
+
89
+ Client references: [Claude Code](https://code.claude.com/docs/en/llm-gateway), [OpenCode](https://opencode.ai/docs/providers/), [Oh My Pi](https://github.com/can1357/oh-my-pi), [Continue secrets](https://docs.continue.dev/customize/deep-dives/secrets), [Cursor keys](https://cursor.com/help/models-and-usage/api-keys), [VS Code language models](https://code.visualstudio.com/docs/agent-customization/language-models), [Cline](https://docs.cline.bot/provider-config/openai-compatible), [Roo Code](https://docs.roocode.com/providers/openai-compatible), and [Codex custom providers](https://developers.openai.com/codex/config-reference/).
@@ -2,37 +2,91 @@
2
2
  import {parseArgs} from 'node:util';
3
3
  import {createInterface} from 'node:readline/promises';
4
4
  import {readFile} from 'node:fs/promises';
5
- import {models,checkKey,planConfiguration,writeConfiguration,environment} from '../lib/helper.mjs';
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import {spawn} from 'node:child_process';
8
+ import {models,modelId,checkKey,planConfiguration,writeConfiguration,environment,INTEGRATIONS,guidedConfiguration} from '../lib/helper.mjs';
9
+ import {startResponsesBridge} from '../lib/bridge.mjs';
6
10
 
7
11
  const HELP=`AtmoRouter helper
8
- Node.js 22+; no runtime dependencies.
12
+ Node.js 22+.
9
13
 
10
14
  Commands:
11
15
  models [--search TEXT] List live text models; no key required
12
16
  check-key Check ATMOROUTER_API_KEY without inference
13
17
  configure --target TARGET --model ID Preview settings (default: no write)
18
+ targets List automatic, guided, and bridge integrations
19
+ bridge [--port 43187] Run a loopback-only Responses bridge
20
+ codex --model ID [-- CODEX_ARGS] Start installed Codex through the local bridge
14
21
  No command Interactive configuration wizard
15
22
 
16
- Targets: claude, opencode, openai, anthropic
23
+ Run targets for current compatibility and limitations.
17
24
  Options:
18
- --write Apply JSON configuration after confirmation
25
+ --write Apply configuration after confirmation
19
26
  --yes Confirm writes for non-interactive use
20
27
  --config-dir PATH Explicit configuration folder
21
28
  --offline Skip catalog lookup; review model ID yourself
22
29
  --shell posix|powershell Environment snippet format
23
30
  --help Show this help
24
31
  --version Show the installed version
32
+ --port NUMBER Local bridge port (default: 43187)
25
33
 
26
34
  Keys are read only from ATMOROUTER_API_KEY for check-key. They are never printed
27
35
  or stored in generated files. Set ANTHROPIC_AUTH_TOKEN separately for Claude.
28
- No Responses/Codex, payment, key-creation, or account-management actions.`;
36
+ Codex uses a local stateless text/tool Responses bridge, not a native gateway
37
+ Responses endpoint. Normal model charges apply. No payment or account actions.`;
38
+
39
+ function codexExecutable(){
40
+ const extensions=process.platform==='win32'?['.exe','.cmd','.bat','']:[''];
41
+ for(const directory of (process.env.PATH || '').split(path.delimiter))for(const extension of extensions){
42
+ const file=path.join(directory,'codex'+extension);if(!fs.existsSync(file))continue;
43
+ if(process.platform==='win32'&&['.cmd','.bat'].includes(extension)){
44
+ for(const wrapper of [path.join(directory,'node_modules/@openai/codex/bin/codex.js'),path.resolve(directory,'../@openai/codex/bin/codex.js')])if(fs.existsSync(wrapper))return {file:process.execPath,args:[wrapper]};
45
+ continue;
46
+ }
47
+ return {file,args:[]};
48
+ }
49
+ throw Error('Install the official Codex CLI and add it to PATH first, or use bridge with a manually configured client.');
50
+ }
51
+ async function launchCodex(model,args){
52
+ if(!/^ar-[a-f0-9]{48}$/.test(process.env.ATMOROUTER_API_KEY || ''))throw Error('Set ATMOROUTER_API_KEY privately before starting Codex.');
53
+ const executable=codexExecutable();
54
+ const bridge=await startResponsesBridge({port:0});
55
+ try {
56
+ console.log('Starting Codex through a loopback-only bridge. Hosted web search is disabled for this run. Model requests are billed by AtmoRouter.');
57
+ const configuration=['-c','model_provider="atmorouter"','-c','model='+JSON.stringify(model),'-c',`model_providers.atmorouter={name="AtmoRouter",base_url="${bridge.url}",env_key="ATMOROUTER_API_KEY",wire_api="responses",requires_openai_auth=false,supports_websockets=false}`,'-c','web_search="disabled"'];
58
+ const code=await new Promise((resolve,reject)=>{
59
+ const child=spawn(executable.file,[...executable.args,...configuration,...args],{stdio:'inherit',env:process.env});
60
+ let receivedSignal;
61
+ const interrupt=()=>{receivedSignal='SIGINT';child.kill('SIGINT');};
62
+ const terminate=()=>{receivedSignal='SIGTERM';child.kill('SIGTERM');};
63
+ const cleanup=()=>{process.off('SIGINT',interrupt);process.off('SIGTERM',terminate);};
64
+ process.on('SIGINT',interrupt);process.on('SIGTERM',terminate);
65
+ child.once('error',error=>{cleanup();reject(error);});
66
+ child.once('exit',(value,signal)=>{cleanup();resolve(value ?? ((signal||receivedSignal)==='SIGINT'?130:(signal||receivedSignal)==='SIGTERM'?143:1));});
67
+ });
68
+ process.exitCode=code;
69
+ } finally {await bridge.close();}
70
+ }
29
71
  async function main(){
30
- const {values,positionals}=parseArgs({allowPositionals:true,options:{target:{type:'string'},model:{type:'string'},search:{type:'string'},'config-dir':{type:'string'},shell:{type:'string',default:'posix'},write:{type:'boolean'},yes:{type:'boolean'},offline:{type:'boolean'},help:{type:'boolean'},version:{type:'boolean'}}});
72
+ const {values,positionals}=parseArgs({allowPositionals:true,options:{target:{type:'string'},model:{type:'string'},search:{type:'string'},'config-dir':{type:'string'},shell:{type:'string',default:'posix'},port:{type:'string',default:'43187'},write:{type:'boolean'},yes:{type:'boolean'},offline:{type:'boolean'},help:{type:'boolean'},version:{type:'boolean'}}});
31
73
  if(values.version){console.log(JSON.parse(await readFile(new URL('../package.json',import.meta.url),'utf8')).version);return;}
32
74
  if(values.help){console.log(HELP);return;}
33
75
  let command=positionals[0],target=values.target,model=values.model,rl;
34
76
  try {
35
- if(positionals.length>1)throw Error('Unexpected command arguments. Use --help.');
77
+ if(positionals.length>1&&command!=='codex')throw Error('Unexpected command arguments. Use --help.');
78
+ if(command==='targets'){console.log(INTEGRATIONS.map(t=>`${t.id} [${t.mode}] — ${t.name}: ${t.description}`).join('\n'));return;}
79
+ if(command==='bridge'){
80
+ if(!/^\d+$/.test(values.port)||Number(values.port)<1||Number(values.port)>65535)throw Error('Bridge port must be an integer from 1 to 65535.');
81
+ const bridge=await startResponsesBridge({port:Number(values.port)});
82
+ console.log('Local Responses bridge: '+bridge.url+'\nStateless text/tools only; normal AtmoRouter charges apply. Press Ctrl+C to stop.');
83
+ console.log('In your Codex configuration, set model_provider = "atmorouter" and your chosen model and web_search = "disabled" before provider tables. Then add:\n\n[model_providers.atmorouter]\nname = "AtmoRouter local bridge"\nbase_url = "'+bridge.url+'"\nenv_key = "ATMOROUTER_API_KEY"\nwire_api = "responses"\nrequires_openai_auth = false\nsupports_websockets = false\n\nSet ATMOROUTER_API_KEY privately in the terminal that starts Codex. Run configure --target codex --model YOUR_MODEL_ID for full instructions.');
84
+ await new Promise(resolve=>{process.once('SIGINT',resolve);process.once('SIGTERM',resolve);});await bridge.close();return;
85
+ }
86
+ if(command==='codex'){
87
+ modelId(model);if(!values.offline&&!(await models()).some(m=>m.id===model))throw Error('Choose an enabled text model from the catalog.');
88
+ await launchCodex(model,positionals.slice(1));return;
89
+ }
36
90
  if(command==='check-key'){console.log(await checkKey(process.env.ATMOROUTER_API_KEY));return;}
37
91
  if(command==='models'){
38
92
  const rows=(await models()).filter(m=>m.id.toLowerCase().includes((values.search || '').toLowerCase()));
@@ -41,8 +95,9 @@ async function main(){
41
95
  if(!command){
42
96
  if(!process.stdin.isTTY)throw Error('Use configure --target TARGET --model ID for non-interactive use.');
43
97
  rl=createInterface({input:process.stdin,output:process.stdout});
44
- console.log('Preview a connection for Claude Code, OpenCode, or an SDK. No secrets will be saved.');
45
- target=(await rl.question('Target (claude/opencode/openai/anthropic): ')).trim();
98
+ console.log('Choose an integration. No API key will be printed or saved.');
99
+ console.log(INTEGRATIONS.map(t=>`${t.id}: ${t.name} [${t.mode}]`).join('\n'));
100
+ target=(await rl.question('Target: ')).trim();
46
101
  const query=(await rl.question('Search model IDs: ')).trim().toLowerCase();
47
102
  const rows=(await models()).filter(m=>m.id.toLowerCase().includes(query)).slice(0,25);
48
103
  if(!rows.length)throw Error('No matching models. Try a different search.');
@@ -52,6 +107,14 @@ async function main(){
52
107
  model=rows[selected-1].id;command='configure';
53
108
  } else if(command!=='configure')throw Error('Unknown command. Use --help.');
54
109
  if(!target||!model)throw Error('configure requires --target and --model.');
110
+ modelId(model);
111
+ const integration=INTEGRATIONS.find(t=>t.id===target);
112
+ if(!integration)throw Error('Unknown target. Run targets to list integrations.');
113
+ if(integration.mode==='unavailable')throw Error(guidedConfiguration(target,model,{shell:values.shell}));
114
+ if(['guided','bridge'].includes(integration.mode)){
115
+ if(values.write)throw Error('This integration uses guided setup. No configuration files were changed.');
116
+ console.log(guidedConfiguration(target,model,{shell:values.shell}));return;
117
+ }
55
118
  if(!values.offline&&!rl){if(!(await models()).some(m=>m.id===model))throw Error('Model is not in the live text catalog. Use models to choose one.');}
56
119
  if(['openai','anthropic'].includes(target)){
57
120
  if(values.write)throw Error('Environment snippets are preview-only. Copy them into your shell.');
@@ -60,7 +123,8 @@ async function main(){
60
123
  const plan=await planConfiguration(target,model,{directory:values['config-dir']});
61
124
  // Print only the intended patch. Existing settings may contain secrets.
62
125
  console.log('Configuration: '+plan.file+'\nChanges:\n'+JSON.stringify(plan.patch,null,2));
63
- console.log(target==='claude'?'Before starting Claude, set ANTHROPIC_AUTH_TOKEN to your AtmoRouter key.':'Before starting OpenCode, set ATMOROUTER_API_KEY to your AtmoRouter key.');
126
+ console.log(target==='claude'?'Before starting Claude, set ANTHROPIC_AUTH_TOKEN to your AtmoRouter key.':'Before starting the agent, configure ATMOROUTER_API_KEY privately.');
127
+ if(plan.instructions)console.log(plan.instructions);
64
128
  let apply=false;
65
129
  if(rl)apply=/^y(es)?$/i.test((await rl.question('Apply these changes? [y/N] ')).trim());
66
130
  else if(values.write){
package/lib/bridge.mjs ADDED
@@ -0,0 +1,165 @@
1
+ import http from 'node:http';
2
+ import {randomUUID} from 'node:crypto';
3
+
4
+ const UPSTREAM = 'https://atmorouter.dev/v1/chat/completions';
5
+ const LIMIT = 32 * 1024 * 1024;
6
+ class BridgeError extends Error { constructor(status, code) { super(code); this.status=status; this.code=code; } }
7
+ const invalid = () => { throw new BridgeError(400,'unsupported_request'); };
8
+ const object = x => x !== null && typeof x === 'object' && !Array.isArray(x);
9
+ function keys(value, allowed) { if(!object(value) || Object.keys(value).some(k=>!allowed.includes(k))) invalid(); }
10
+ function textContent(content) {
11
+ if(typeof content==='string') return content;
12
+ if(!Array.isArray(content)) invalid();
13
+ return content.map(p=>{keys(p,['type','text','annotations']);if(!['input_text','output_text','text'].includes(p.type)||typeof p.text!=='string')invalid();return p.text;}).join('');
14
+ }
15
+ function translate(body) {
16
+ keys(body,['model','input','instructions','stream','store','tools','tool_choice','parallel_tool_calls','max_output_tokens','output_tokens','temperature','top_p','text','reasoning','metadata','client_metadata','include','truncation','service_tier','prompt_cache_key','safety_identifier']);
17
+ if(typeof body.model!=='string'||!body.model||body.store===true|| (body.store!==undefined&&body.store!==false) || (body.stream!==undefined&&typeof body.stream!=='boolean'))invalid();
18
+ if(body.include!==undefined && (!Array.isArray(body.include)||body.include.some(x=>x!=='reasoning.encrypted_content')))invalid();
19
+ if(body.truncation!==undefined && body.truncation!=='disabled')invalid();
20
+ const messages=[], toolMap=new Map();toolMap.namespaces=new Map();
21
+ const identities=new Map(),namespacedTools=new WeakSet();
22
+ const flatName=(namespace,name)=>{if(namespace!==undefined&&(typeof namespace!=='string'||!/^[-a-zA-Z0-9_]+$/.test(namespace)))invalid();const flat=namespace?`${namespace}__${name}`:name;if(typeof name!=='string'||!name||!/^[-a-zA-Z0-9_]{1,64}$/.test(flat))invalid();const identity=JSON.stringify([namespace??null,name]);if(identities.has(flat)&&identities.get(flat)!==identity)invalid();identities.set(flat,identity);if(namespace!==undefined)toolMap.namespaces.set(flat,{name,namespace});return flat;};
23
+ if(body.instructions!==undefined){if(typeof body.instructions!=='string')invalid();messages.push({role:'system',content:body.instructions});}
24
+ const input=typeof body.input==='string'?[{role:'user',content:body.input}]:body.input;
25
+ if(!Array.isArray(input))invalid();
26
+ for(const item of input){
27
+ if(!object(item))invalid();
28
+ if(item.type==='function_call'||item.type==='custom_tool_call'){
29
+ keys(item,['type','id','call_id','name','namespace','arguments','input','status']);
30
+ if(item.type==='custom_tool_call'&&typeof item.input!=='string')invalid();
31
+ const args=item.type==='custom_tool_call'?JSON.stringify({input:item.input}):item.arguments;
32
+ if(typeof item.call_id!=='string'||typeof item.name!=='string'||typeof args!=='string')invalid();
33
+ const call={id:item.call_id,type:'function',function:{name:flatName(item.namespace,item.name),arguments:args}};
34
+ const prior=messages.at(-1);
35
+ if(prior?.role==='assistant'){prior.tool_calls??=[];prior.tool_calls.push(call);}else messages.push({role:'assistant',content:null,tool_calls:[call]});
36
+ } else if(item.type==='function_call_output'||item.type==='custom_tool_call_output'){
37
+ keys(item,['type','id','call_id','output','status']);
38
+ if(typeof item.call_id!=='string')invalid();
39
+ messages.push({role:'tool',tool_call_id:item.call_id,content:textContent(item.output)});
40
+ } else {
41
+ keys(item,['type','id','role','content','status']);
42
+ if(item.type!==undefined&&item.type!=='message')invalid();
43
+ if(!['system','developer','user','assistant'].includes(item.role))invalid();
44
+ messages.push({role:item.role==='developer'?'system':item.role,content:textContent(item.content)});
45
+ }
46
+ }
47
+ const out={model:body.model,messages,stream:body.stream===true};
48
+ if(body.tools!==undefined){
49
+ if(!Array.isArray(body.tools))invalid();
50
+ const tools=[];for(const tool of body.tools){if(tool?.type==='namespace'){keys(tool,['type','name','description','tools']);if(typeof tool.name!=='string'||!tool.name||!Array.isArray(tool.tools)||tool.description!==undefined&&typeof tool.description!=='string')invalid();for(const child of tool.tools){if(!object(child)||!['function','custom'].includes(child.type)||child.description!==undefined&&typeof child.description!=='string')invalid();const name=flatName(tool.name,child.name);const normalized={...child,name,...(tool.description?{description:`${tool.description}\n\n${child.description??''}`}:{})};namespacedTools.add(normalized);tools.push(normalized);}}else tools.push(tool);}
51
+ out.tools=tools.map(tool=>{
52
+ if(!object(tool))invalid();
53
+ if(!namespacedTools.has(tool))flatName(undefined,tool.name);if(toolMap.has(tool.name))invalid();
54
+ if(tool.type==='function'){
55
+ keys(tool,['type','name','description','parameters','strict']);
56
+ if(typeof tool.name!=='string'||(tool.parameters!==undefined&&!object(tool.parameters)))invalid();
57
+ toolMap.set(tool.name,'function');
58
+ return {type:'function',function:{name:tool.name,...(tool.description!==undefined?{description:tool.description}:{}),parameters:tool.parameters??{type:'object',properties:{}},...(tool.strict!==undefined?{strict:tool.strict}:{})}};
59
+ }
60
+ if(tool.type==='custom'){
61
+ keys(tool,['type','name','description','format']);
62
+ if(typeof tool.name!=='string')invalid();
63
+ // Grammar-constrained custom tools cannot be preserved by Chat Completions.
64
+ if(tool.format!==undefined&&tool.format.type!=='text')invalid();
65
+ toolMap.set(tool.name,'custom');
66
+ return {type:'function',function:{name:tool.name,description:tool.description,parameters:{type:'object',properties:{input:{type:'string'}},required:['input'],additionalProperties:false}}};
67
+ }
68
+ invalid();
69
+ });
70
+ }
71
+ if(body.tool_choice!==undefined){
72
+ if(['auto','none','required'].includes(body.tool_choice))out.tool_choice=body.tool_choice;
73
+ else {keys(body.tool_choice,['type','name','namespace']);const name=flatName(body.tool_choice.namespace,body.tool_choice.name);if(!['function','custom'].includes(body.tool_choice.type)||!toolMap.has(name))invalid();out.tool_choice={type:'function',function:{name}};}
74
+ }
75
+ for(const k of ['temperature','top_p','parallel_tool_calls','service_tier'])if(body[k]!==undefined)out[k]=body[k];
76
+ const max=body.max_output_tokens??body.output_tokens;
77
+ if(max!==undefined){if(!Number.isSafeInteger(max)||max<1)invalid();out.max_tokens=max;}
78
+ if(body.reasoning!==undefined){keys(body.reasoning,['effort','summary']);if(body.reasoning.summary!==undefined&&!['auto','concise','detailed'].includes(body.reasoning.summary))invalid();if(body.reasoning.effort!==undefined){if(!['none','minimal','low','medium','high','xhigh'].includes(body.reasoning.effort))invalid();out.reasoning_effort=body.reasoning.effort;}}
79
+ if(body.text!==undefined){keys(body.text,['format','verbosity']);if(body.text.verbosity!==undefined)invalid();if(body.text.format){const f=body.text.format;if(f.type==='text'){keys(f,['type']);}else if(f.type==='json_object'){keys(f,['type']);out.response_format=f;}else if(f.type==='json_schema'){keys(f,['type','name','description','schema','strict']);out.response_format={type:'json_schema',json_schema:{name:f.name,schema:f.schema,...(f.description!==undefined?{description:f.description}:{}),...(f.strict!==undefined?{strict:f.strict}:{})}};}else invalid();}}
80
+ if(out.stream)out.stream_options={include_usage:true};
81
+ return {request:out,toolMap};
82
+ }
83
+ function usage(u){return {input_tokens:u?.prompt_tokens??0,input_tokens_details:{cached_tokens:u?.prompt_tokens_details?.cached_tokens??0},output_tokens:u?.completion_tokens??0,output_tokens_details:{reasoning_tokens:u?.completion_tokens_details?.reasoning_tokens??0},total_tokens:u?.total_tokens??((u?.prompt_tokens??0)+(u?.completion_tokens??0))};}
84
+ function responseBase(body){return {id:'resp_'+randomUUID().replaceAll('-',''),object:'response',created_at:Math.floor(Date.now()/1000),status:'in_progress',error:null,incomplete_details:null,instructions:body.instructions??null,model:body.model,output:[],parallel_tool_calls:body.parallel_tool_calls??true,store:false,tool_choice:body.tool_choice??'auto',tools:body.tools??[],usage:null};}
85
+ function toolItem(name,args,callId,map,id){
86
+ if(map.get(name)==='custom'){
87
+ let input;try{input=JSON.parse(args).input;}catch{throw new BridgeError(502,'invalid_upstream_response');}
88
+ if(typeof input!=='string')throw new BridgeError(502,'invalid_upstream_response');
89
+ return {type:'custom_tool_call',id,call_id:callId,...(map.namespaces.get(name)??{name}),input,status:'completed'};
90
+ }
91
+ return {type:'function_call',id,call_id:callId,...(map.namespaces.get(name)??{name}),arguments:args,status:'completed'};
92
+ }
93
+ async function boundedJSON(upstream,limit){const reader=upstream.body?.getReader();if(!reader)throw new BridgeError(502,'invalid_upstream_response');let size=0;const chunks=[];while(true){const {value,done}=await reader.read();if(done)break;size+=value.byteLength;if(size>limit){await reader.cancel();throw new BridgeError(502,'upstream_output_limit');}chunks.push(Buffer.from(value));}try{return JSON.parse(Buffer.concat(chunks).toString('utf8'));}catch{throw new BridgeError(502,'invalid_upstream_response');}}
94
+ async function* frames(body,limit){
95
+ const reader=body?.getReader();if(!reader)throw new BridgeError(502,'invalid_upstream_response');
96
+ const decoder=new TextDecoder('utf-8',{fatal:true});let buffer='',total=0,data=[];
97
+ try {while(true){const {value,done}=await reader.read();total+=value?.byteLength??0;if(total>limit)throw new BridgeError(502,'upstream_output_limit');buffer+=done?decoder.decode():decoder.decode(value,{stream:true});
98
+ while(true){const match=/\r\n|\r|\n/.exec(buffer);if(!match)break;if(!done&&match[0]==='\r'&&match.index===buffer.length-1)break;const line=buffer.slice(0,match.index);buffer=buffer.slice(match.index+match[0].length);if(line===''){if(data.length){yield data.join('\n');data=[];}}else if(line.startsWith('data:'))data.push(line.slice(5).replace(/^ /,''));}
99
+ if(buffer.length>1024*1024||data.join('\n').length>1024*1024)throw new BridgeError(502,'upstream_frame_limit');
100
+ if(done){if(buffer||data.length)throw new BridgeError(502,'truncated_upstream_stream');break;}
101
+ }}finally{await reader.cancel().catch(()=>{});reader.releaseLock();}
102
+ }
103
+ async function streamResponse(upstream,res,base,map,limit){
104
+ let seq=0;const output=[],tools=new Map();let message,finished=false,done=false,finishReason,tokenUsage;
105
+ async function emit(type,fields={}){if(res.destroyed)throw new BridgeError(499,'request_cancelled');const event={type,sequence_number:seq++,...fields};if(!res.write(`event: ${type}\ndata: ${JSON.stringify(event)}\n\n`))await new Promise((resolve,reject)=>{const cleanup=()=>{res.off('drain',drain);res.off('close',close);res.off('error',close);};const drain=()=>{cleanup();resolve();};const close=()=>{cleanup();reject(new BridgeError(499,'request_cancelled'));};res.once('drain',drain);res.once('close',close);res.once('error',close);});}
106
+ await emit('response.created',{response:{...base}});await emit('response.in_progress',{response:{...base}});
107
+ const add=async item=>{const index=output.length;output.push(item);await emit('response.output_item.added',{output_index:index,item:{...item}});return index;};
108
+ try {
109
+ for await(const frame of frames(upstream.body,limit)){
110
+ if(frame==='[DONE]'){done=true;break;}
111
+ let chunk;try{chunk=JSON.parse(frame);}catch{throw new BridgeError(502,'invalid_upstream_stream');}
112
+ if(chunk.error)throw new BridgeError(502,'upstream_stream_error');
113
+ if(chunk.usage)tokenUsage=chunk.usage;
114
+ if(!Array.isArray(chunk.choices))throw new BridgeError(502,'invalid_upstream_stream');
115
+ for(const choice of chunk.choices){if(choice.index!==undefined&&choice.index!==0)throw new BridgeError(502,'invalid_upstream_stream');const d=choice.delta??{};
116
+ if(d.refusal||d.audio)throw new BridgeError(502,'unsupported_upstream_output');
117
+ if(d.content){if(finished||typeof d.content!=='string')throw new BridgeError(502,'invalid_upstream_stream');if(!message){const item={id:'msg_'+randomUUID(),type:'message',status:'in_progress',role:'assistant',content:[]};const index=await add(item);const part={type:'output_text',text:'',annotations:[]};item.content.push(part);message={item,index,part};await emit('response.content_part.added',{item_id:item.id,output_index:index,content_index:0,part:{...part}});}message.part.text+=d.content;await emit('response.output_text.delta',{item_id:message.item.id,output_index:message.index,content_index:0,delta:d.content});}
118
+ for(const t of d.tool_calls??[]){if(finished||!Number.isSafeInteger(t.index)||t.index<0||t.index>1024||t.type&&t.type!=='function'||t.id!==undefined&&typeof t.id!=='string'||t.function?.name!==undefined&&typeof t.function.name!=='string'||t.function?.arguments!==undefined&&typeof t.function.arguments!=='string')throw new BridgeError(502,'invalid_upstream_stream');let entry=tools.get(t.index);if(!entry){entry={id:'fc_'+randomUUID(),call_id:t.id??'',name:'',arguments:'',emitted:0,index:null};tools.set(t.index,entry);}if(t.id){if(entry.call_id&&entry.call_id!==t.id)throw new BridgeError(502,'invalid_upstream_stream');entry.call_id=t.id;}if(t.function?.name){if(entry.index!==null)throw new BridgeError(502,'invalid_upstream_stream');entry.name+=t.function.name;}if(t.function?.arguments)entry.arguments+=t.function.arguments;
119
+ if(entry.index===null&&entry.name&&entry.call_id&&entry.arguments.length){if(!map.has(entry.name))throw new BridgeError(502,'unknown_upstream_tool');const custom=map.get(entry.name)==='custom';entry.index=await add({id:entry.id,type:custom?'custom_tool_call':'function_call',status:'in_progress',call_id:entry.call_id,...(map.namespaces.get(entry.name)??{name:entry.name}),...(custom?{input:''}:{arguments:''})});}
120
+ if(entry.index!==null&&map.get(entry.name)!=='custom'&&entry.arguments.length>entry.emitted){await emit('response.function_call_arguments.delta',{item_id:entry.id,output_index:entry.index,delta:entry.arguments.slice(entry.emitted)});entry.emitted=entry.arguments.length;}
121
+ }
122
+ if(choice.finish_reason!==null&&choice.finish_reason!==undefined){if(finished)throw new BridgeError(502,'invalid_upstream_stream');finished=true;finishReason=choice.finish_reason;}
123
+ }
124
+ }
125
+ if(!finished||!done)throw new BridgeError(502,'truncated_upstream_stream');
126
+ if(!['stop','tool_calls','length'].includes(finishReason))throw new BridgeError(502,'unsupported_upstream_output');
127
+ if(finishReason==='length'&&tools.size)throw new BridgeError(502,'truncated_upstream_tool_call');
128
+ if(message){message.item.status='completed';await emit('response.output_text.done',{item_id:message.item.id,output_index:message.index,content_index:0,text:message.part.text});await emit('response.content_part.done',{item_id:message.item.id,output_index:message.index,content_index:0,part:message.part});}
129
+ for(const entry of tools.values()){if(entry.index===null){if(!entry.call_id||!map.has(entry.name))throw new BridgeError(502,'invalid_upstream_stream');const custom=map.get(entry.name)==='custom';entry.index=await add({id:entry.id,type:custom?'custom_tool_call':'function_call',status:'in_progress',call_id:entry.call_id,...(map.namespaces.get(entry.name)??{name:entry.name}),...(custom?{input:''}:{arguments:''})});}const item=toolItem(entry.name,entry.arguments,entry.call_id,map,entry.id);output[entry.index]=item;if(item.type==='custom_tool_call'){await emit('response.custom_tool_call_input.delta',{item_id:item.id,output_index:entry.index,delta:item.input});await emit('response.custom_tool_call_input.done',{item_id:item.id,output_index:entry.index,input:item.input});}else await emit('response.function_call_arguments.done',{item_id:item.id,output_index:entry.index,arguments:item.arguments});}
130
+ for(let i=0;i<output.length;i++)await emit('response.output_item.done',{output_index:i,item:output[i]});
131
+ const response={...base,status:finishReason==='length'?'incomplete':'completed',incomplete_details:finishReason==='length'?{reason:'max_output_tokens'}:null,output,usage:usage(tokenUsage)};await emit(finishReason==='length'?'response.incomplete':'response.completed',{response});
132
+ }catch(error){if(!res.destroyed)await emit('response.failed',{response:{...base,status:'failed',error:{code:error instanceof BridgeError?error.code:'upstream_stream_error',message:'The upstream response could not be completed.'},output:[]}});}
133
+ res.end();
134
+ }
135
+ function forwardHeaders(upstream,res){for(const [name,value] of upstream.headers){if((['x-request-id','request-id','retry-after'].includes(name)||/^x-(?:atmorouter-)?(?:cost|usage|ratelimit)[a-z0-9-]*$/.test(name))&&/^[\x20-\x7e]{1,256}$/.test(value))res.setHeader(name,value);}}
136
+
137
+ /** Starts a stateless Responses adapter bound exclusively to IPv4 loopback. */
138
+ export async function startResponsesBridge({port=0,fetcher=fetch,timeoutMs=120000,maxBodyBytes=LIMIT,maxOutputBytes=LIMIT}={}){
139
+ const controllers=new Set();let actualPort;
140
+ const server=http.createServer(async(req,res)=>{
141
+ const controller=new AbortController();controllers.add(controller);const abort=()=>controller.abort();req.on('aborted',abort);res.on('close',()=>{if(!res.writableEnded)abort();});const timer=setTimeout(abort,timeoutMs);timer.unref();
142
+ try{
143
+ if(req.headers.origin!==undefined)throw new BridgeError(403,'browser_requests_forbidden');
144
+ if(req.headers.host!==`127.0.0.1:${actualPort}`)throw new BridgeError(403,'invalid_host');
145
+ if(req.url!=='/v1/responses')throw new BridgeError(404,'not_found');
146
+ if(req.method!=='POST')throw new BridgeError(405,'method_not_allowed');
147
+ if(!/^Bearer ar-[a-fA-F0-9]{48}$/.test(req.headers.authorization??''))throw new BridgeError(401,'invalid_api_key');
148
+ if(!/^application\/json(?:\s*;|$)/i.test(req.headers['content-type']??''))throw new BridgeError(415,'json_required');
149
+ if(Number(req.headers['content-length'])>maxBodyBytes)throw new BridgeError(413,'request_too_large');
150
+ const chunks=[];let size=0;for await(const chunk of req){size+=chunk.length;if(size>maxBodyBytes)throw new BridgeError(413,'request_too_large');chunks.push(chunk);}
151
+ let body;try{body=JSON.parse(Buffer.concat(chunks).toString('utf8'));}catch{throw new BridgeError(400,'invalid_json');}
152
+ const {request,toolMap}=translate(body);
153
+ const upstream=await fetcher(UPSTREAM,{method:'POST',redirect:'error',headers:{Authorization:req.headers.authorization,'Content-Type':'application/json',Accept:request.stream?'text/event-stream':'application/json'},body:JSON.stringify(request),signal:controller.signal});
154
+ forwardHeaders(upstream,res);
155
+ if(!upstream.ok){await upstream.body?.cancel();throw new BridgeError(upstream.status>=400&&upstream.status<=599?upstream.status:502,'upstream_request_failed');}
156
+ const base=responseBase(body);
157
+ if(request.stream){if(!upstream.headers.get('content-type')?.includes('text/event-stream'))throw new BridgeError(502,'invalid_upstream_response');res.writeHead(200,{'Content-Type':'text/event-stream','Cache-Control':'no-store','Connection':'keep-alive','X-Accel-Buffering':'no'});await streamResponse(upstream,res,base,toolMap,maxOutputBytes);}
158
+ else {const data=await boundedJSON(upstream,maxOutputBytes),choice=data.choices?.[0];if(data.error||!choice||!['stop','tool_calls','length'].includes(choice.finish_reason))throw new BridgeError(502,'invalid_upstream_response');const m=choice.message,output=[];if(choice.finish_reason==='length'&&m?.tool_calls?.length)throw new BridgeError(502,'truncated_upstream_tool_call');if(!object(m)||m.refusal)throw new BridgeError(502,'invalid_upstream_response');if(m.content!==null&&m.content!==undefined){if(typeof m.content!=='string')throw new BridgeError(502,'unsupported_upstream_output');output.push({id:'msg_'+randomUUID(),type:'message',status:'completed',role:'assistant',content:[{type:'output_text',text:m.content,annotations:[]}]});}for(const t of m.tool_calls??[]){if(t.type!=='function'||!toolMap.has(t.function?.name)||typeof t.function.arguments!=='string'||typeof t.id!=='string')throw new BridgeError(502,'invalid_upstream_response');output.push(toolItem(t.function.name,t.function.arguments,t.id,toolMap,'fc_'+randomUUID()));}res.writeHead(200,{'Content-Type':'application/json','Cache-Control':'no-store'});res.end(JSON.stringify({...base,status:choice.finish_reason==='length'?'incomplete':'completed',incomplete_details:choice.finish_reason==='length'?{reason:'max_output_tokens'}:null,output,usage:usage(data.usage)}));}
159
+ }catch(error){if(!res.headersSent&&!res.destroyed){const status=error instanceof BridgeError?error.status:controller.signal.aborted?504:502;res.writeHead(status,{'Content-Type':'application/json','Cache-Control':'no-store'});res.end(JSON.stringify({error:{type:'bridge_error',code:error instanceof BridgeError?error.code:controller.signal.aborted?'upstream_timeout':'upstream_unavailable',message:error instanceof BridgeError&&error.code==='unsupported_request'?'Unsupported Responses feature. Use stateless text/function tools; hosted tools, grammar tools, images and stored sessions are unsupported. See https://atmorouter.dev/docs/helper.':'The local adapter could not complete this request.'}}));}else if(!res.destroyed)res.end();}
160
+ finally{clearTimeout(timer);controllers.delete(controller);}
161
+ });
162
+ server.requestTimeout=timeoutMs;server.headersTimeout=Math.min(timeoutMs,60000);server.maxHeadersCount=100;
163
+ await new Promise((resolve,reject)=>{server.once('error',reject);server.listen(port,'127.0.0.1',resolve);});actualPort=server.address().port;
164
+ return {url:`http://127.0.0.1:${actualPort}/v1`,close:async()=>{for(const controller of controllers)controller.abort();server.closeAllConnections();await new Promise((resolve,reject)=>server.close(error=>error?reject(error):resolve()));}};
165
+ }
package/lib/helper.mjs CHANGED
@@ -2,6 +2,8 @@ import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import os from 'node:os';
4
4
  import {randomUUID} from 'node:crypto';
5
+ import {planYamlConfiguration,assertSafeConfigurationPath} from './integrations.mjs';
6
+ export {INTEGRATIONS,guidedConfiguration} from './integrations.mjs';
5
7
 
6
8
  export const ORIGIN = 'https://atmorouter.dev';
7
9
  const isRecord = value => value !== null && typeof value === 'object' && !Array.isArray(value);
@@ -36,7 +38,8 @@ export function environment(target, model, shell='posix') {
36
38
  }
37
39
  export async function planConfiguration(target, model, {directory,home=os.homedir(),env=process.env}={}) {
38
40
  modelId(model);
39
- if (!['claude','opencode'].includes(target)) throw Error('Configuration target must be claude or opencode. Codex/Responses is not supported.');
41
+ if (['omp','continue'].includes(target)) return planYamlConfiguration(target,model,{directory,home,env});
42
+ if (!['claude','opencode'].includes(target)) throw Error('Automatic configuration target must be claude, opencode, omp, or continue.');
40
43
  const folder=path.resolve(directory || (target==='claude'?path.join(home,'.claude'):path.join(env.XDG_CONFIG_HOME || path.join(home,'.config'),'opencode')));
41
44
  const file=path.join(folder,target==='claude'?'settings.json':'opencode.json');
42
45
  if (target==='opencode') {
@@ -71,6 +74,7 @@ export async function planConfiguration(target, model, {directory,home=os.homedi
71
74
  return {file,patch,original,content:JSON.stringify(merged,null,2)+'\n',target};
72
75
  }
73
76
  export async function writeConfiguration(plan) {
77
+ await assertSafeConfigurationPath(plan.file);
74
78
  let current=null;
75
79
  try { const stat=await fs.lstat(plan.file);if(stat.isSymbolicLink()||!stat.isFile())throw Error('Target is no longer a regular file.');current=await fs.readFile(plan.file,'utf8'); } catch(e) {if(e.code!=='ENOENT')throw e;}
76
80
  if(current!==plan.original)throw Error('Configuration changed after preview. Run the helper again.');
@@ -0,0 +1,133 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import os from 'node:os';
4
+ import {parseDocument,isMap,isSeq,isAlias,visit} from 'yaml';
5
+
6
+ const base='https://atmorouter.dev/v1';
7
+ const secret='${{ secrets.ATMOROUTER_API_KEY }}';
8
+ const entries=[
9
+ ['claude','Claude Code','automatic','Merge Claude settings with environment authentication.','https://code.claude.com/docs/en/llm-gateway'],
10
+ ['opencode','OpenCode','automatic','Merge an OpenAI-compatible provider.','https://opencode.ai/docs/providers/'],
11
+ ['omp','Oh My Pi','automatic','Merge models.yml using an environment key reference.','https://github.com/can1357/oh-my-pi/blob/main/docs/models.md'],
12
+ ['continue','Continue','automatic','Merge config.yaml; configure IDE secrets manually.','https://docs.continue.dev/reference'],
13
+ ['cursor','Cursor','guided','Configure API keys in the IDE UI where supported.','https://cursor.com/help/models-and-usage/api-keys'],
14
+ ['vscode','VS Code','guided','Configure the Custom Endpoint language model provider.','https://code.visualstudio.com/docs/agent-customization/language-models'],
15
+ ['cline','Cline','guided','Configure the OpenAI Compatible provider in settings.','https://docs.cline.bot/provider-config/openai-compatible'],
16
+ ['roo','Roo Code','guided','Configure OpenAI Compatible with a native tool-capable model.','https://docs.roocode.com/providers/openai-compatible'],
17
+ ['codex','Codex','bridge','Use the local Responses-to-Chat-Completions bridge.','https://developers.openai.com/codex/config-reference/'],
18
+ ['antigravity','Antigravity','unavailable','Native IDE custom endpoints are not documented.','https://antigravity.google/docs/'],
19
+ ['openai','OpenAI environment','environment','Print OpenAI-compatible environment variables.','https://atmorouter.dev/docs'],
20
+ ['anthropic','Anthropic environment','environment','Print Anthropic-compatible environment variables.','https://atmorouter.dev/docs'],
21
+ ];
22
+ export const INTEGRATIONS=Object.freeze(entries.map(([id,name,mode,description,docsUrl])=>Object.freeze({id,name,mode,description,docsUrl})));
23
+
24
+ function validateModel(model) {
25
+ if(typeof model!=='string'||model!==model.trim()||!/^[A-Za-z0-9][A-Za-z0-9._:/-]{0,200}$/.test(model))throw Error('Choose a valid model ID from the catalog.');
26
+ }
27
+ export function guidedConfiguration(target,model,{shell='posix'}={}) {
28
+ validateModel(model);
29
+ if(!['posix','powershell'].includes(shell))throw Error('Shell must be posix or powershell.');
30
+ const info=INTEGRATIONS.find(item=>item.id===target);
31
+ if(!info)throw Error('Unknown integration target.');
32
+ const details=`Endpoint: ${base}\nModel: ${model}\nAPI key: <your ATMOROUTER_API_KEY> (${target==='codex'?'set securely in the environment':'enter securely in the application UI'})`;
33
+ let instructions;
34
+ if(target==='cursor')instructions='Open Cursor Settings > Models > API Keys. Use an OpenAI-compatible custom base URL only if your installed version exposes that option. Enter the endpoint, model, and key in the UI, then verify chat. Custom keys do not promise Tab completion or every agent feature.';
35
+ else if(target==='vscode')instructions='Open Manage Language Models > Add Models > Custom Endpoint. Open its configuration in chatLanguageModels.json and merge this provider into the existing array. VS Code prompts for the key and stores it securely. Enable toolCalling only after verifying native tool support for this model.\n'+JSON.stringify({name:'AtmoRouter',vendor:'customendpoint',apiType:'chat-completions',apiKey:'${input:atmorouterApiKey}',models:[{id:model,name:model,url:base+'/chat/completions'}]},null,2);
36
+ else if(target==='cline'||target==='roo')instructions=`Open ${info.name} settings and select OpenAI Compatible. Enter the base URL, model ID, and key in the UI. ${target==='roo'?'Roo requires native tool calling; verify this model supports it before using agent mode.':'Verify the selected model supports the tool calling needed by your workflow.'} Set context limits or capabilities only from verified model documentation.`;
37
+ else if(target==='codex')instructions=`Set ATMOROUTER_API_KEY securely in your ${shell==='powershell'?'PowerShell':'shell'} environment, then launch your installed Codex through the loopback bridge:\nnpx @atmorouter/helper codex --model ${model}\nThe launcher supplies configuration for this run without overwriting your Codex config. Compatibility depends on the installed Codex version and the client/model tool behavior; verify the launch in your environment.\n\nFor a standalone bridge:\nnpx @atmorouter/helper bridge --port 43187\nMerge this TOML configuration manually if needed (root settings must precede provider tables):\nmodel = "${model}"\nmodel_provider = "atmorouter"\nweb_search = "disabled"\n\n[model_providers.atmorouter]\nname = "AtmoRouter local bridge"\nbase_url = "http://127.0.0.1:43187/v1"\nenv_key = "ATMOROUTER_API_KEY"\nwire_api = "responses"\nrequires_openai_auth = false\nsupports_websockets = false\n\nKeys stay in the environment. This bridge supports a stateless text and function-tool subset. Stored responses, images, hosted tools, and remote compaction are unsupported.`;
38
+ else if(target==='antigravity')instructions='Unsupported native IDE setup: no documented native custom endpoint configuration is available. No IDE files will be modified. The SDK LocalOpenAIAgentConfig transport has no authenticated API-key field; SDK examples do not establish authenticated native IDE support.';
39
+ else if(target==='continue')instructions='Use configure --target continue for a YAML plan. For the IDE, manually set ATMOROUTER_API_KEY=<your key> in ~/.continue/.env and restart the IDE. Shell environment variables work with Continue CLI only. Docs: https://docs.continue.dev/faqs';
40
+ else throw Error('This integration uses automatic configuration, environment snippets, or the local Codex bridge.');
41
+ return `${info.name}\n${details}\n\n${instructions}\nDocs: ${info.docsUrl}`;
42
+ }
43
+
44
+ export async function assertSafeConfigurationPath(file) {
45
+ for(let current=path.resolve(file);;current=path.dirname(current)) {
46
+ try {const stat=await fs.lstat(current);if(stat.isSymbolicLink())throw Error('Configuration path contains a symbolic link; merge manually.');}
47
+ catch(error){if(error.code!=='ENOENT')throw error;}
48
+ if(path.dirname(current)===current)break;
49
+ }
50
+ }
51
+ async function exists(file) {
52
+ try {await fs.lstat(file);return true;}catch(error){if(error.code==='ENOENT')return false;throw error;}
53
+ }
54
+ function mapping(doc,keys,label) {
55
+ const node=doc.getIn(keys,true);
56
+ if(node!==undefined&&!isMap(node))throw Error(`${label} must be a YAML mapping; merge manually.`);
57
+ if(node===undefined)doc.setIn(keys,doc.createNode({}));
58
+ return doc.getIn(keys,true);
59
+ }
60
+ function checkCredentials(node,expected) {
61
+ if(!node)return;
62
+ visit(node,{
63
+ Pair(_,pair){
64
+ const key=String(pair.key?.value ?? '').toLowerCase();
65
+ if(/authorization|api[-_]?key|secret|credential|cookie/.test(key)||['auth','token','authtoken','accesstoken','access_token','refreshtoken','refresh_token','bearertoken','bearer_token','x-auth-token'].includes(key)) {
66
+ if(key==='apikey'&&pair.value?.value===expected)return;
67
+ throw Error('Existing AtmoRouter configuration has credential headers or secret overrides. Resolve them manually.');
68
+ }
69
+ },
70
+ });
71
+ }
72
+ function parseConfiguration(original) {
73
+ const doc=parseDocument(original??'{}\n',{uniqueKeys:true,strict:true});
74
+ if(doc.errors.length||doc.warnings.length)throw Error('Existing YAML is malformed, has duplicate keys, or uses unsupported tags; merge manually.');
75
+ if(!isMap(doc.contents))throw Error('Configuration root must be a YAML mapping.');
76
+ visit(doc,(_,node)=>{
77
+ if(isAlias(node)||node?.tag||node?.key?.value==='<<')throw Error('YAML aliases, merge keys, and explicit tags require a manual merge.');
78
+ });
79
+ return doc;
80
+ }
81
+ export async function planYamlConfiguration(target,model,{directory,home=os.homedir(),env=process.env}={}) {
82
+ validateModel(model);
83
+ if(!['omp','continue'].includes(target))throw Error('Unknown YAML integration.');
84
+ if(target==='omp'&&!directory&&(env.PI_CONFIG_DIR||env.PI_PROFILE||env.OMP_PROFILE))throw Error('Custom OMP config roots or named profiles require an explicit --config-dir pointing to the active agent directory.');
85
+ const folder=path.resolve(directory||(target==='omp'?(env.PI_CODING_AGENT_DIR||path.join(home,'.omp','agent')):path.join(home,'.continue')));
86
+ let name=target==='omp'?'models.yml':'config.yaml';
87
+ if(target==='omp') {
88
+ const yml=await exists(path.join(folder,'models.yml')),yaml=await exists(path.join(folder,'models.yaml'));
89
+ if(yml&&yaml)throw Error('Both models.yml and models.yaml exist; resolve the alternate configuration manually.');
90
+ if(await exists(path.join(folder,'models.json'))||await exists(path.join(folder,'models.jsonc')))throw Error('Legacy OMP models.json configuration exists; migrate it manually first.');
91
+ if(yaml)name='models.yaml';
92
+ }else if(await exists(path.join(folder,'config.yml'))||await exists(path.join(folder,'config.json'))||await exists(path.join(folder,'config.ts')))throw Error('An alternate or legacy Continue configuration exists; merge it manually.');
93
+ const file=path.join(folder,name);
94
+ await assertSafeConfigurationPath(file);
95
+ let original=null;
96
+ try {const stat=await fs.lstat(file);if(!stat.isFile())throw Error('Configuration must be a regular file.');original=await fs.readFile(file,'utf8');}catch(error){if(error.code!=='ENOENT')throw error;}
97
+ const doc=parseConfiguration(original);
98
+ let patch;
99
+ if(target==='omp') {
100
+ mapping(doc,['providers'],'OMP providers');
101
+ const provider=mapping(doc,['providers','atmorouter'],'OMP AtmoRouter provider');
102
+ checkCredentials(provider,'ATMOROUTER_API_KEY');
103
+ const models=provider.get('models',true);
104
+ if(models!==undefined&&!isSeq(models))throw Error('OMP AtmoRouter models must be a sequence.');
105
+ const added={id:model,name:model};
106
+ const fields={baseUrl:base,apiKey:'ATMOROUTER_API_KEY',api:'openai-completions',authHeader:true};
107
+ for(const [key,value] of Object.entries(fields))provider.set(key,value);
108
+ if(!models)provider.set('models',doc.createNode([added]));
109
+ else if(!models.items.some(item=>isMap(item)&&item.get('id')===model))models.add(doc.createNode(added));
110
+ patch={providers:{atmorouter:{...fields,models:[added]}}};
111
+ }else {
112
+ if(original===null)for(const [key,value] of Object.entries({name:'AtmoRouter',version:'1.0.0',schema:'v1'}))doc.set(key,value);
113
+ let models=doc.get('models',true);
114
+ if(models!==undefined&&!isSeq(models))throw Error('Continue models must be a YAML sequence.');
115
+ if(!models){doc.set('models',doc.createNode([]));models=doc.get('models',true);}
116
+ const name=`AtmoRouter (${model})`;
117
+ const matching=[];
118
+ for(const item of models.items) {
119
+ if(!isMap(item))throw Error('Continue model entries must be mappings; merge manually.');
120
+ if(item.get('apiBase')===base||String(item.get('name')||'').startsWith('AtmoRouter ('))checkCredentials(item,secret);
121
+ if(item.get('name')===name||(item.get('apiBase')===base&&item.get('model')===model))matching.push(item);
122
+ }
123
+ if(matching.length>1)throw Error('Multiple matching AtmoRouter models exist; merge manually.');
124
+ const added={name,provider:'openai',model,apiBase:base,apiKey:secret,roles:['chat','edit','apply']};
125
+ if(matching.length)for(const [key,value] of Object.entries(added))matching[0].set(key,doc.createNode(value));
126
+ else models.add(doc.createNode(added));
127
+ patch={models:[added]};
128
+ }
129
+ const instructions=target==='continue'
130
+ ? 'For Continue IDE, manually set ATMOROUTER_API_KEY=<your key> in ~/.continue/.env and restart the IDE. For a custom config directory, use a Continue-supported .env location. Shell environment variables work with Continue CLI only. See https://docs.continue.dev/faqs. Verify model tool support before agent mode.'
131
+ : `Set ATMOROUTER_API_KEY in the environment before launching OMP, then run: omp --model atmorouter/${model}. Named profiles require --config-dir pointing to the active agent directory. Verify native tool support for agent use.`;
132
+ return {file,target,original,patch,content:doc.toString(),instructions};
133
+ }
package/package.json CHANGED
@@ -1,14 +1,42 @@
1
1
  {
2
2
  "name": "@atmorouter/helper",
3
- "version": "0.1.0",
4
- "description": "Configure AtmoRouter connections for Claude Code and OpenCode",
3
+ "version": "0.2.0",
4
+ "description": "Configure AtmoRouter coding agents, guide IDE setup, and run a local Codex bridge",
5
5
  "homepage": "https://atmorouter.dev/docs/helper",
6
- "keywords": ["atmorouter", "claude-code", "opencode", "setup", "cli"],
7
- "publishConfig": { "access": "public", "registry": "https://registry.npmjs.org/" },
6
+ "keywords": [
7
+ "atmorouter",
8
+ "claude-code",
9
+ "opencode",
10
+ "codex",
11
+ "cursor",
12
+ "continue",
13
+ "setup",
14
+ "cli"
15
+ ],
16
+ "publishConfig": {
17
+ "access": "public",
18
+ "registry": "https://registry.npmjs.org/"
19
+ },
8
20
  "type": "module",
9
21
  "license": "UNLICENSED",
10
- "engines": { "node": ">=22" },
11
- "bin": { "atmorouter-helper": "bin/atmorouter-helper.mjs" },
12
- "files": ["bin", "lib", "README.md"],
13
- "scripts": { "test": "node --test", "test:package": "node scripts/verify-package.mjs" }
22
+ "engines": {
23
+ "node": ">=22"
24
+ },
25
+ "bin": {
26
+ "atmorouter-helper": "bin/atmorouter-helper.mjs"
27
+ },
28
+ "files": [
29
+ "bin/atmorouter-helper.mjs",
30
+ "lib/helper.mjs",
31
+ "lib/integrations.mjs",
32
+ "lib/bridge.mjs",
33
+ "README.md"
34
+ ],
35
+ "scripts": {
36
+ "test": "node --test",
37
+ "test:package": "node scripts/verify-package.mjs"
38
+ },
39
+ "dependencies": {
40
+ "yaml": "2.9.1"
41
+ }
14
42
  }