@open-cr-agent/runtime-opencode 0.1.2 → 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/dist/binary.d.ts CHANGED
@@ -5,6 +5,7 @@ export interface Host {
5
5
  musl: boolean;
6
6
  baseline: boolean;
7
7
  }
8
- export declare function resolveOpencodeBinary(env: Env): string;
8
+ export declare function resolveOpencodeBinary(env: Env, opencodePackage?: string): string;
9
+ export declare function isNativeExecutable(path: string): boolean;
9
10
  export declare function binaryVariants(host: Host): string[];
10
11
  //# sourceMappingURL=binary.d.ts.map
package/dist/binary.js CHANGED
@@ -1,11 +1,13 @@
1
1
  import { spawnSync } from "node:child_process";
2
- import { existsSync, readFileSync } from "node:fs";
2
+ import { closeSync, existsSync, openSync, readFileSync, readSync } from "node:fs";
3
3
  import { createRequire } from "node:module";
4
4
  import { dirname, join, resolve } from "node:path";
5
5
  const PLATFORMS = { darwin: "darwin", linux: "linux", win32: "windows" };
6
6
  // Resolved from opencode-ai's platform packages directly, because newer npm
7
- // versions block the postinstall script that would otherwise place it.
8
- export function resolveOpencodeBinary(env) {
7
+ // versions block the postinstall script that would otherwise place it. An
8
+ // install without optional dependencies has no platform package, but where
9
+ // that script ran, it downloaded the binary into opencode-ai's own bin/.
10
+ export function resolveOpencodeBinary(env, opencodePackage = createRequire(import.meta.url).resolve("opencode-ai/package.json")) {
9
11
  const override = env.OCRA_OPENCODE_BIN;
10
12
  // OpenCode runs in a directory of its own, where a relative path would
11
13
  // not resolve; a bare name is still looked up in PATH.
@@ -13,7 +15,7 @@ export function resolveOpencodeBinary(env) {
13
15
  return /[\\/]/.test(override) ? resolve(override) : override;
14
16
  const platform = PLATFORMS[process.platform] ?? process.platform;
15
17
  const executable = platform === "windows" ? "opencode.exe" : "opencode";
16
- const fromOpencode = createRequire(createRequire(import.meta.url).resolve("opencode-ai/package.json"));
18
+ const fromOpencode = createRequire(opencodePackage);
17
19
  for (const variant of binaryVariants(currentHost(platform))) {
18
20
  try {
19
21
  const pkg = fromOpencode.resolve(`opencode-${platform}-${process.arch}${variant}/package.json`);
@@ -23,7 +25,34 @@ export function resolveOpencodeBinary(env) {
23
25
  }
24
26
  catch { }
25
27
  }
26
- throw new Error(`No OpenCode binary for ${platform}-${process.arch}; set OCRA_OPENCODE_BIN`);
28
+ // The script names the binary opencode.exe on every platform.
29
+ const placed = join(dirname(opencodePackage), "bin", "opencode.exe");
30
+ if (isNativeExecutable(placed))
31
+ return placed;
32
+ throw new Error(`No OpenCode binary for ${platform}-${process.arch}: install without --omit=optional, or set OCRA_OPENCODE_BIN`);
33
+ }
34
+ // Until the install script replaces it, opencode-ai's bin/opencode.exe is a
35
+ // shell script that only prints an error, so the file must be a real
36
+ // executable: ELF, Mach-O (either byte order, or universal) or Windows PE.
37
+ export function isNativeExecutable(path) {
38
+ let fd;
39
+ try {
40
+ fd = openSync(path, "r");
41
+ const head = Buffer.alloc(4);
42
+ if (readSync(fd, head, 0, 4, 0) < 4)
43
+ return false;
44
+ const magic = head.readUInt32BE(0);
45
+ return (magic === 0x7f454c46 ||
46
+ [0xfeedface, 0xfeedfacf, 0xcefaedfe, 0xcffaedfe, 0xcafebabe].includes(magic) ||
47
+ head.subarray(0, 2).toString("latin1") === "MZ");
48
+ }
49
+ catch {
50
+ return false;
51
+ }
52
+ finally {
53
+ if (fd !== undefined)
54
+ closeSync(fd);
55
+ }
27
56
  }
28
57
  // The builds to try, best first. npm installs every build whose os and cpu
29
58
  // match, and the glibc builds declare no libc, so musl Linux has both kinds;
package/dist/runtime.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { type AgentEvent, type AgentRuntime, type AgentTaskSpec, type CompletionRequest, type CompletionResult, type RuntimeOptions } from "@open-cr-agent/core";
1
+ import { type AgentEvent, type AgentRuntime, type AgentTaskSpec, type CompletionRequest, type CompletionResult, type CustomProvider, type RuntimeOptions } from "@open-cr-agent/core";
2
2
  import { type ToolServer } from "./tool-server.js";
3
3
  export declare const MCP_SERVER = "ocra";
4
4
  export declare const MAX_AGENT_STEPS = 30;
@@ -27,9 +27,29 @@ export declare class OpenCodeRuntime implements AgentRuntime {
27
27
  private start;
28
28
  private launch;
29
29
  }
30
- export declare function openCodeConfig(tools: Pick<ToolServer, "url" | "headers">, helperTools: Record<string, boolean>): {
30
+ export declare function openCodeConfig(tools: Pick<ToolServer, "url" | "headers">, helperTools: Record<string, boolean>, custom?: Readonly<Record<string, CustomProvider>>): {
31
31
  share: string;
32
32
  autoupdate: boolean;
33
+ provider?: {
34
+ [k: string]: {
35
+ npm: string;
36
+ name: string;
37
+ options: {
38
+ baseURL: string;
39
+ apiKey?: string;
40
+ };
41
+ models: {
42
+ [k: string]: {
43
+ name: string;
44
+ cost: {
45
+ input: number;
46
+ output: number;
47
+ cache_read?: number;
48
+ };
49
+ };
50
+ };
51
+ };
52
+ };
33
53
  mcp: {
34
54
  ocra: {
35
55
  type: string;
package/dist/runtime.js CHANGED
@@ -162,7 +162,8 @@ export class OpenCodeRuntime {
162
162
  return this.infra;
163
163
  }
164
164
  async launch() {
165
- const missing = missingCredentials(this.options.env, providersOf(this.options.models));
165
+ const custom = this.options.providers ?? {};
166
+ const missing = missingCredentials(this.options.env, providersOf(this.options.models), custom);
166
167
  if (missing.length > 0)
167
168
  throw new Error(missing.join("; "));
168
169
  const root = await mkdtemp(join(tmpdir(), "ocra-opencode-"));
@@ -179,8 +180,8 @@ export class OpenCodeRuntime {
179
180
  const server = await startOpencodeServer({
180
181
  binary: this.options.binary ?? resolveOpencodeBinary(this.options.env),
181
182
  cwd: workspace,
182
- env: serverEnv(this.options.env, dirs, providersOf(this.options.models)),
183
- config: openCodeConfig(tools, this.helperTools),
183
+ env: serverEnv(this.options.env, dirs, providersOf(this.options.models), custom),
184
+ config: openCodeConfig(tools, this.helperTools, custom),
184
185
  });
185
186
  started = server;
186
187
  const dispatcher = createUntimedDispatcher();
@@ -206,11 +207,12 @@ export class OpenCodeRuntime {
206
207
  }
207
208
  }
208
209
  }
209
- export function openCodeConfig(tools, helperTools) {
210
+ export function openCodeConfig(tools, helperTools, custom = {}) {
210
211
  const permission = { edit: "deny", bash: "deny", webfetch: "deny", skill: "deny" };
211
212
  return {
212
213
  share: "disabled",
213
214
  autoupdate: false,
215
+ ...(Object.keys(custom).length > 0 ? { provider: providerConfig(custom) } : {}),
214
216
  mcp: {
215
217
  [MCP_SERVER]: {
216
218
  type: "remote",
@@ -238,6 +240,35 @@ export function openCodeConfig(tools, helperTools) {
238
240
  },
239
241
  };
240
242
  }
243
+ // OpenCode's form of a provider declared in configuration. OpenCode bundles
244
+ // the OpenAI-compatible client, so nothing is installed to reach it
245
+ // (docs/spikes/0002). The key stays in the environment: OpenCode reads
246
+ // {env:NAME} itself, and the configuration, which OpenCode may log, never
247
+ // holds it. Prices are per million tokens, as OpenCode's catalog has them.
248
+ function providerConfig(custom) {
249
+ return Object.fromEntries(Object.entries(custom).map(([id, provider]) => [
250
+ id,
251
+ {
252
+ npm: "@ai-sdk/openai-compatible",
253
+ name: id,
254
+ options: {
255
+ baseURL: provider.baseUrl,
256
+ ...(provider.apiKeyEnv ? { apiKey: `{env:${provider.apiKeyEnv}}` } : {}),
257
+ },
258
+ models: Object.fromEntries(Object.entries(provider.models).map(([model, price]) => [
259
+ model,
260
+ {
261
+ name: model,
262
+ cost: {
263
+ input: price.input,
264
+ output: price.output,
265
+ ...(price.cachedInput === undefined ? {} : { cache_read: price.cachedInput }),
266
+ },
267
+ },
268
+ ])),
269
+ },
270
+ ]));
271
+ }
241
272
  function providersOf(models) {
242
273
  return Object.values(models).flatMap((chain) => (chain ?? []).map((m) => parseModel(m).providerID));
243
274
  }
@@ -1,10 +1,12 @@
1
- import type { Env } from "@open-cr-agent/core";
1
+ import type { CustomProvider, Env } from "@open-cr-agent/core";
2
2
  export interface IsolatedDirs {
3
3
  config: string;
4
4
  data: string;
5
5
  state: string;
6
6
  }
7
- export declare function missingCredentials(base: Env, providers: readonly string[]): string[];
7
+ type CustomProviders = Readonly<Record<string, CustomProvider>>;
8
+ export declare function missingCredentials(base: Env, providers: readonly string[], custom?: CustomProviders): string[];
8
9
  export declare const EXTRA_ENV_VARIABLE = "OCRA_RUNTIME_ENV";
9
- export declare function serverEnv(base: Env, dirs: IsolatedDirs, providers: readonly string[]): Record<string, string>;
10
+ export declare function serverEnv(base: Env, dirs: IsolatedDirs, providers: readonly string[], custom?: CustomProviders): Record<string, string>;
11
+ export {};
10
12
  //# sourceMappingURL=server-env.d.ts.map
@@ -73,8 +73,13 @@ const REQUIRED_KEYS = {
73
73
  xai: ["XAI_API_KEY"],
74
74
  azure: ["AZURE_API_KEY"],
75
75
  };
76
- export function missingCredentials(base, providers) {
76
+ export function missingCredentials(base, providers, custom = {}) {
77
77
  return [...new Set(providers)].flatMap((provider) => {
78
+ const declared = custom[provider];
79
+ if (declared) {
80
+ const name = declared.apiKeyEnv;
81
+ return name && !base[name] ? [`No API key for provider "${provider}": set ${name}`] : [];
82
+ }
78
83
  const names = REQUIRED_KEYS[provider];
79
84
  if (!names || names.some((name) => base[name]))
80
85
  return [];
@@ -84,14 +89,28 @@ export function missingCredentials(base, providers) {
84
89
  const GOOGLE_KEY = "GOOGLE_GENERATIVE_AI_API_KEY";
85
90
  const GOOGLE_KEY_ALIASES = ["GEMINI_API_KEY", "GOOGLE_API_KEY"];
86
91
  export const EXTRA_ENV_VARIABLE = "OCRA_RUNTIME_ENV";
87
- // A model chain naming a provider such as "github" or "aws" must not carry
88
- // the CI's or the cloud's credentials across by prefix; name them in
92
+ // OpenCode installs its plugin package from npm into every config directory
93
+ // when it starts, and the SDK of a provider it does not bundle when a model
94
+ // first uses it; no flag turns either off. ocra loads no OpenCode plugins and
95
+ // supports only bundled providers, so both installs get the discard port on
96
+ // this machine as their registry, and no retries: they fail at once, no code
97
+ // is fetched at review time and no .npmrc token is sent (docs/spikes/0002).
98
+ const NPM_SETTINGS = {
99
+ npm_config_registry: "http://127.0.0.1:9/",
100
+ NPM_CONFIG_REGISTRY: "http://127.0.0.1:9/",
101
+ npm_config_fetch_retries: "0",
102
+ };
103
+ // A model chain naming a provider such as "github", "gitlab" or "aws" must
104
+ // not carry the CI's or the cloud's credentials across by prefix (GitLab CI
105
+ // puts CI_JOB_TOKEN and CI_REGISTRY_PASSWORD in every job); name them in
89
106
  // OCRA_RUNTIME_ENV when they are really meant for a provider.
90
107
  const NEVER_BY_PREFIX = [
91
108
  "GITHUB_",
92
109
  "GH_",
93
110
  "ACTIONS_",
94
111
  "RUNNER_",
112
+ "GITLAB_",
113
+ "CI_",
95
114
  "AWS_",
96
115
  "AZURE_",
97
116
  "NPM_",
@@ -99,9 +118,10 @@ const NEVER_BY_PREFIX = [
99
118
  "OCRA_",
100
119
  ];
101
120
  // The child sees only what it needs: system basics, the credentials of the
102
- // providers in the configured model chains, and names listed in
121
+ // providers in the configured model chains (for a provider declared in
122
+ // configuration, only the variable it names), and names listed in
103
123
  // OCRA_RUNTIME_ENV. Other secrets in the user's shell never reach it.
104
- export function serverEnv(base, dirs, providers) {
124
+ export function serverEnv(base, dirs, providers, custom = {}) {
105
125
  const env = {};
106
126
  const copy = (name) => {
107
127
  const value = base[name];
@@ -114,6 +134,12 @@ export function serverEnv(base, dirs, providers) {
114
134
  if (name.startsWith("LC_"))
115
135
  copy(name);
116
136
  for (const provider of new Set(providers)) {
137
+ const declared = custom[provider];
138
+ if (declared) {
139
+ if (declared.apiKeyEnv)
140
+ copy(declared.apiKeyEnv);
141
+ continue;
142
+ }
117
143
  const known = PROVIDER_VARIABLES[provider];
118
144
  if (known) {
119
145
  for (const name of known)
@@ -132,13 +158,14 @@ export function serverEnv(base, dirs, providers) {
132
158
  if (name.trim() !== "")
133
159
  copy(name.trim());
134
160
  }
135
- if (providers.includes("google") && !env[GOOGLE_KEY]) {
161
+ if (providers.includes("google") && !custom.google && !env[GOOGLE_KEY]) {
136
162
  const alias = GOOGLE_KEY_ALIASES.map((name) => base[name]).find((value) => value);
137
163
  if (alias)
138
164
  env[GOOGLE_KEY] = alias;
139
165
  }
140
166
  for (const flag of ISOLATION_FLAGS)
141
167
  env[flag] = "1";
168
+ Object.assign(env, NPM_SETTINGS);
142
169
  env.OPENCODE_CONFIG_DIR = dirs.config;
143
170
  env.XDG_CONFIG_HOME = dirs.config;
144
171
  env.XDG_DATA_HOME = dirs.data;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-cr-agent/runtime-opencode",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
4
4
  "description": "OpenCode-based agent runtime for Open-CR-Agent",
5
5
  "keywords": [
6
6
  "code-review",
@@ -31,7 +31,7 @@
31
31
  ],
32
32
  "dependencies": {
33
33
  "@modelcontextprotocol/sdk": "^1.30.1",
34
- "@open-cr-agent/core": "0.1.2",
34
+ "@open-cr-agent/core": "0.2.0",
35
35
  "@opencode-ai/sdk": "1.18.32",
36
36
  "opencode-ai": "1.18.32",
37
37
  "undici": "^8.11.2",