@the-open-engine/zeroshot 6.7.1 → 6.8.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.
Files changed (44) hide show
  1. package/cli/index.js +114 -16
  2. package/lib/agent-cli-provider/adapters/common.js +1 -1
  3. package/lib/agent-cli-provider/adapters/common.js.map +1 -1
  4. package/lib/agent-cli-provider/adapters/opencode-models.d.ts +7 -0
  5. package/lib/agent-cli-provider/adapters/opencode-models.d.ts.map +1 -0
  6. package/lib/agent-cli-provider/adapters/opencode-models.js +87 -0
  7. package/lib/agent-cli-provider/adapters/opencode-models.js.map +1 -0
  8. package/lib/agent-cli-provider/adapters/opencode.d.ts.map +1 -1
  9. package/lib/agent-cli-provider/adapters/opencode.js +6 -58
  10. package/lib/agent-cli-provider/adapters/opencode.js.map +1 -1
  11. package/lib/agent-cli-provider/single-agent-runtime.d.ts.map +1 -1
  12. package/lib/agent-cli-provider/single-agent-runtime.js +27 -13
  13. package/lib/agent-cli-provider/single-agent-runtime.js.map +1 -1
  14. package/lib/agent-cli-provider/types.d.ts +1 -0
  15. package/lib/agent-cli-provider/types.d.ts.map +1 -1
  16. package/lib/agent-cli-provider/types.js.map +1 -1
  17. package/lib/git-remote-utils.js +90 -7
  18. package/package.json +6 -15
  19. package/protocol/openengine-cluster/v1/worker.schema.json +47 -0
  20. package/scripts/assert-release-published.js +161 -2
  21. package/scripts/release-dry-run.js +83 -0
  22. package/scripts/release-preflight.js +24 -12
  23. package/scripts/release-recovery.js +149 -0
  24. package/scripts/semantic-release-notes.js +44 -0
  25. package/scripts/setup-merge-queue.sh +104 -118
  26. package/src/agent/agent-task-executor.js +18 -9
  27. package/src/agent-cli-provider/adapters/common.ts +1 -1
  28. package/src/agent-cli-provider/adapters/opencode-models.ts +100 -0
  29. package/src/agent-cli-provider/adapters/opencode.ts +8 -65
  30. package/src/agent-cli-provider/single-agent-runtime.ts +51 -33
  31. package/src/agent-cli-provider/types.ts +1 -0
  32. package/src/agent-wrapper.js +14 -2
  33. package/src/agents/git-pusher-template.js +94 -19
  34. package/src/attach/socket-discovery.js +9 -19
  35. package/src/attach/socket-paths.js +85 -0
  36. package/src/claude-task-runner.js +121 -37
  37. package/src/config-validator.js +2 -2
  38. package/src/isolation-manager.js +164 -74
  39. package/src/orchestrator.js +83 -34
  40. package/src/providers/index.js +5 -0
  41. package/src/task-run-model-args.js +155 -0
  42. package/task-lib/attachable-watcher.js +3 -9
  43. package/task-lib/commands/run.js +17 -2
  44. package/task-lib/runner.js +87 -29
