@route_33/opencode 0.4.4 → 0.4.6

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
@@ -30,7 +30,7 @@ This plugin never writes your `opencode.json` or `opencode.jsonc`: opencode has
30
30
 
31
31
  Run `opencode auth login` and choose `route_33`, or set the `ROUTE33_API_KEY` environment variable. Without a key, the TUI wizard offers to save one: per-project inside a git workspace, global otherwise. A globally stored key applies only outside git workspaces — inside a workspace, keys resolve from the environment, the workspace config, or `.opencode/route33.key` (per-project keys are the metering boundary). Skipping the project-key wizard snoozes it for 7 days — "Skip" for that workspace only, "Skip for all workspaces" everywhere (both live in opencode's TUI kv store; remove `project-key-prompt.dismissed-at*` from `~/.local/state/opencode/kv.json` to re-arm sooner). `ROUTE33_BASE_URL` overrides the default gateway URL; `ROUTE33_TUI_PROMPT=0` disables the wizard.
32
32
 
33
- Your key is never written into the resolved provider configuration: authentication is attached per request at fetch time, so `opencode debug config` output contains no credential material.
33
+ Your key is never written into the resolved provider configuration: authentication is attached per request at fetch time, so `opencode debug config` output contains no credential material. One inert exception: the options carry the non-secret marker `__route33_fetch_shim_auth__` as `apiKey`, because the native per-model SDKs used for `anthropic/`, `openai/`, and `mistralai/` models (`@ai-sdk/anthropic`, `@ai-sdk/openai`, `@ai-sdk/mistral`) refuse to construct a request without one — the marker satisfies that null check, never authenticates anything (the gateway reads only the request-time `Authorization` header, which the fetch shim injects), and no vendor env var (`ANTHROPIC_API_KEY` etc.) is needed or consulted. The marker is skipped by key resolution and never overrides an `apiKey` you authored yourself; it is visible in config dumps precisely because it is not a secret.
34
34
 
35
35
  ## Model catalog
36
36
 
@@ -100,6 +100,20 @@ Model references in your config that differ from the served ID only by digit-bou
100
100
 
101
101
  The plugin reads only these variables. `ROUTE33_ENRICH_CAPS` is no longer supported.
102
102
 
103
+ ## Exhaustion retry
104
+
105
+ Starting with v0.4.6, the plugin wraps live chat/compat requests in an exhaustion retry layer. It triggers only on an HTTP `429` whose error code is `PROVIDER_LANES_EXHAUSTED` (all provider lanes exhausted). It reads the gateway's `Retry-After` response header and falls back to a client-side schedule of `5·2^(n−1)` seconds capped at 1200 when the header is missing. It waits through the delay while respecting the request's abort signal, then re-issues the identical request. It keeps retrying indefinitely until the request succeeds or the user cancels it.
106
+
107
+ Non-exhaustion errors (network failures, 4xx responses from the gateway other than the exhaustion code, etc.) are passed through untouched.
108
+
109
+ Each wait is logged with the attempt number, waited seconds, and the gateway's `correlationId`:
110
+
111
+ ```
112
+ route33: exhausted; waiting <seconds>s before retry <attempt> (correlationId=<correlationId>)
113
+ ```
114
+
115
+ The provider option `headerTimeout` is set to `1_500_000` ms (25 minutes). This is higher than opencode's own 300-second fetch default so that scheduled waits are not aborted while waiting for a lane to cool down. It is safe because the gateway caps provider hangs at 120 s TTFB and 240 s first-event timeouts, so the connection is never silent for anywhere near 25 minutes.
116
+
103
117
  ## License
104
118
 
105
119
  Proprietary — "SEE LICENSE IN LICENSE". Install and use as-is via npm; copying, modification, and redistribution are not permitted. Provided AS-IS, without warranty or liability.
package/dist/index.js CHANGED
@@ -94,6 +94,9 @@ var logger = {
94
94
  // src/shared/models-dev-ttl-ms.ts
95
95
  var MODELS_DEV_TTL_MS = 864e5;
96
96
 
97
+ // src/shared/native-sdk-api-key-placeholder.ts
98
+ var NATIVE_SDK_API_KEY_PLACEHOLDER = "__route33_fetch_shim_auth__";
99
+
97
100
  // src/shared/resolve-cache-dir.ts
98
101
  import * as os2 from "os";
99
102
  import * as path3 from "path";
@@ -217,9 +220,10 @@ function usable4(value) {
217
220
  }
218
221
  function keyFromConfig(cfg) {
219
222
  const literal = usable4(cfg?.provider?.route_33?.options?.apiKey);
220
- if (literal !== void 0 && !literal.includes(UNRESOLVED_TEMPLATE_MARKER2))
221
- return literal;
222
- return void 0;
223
+ if (literal === void 0) return void 0;
224
+ if (literal.includes(UNRESOLVED_TEMPLATE_MARKER2)) return void 0;
225
+ if (literal === NATIVE_SDK_API_KEY_PLACEHOLDER) return void 0;
226
+ return literal;
223
227
  }
224
228
  function resolveKey(cfg, env = process.env, worktree) {
225
229
  return usable4(env[ENV_API_KEY]) ?? keyFromConfig(cfg) ?? readWorkspaceKey(worktree) ?? (worktree !== void 0 && isInsideGitWorktree(worktree) ? void 0 : readAuthEntry(env).key);
@@ -242,6 +246,96 @@ function buildRoute33Auth(env = process.env, worktree) {
242
246
  };
243
247
  }
244
248
 
249
+ // src/config/abortable-sleep.ts
250
+ function abortableSleep(ms, signal) {
251
+ return new Promise((resolve3, reject) => {
252
+ if (signal === void 0) {
253
+ setTimeout(resolve3, ms);
254
+ return;
255
+ }
256
+ if (signal.aborted) {
257
+ reject(signal.reason);
258
+ return;
259
+ }
260
+ const onAbort = () => {
261
+ clearTimeout(timer);
262
+ reject(signal.reason);
263
+ };
264
+ const timer = setTimeout(() => {
265
+ signal.removeEventListener("abort", onAbort);
266
+ resolve3();
267
+ }, ms);
268
+ signal.addEventListener("abort", onAbort, { once: true });
269
+ });
270
+ }
271
+
272
+ // src/config/parse-exhaustion-envelope.ts
273
+ var EXHAUSTION_CODE = "PROVIDER_LANES_EXHAUSTED";
274
+ async function parseExhaustionEnvelope(response) {
275
+ let body;
276
+ try {
277
+ body = await response.clone().json();
278
+ } catch (error) {
279
+ if (error instanceof Error) return void 0;
280
+ throw error;
281
+ }
282
+ if (typeof body !== "object" || body === null || Array.isArray(body))
283
+ return void 0;
284
+ const nestedError = Reflect.get(body, "error");
285
+ const nestedCode = typeof nestedError === "object" && nestedError !== null ? Reflect.get(nestedError, "code") : void 0;
286
+ if (Reflect.get(body, "code") !== EXHAUSTION_CODE && nestedCode !== EXHAUSTION_CODE)
287
+ return void 0;
288
+ const directId = Reflect.get(body, "correlationId");
289
+ const nestedId = typeof nestedError === "object" && nestedError !== null ? Reflect.get(nestedError, "correlationId") : void 0;
290
+ return {
291
+ correlationId: typeof directId === "string" ? directId : typeof nestedId === "string" ? nestedId : void 0
292
+ };
293
+ }
294
+
295
+ // src/config/exhaustion-retry-fetch.ts
296
+ function buildExhaustionRetryFetch(innerFetch, options) {
297
+ const sleep = options?.sleep ?? abortableSleep;
298
+ const maxWaitSeconds = options?.maxWaitSeconds ?? 1200;
299
+ const baseSeconds = options?.baseSeconds ?? 5;
300
+ return async (input, init) => {
301
+ const body = init?.body;
302
+ const resendable = body === void 0 || typeof body === "string" || body instanceof ArrayBuffer || ArrayBuffer.isView(body) || body instanceof Blob || body instanceof URLSearchParams || body instanceof FormData;
303
+ let model;
304
+ if (typeof body === "string") {
305
+ try {
306
+ const parsed = JSON.parse(body);
307
+ if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
308
+ const candidate = Reflect.get(parsed, "model");
309
+ if (typeof candidate === "string") model = candidate;
310
+ }
311
+ } catch (error) {
312
+ if (!(error instanceof SyntaxError)) throw error;
313
+ }
314
+ }
315
+ let attempt = 0;
316
+ while (true) {
317
+ const response = await innerFetch(input, init);
318
+ if (response.status !== 429) return response;
319
+ const contentType = response.headers.get("content-type") ?? "";
320
+ if (!/(?:^|\/)json(?:;|$)|\+json(?:;|$)/i.test(contentType)) return response;
321
+ const exhaustion = await parseExhaustionEnvelope(response);
322
+ if (exhaustion === void 0 || !resendable) return response;
323
+ attempt += 1;
324
+ const retryAfter = response.headers.get("retry-after");
325
+ const advisedSeconds = retryAfter !== null && /^\d+$/.test(retryAfter) ? Number(retryAfter) : void 0;
326
+ const seconds = advisedSeconds !== void 0 && advisedSeconds > 0 ? Math.min(advisedSeconds, maxWaitSeconds) : Math.min(baseSeconds * 2 ** (attempt - 1), maxWaitSeconds);
327
+ logger.warn("provider lanes exhausted; retrying", {
328
+ attempt,
329
+ seconds,
330
+ correlationId: exhaustion.correlationId,
331
+ ...model === void 0 ? {} : { model }
332
+ });
333
+ await sleep(seconds * 1e3, init?.signal ?? void 0);
334
+ if (init?.signal?.aborted === true) throw init.signal.reason;
335
+ }
336
+ };
337
+ }
338
+
245
339
  // src/config/live-auth-fetch.ts
246
340
  function buildLiveAuthFetch(resolveKeyNow, innerFetch) {
247
341
  return (input, init) => {
@@ -290,10 +384,13 @@ function buildOpenAiChatCompatFetch(fetchImpl) {
290
384
 
291
385
  // src/config/build-options.ts
292
386
  function buildOptions(baseURL, resolveKeyNow = () => void 0) {
293
- const options = { baseURL };
294
- options.fetch = buildLiveAuthFetch(
295
- resolveKeyNow,
296
- buildOpenAiChatCompatFetch()
387
+ const options = {
388
+ baseURL,
389
+ apiKey: NATIVE_SDK_API_KEY_PLACEHOLDER,
390
+ headerTimeout: 15e5
391
+ };
392
+ options.fetch = buildExhaustionRetryFetch(
393
+ buildLiveAuthFetch(resolveKeyNow, buildOpenAiChatCompatFetch())
297
394
  );
298
395
  return options;
299
396
  }
@@ -479,6 +576,7 @@ var CatalogFetchError = class extends Error {
479
576
  var DEFAULT_FETCH_TIMEOUT_MS = 5e3;
480
577
 
481
578
  // src/catalog/extract-pricing.ts
579
+ var PRICING_CURRENCY = "USD";
482
580
  function isRecord4(value) {
483
581
  return typeof value === "object" && value !== null && !Array.isArray(value);
484
582
  }
@@ -495,6 +593,9 @@ var MODELS_DEV_KEYS = [
495
593
  "cache_read",
496
594
  "cache_write"
497
595
  ];
596
+ function parsePricingCurrency(value) {
597
+ return value === void 0 || value === PRICING_CURRENCY ? PRICING_CURRENCY : void 0;
598
+ }
498
599
  function perTokenRate(value) {
499
600
  if (typeof value !== "string" || value.trim() === "") return void 0;
500
601
  const parsed = Number(value);
@@ -506,11 +607,15 @@ function toPer1M(perToken) {
506
607
  function per1MRate(value) {
507
608
  return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : void 0;
508
609
  }
509
- function extractOpenRouterPricing(raw) {
610
+ function extractOpenRouterPricing(raw, currency) {
510
611
  const input = perTokenRate(raw.prompt);
511
612
  const output = perTokenRate(raw.completion);
512
613
  if (input === void 0 || output === void 0) return void 0;
513
- const cost = { input: toPer1M(input), output: toPer1M(output) };
614
+ const cost = {
615
+ input: toPer1M(input),
616
+ output: toPer1M(output),
617
+ currency
618
+ };
514
619
  for (const [field, target] of [
515
620
  ["input_cache_read", "cache_read"],
516
621
  ["input_cache_write", "cache_write"]
@@ -522,11 +627,11 @@ function extractOpenRouterPricing(raw) {
522
627
  }
523
628
  return cost;
524
629
  }
525
- function extractModelsDevPricing(raw) {
630
+ function extractModelsDevPricing(raw, currency) {
526
631
  const input = per1MRate(raw.input);
527
632
  const output = per1MRate(raw.output);
528
633
  if (input === void 0 || output === void 0) return void 0;
529
- const cost = { input, output };
634
+ const cost = { input, output, currency };
530
635
  for (const field of ["cache_read", "cache_write"]) {
531
636
  if (raw[field] === void 0) continue;
532
637
  const rate = per1MRate(raw[field]);
@@ -537,11 +642,13 @@ function extractModelsDevPricing(raw) {
537
642
  }
538
643
  function extractPricing(raw) {
539
644
  if (!isRecord4(raw)) return void 0;
645
+ const currency = parsePricingCurrency(raw.currency);
646
+ if (currency === void 0) return void 0;
540
647
  const hasOpenRouterKeys = OPENROUTER_KEYS.some((key) => key in raw);
541
648
  const hasModelsDevKeys = MODELS_DEV_KEYS.some((key) => key in raw);
542
649
  if (hasOpenRouterKeys && hasModelsDevKeys) return void 0;
543
- if (hasOpenRouterKeys) return extractOpenRouterPricing(raw);
544
- if (hasModelsDevKeys) return extractModelsDevPricing(raw);
650
+ if (hasOpenRouterKeys) return extractOpenRouterPricing(raw, currency);
651
+ if (hasModelsDevKeys) return extractModelsDevPricing(raw, currency);
545
652
  return void 0;
546
653
  }
547
654
 
@@ -623,6 +730,7 @@ function toCatalogModels(entries, env) {
623
730
  if (capabilities !== void 0) model.capabilities = capabilities;
624
731
  const variants = parseVariants(entry.variants);
625
732
  if (variants !== void 0) model.variants = variants;
733
+ if (typeof entry.hasZdrLane === "boolean") model.hasZdrLane = entry.hasZdrLane;
626
734
  const modalities = parseModalities(entry.modalities);
627
735
  if (modalities !== void 0) model.modalities = modalities;
628
736
  if (typeof entry.attachment === "boolean") model.attachment = entry.attachment;
@@ -1036,6 +1144,11 @@ var FORCED_OPTION_FIELDS = /* @__PURE__ */ new Set([
1036
1144
  "baseURL",
1037
1145
  "headers"
1038
1146
  ]);
1147
+ function isForcedValue(field, value) {
1148
+ if (field === "apiKey" && value === NATIVE_SDK_API_KEY_PLACEHOLDER)
1149
+ return false;
1150
+ return FORCED_OPTION_FIELDS.has(field);
1151
+ }
1039
1152
  function mergeProvider(target, patch) {
1040
1153
  if (target.npm === void 0) target.npm = patch.npm;
1041
1154
  if (target.name === void 0) target.name = patch.name;
@@ -1045,7 +1158,7 @@ function mergeProvider(target, patch) {
1045
1158
  const options = target.options;
1046
1159
  for (const [field, value] of Object.entries(patch.options)) {
1047
1160
  if (value === void 0) continue;
1048
- if (FORCED_OPTION_FIELDS.has(field) || options[field] === void 0)
1161
+ if (isForcedValue(field, value) || options[field] === void 0)
1049
1162
  options[field] = value;
1050
1163
  }
1051
1164
  }
package/dist/tui/index.js CHANGED
@@ -31,6 +31,9 @@ var logger = {
31
31
  }
32
32
  };
33
33
 
34
+ // src/shared/native-sdk-api-key-placeholder.ts
35
+ var NATIVE_SDK_API_KEY_PLACEHOLDER = "__route33_fetch_shim_auth__";
36
+
34
37
  // src/shared/resolve-cache-dir.ts
35
38
  import * as os from "os";
36
39
  import * as path2 from "path";
@@ -142,9 +145,10 @@ function usable3(value) {
142
145
  }
143
146
  function keyFromConfig(cfg) {
144
147
  const literal = usable3(cfg?.provider?.route_33?.options?.apiKey);
145
- if (literal !== void 0 && !literal.includes(UNRESOLVED_TEMPLATE_MARKER))
146
- return literal;
147
- return void 0;
148
+ if (literal === void 0) return void 0;
149
+ if (literal.includes(UNRESOLVED_TEMPLATE_MARKER)) return void 0;
150
+ if (literal === NATIVE_SDK_API_KEY_PLACEHOLDER) return void 0;
151
+ return literal;
148
152
  }
149
153
  function resolveKey(cfg, env = process.env, worktree) {
150
154
  return usable3(env[ENV_API_KEY]) ?? keyFromConfig(cfg) ?? readWorkspaceKey(worktree) ?? (worktree !== void 0 && isInsideGitWorktree(worktree) ? void 0 : readAuthEntry(env).key);
@@ -125,7 +125,7 @@ These entries were cross-checked against models.dev on 2026-08-10 and should be
125
125
  - Anthropic `claude-opus-4-8`, `claude-sonnet-5`, `claude-fable-5`: `1,000,000` context / `128,000` output
126
126
  - Anthropic `claude-haiku-4-5`: `200,000` context / `64,000` output
127
127
  - `moonshotai/kimi-k3`: `1,048,576` context / `131,072` output, with the `max` variant (`reasoningEffort: "max"`)
128
- - `minimax/minimax-m3`, `nvidia/nemotron-3-ultra`, `mistralai/mistral-medium-3.5`, and `qwen/qwen3.7-plus` keep their curated values (none of these appear in models.dev, so platform values stand).
128
+ - `minimax/minimax-m3`, `nvidia/nemotron-3-ultra`, and `mistralai/mistral-medium-3.5` keep their curated values (none of these appear in models.dev, so platform values stand).
129
129
 
130
130
  ## Alternative: separate metadata endpoint
131
131
 
@@ -0,0 +1,570 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>The route33 opencode Setup — Complete Guide</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com">
8
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
9
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
10
+ <style>
11
+ :root {
12
+ --paper: #f5f1e8;
13
+ --paper-raised: #fdfbf6;
14
+ --ink: #1a1a18;
15
+ --ink-soft: #4a4a44;
16
+ --ink-faint: #8a8a80;
17
+ --accent: #f0b90b;
18
+ --accent-soft: #f7d654;
19
+ --line: #d8d2c2;
20
+ --code-bg: #26241f;
21
+ --code-ink: #e8e4d8;
22
+ --ok: #2e7d4f;
23
+ --warn: #b45309;
24
+ }
25
+ * { margin: 0; padding: 0; box-sizing: border-box; }
26
+ html { scroll-behavior: smooth; }
27
+ body {
28
+ background: var(--paper);
29
+ color: var(--ink);
30
+ font-family: 'Inter', system-ui, sans-serif;
31
+ font-size: 16px;
32
+ line-height: 1.65;
33
+ -webkit-font-smoothing: antialiased;
34
+ }
35
+ .wrap { max-width: 820px; margin: 0 auto; padding: 0 24px 96px; }
36
+
37
+ header {
38
+ border-bottom: 3px solid var(--ink);
39
+ padding: 48px 0 32px;
40
+ margin-bottom: 40px;
41
+ }
42
+ .brand {
43
+ display: flex; align-items: center; gap: 12px;
44
+ font-family: 'JetBrains Mono', monospace;
45
+ font-size: 13px; font-weight: 600;
46
+ letter-spacing: 0.08em; text-transform: uppercase;
47
+ color: var(--ink-soft); margin-bottom: 20px;
48
+ }
49
+ .brand-mark {
50
+ width: 28px; height: 28px; border: 2.5px solid var(--ink);
51
+ border-radius: 50%; display: inline-flex; align-items: center; justify-content: center;
52
+ font-weight: 600; font-size: 14px; color: var(--ink);
53
+ border-bottom-color: var(--accent);
54
+ }
55
+ h1 {
56
+ font-size: clamp(30px, 5vw, 42px);
57
+ font-weight: 700; letter-spacing: -0.02em; line-height: 1.15;
58
+ }
59
+ h1 .hl { box-shadow: inset 0 -0.38em 0 var(--accent); }
60
+ .lede { margin-top: 14px; font-size: 18px; color: var(--ink-soft); max-width: 62ch; }
61
+
62
+ .toc {
63
+ background: var(--paper-raised);
64
+ border: 1px solid var(--line);
65
+ border-radius: 8px;
66
+ padding: 20px 24px;
67
+ margin: 32px 0 8px;
68
+ }
69
+ .toc-title {
70
+ font-family: 'JetBrains Mono', monospace;
71
+ font-size: 11.5px; font-weight: 600; letter-spacing: 0.08em;
72
+ text-transform: uppercase; color: var(--ink-faint); margin-bottom: 10px;
73
+ }
74
+ .toc ol { margin: 0 0 0 20px; }
75
+ .toc li { margin: 4px 0; font-size: 14.5px; }
76
+
77
+ section { margin-top: 56px; }
78
+ .step-head { display: flex; align-items: baseline; gap: 14px; margin-bottom: 6px; }
79
+ .step-num {
80
+ font-family: 'JetBrains Mono', monospace;
81
+ font-size: 13px; font-weight: 600;
82
+ background: var(--ink); color: var(--paper);
83
+ padding: 3px 10px; border-radius: 3px;
84
+ flex-shrink: 0; transform: translateY(-2px);
85
+ }
86
+ h2 { font-size: 24px; font-weight: 700; letter-spacing: -0.01em; }
87
+ h3 { font-size: 17px; font-weight: 600; margin-top: 28px; margin-bottom: 8px; }
88
+ p { margin: 12px 0; color: var(--ink-soft); }
89
+ p strong, li strong { color: var(--ink); }
90
+ ul, ol { margin: 12px 0 12px 22px; color: var(--ink-soft); }
91
+ li { margin: 6px 0; }
92
+ a { color: var(--ink); text-decoration-color: var(--accent); text-decoration-thickness: 2px; text-underline-offset: 2px; }
93
+ a:hover { background: var(--accent-soft); }
94
+
95
+ code {
96
+ font-family: 'JetBrains Mono', monospace;
97
+ font-size: 0.86em;
98
+ background: rgba(26,26,24,0.07);
99
+ padding: 2px 6px; border-radius: 4px;
100
+ }
101
+ pre {
102
+ background: var(--code-bg); color: var(--code-ink);
103
+ border-radius: 8px; padding: 18px 20px;
104
+ margin: 16px 0; overflow-x: auto;
105
+ font-family: 'JetBrains Mono', monospace;
106
+ font-size: 13.5px; line-height: 1.6;
107
+ border-left: 4px solid var(--accent);
108
+ }
109
+ pre code { background: none; padding: 0; font-size: inherit; }
110
+ pre .c { color: #8a8577; }
111
+ pre .p { color: var(--accent); }
112
+
113
+ .note, .warning, .tip {
114
+ border-radius: 8px; padding: 14px 18px; margin: 18px 0;
115
+ font-size: 14.5px; line-height: 1.55;
116
+ }
117
+ .note { background: var(--paper-raised); border: 1px solid var(--line); border-left: 4px solid var(--ink); }
118
+ .warning { background: #fdf3e0; border: 1px solid #ecd9ae; border-left: 4px solid var(--warn); }
119
+ .tip { background: #eef5ef; border: 1px solid #cfe3d3; border-left: 4px solid var(--ok); }
120
+ .note .tag, .warning .tag, .tip .tag {
121
+ display: block; font-family: 'JetBrains Mono', monospace;
122
+ font-size: 11px; font-weight: 600; letter-spacing: 0.08em;
123
+ text-transform: uppercase; margin-bottom: 4px; color: var(--ink);
124
+ }
125
+ .note p, .warning p, .tip p { margin: 4px 0; }
126
+
127
+ table { width: 100%; border-collapse: collapse; margin: 18px 0; font-size: 14px; }
128
+ th {
129
+ text-align: left; font-family: 'JetBrains Mono', monospace;
130
+ font-size: 11.5px; letter-spacing: 0.06em; text-transform: uppercase;
131
+ color: var(--ink-soft); border-bottom: 2px solid var(--ink);
132
+ padding: 8px 12px 8px 0;
133
+ }
134
+ td { padding: 9px 12px 9px 0; border-bottom: 1px solid var(--line); vertical-align: top; color: var(--ink-soft); }
135
+ td code { font-size: 12.5px; }
136
+
137
+ .checklist { list-style: none; margin-left: 0; }
138
+ .checklist li { padding-left: 30px; position: relative; margin: 10px 0; }
139
+ .checklist li::before {
140
+ content: ""; position: absolute; left: 0; top: 3px;
141
+ width: 16px; height: 16px;
142
+ border: 2px solid var(--ink); border-radius: 4px;
143
+ background: var(--paper-raised);
144
+ }
145
+ .checklist li::after {
146
+ content: ""; position: absolute; left: 4.5px; top: 6px;
147
+ width: 5px; height: 9px;
148
+ border: solid var(--ok); border-width: 0 2.5px 2.5px 0;
149
+ transform: rotate(42deg);
150
+ }
151
+
152
+ .tree {
153
+ background: var(--code-bg); color: var(--code-ink);
154
+ border-radius: 8px; padding: 18px 20px;
155
+ margin: 16px 0; overflow-x: auto;
156
+ font-family: 'JetBrains Mono', monospace;
157
+ font-size: 13px; line-height: 1.7;
158
+ border-left: 4px solid var(--accent);
159
+ white-space: pre;
160
+ }
161
+ .tree .dir { color: var(--accent); font-weight: 600; }
162
+ .tree .cm { color: #8a8577; }
163
+
164
+ footer {
165
+ margin-top: 80px; padding-top: 24px;
166
+ border-top: 1px solid var(--line);
167
+ font-family: 'JetBrains Mono', monospace;
168
+ font-size: 12px; color: var(--ink-faint);
169
+ display: flex; justify-content: space-between; flex-wrap: wrap; gap: 8px;
170
+ }
171
+ </style>
172
+ </head>
173
+ <body>
174
+ <div class="wrap">
175
+ <header>
176
+ <div class="brand"><span class="brand-mark">R</span> route33.ai &times; opencode</div>
177
+ <h1>The route33 opencode setup, <span class="hl">end to end</span></h1>
178
+ <p class="lede">Everything you need for a fully-equipped opencode harness on the route33.ai gateway: the terminal, the provider plugin, the oh-my-openagent orchestration layer, codebase intelligence, and how to shape your own agents. Roughly fifteen minutes, soup to nuts.</p>
179
+ </header>
180
+
181
+ <nav class="toc">
182
+ <div class="toc-title">In this guide</div>
183
+ <ol>
184
+ <li><a href="#stack">The moving parts</a></li>
185
+ <li><a href="#wezterm">WezTerm — the terminal</a></li>
186
+ <li><a href="#opencode-install">opencode itself</a></li>
187
+ <li><a href="#plugin">@route_33/opencode — the provider plugin</a></li>
188
+ <li><a href="#auth">Connecting your API key</a></li>
189
+ <li><a href="#omoa">oh-my-openagent — the orchestration layer</a></li>
190
+ <li><a href="#codebase-mcp">codebase-memory-mcp — codebase intelligence</a></li>
191
+ <li><a href="#dot-opencode">The .opencode/ directory, explained</a></li>
192
+ <li><a href="#agents">Configuring your own agents</a></li>
193
+ <li><a href="#verify">Verifying the whole stack</a></li>
194
+ <li><a href="#reference">Reference: env vars &amp; troubleshooting</a></li>
195
+ </ol>
196
+ </nav>
197
+
198
+ <!-- ============ 1. THE STACK ============ -->
199
+ <section id="stack">
200
+ <div class="step-head"><span class="step-num">1</span><h2>The moving parts</h2></div>
201
+ <p>Five pieces of software cooperate in this setup. Knowing which layer owns what saves you from debugging the wrong one later:</p>
202
+ <table>
203
+ <tr><th>Layer</th><th>Component</th><th>What it owns</th></tr>
204
+ <tr><td><strong>Terminal</strong></td><td>WezTerm</td><td>Renders the TUI. GPU-accelerated, multiplexed panes, configured in Lua.</td></tr>
205
+ <tr><td><strong>Agent runtime</strong></td><td>opencode</td><td>The TUI + server. Loads plugins, MCP servers, agents, commands.</td></tr>
206
+ <tr><td><strong>Provider</strong></td><td>@route_33/opencode</td><td>Registers the <code>route_33</code> provider: key-scoped model discovery, cost/limit metadata, auth, key-setup wizard.</td></tr>
207
+ <tr><td><strong>Orchestration</strong></td><td>oh-my-openagent</td><td>The agent roster (Sisyphus, Oracle, explore, …), delegation categories, background tasks, fallback strategy, CodeGraph.</td></tr>
208
+ <tr><td><strong>Code intelligence</strong></td><td>codebase-memory-mcp</td><td>A knowledge graph of your repo (callers, traces, architecture) over MCP. Optional — see the note in its section.</td></tr>
209
+ </table>
210
+ </section>
211
+
212
+ <!-- ============ 2. WEZTERM ============ -->
213
+ <section id="wezterm">
214
+ <div class="step-head"><span class="step-num">2</span><h2>WezTerm — the terminal</h2></div>
215
+ <p>opencode is a full-screen TUI. Any modern terminal works, but WezTerm is the recommended one: GPU-accelerated rendering, native panes and tabs (handy when oh-my-openagent fans out background agents), and a Lua config file that lives happily in version control.</p>
216
+
217
+ <h3>Install (macOS)</h3>
218
+ <pre><code><span class="p">$</span> brew install --cask wezterm</code></pre>
219
+ <p>Linux and Windows packages are on <a href="https://wezterm.org/installation.html">wezterm.org</a>.</p>
220
+
221
+ <h3>Minimal config</h3>
222
+ <p>WezTerm reads <code>~/.wezterm.lua</code> (or <code>~/.config/wezterm/wezterm.lua</code>). A sensible starting point for opencode work:</p>
223
+ <pre><code><span class="c">-- ~/.wezterm.lua</span>
224
+ local wezterm = require 'wezterm'
225
+ local config = wezterm.config_builder()
226
+
227
+ config.font = wezterm.font 'JetBrains Mono'
228
+ config.font_size = 13.0
229
+ config.color_scheme = 'Builtin Solarized Dark'
230
+ config.window_padding = { left = 12, right = 12, top = 12, bottom = 12 }
231
+
232
+ <span class="c">-- Panes for watching background agents alongside your main session</span>
233
+ config.keys = {
234
+ { key = 'd', mods = 'CMD', action = wezterm.action.SplitHorizontal },
235
+ { key = 'd', mods = 'CMD|SHIFT', action = wezterm.action.SplitVertical },
236
+ }
237
+
238
+ return config</code></pre>
239
+
240
+ <div class="tip">
241
+ <span class="tag">Why panes matter here</span>
242
+ <p>oh-my-openagent can render background agents into visible terminal panes (<code>tmux: enabled</code> in its config). With WezTerm splits you get the same visibility natively — main session on the left, a log tail or second session on the right.</p>
243
+ </div>
244
+ </section>
245
+
246
+ <!-- ============ 3. OPENCODE ============ -->
247
+ <section id="opencode-install">
248
+ <div class="step-head"><span class="step-num">3</span><h2>opencode itself</h2></div>
249
+ <p>You need opencode <strong>1.17.0 or newer</strong> (the plugin peer-depends on it). Installers:</p>
250
+ <pre><code><span class="c"># macOS / Linux — install script</span>
251
+ <span class="p">$</span> curl -fsSL https://opencode.ai/install | bash
252
+
253
+ <span class="c"># or Homebrew</span>
254
+ <span class="p">$</span> brew install sst/tap/opencode
255
+
256
+ <span class="c"># or npm</span>
257
+ <span class="p">$</span> npm install -g opencode-ai</code></pre>
258
+ <p>Check what you got:</p>
259
+ <pre><code><span class="p">$</span> opencode --version
260
+ 1.17.20</code></pre>
261
+ </section>
262
+ <section id="plugin">
263
+ <div class="step-head"><span class="step-num">4</span><h2>@route_33/opencode — the provider plugin</h2></div>
264
+ <p>This is the piece that makes route33 models appear in opencode. It discovers the catalog live from the gateway, decorates models with cost/limit/capability metadata, and owns authentication.</p>
265
+
266
+ <h3>Install with the built-in installer</h3>
267
+ <pre><code><span class="p">$</span> opencode plugin @route_33/opencode</code></pre>
268
+ <p>Run it from your project directory to register the plugin in that project's <code>.opencode/</code>, or pass <code>--global</code> (or run it from your home directory) to register it in <code>~/.config/opencode/</code> for every project. Either way it patches two config files at the chosen level:</p>
269
+ <table>
270
+ <tr><th>File</th><th>What it registers</th></tr>
271
+ <tr><td><code>opencode.json(c)</code></td><td>The <strong>server plugin</strong> — model discovery, cost/limit enrichment, auth.</td></tr>
272
+ <tr><td><code>tui.json</code></td><td>The <strong>TUI plugin</strong> — the first-run key-setup wizard.</td></tr>
273
+ </table>
274
+ <p>Re-running with a new version needs <code>--force</code> to replace the existing pin.</p>
275
+
276
+ <div class="warning">
277
+ <span class="tag">"Package not found" outside the route33 repo?</span>
278
+ <p>The installer resolves the module name against npm — it works from any directory <em>as long as the package is published</em>. If your registry doesn't serve <code>@route_33/opencode</code> yet (or you're on a pre-release checkout), install from a local path instead. Two forms are known to work:</p>
279
+ <pre><code><span class="c">// opencode.json(c) — bare relative path, resolved against the config file's location</span>
280
+ {
281
+ "plugin": ["../packages/integrations/opencode-route33"]
282
+ }</code></pre>
283
+ <pre><code><span class="c">// or an absolute file:// URL (works everywhere, but is machine-bound)</span>
284
+ {
285
+ "plugin": ["file:///Users/you/route_33/packages/integrations/opencode-route33"]
286
+ }</code></pre>
287
+ <p>Avoid the <code>file:../relative</code> form — empirically (opencode 1.18.4) it fails to resolve. A local-path install covers the server plugin only; add the same entry to <code>tui.json</code> yourself if you want the key-setup wizard, and run <code>bun install &amp;&amp; bun run build</code> in the package directory first so <code>dist/</code> exists.</p>
288
+ </div>
289
+
290
+ <h3>Prefer to do it by hand?</h3>
291
+ <pre><code><span class="c">// ~/.config/opencode/opencode.json</span>
292
+ {
293
+ "plugin": ["@route_33/opencode"]
294
+ }</code></pre>
295
+ <pre><code><span class="c">// ~/.config/opencode/tui.json</span>
296
+ {
297
+ "plugin": ["@route_33/opencode"]
298
+ }</code></pre>
299
+
300
+ <div class="note">
301
+ <span class="tag">Good to know</span>
302
+ <p>The plugin never writes to your config on its own. opencode has no TUI-plugin auto-discovery, so if you skip the <code>tui.json</code> entry the setup wizard simply never appears — the server plugin (the part that actually serves models) works regardless.</p>
303
+ </div>
304
+
305
+ <div class="tip">
306
+ <span class="tag">Working inside the route33 repo?</span>
307
+ <p>You're already done with this step. The repo's <code>.opencode/opencode.jsonc</code> declares <code>@route_33/opencode@latest</code> for you — skip ahead to <a href="#auth">the auth step</a>.</p>
308
+ </div>
309
+ </section>
310
+
311
+ <section id="auth">
312
+ <div class="step-head"><span class="step-num">5</span><h2>Connecting your API key</h2></div>
313
+ <p>Mint a key in the route33 dashboard under your organization's <em>API keys</em> section — one per project, since per-project keys are the metering boundary. Then pick whichever resolution path fits your workflow; the first one that resolves wins.</p>
314
+
315
+ <h3>Option A — environment variable (highest precedence)</h3>
316
+ <pre><code><span class="p">$</span> export ROUTE33_API_KEY=r33_your_key_here</code></pre>
317
+ <p>Good for CI, containers, and one-off shells.</p>
318
+
319
+ <h3>Option B — opencode's auth store</h3>
320
+ <pre><code><span class="p">$</span> opencode auth login</code></pre>
321
+ <p>Choose <code>route_33</code> from the provider list and paste your key.</p>
322
+
323
+ <h3>Option C — let the wizard handle it</h3>
324
+ <p>If neither of the above is set, the TUI wizard pops up on first launch and offers to save a key for you:</p>
325
+ <ul>
326
+ <li><strong>Inside a git workspace</strong> → saved per-project to <code>&lt;project&gt;/.opencode/route33.key</code>. The file is written with mode <code>0600</code> and the plugin auto-adds it (plus <code>tui.json</code>) to <code>.opencode/.gitignore</code> so it never gets committed.</li>
327
+ <li><strong>Outside a git workspace</strong> → saved globally. A global key only applies <em>outside</em> git workspaces — inside one, the per-project key always wins.</li>
328
+ </ul>
329
+
330
+ <div class="note">
331
+ <span class="tag">Snoozing the wizard</span>
332
+ <p>Hitting "Skip" silences the prompt for 7 days — for that workspace only, or everywhere if you pick "Skip for all workspaces". To re-arm it sooner, remove the <code>project-key-prompt.dismissed-at*</code> entries from <code>~/.local/state/opencode/kv.json</code>. To disable the wizard permanently, set <code>ROUTE33_TUI_PROMPT=0</code>.</p>
333
+ </div>
334
+
335
+ <h3>Pointing at a non-production gateway (optional)</h3>
336
+ <p>Out of the box the plugin talks to <code>https://api.route33.ai/v1</code>. To develop against a local kind cluster:</p>
337
+ <pre><code><span class="c"># Example: your local kind cluster's API host</span>
338
+ <span class="p">$</span> export ROUTE33_BASE_URL=https://api-&lt;hash&gt;-&lt;user&gt;-&lt;workspace&gt;.route33.ai/v1</code></pre>
339
+ <p>In the route33 repo, that hostname is computed for you by <code>deploy/lib/workspace-env.sh</code> (the <code>R33_HOST_API</code> variable).</p>
340
+ </section>
341
+ <section id="omoa">
342
+ <div class="step-head"><span class="step-num">6</span><h2>oh-my-openagent — the orchestration layer</h2></div>
343
+ <p><strong>oh-my-openagent</strong> (OMOA) is the plugin that turns a single opencode session into an orchestrated agent harness. It ships the named agent roster you interact with — Sisyphus (the orchestrator), Oracle (deep reasoning), explore and librarian (parallel search), Metis and Momus (planning) — plus delegation categories, background tasks, runtime model fallback, and a built-in CodeGraph for code intelligence.</p>
344
+
345
+ <h3>Install</h3>
346
+ <p>Add it to the <code>plugin</code> array in both config files, alongside the route33 plugin:</p>
347
+ <pre><code><span class="c">// ~/.config/opencode/opencode.json</span>
348
+ {
349
+ "plugin": [
350
+ "oh-my-openagent@latest",
351
+ "@route_33/opencode@latest"
352
+ ]
353
+ }</code></pre>
354
+ <pre><code><span class="c">// ~/.config/opencode/tui.json</span>
355
+ {
356
+ "plugin": ["oh-my-openagent@latest"]
357
+ }</code></pre>
358
+
359
+ <h3>Configure: oh-my-openagent.jsonc</h3>
360
+ <p>OMOA reads its own config file, <code>oh-my-openagent.jsonc</code>, from the same directory as your opencode config — global at <code>~/.config/opencode/</code>, per-project at <code>&lt;repo&gt;/.opencode/</code>. This is where you bind models to agents and categories. The route33 repo ships a working example — the highlights:</p>
361
+ <pre><code><span class="c">// .opencode/oh-my-openagent.jsonc (abridged)</span>
362
+ {
363
+ <span class="c">// 429/5xx depletion failover, back to primary once quota resets</span>
364
+ "runtime_fallback": { "enabled": true, "notify_on_fallback": true },
365
+
366
+ <span class="c">// OMOA's built-in CodeGraph = the single code-intelligence source</span>
367
+ "codegraph": { "enabled": true, "telemetry": false },
368
+
369
+ <span class="c">// Fan-out limits for parallel background agents</span>
370
+ "background_task": {
371
+ "defaultConcurrency": 8,
372
+ "providerConcurrency": { "route_33": 10, "route_33/anthropic": 4 }
373
+ },
374
+
375
+ "agents": {
376
+ "sisyphus": { "model": "route_33/moonshotai/kimi-k3", "variant": "max" },
377
+ "oracle": { "model": "route_33/openai/gpt-5.6-sol", "variant": "high" },
378
+ "explore": { "model": "route_33/openai/gpt-5.6-luna" },
379
+ "librarian": { "model": "route_33/openai/gpt-5.6-luna" }
380
+ },
381
+
382
+ "categories": {
383
+ "visual-engineering": { "model": "route_33/openai/gpt-5.6-sol", "variant": "high" },
384
+ "deep": { "model": "route_33/openai/gpt-5.6-terra", "variant": "xhigh" },
385
+ "quick": { "model": "route_33/openai/gpt-5.6-luna" },
386
+ "writing": { "model": "route_33/moonshotai/kimi-k2.7-code" }
387
+ }
388
+ }</code></pre>
389
+ <p>The mental model: <strong><code>agents</code></strong> binds a model to a named persona, <strong><code>categories</code></strong> binds a model to a <em>kind of work</em> (visual, deep research, quick fixes, writing). When Sisyphus delegates, the category resolver picks the model from the category first, falling back to the agent default.</p>
390
+
391
+ <div class="note">
392
+ <span class="tag">Variants</span>
393
+ <p><code>"variant"</code> selects a reasoning tier the model advertises (e.g. kimi-k3's <code>max</code>, gpt-5.6's <code>high</code>/<code>xhigh</code>). Variants come from the gateway's broadcast — the route33 plugin passes them through verbatim, so OMOA variant names and gateway variant names are the same vocabulary.</p>
394
+ </div>
395
+
396
+ <div class="tip">
397
+ <span class="tag">Flavours</span>
398
+ <p>Keep alternate OMOA configs as <em>flavours</em> in <code>.opencode/omo-flavours/&lt;name&gt;.jsonc</code> (the route33 repo keeps <code>r33-kimi-k3.jsonc</code> there). Copy one over <code>oh-my-openagent.jsonc</code> to switch the whole model matrix in one move.</p>
399
+ </div>
400
+ </section>
401
+
402
+ <section id="codebase-mcp">
403
+ <div class="step-head"><span class="step-num">7</span><h2>codebase-memory-mcp — codebase intelligence</h2></div>
404
+ <p><strong>codebase-memory-mcp</strong> builds a persistent knowledge graph of your repository — functions, classes, call chains, architecture — and exposes it to opencode over MCP. Agents can then answer "who calls this?" or "trace this call chain" structurally instead of grepping.</p>
405
+
406
+ <h3>Install and register</h3>
407
+ <pre><code><span class="c"># Install the CLI + server (auto-detects opencode and offers to register itself)</span>
408
+ <span class="p">$</span> npm install -g codebase-memory-mcp
409
+ <span class="p">$</span> codebase-memory-mcp install</code></pre>
410
+ <p>Or register it manually in your opencode config:</p>
411
+ <pre><code><span class="c">// ~/.config/opencode/opencode.json</span>
412
+ {
413
+ "mcp": {
414
+ "codebase-memory-mcp": {
415
+ "type": "local",
416
+ "enabled": true,
417
+ "command": ["codebase-memory-mcp"]
418
+ }
419
+ }
420
+ }</code></pre>
421
+ <p>Then index your repo once — agents do the rest via tools like <code>search_graph</code>, <code>trace_path</code>, <code>get_code_snippet</code>, and <code>get_architecture</code>:</p>
422
+ <pre><code><span class="p">$</span> codebase-memory-mcp cli index_repository '{"path": "."}'</code></pre>
423
+ <p>An optional graph-visualization UI runs on port 9749 (<code>codebase-memory-mcp --ui=true</code>).</p>
424
+
425
+ <div class="warning">
426
+ <span class="tag">Pick ONE code-intelligence source</span>
427
+ <p>OMOA ships its own built-in CodeGraph with a fresher watcher-based index. Running both means two indexes and agents torn between two tool vocabularies. The route33 repo's decision (2026-07-14): <strong>CodeGraph on, codebase-memory-mcp off</strong> — that's why <code>.opencode/opencode.jsonc</code> carries it under <code>"enabled": false</code> and the four <code>codebase-memory-*</code> skills sit in OMOA's <code>disabled_skills</code>. Flip the flag to <code>true</code> only if you've disabled CodeGraph.</p>
428
+ </div>
429
+ </section>
430
+ <section id="dot-opencode">
431
+ <div class="step-head"><span class="step-num">8</span><h2>The .opencode/ directory, explained</h2></div>
432
+ <p>opencode merges configuration from two roots. Everything in this guide lands in one of them:</p>
433
+ <table>
434
+ <tr><th>Root</th><th>Scope</th><th>Commit?</th></tr>
435
+ <tr><td><code>~/.config/opencode/</code></td><td>Global — every project on your machine</td><td>n/a (your dotfiles)</td></tr>
436
+ <tr><td><code>&lt;repo&gt;/.opencode/</code></td><td>Project — wins over global inside that repo</td><td>Yes, mostly — see below</td></tr>
437
+ </table>
438
+
439
+ <p>A fully-equipped project directory (this is the route33 repo's real layout):</p>
440
+ <div class="tree"><span class="dir">.opencode/</span>
441
+ |-- <span class="dir">opencode.jsonc</span> <span class="cm"># plugins + MCP servers for THIS project</span>
442
+ |-- <span class="dir">oh-my-openagent.jsonc</span> <span class="cm"># OMOA model matrix: agents + categories</span>
443
+ |-- <span class="dir">agents/</span> <span class="cm"># custom agent definitions (*.md with frontmatter)</span>
444
+ | `-- 2po-commit.md
445
+ |-- <span class="dir">commands/</span> <span class="cm"># custom slash commands (*.md)</span>
446
+ | `-- 2po-commit.md
447
+ |-- <span class="dir">omo-flavours/</span> <span class="cm"># alternate OMOA configs to swap in</span>
448
+ | `-- r33-kimi-k3.jsonc
449
+ |-- <span class="dir">route33.key</span> <span class="cm"># per-project API key (GIT-IGNORED, mode 0600)</span>
450
+ |-- <span class="dir">tui.json</span> <span class="cm"># TUI plugin registration (GIT-IGNORED)</span>
451
+ `-- <span class="dir">.gitignore</span> <span class="cm"># guards the two files above + node_modules</span></div>
452
+
453
+ <h3>What each file is for</h3>
454
+ <table>
455
+ <tr><th>File</th><th>Purpose</th></tr>
456
+ <tr><td><code>opencode.jsonc</code></td><td>Project-level opencode config: the <code>plugin</code> array, <code>mcp</code> servers, provider overrides. JSONC — comments allowed.</td></tr>
457
+ <tr><td><code>tui.json</code></td><td>TUI plugin registration. Written by <code>opencode plugin</code> or the wizard; git-ignored because it can hold machine-local state.</td></tr>
458
+ <tr><td><code>oh-my-openagent.jsonc</code></td><td>OMOA's own config — the agent/category model matrix from <a href="#omoa">section 6</a>.</td></tr>
459
+ <tr><td><code>agents/*.md</code></td><td>Custom agents (next section). Markdown body = system prompt, YAML frontmatter = config.</td></tr>
460
+ <tr><td><code>commands/*.md</code></td><td>Custom slash commands — a prompt template invoked as <code>/name</code> in the TUI.</td></tr>
461
+ <tr><td><code>route33.key</code></td><td>The per-project API key written by the wizard. Never committed — the plugin maintains <code>.opencode/.gitignore</code> for you.</td></tr>
462
+ <tr><td><code>AGENTS.md</code> (repo root)</td><td>Not inside <code>.opencode/</code>, but part of the same story: the project knowledge base every agent reads. The route33 repo's root <code>AGENTS.md</code> is the canonical example.</td></tr>
463
+ </table>
464
+
465
+ <div class="note">
466
+ <span class="tag">Global vs project, in practice</span>
467
+ <p>Keep your <em>personal</em> setup global — plugins, the auth-store key, your default OMOA matrix in <code>~/.config/opencode/</code>. Keep <em>team-shared</em> setup in the repo — the project's plugin pins, the OMOA matrix tuned to this codebase, custom agents and commands everyone should have. Project config wins on conflict, so a repo can tighten a model choice without touching your other projects.</p>
468
+ </div>
469
+ </section>
470
+ <section id="agents">
471
+ <div class="step-head"><span class="step-num">9</span><h2>Configuring your own agents</h2></div>
472
+ <p>Custom agents are Markdown files with YAML frontmatter, dropped in <code>.opencode/agents/</code> (project) or <code>~/.config/opencode/agents/</code> (global). The filename becomes the agent name; the frontmatter configures it; the Markdown body is the system prompt.</p>
473
+
474
+ <h3>Anatomy of an agent file</h3>
475
+ <p>This is a trimmed version of the route33 repo's real <code>.opencode/agents/2po-commit.md</code> — a locked-down commit agent that can run git but can't edit files:</p>
476
+ <pre><code>---
477
+ description: Create git commits for current changes
478
+ mode: primary
479
+ model: route_33/openai/gpt-5.6-luna
480
+ hidden: true
481
+ permission:
482
+ edit: deny
483
+ bash:
484
+ "*": deny
485
+ "git status*": allow
486
+ "git diff*": allow
487
+ "git add*": allow
488
+ "git commit*": allow
489
+ webfetch: deny
490
+ ---
491
+
492
+ You are a git commit assistant. Your only job is to stage and
493
+ commit ALL changes with a well-crafted commit message.
494
+ ...</code></pre>
495
+
496
+ <h3>The frontmatter keys that matter</h3>
497
+ <table>
498
+ <tr><th>Key</th><th>Values</th><th>Effect</th></tr>
499
+ <tr><td><code>description</code></td><td>free text</td><td>Shown in the agent picker; also how the orchestrator decides when to delegate to this agent.</td></tr>
500
+ <tr><td><code>mode</code></td><td><code>primary</code> | <code>subagent</code> | <code>all</code></td><td><code>primary</code> = you can switch to it directly (Tab in the TUI). <code>subagent</code> = only invocable via delegation.</td></tr>
501
+ <tr><td><code>model</code></td><td><code>provider/model</code></td><td>Pin this agent to a model — e.g. a cheap <code>gpt-5.6-luna</code> for a high-volume commit bot. Beats the global default.</td></tr>
502
+ <tr><td><code>hidden</code></td><td><code>true</code> | <code>false</code></td><td>Hide from the picker while keeping it delegable.</td></tr>
503
+ <tr><td><code>permission</code></td><td>per-tool allow/deny</td><td>Default-deny with glob exceptions, as above: this agent can only run four git commands and nothing else.</td></tr>
504
+ </table>
505
+
506
+ <h3>Custom slash commands</h3>
507
+ <p>The sibling <code>.opencode/commands/</code> folder holds prompt templates invoked as <code>/name</code>. Same Markdown format — a <code>description</code> in frontmatter, the prompt in the body, optional <code>agent</code> and <code>model</code> pins. The repo's <code>2po-commit.md</code> command is the companion to the agent above: typing <code>/2po-commit</code> runs that prompt through that agent.</p>
508
+
509
+ <div class="tip">
510
+ <span class="tag">The design rule of thumb</span>
511
+ <p><strong>Agents</strong> are for <em>who does the work</em> (a persona with a model and a permission envelope). <strong>Commands</strong> are for <em>what work to do</em> (a reusable prompt). <strong>OMOA categories</strong> are for <em>which model fits this kind of work</em> when the orchestrator delegates. Most setups need a handful of commands, one or two custom agents, and zero new categories.</p>
512
+ </div>
513
+ </section>
514
+ <section id="verify">
515
+ <div class="step-head"><span class="step-num">10</span><h2>Verifying the whole stack</h2></div>
516
+ <p>Launch <code>opencode</code> in your project and run through this checklist:</p>
517
+ <ul class="checklist">
518
+ <li>route33 models appear in the model picker as <code>vendor/slug</code> IDs (<code>anthropic/claude-haiku-4-5</code>, <code>openai/gpt-5.5</code>, …)</li>
519
+ <li>Costs and context limits show up on model entries</li>
520
+ <li>A chat request to a route33 model completes</li>
521
+ <li>The agent picker (Tab) lists the OMOA roster — Sisyphus, Oracle, explore, librarian, plus your custom agents</li>
522
+ <li>Your custom slash command appears when you type <code>/</code></li>
523
+ <li>Asking "who calls X?" uses graph tools (CodeGraph or codebase-memory-mcp, whichever you enabled)</li>
524
+ </ul>
525
+
526
+ <div class="warning">
527
+ <span class="tag">Seeing "0 models injected"?</span>
528
+ <p>The warning <code>route33: no model catalog available — 0 models injected</code> means no key resolved. Re-run <code>opencode auth login</code>, check your <code>ROUTE33_API_KEY</code>, or confirm <code>.opencode/route33.key</code> exists in your project. The key dictates which models are available — without one, discovery has nothing to work with.</p>
529
+ </div>
530
+ </section>
531
+
532
+ <section id="reference">
533
+ <div class="step-head"><span class="step-num">&#9881;</span><h2>Reference: env vars &amp; troubleshooting</h2></div>
534
+ <table>
535
+ <tr><th>Variable</th><th>Effect</th></tr>
536
+ <tr><td><code>ROUTE33_API_KEY</code></td><td>Gateway API key. Highest precedence.</td></tr>
537
+ <tr><td><code>ROUTE33_BASE_URL</code></td><td>Override the default gateway URL (<code>https://api.route33.ai/v1</code>).</td></tr>
538
+ <tr><td><code>ROUTE33_TUI_PROMPT</code></td><td>Set to <code>0</code> to disable the key-setup wizard.</td></tr>
539
+ <tr><td><code>ROUTE33_DISCOVERY</code></td><td>Set to <code>0</code> to disable dynamic catalog discovery (injects zero models, no warning).</td></tr>
540
+ <tr><td><code>ROUTE33_STRICT</code></td><td>Set to <code>0</code> to downgrade unknown-model reference errors to a warning plus a stub entry.</td></tr>
541
+ <tr><td><code>ROUTE33_CACHE_DIR</code></td><td>Override the on-disk catalog cache directory.</td></tr>
542
+ </table>
543
+
544
+ <h3>Common bumps</h3>
545
+ <table>
546
+ <tr><th>Symptom</th><th>Likely cause and fix</th></tr>
547
+ <tr><td>No route33 models in picker</td><td>No key resolved → <a href="#auth">section 5</a>. Or discovery opted out → unset <code>ROUTE33_DISCOVERY</code>.</td></tr>
548
+ <tr><td>Key wizard never appears</td><td>Missing <code>tui.json</code> plugin entry → <a href="#plugin">section 4</a>. Or snoozed → clear <code>project-key-prompt.dismissed-at*</code> in <code>~/.local/state/opencode/kv.json</code>.</td></tr>
549
+ <tr><td>Global key ignored inside a repo</td><td>By design — per-project keys win inside git workspaces. Save a project key via the wizard.</td></tr>
550
+ <tr><td>Agents use the wrong models</td><td>Project <code>oh-my-openagent.jsonc</code> overrides global → check <code>&lt;repo&gt;/.opencode/</code> first.</td></tr>
551
+ <tr><td>Duplicate graph tools / confused search</td><td>Both CodeGraph and codebase-memory-mcp enabled → pick one, <a href="#codebase-mcp">section 7</a>.</td></tr>
552
+ <tr><td><code>file:</code>-prefixed local plugin path fails</td><td>Known opencode quirk — use the bare relative form (<code>../packages/integrations/opencode-route33</code>) or an absolute <code>file://</code> URL → <a href="#plugin">section 4</a>.</td></tr>
553
+ <tr><td><code>opencode plugin</code> says "package not found"</td><td>Your npm registry doesn't serve <code>@route_33/opencode</code> → install from a local path instead, <a href="#plugin">section 4</a>.</td></tr>
554
+ </table>
555
+
556
+ <h3>Small conveniences worth knowing</h3>
557
+ <ul>
558
+ <li><strong>Dot/dash tolerance</strong> — a config reference like <code>anthropic/claude-opus-4.8</code> automatically resolves to the served <code>anthropic/claude-opus-4-8</code>. Arbitrary renames don't resolve.</li>
559
+ <li><strong>Stale-cache fallback</strong> — if the gateway is unreachable, the plugin serves the last good cached catalog rather than failing. Only a cold start with no cache and no gateway yields zero models.</li>
560
+ <li><strong>Live key reload</strong> — a key saved through the wizard takes effect immediately, no restart needed.</li>
561
+ </ul>
562
+ </section>
563
+
564
+ <footer>
565
+ <span>@route_33/opencode &middot; v0.4.0</span>
566
+ <span>source: packages/integrations/opencode-route33</span>
567
+ </footer>
568
+ </div>
569
+ </body>
570
+ </html>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@route_33/opencode",
3
- "version": "0.4.4",
3
+ "version": "0.4.6",
4
4
  "type": "module",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "description": "opencode provider plugin for the route33.ai AI gateway \u2014 key-scoped model discovery, cost/limit enrichment, auth, and the key-setup wizard.",