@@ -0,0 +1,149 @@
1
+ #!/usr/bin/env node
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { execFileSync, spawnSync } = require('child_process');
6
+
7
+ function run(command, args, options = {}) {
8
+ return execFileSync(command, args, {
9
+ encoding: 'utf8',
10
+ stdio: options.stdio || ['ignore', 'pipe', 'pipe'],
11
+ ...options,
12
+ }).trim();
13
+ }
14
+
15
+ function parseReleaseTag(tag) {
16
+ const match = String(tag || '').match(/^v(\d+\.\d+\.\d+)$/);
17
+ if (!match) throw new Error('release_tag must match vX.Y.Z');
18
+ return match[1];
19
+ }
20
+
21
+ function validateCommit(commit) {
22
+ if (!/^[0-9a-f]{40}$/.test(String(commit || ''))) {
23
+ throw new Error('release_commit must be a full lowercase commit SHA');
24
+ }
25
+ return commit;
26
+ }
27
+
28
+ function verifyImmutableSource(tag, commit) {
29
+ run('git', ['show-ref', '--verify', `refs/tags/${tag}`]);
30
+ const head = run('git', ['rev-parse', 'HEAD']);
31
+ const tagCommit = run('git', ['rev-list', '-n', '1', `refs/tags/${tag}`]);
32
+ if (head !== commit || tagCommit !== commit) {
33
+ throw new Error('checkout, protected tag, and release_commit must reference the same commit');
34
+ }
35
+ run('git', ['merge-base', '--is-ancestor', commit, 'origin/main']);
36
+ }
37
+
38
+ function npmPackageMetadata(name, version) {
39
+ const result = spawnSync(
40
+ 'npm',
41
+ ['view', `${name}@${version}`, 'version', 'gitHead', 'dist.attestations', '--json'],
42
+ { encoding: 'utf8' }
43
+ );
44
+ if (result.status === 0) return JSON.parse(result.stdout);
45
+ if (/\bE404\b|is not in this registry|No match found/i.test(result.stderr || '')) return null;
46
+ throw new Error(`npm registry lookup failed: ${(result.stderr || result.stdout).trim()}`);
47
+ }
48
+
49
+ function verifyExistingNpmVersion(metadata, version, commit) {
50
+ if (metadata.version !== version) {
51
+ throw new Error(`npm returned ${metadata.version}; expected ${version}`);
52
+ }
53
+ if (metadata.gitHead !== commit) {
54
+ throw new Error(`npm gitHead ${metadata.gitHead || '(missing)'} does not match ${commit}`);
55
+ }
56
+ if (!metadata['dist.attestations']?.provenance) {
57
+ throw new Error('npm package exists without a provenance attestation');
58
+ }
59
+ }
60
+
61
+ function recoverNpm(name, version, commit) {
62
+ const existing = npmPackageMetadata(name, version);
63
+ if (existing) {
64
+ verifyExistingNpmVersion(existing, version, commit);
65
+ console.log(`${name}@${version} already exists with matching provenance; nothing to recover`);
66
+ return;
67
+ }
68
+
69
+ run('npm', ['version', version, '--no-git-tag-version', '--allow-same-version']);
70
+ run('npm', ['publish', '--access', 'public', '--provenance'], { stdio: 'inherit' });
71
+
72
+ const published = npmPackageMetadata(name, version);
73
+ if (!published) throw new Error(`${name}@${version} was not visible after publication`);
74
+ verifyExistingNpmVersion(published, version, commit);
75
+ console.log(`Recovered ${name}@${version}`);
76
+ }
77
+
78
+ function releaseExists(tag) {
79
+ const result = spawnSync('gh', ['release', 'view', tag], { encoding: 'utf8' });
80
+ if (result.status === 0) return true;
81
+ if (/release not found|HTTP 404/i.test(`${result.stdout}\n${result.stderr}`)) return false;
82
+ throw new Error(`GitHub Release lookup failed: ${(result.stderr || result.stdout).trim()}`);
83
+ }
84
+
85
+ function recoverGithubRelease(tag, commit, version) {
86
+ if (releaseExists(tag)) {
87
+ console.log(`GitHub Release ${tag} already exists; nothing to recover`);
88
+ return;
89
+ }
90
+
91
+ const notesPath = path.join(process.cwd(), 'docs', 'releases', `${tag}.md`);
92
+ const args = ['release', 'create', tag, '--verify-tag', '--target', commit, '--title', tag];
93
+ if (fs.existsSync(notesPath)) {
94
+ args.push('--notes-file', notesPath);
95
+ } else {
96
+ args.push('--generate-notes');
97
+ const tags = run('git', [
98
+ 'tag',
99
+ '--list',
100
+ 'v[0-9]*',
101
+ '--merged',
102
+ `${commit}^`,
103
+ '--sort=-version:refname',
104
+ ])
105
+ .split(/\r?\n/)
106
+ .filter(Boolean);
107
+ if (tags[0]) args.push('--notes-start-tag', tags[0]);
108
+ }
109
+
110
+ run('gh', args, { stdio: 'inherit' });
111
+ if (!releaseExists(tag)) throw new Error(`GitHub Release ${tag} was not created`);
112
+ console.log(`Recovered GitHub Release ${tag} for ${version}`);
113
+ }
114
+
115
+ function main() {
116
+ const action = process.env.RECOVERY_ACTION;
117
+ if (!['recover-npm', 'recover-github-release'].includes(action)) {
118
+ throw new Error('RECOVERY_ACTION must be recover-npm or recover-github-release');
119
+ }
120
+
121
+ const tag = process.env.RELEASE_TAG;
122
+ const commit = validateCommit(process.env.RELEASE_COMMIT);
123
+ const version = parseReleaseTag(tag);
124
+ verifyImmutableSource(tag, commit);
125
+
126
+ const packageJson = require('../package.json');
127
+ if (action === 'recover-npm') {
128
+ recoverNpm(packageJson.name, version, commit);
129
+ } else {
130
+ recoverGithubRelease(tag, commit, version);
131
+ }
132
+ }
133
+
134
+ if (require.main === module) {
135
+ try {
136
+ main();
137
+ } catch (error) {
138
+ console.error(`Release recovery failed: ${error.message}`);
139
+ process.exit(1);
140
+ }
141
+ }
142
+
143
+ module.exports = {
144
+ npmPackageMetadata,
145
+ parseReleaseTag,
146
+ releaseExists,
147
+ validateCommit,
148
+ verifyExistingNpmVersion,
149
+ };
@@ -0,0 +1,44 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+
4
+ function curatedNotesPath(cwd, version) {
5
+ return path.join(cwd, 'docs', 'releases', `v${version}.md`);
6
+ }
7
+
8
+ function readCuratedNotes(cwd, version) {
9
+ const notesPath = curatedNotesPath(cwd, version);
10
+ if (!fs.existsSync(notesPath)) return null;
11
+
12
+ const notes = fs.readFileSync(notesPath, 'utf8').trim();
13
+ if (!notes) {
14
+ throw new Error(`Curated release notes are empty: ${notesPath}`);
15
+ }
16
+ return `${notes}\n`;
17
+ }
18
+
19
+ async function conventionalNotes(pluginConfig, context) {
20
+ const generator = await import('@semantic-release/release-notes-generator');
21
+ return generator.generateNotes(pluginConfig.conventional || {}, context);
22
+ }
23
+
24
+ async function generateNotesWithFallback(pluginConfig, context, fallback = conventionalNotes) {
25
+ const version = context.nextRelease?.version;
26
+ if (!version) throw new Error('nextRelease.version is required to generate release notes');
27
+
28
+ const curated = readCuratedNotes(context.cwd || process.cwd(), version);
29
+ if (curated) return curated;
30
+ const generated = await fallback(pluginConfig, context);
31
+ return generated;
32
+ }
33
+
34
+ async function generateNotes(pluginConfig, context) {
35
+ const notes = await generateNotesWithFallback(pluginConfig, context);
36
+ return notes;
37
+ }
38
+
39
+ module.exports = {
40
+ curatedNotesPath,
41
+ generateNotes,
42
+ generateNotesWithFallback,
43
+ readCuratedNotes,
44
+ };
@@ -1,75 +1,79 @@
1
1
  #!/bin/bash
2
- # Setup GitHub merge queue and branch protection for zeroshot
3
- # Run once after creating the repo or to update settings
4
2
 
5
- set -e
3
+ set -euo pipefail
6
4
 
7
5
  REPO="the-open-engine/zeroshot"
6
+ MAIN_RULESET_NAME="Protect main trunk"
7
+ TAG_RULESET_NAME="Make release tags immutable"
8
8
 
9
- echo "╔═══════════════════════════════════════════════════════════════════╗"
10
- echo "║ Setting up merge queue for $REPO ║"
11
- echo "╚═══════════════════════════════════════════════════════════════════╝"
12
- echo ""
13
-
14
- # Check gh is authenticated
15
9
  if ! gh auth status &>/dev/null; then
16
- echo "ERROR: Not authenticated with GitHub CLI"
17
- echo " Run: gh auth login"
10
+ echo "ERROR: Authenticate GitHub CLI with: gh auth login"
18
11
  exit 1
19
12
  fi
20
13
 
21
- # Check we have admin access
22
- if ! gh api "repos/$REPO" --jq '.permissions.admin' | grep -q true; then
23
- echo "❌ ERROR: You need admin access to $REPO"
14
+ if [[ "$(gh api "repos/$REPO" --jq '.permissions.admin')" != "true" ]]; then
15
+ echo "ERROR: Repository admin access is required"
24
16
  exit 1
25
17
  fi
26
18
 
27
- echo "✓ Authenticated with admin access"
28
- echo ""
29
-
30
- # ============================================================================
31
- # Configure 'dev' branch protection (merge target)
32
- # ============================================================================
33
-
34
- echo "→ Configuring 'dev' branch protection..."
35
-
36
- gh api --method PUT "repos/$REPO/branches/dev/protection" \
37
- --input - <<EOF
38
- {
39
- "required_status_checks": {
40
- "strict": true,
41
- "contexts": ["check", "install-matrix (ubuntu-latest, 20)", "install-matrix (macos-latest, 20)"]
42
- },
43
- "enforce_admins": false,
44
- "required_pull_request_reviews": null,
45
- "restrictions": null,
46
- "allow_force_pushes": false,
47
- "allow_deletions": false,
48
- "required_linear_history": true,
49
- "required_conversation_resolution": false
19
+ upsert_ruleset() {
20
+ local name="$1"
21
+ local payload="$2"
22
+ local id
23
+ id="$(gh api "repos/$REPO/rulesets" --jq ".[] | select(.name == \"$name\") | .id" | head -1)"
24
+ if [[ -n "$id" ]]; then
25
+ gh api --method PUT "repos/$REPO/rulesets/$id" --input "$payload" >/dev/null
26
+ else
27
+ gh api --method POST "repos/$REPO/rulesets" --input "$payload" >/dev/null
28
+ fi
50
29
  }
51
- EOF
52
-
53
- echo "✓ 'dev' branch protection configured"
54
30
 
55
- # Enable merge queue for dev branch
56
- echo "→ Enabling merge queue for 'dev' branch..."
31
+ main_payload="$(mktemp)"
32
+ tag_payload="$(mktemp)"
33
+ trap 'rm -f "$main_payload" "$tag_payload"' EXIT
57
34
 
58
- # Note: Merge queue requires GitHub Enterprise or public repos with Actions
59
- # Using the ruleset API which supports merge queue
60
- gh api --method POST "repos/$REPO/rulesets" \
61
- --input - <<EOF 2>/dev/null || echo " (ruleset may already exist)"
35
+ cat > "$main_payload" <<'JSON'
62
36
  {
63
- "name": "dev-merge-queue",
37
+ "name": "Protect main trunk",
64
38
  "target": "branch",
65
39
  "enforcement": "active",
66
40
  "conditions": {
67
41
  "ref_name": {
68
- "include": ["refs/heads/dev"],
42
+ "include": ["refs/heads/main"],
69
43
  "exclude": []
70
44
  }
71
45
  },
72
46
  "rules": [
47
+ {"type": "deletion"},
48
+ {"type": "non_fast_forward"},
49
+ {"type": "required_linear_history"},
50
+ {
51
+ "type": "pull_request",
52
+ "parameters": {
53
+ "required_approving_review_count": 0,
54
+ "dismiss_stale_reviews_on_push": true,
55
+ "required_reviewers": [],
56
+ "require_code_owner_review": false,
57
+ "dismissal_restriction": {
58
+ "enabled": false,
59
+ "allowed_actors": []
60
+ },
61
+ "require_last_push_approval": false,
62
+ "required_review_thread_resolution": true,
63
+ "allowed_merge_methods": ["squash"]
64
+ }
65
+ },
66
+ {
67
+ "type": "required_status_checks",
68
+ "parameters": {
69
+ "strict_required_status_checks_policy": true,
70
+ "do_not_enforce_on_create": false,
71
+ "required_status_checks": [
72
+ {"context": "required", "integration_id": 15368},
73
+ {"context": "semantic", "integration_id": 15368}
74
+ ]
75
+ }
76
+ },
73
77
  {
74
78
  "type": "merge_queue",
75
79
  "parameters": {
@@ -82,49 +86,34 @@ gh api --method POST "repos/$REPO/rulesets" \
82
86
  "min_entries_to_merge_wait_minutes": 1
83
87
  }
84
88
  }
85
- ]
89
+ ],
90
+ "bypass_actors": []
86
91
  }
87
- EOF
88
-
89
- echo "✓ Merge queue enabled for 'dev'"
92
+ JSON
90
93
 
91
- # ============================================================================
92
- # Configure 'main' branch protection (release branch)
93
- # ============================================================================
94
-
95
- echo "→ Configuring 'main' branch protection..."
96
-
97
- gh api --method PUT "repos/$REPO/branches/main/protection" \
98
- --input - <<EOF
94
+ cat > "$tag_payload" <<'JSON'
99
95
  {
100
- "required_status_checks": {
101
- "strict": true,
102
- "contexts": ["check", "install-matrix (ubuntu-latest, 20)", "install-matrix (macos-latest, 20)"]
103
- },
104
- "enforce_admins": false,
105
- "required_pull_request_reviews": {
106
- "required_approving_review_count": 1,
107
- "dismiss_stale_reviews": true,
108
- "require_code_owner_reviews": false
96
+ "name": "Make release tags immutable",
97
+ "target": "tag",
98
+ "enforcement": "active",
99
+ "conditions": {
100
+ "ref_name": {
101
+ "include": ["refs/tags/v*"],
102
+ "exclude": []
103
+ }
109
104
  },
110
- "restrictions": null,
111
- "allow_force_pushes": false,
112
- "allow_deletions": false,
113
- "required_linear_history": true,
114
- "required_conversation_resolution": false
105
+ "rules": [
106
+ {"type": "deletion"},
107
+ {"type": "non_fast_forward"}
108
+ ],
109
+ "bypass_actors": []
115
110
  }
116
- EOF
111
+ JSON
117
112
 
118
- echo " 'main' branch protection configured"
113
+ upsert_ruleset "$MAIN_RULESET_NAME" "$main_payload"
114
+ upsert_ruleset "$TAG_RULESET_NAME" "$tag_payload"
119
115
 
120
- # ============================================================================
121
- # Configure repository settings
122
- # ============================================================================
123
-
124
- echo "→ Configuring repository settings..."
125
-
126
- gh api --method PATCH "repos/$REPO" \
127
- --input - <<EOF
116
+ gh api --method PATCH "repos/$REPO" --input - <<'JSON' >/dev/null
128
117
  {
129
118
  "allow_squash_merge": true,
130
119
  "allow_merge_commit": false,
@@ -134,37 +123,34 @@ gh api --method PATCH "repos/$REPO" \
134
123
  "delete_branch_on_merge": true,
135
124
  "allow_auto_merge": true
136
125
  }
137
- EOF
138
-
139
- echo " Repository settings configured"
140
-
141
- # ============================================================================
142
- # Summary
143
- # ============================================================================
144
-
145
- echo ""
146
- echo "╔═══════════════════════════════════════════════════════════════════╗"
147
- echo "║ ✓ Merge queue setup complete! ║"
148
- echo "╚═══════════════════════════════════════════════════════════════════╝"
149
- echo ""
150
- echo "Workflow:"
151
- echo " feature-branch (local)"
152
- echo " ↓"
153
- echo " pre-push hook → lint + typecheck (~5s)"
154
- echo ""
155
- echo " push to origin/feature-branch"
156
- echo ""
157
- echo " gh pr create --base dev"
158
- echo " ↓"
159
- echo " CI runs tests on PR branch"
160
- echo " ↓"
161
- echo " gh pr merge --auto --squash enters merge queue"
162
- echo " ↓"
163
- echo " Queue rebases PR on latest dev + runs CI again"
164
- echo ""
165
- echo " Merge to dev (only if CI passes on rebased code)"
166
- echo ""
167
- echo "Release workflow:"
168
- echo " gh pr create --base main --head dev --title \"Release\""
169
- echo " → CI passes → merge → semantic-release publishes"
170
- echo ""
126
+ JSON
127
+
128
+ gh api --method PUT "repos/$REPO/environments/release" --input - <<'JSON' >/dev/null
129
+ {
130
+ "deployment_branch_policy": {
131
+ "protected_branches": false,
132
+ "custom_branch_policies": true
133
+ }
134
+ }
135
+ JSON
136
+
137
+ if ! gh api "repos/$REPO/environments/release/deployment-branch-policies" \
138
+ --jq '.branch_policies[] | select(.name == "main") | .id' | grep -q .; then
139
+ gh api --method POST "repos/$REPO/environments/release/deployment-branch-policies" \
140
+ -f name=main -f type=branch >/dev/null
141
+ fi
142
+
143
+ gh variable set RELEASE_AUTOMATION_ENABLED --repo "$REPO" --body false
144
+
145
+ if gh api "repos/$REPO/branches/main/protection" >/dev/null 2>&1; then
146
+ gh api --method DELETE "repos/$REPO/branches/main/protection"
147
+ fi
148
+
149
+ while IFS= read -r ruleset_id; do
150
+ [[ -n "$ruleset_id" ]] && gh api --method DELETE "repos/$REPO/rulesets/$ruleset_id"
151
+ done < <(
152
+ gh api "repos/$REPO/rulesets" \
153
+ --jq '.[] | select(.name == "dev-merge-queue" or .name == "main-merge-queue") | .id'
154
+ )
155
+
156
+ echo "Configured protected main trunk, merge queue, immutable release tags, and disabled release automation."
@@ -23,6 +23,10 @@ const {
23
23
  prepareClaudeConfigDir,
24
24
  resolveRepoMcpConfigPath,
25
25
  } = require('../worktree-claude-config.js');
26
+ const {
27
+ appendTaskRunModelArgs,
28
+ wrapTaskRunWithIsolatedSettings,
29
+ } = require('../task-run-model-args.js');
26
30
  const { buildRawLogOnlyMetadata } = require('./context-replay-policy');
27
31
 
28
32
  function runCommandWithTimeout(command, args, options = {}, callback = null) {
@@ -722,14 +726,10 @@ function resolveOutputFormatConfig(agent) {
722
726
 
723
727
  function buildTaskRunArgs({ agent, providerName, modelSpec, runOutputFormat }) {
724
728
  const args = ['task', 'run', '--output-format', runOutputFormat, '--provider', providerName];
725
-
726
- if (modelSpec?.model) {
727
- args.push('--model', modelSpec.model);
728
- }
729
-
730
- if (modelSpec?.reasoningEffort) {
731
- args.push('--reasoning-effort', modelSpec.reasoningEffort);
732
- }
729
+ const modelSpecSource = agent._resolveModelSpecSource
730
+ ? agent._resolveModelSpecSource()
731
+ : 'direct';
732
+ appendTaskRunModelArgs(args, modelSpec, modelSpecSource);
733
733
 
734
734
  // Add verification mode flag if configured
735
735
  if (agent.config.verificationMode) {
@@ -1509,11 +1509,14 @@ async function spawnClaudeTaskIsolated(agent, context) {
1509
1509
  const { manager, clusterId } = agent.isolation;
1510
1510
  const providerName = agent._resolveProvider ? agent._resolveProvider() : 'claude';
1511
1511
  const modelSpec = resolveAgentModelSpec(agent);
1512
+ const modelSpecSource = agent._resolveModelSpecSource
1513
+ ? agent._resolveModelSpecSource()
1514
+ : 'direct';
1512
1515
 
1513
1516
  agent._log(`📦 Agent ${agent.id}: Running task in isolated container using zeroshot task run...`);
1514
1517
 
1515
1518
  const { desiredOutputFormat, runOutputFormat } = resolveOutputFormatConfig(agent);
1516
- const command = [
1519
+ let command = [
1517
1520
  'zeroshot',
1518
1521
  ...buildTaskRunArgs({
1519
1522
  agent,
@@ -1531,6 +1534,12 @@ async function spawnClaudeTaskIsolated(agent, context) {
1531
1534
  });
1532
1535
 
1533
1536
  command.push(finalContext);
1537
+ command = wrapTaskRunWithIsolatedSettings(command, {
1538
+ providerName,
1539
+ settings: loadSettings(),
1540
+ modelSpecSource,
1541
+ modelSpec,
1542
+ });
1534
1543
 
1535
1544
  // STEP 1: Spawn task and extract task ID (same as non-isolated mode)
1536
1545
  // Timeout for spawn phase - if CLI hangs during init (e.g., opencode 429 bug), kill it
@@ -99,7 +99,7 @@ export function validateModelIdFromCatalog(
99
99
  modelId: string | null | undefined
100
100
  ): string | null | undefined {
101
101
  if (!modelId) return modelId;
102
- if (catalog[modelId] !== undefined) return modelId;
102
+ if (Object.prototype.hasOwnProperty.call(catalog, modelId)) return modelId;
103
103
  const validModels = Object.keys(catalog).join(', ');
104
104
  throw new InvalidProviderModelError(
105
105
  `Invalid model "${modelId}" for provider "${provider}". Valid models: ${validModels}.`
@@ -0,0 +1,100 @@
1
+ import { unknownToMessage } from '../json';
2
+ import {
3
+ InvalidProviderModelError,
4
+ type LevelModelSpec,
5
+ type LevelOverrides,
6
+ type ModelCatalogEntry,
7
+ type ModelLevel,
8
+ type ResolvedModelSpec,
9
+ } from '../types';
10
+ import { validateModelIdFromCatalog } from './common';
11
+
12
+ export const MODEL_CATALOG: Readonly<Record<string, ModelCatalogEntry>> = {
13
+ 'opencode/big-pickle': { rank: 1 },
14
+ 'opencode/glm-4.7-free': { rank: 1 },
15
+ 'opencode/gpt-5-nano': { rank: 1 },
16
+ 'opencode/grok-code': { rank: 1 },
17
+ 'opencode/minimax-m2.1-free': { rank: 1 },
18
+ 'google/gemini-1.5-flash': { rank: 1 },
19
+ 'google/gemini-1.5-flash-8b': { rank: 1 },
20
+ 'google/gemini-1.5-pro': { rank: 1 },
21
+ 'google/gemini-2.0-flash': { rank: 1 },
22
+ 'google/gemini-2.0-flash-lite': { rank: 1 },
23
+ 'google/gemini-2.5-flash': { rank: 1 },
24
+ 'google/gemini-2.5-flash-image': { rank: 1 },
25
+ 'google/gemini-2.5-flash-image-preview': { rank: 1 },
26
+ 'google/gemini-2.5-flash-lite': { rank: 1 },
27
+ 'google/gemini-2.5-flash-lite-preview-06-17': { rank: 1 },
28
+ 'google/gemini-2.5-flash-lite-preview-09-2025': { rank: 1 },
29
+ 'google/gemini-2.5-flash-preview-04-17': { rank: 1 },
30
+ 'google/gemini-2.5-flash-preview-05-20': { rank: 1 },
31
+ 'google/gemini-2.5-flash-preview-09-2025': { rank: 1 },
32
+ 'google/gemini-2.5-flash-preview-tts': { rank: 1 },
33
+ 'google/gemini-2.5-pro': { rank: 1 },
34
+ 'google/gemini-2.5-pro-preview-05-06': { rank: 1 },
35
+ 'google/gemini-2.5-pro-preview-06-05': { rank: 1 },
36
+ 'google/gemini-2.5-pro-preview-tts': { rank: 1 },
37
+ 'google/gemini-3-flash-preview': { rank: 1 },
38
+ 'google/gemini-3-pro-preview': { rank: 1 },
39
+ 'google/gemini-embedding-001': { rank: 1 },
40
+ 'google/gemini-flash-latest': { rank: 1 },
41
+ 'google/gemini-flash-lite-latest': { rank: 1 },
42
+ 'google/gemini-live-2.5-flash': { rank: 1 },
43
+ 'google/gemini-live-2.5-flash-preview-native-audio': { rank: 1 },
44
+ 'openai/gpt-5.1-codex-max': { rank: 1 },
45
+ 'openai/gpt-5.1-codex-mini': { rank: 1 },
46
+ 'openai/gpt-5.2': { rank: 1 },
47
+ 'openai/gpt-5.2-codex': { rank: 1 },
48
+ };
49
+
50
+ export const LEVEL_MAPPING: Readonly<Record<ModelLevel, LevelModelSpec>> = {
51
+ level1: { rank: 1, model: null, reasoningEffort: 'low' },
52
+ level2: { rank: 2, model: null, reasoningEffort: 'medium' },
53
+ level3: { rank: 3, model: null, reasoningEffort: 'high' },
54
+ };
55
+
56
+ function containsControlCharacter(value: string): boolean {
57
+ return Array.from(value).some((character) => {
58
+ const codePoint = character.codePointAt(0);
59
+ return codePoint !== undefined && (codePoint <= 0x1f || codePoint === 0x7f);
60
+ });
61
+ }
62
+
63
+ export function resolveModelSpec(level: ModelLevel, overrides?: LevelOverrides): ResolvedModelSpec {
64
+ const base = LEVEL_MAPPING[level] ?? LEVEL_MAPPING.level2;
65
+ const override = overrides?.[level];
66
+ const selectedModel = override?.model ?? base.model;
67
+ return {
68
+ level,
69
+ model:
70
+ override?.model === undefined || override.model === null
71
+ ? (validateModelId(selectedModel) ?? null)
72
+ : (validateConfiguredModelId(selectedModel) ?? null),
73
+ reasoningEffort: override?.reasoningEffort ?? base.reasoningEffort,
74
+ };
75
+ }
76
+
77
+ export function validateConfiguredModelId(
78
+ modelId: string | null | undefined
79
+ ): string | null | undefined {
80
+ if (modelId && Object.prototype.hasOwnProperty.call(MODEL_CATALOG, modelId)) return modelId;
81
+ if (typeof modelId !== 'string') return validateModelId(modelId);
82
+ const segments = modelId.split('/');
83
+ if (
84
+ segments.length >= 2 &&
85
+ segments.every(Boolean) &&
86
+ !/\s/u.test(modelId) &&
87
+ !containsControlCharacter(modelId)
88
+ ) {
89
+ return modelId;
90
+ }
91
+ throw new InvalidProviderModelError(
92
+ `Invalid configured model "${unknownToMessage(
93
+ modelId
94
+ )}" for provider "opencode". Expected "provider/model" with no whitespace, control characters, or empty path segments.`
95
+ );
96
+ }
97
+
98
+ export function validateModelId(modelId: string | null | undefined): string | null | undefined {
99
+ return validateModelIdFromCatalog('opencode', MODEL_CATALOG, modelId);
100
+ }