opencode2pi 0.0.0-stage → 0.3.1

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.
@@ -0,0 +1,198 @@
1
+ /**
2
+ * Capability metadata for the free lane (docs/PLAN.md §T1), sourced from
3
+ * models.dev.
4
+ *
5
+ * Why this file exists at all: a provider that under-reports capabilities does
6
+ * not fail loudly. It fails by *quietly truncating* — the host falls back to a
7
+ * generic 128000/16384 and the request dies somewhere deep in a long context
8
+ * with nothing pointing at the real cause (docs/FINDINGS.md §7.2). So every
9
+ * value this module emits is either measured or explicitly defaulted *here*,
10
+ * and each default carries a reason string that reaches the log.
11
+ *
12
+ * The mapping is deliberately flat. The host adopts `contextWindow`,
13
+ * `maxTokens`, `cost`, `input`, `reasoning` and `thinking` verbatim and
14
+ * silently ignores a nested `limits: {context, output}` (docs/FINDINGS.md
15
+ * §7.1/§7.2), so models.dev's `limit.context` / `limit.output` must be renamed
16
+ * on the way through rather than passed through.
17
+ */
18
+
19
+ import type { Effort } from '@earendil-works/pi-ai'
20
+ import type { ProviderModelConfig } from '@oh-my-pi/pi-coding-agent'
21
+
22
+ import { UNAVAILABLE } from './seed.ts'
23
+
24
+ /**
25
+ * Fallbacks used only when models.dev has nothing usable for a field. These
26
+ * mirror the host's generic values on purpose: matching them keeps behaviour
27
+ * identical to today, while emitting them explicitly (with a logged reason)
28
+ * removes the silent part.
29
+
30
+ */
31
+ export const DEFAULT_CONTEXT_WINDOW = 128_000
32
+ export const DEFAULT_MAX_TOKENS = 16_384
33
+
34
+ /** Effort levels the host accepts; anything else is dropped rather than forwarded. */
35
+ const KNOWN_EFFORTS: ReadonlySet<string> = new Set(['minimal', 'low', 'medium', 'high', 'xhigh', 'max'])
36
+
37
+ function isEffort(value: unknown): value is Effort {
38
+ return typeof value === 'string' && KNOWN_EFFORTS.has(value)
39
+ }
40
+
41
+ /**
42
+ * A models.dev entry, read defensively: this is third-party JSON, so every
43
+ * field is `unknown` until proven otherwise.
44
+ */
45
+ export interface ModelsDevEntry {
46
+ readonly id: string
47
+ readonly name?: string
48
+ readonly limit?: { readonly context?: unknown; readonly output?: unknown }
49
+ readonly cost?: { readonly input?: unknown; readonly output?: unknown; readonly cache_read?: unknown; readonly cache_write?: unknown }
50
+ readonly modalities?: { readonly input?: unknown }
51
+ readonly reasoning?: unknown
52
+ readonly reasoning_options?: unknown
53
+ readonly deprecated?: unknown
54
+ }
55
+
56
+ /** Reads a token limit, falling back to `fallback` and recording why. */
57
+ function readLimit(raw: unknown, label: string, fallback: number, notes: string[]): number {
58
+ if (typeof raw !== 'number' || !Number.isInteger(raw) || raw <= 0) {
59
+ notes.push(`${label} missing or invalid → ${fallback}`)
60
+ return fallback
61
+ }
62
+ return raw
63
+ }
64
+
65
+ /** Reads a price component, falling back to free (0) and recording why. */
66
+ function readCost(raw: unknown, label: string, notes: string[]): number {
67
+ if (typeof raw !== 'number' || !Number.isFinite(raw) || raw < 0) {
68
+ notes.push(`${label} missing or invalid → 0`)
69
+ return 0
70
+ }
71
+ return raw
72
+ }
73
+
74
+ /** Text + image are the only input kinds the host models; audio/video are dropped. */
75
+ function inputModalities(entry: ModelsDevEntry, notes: string[]): ('text' | 'image')[] {
76
+ const raw = entry.modalities?.input
77
+ if (!Array.isArray(raw)) {
78
+ notes.push('modalities.input missing or invalid → text')
79
+ return ['text']
80
+ }
81
+ const declared = raw.filter((value): value is 'text' | 'image' => value === 'text' || value === 'image')
82
+ const unsupported = raw.filter(
83
+ (value) => typeof value === 'string' && value !== 'text' && value !== 'image',
84
+ )
85
+ if (unsupported.length > 0) notes.push(`input modalities not host-modellable, dropped: ${unsupported.join(', ')}`)
86
+ if (declared.includes('text')) return declared
87
+ notes.push('modalities.input declares no text → forcing text')
88
+ return ['text', ...declared]
89
+ }
90
+
91
+ /**
92
+ * Effort control surface. models.dev expresses this as a list of option
93
+ * objects; only `{type: 'effort', values: [...]}` maps onto the host's
94
+ * `thinking.efforts`. A `toggle` model has no effort axis, so it gets
95
+ * `thinking: undefined` and the host derives its default four levels from
96
+ * `reasoning: true` — which is exactly what an empty efforts list must NOT do,
97
+ * since the host contract requires efforts to be non-empty.
98
+ */
99
+ function thinkingConfig(entry: ModelsDevEntry, reasoning: boolean): ProviderModelConfig['thinking'] {
100
+ if (!reasoning || !Array.isArray(entry.reasoning_options)) return undefined
101
+
102
+ const efforts: Effort[] = []
103
+ for (const option of entry.reasoning_options) {
104
+ if (typeof option !== 'object' || option === null) continue
105
+ if (!('type' in option) || option.type !== 'effort') continue
106
+ if (!('values' in option) || !Array.isArray(option.values)) continue
107
+ for (const value of option.values) {
108
+ if (isEffort(value) && !efforts.includes(value)) efforts.push(value)
109
+ }
110
+ }
111
+ if (efforts.length === 0) return undefined
112
+ return { mode: 'effort', efforts }
113
+ }
114
+
115
+ export interface NormalizedModel {
116
+ readonly config: ProviderModelConfig
117
+ /** Reasons any field fell back to a default. Empty when the entry was complete. */
118
+ readonly notes: readonly string[]
119
+ }
120
+
121
+ /**
122
+ * Project one models.dev entry onto the host's model config contract.
123
+ *
124
+ * `maxTokens` is clamped to `contextWindow`: `nemotron-3.5-lightning-free`
125
+ * reports `output == context == 262144`, and a per-response cap that exceeds
126
+ * the model's own window is nonsense even when upstream publishes it.
127
+ */
128
+ export function normalizeEntry(entry: ModelsDevEntry): NormalizedModel {
129
+ const notes: string[] = []
130
+
131
+ const contextWindow = readLimit(entry.limit?.context, 'limit.context', DEFAULT_CONTEXT_WINDOW, notes)
132
+ const declaredMax = readLimit(entry.limit?.output, 'limit.output', DEFAULT_MAX_TOKENS, notes)
133
+ let maxTokens = declaredMax
134
+ if (maxTokens > contextWindow) {
135
+ notes.push(`limit.output ${maxTokens} exceeds context ${contextWindow} → clamped`)
136
+ maxTokens = contextWindow
137
+ }
138
+
139
+ const reasoning = entry.reasoning === true
140
+ const thinking = thinkingConfig(entry, reasoning)
141
+ if (reasoning && Array.isArray(entry.reasoning_options) && thinking === undefined) {
142
+ notes.push('reasoning_options carries no effort axis → host defaults')
143
+ }
144
+
145
+ const name = typeof entry.name === 'string' && entry.name.length > 0 ? entry.name : entry.id
146
+ if (name === entry.id) notes.push('name missing → using id')
147
+
148
+ const config: ProviderModelConfig = {
149
+ id: entry.id,
150
+ name,
151
+ reasoning,
152
+ input: inputModalities(entry, notes),
153
+ cost: {
154
+ input: readCost(entry.cost?.input, 'cost.input', notes),
155
+ output: readCost(entry.cost?.output, 'cost.output', notes),
156
+ cacheRead: readCost(entry.cost?.cache_read, 'cost.cache_read', notes),
157
+ cacheWrite: readCost(entry.cost?.cache_write, 'cost.cache_write', notes),
158
+ },
159
+ contextWindow,
160
+ maxTokens,
161
+ }
162
+ if (thinking) config.thinking = thinking
163
+
164
+ return { config, notes }
165
+ }
166
+
167
+ /**
168
+ * Free-lane judgment for one id.
169
+ *
170
+ * Order is the whole point (docs/PLAN.md §T2):
171
+ *
172
+ * 1. a verified-rejected id never ships, whatever its price says — both are
173
+ * priced at zero upstream and would otherwise be re-advertised forever;
174
+ * 2. `deprecated` vetoes, including a `*-free` name: upstream retiring a
175
+ * model outranks the name it was published under;
176
+ * 3. verified ids pass unconditionally, because their name carries no signal
177
+ * (`big-pickle` is the case that matters);
178
+ * 4. **metadata decides when it is complete.** `deepseek-v4-flash-free` stayed
179
+ * in opencode2dsh's list forever precisely because name-matching ran
180
+ * first, so price outranks name here;
181
+ * 5. only when metadata is absent or incomplete does the `free` name
182
+ * heuristic apply — the branch exists for ids that are live upstream and
183
+ * missing from models.dev entirely.
184
+ */
185
+ export function isFreeModel(
186
+ id: string,
187
+ entry: ModelsDevEntry | undefined,
188
+ verifiedFree: Readonly<Record<string, true>>,
189
+ ): boolean {
190
+ if (id in UNAVAILABLE) return false
191
+ if (entry?.deprecated) return false
192
+ if (verifiedFree[id] === true) return true
193
+
194
+ const costInput = entry?.cost?.input
195
+ const costOutput = entry?.cost?.output
196
+ if (typeof costInput !== 'number' || typeof costOutput !== 'number') return id.includes('free')
197
+ return costInput === 0 && costOutput === 0
198
+ }
package/src/seed.ts ADDED
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Static model seed — tier S3 of the discovery ladder (docs/PLAN.md §T2) and the
3
+ * offline fallback for capability metadata (docs/PLAN.md §T1).
4
+ *
5
+ * Two jobs, both forced by measured host behaviour:
6
+ *
7
+ * 1. **`models` must be populated at registration time.** With only
8
+ * `fetchDynamicModels`, `--model provider/id` fails cold with
9
+ * `Model not found` in 0.6 s — async discovery lands too late to take part
10
+ * in `--model` resolution (docs/FINDINGS.md §7.3). The seed is what makes
11
+ * cold start work at all.
12
+ *
13
+ * 2. **Every entry carries real numbers.** `ProviderModelConfig` marks
14
+ * `contextWindow` / `maxTokens` required, and a nested `limits: {context,
15
+ * output}` is silently ignored, falling back to the host's generic
16
+ * 128000/16384 with no warning (docs/FINDINGS.md §7.2). The values below
17
+ * are snapshots of models.dev `limit` for these ids, so an offline run
18
+ * gets true capabilities instead of the silent fallback.
19
+ */
20
+
21
+ import type { ProviderModelConfig } from '@oh-my-pi/pi-coding-agent'
22
+
23
+ /** Ids verified end-to-end against the anonymous lane. */
24
+ export const SEED_MODELS: readonly ProviderModelConfig[] = [
25
+ {
26
+ id: 'big-pickle',
27
+ name: 'big-pickle (free)',
28
+ reasoning: true,
29
+ input: ['text'],
30
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
31
+ contextWindow: 200_000,
32
+ maxTokens: 32_000,
33
+ },
34
+ {
35
+ id: 'mimo-v2.6-flash-free',
36
+ name: 'MiMo v2.6 Flash (free)',
37
+ reasoning: true,
38
+ input: ['text', 'image'],
39
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
40
+ contextWindow: 200_000,
41
+ maxTokens: 32_000,
42
+ },
43
+ {
44
+ id: 'ling-3.1-flash-free',
45
+ name: 'Ling 3.1 Flash (free)',
46
+ reasoning: true,
47
+ input: ['text'],
48
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
49
+ contextWindow: 262_144,
50
+ maxTokens: 32_768,
51
+ },
52
+ {
53
+ id: 'nemotron-3.5-lightning-free',
54
+ name: 'Nemotron 3.5 Lightning (free)',
55
+ reasoning: true,
56
+ input: ['text'],
57
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
58
+ contextWindow: 262_144,
59
+ maxTokens: 262_144,
60
+ },
61
+ {
62
+ // models.dev also declares `video`; the host models text+image only.
63
+ id: 'space-bunny-free',
64
+ name: 'Space Bunny (free)',
65
+ reasoning: true,
66
+ input: ['text', 'image'],
67
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
68
+ contextWindow: 1_048_576,
69
+ maxTokens: 524_288,
70
+ },
71
+ {
72
+ id: 'fledge-alpha-free',
73
+ name: 'Fledge Alpha (free)',
74
+ reasoning: true,
75
+ input: ['text', 'image'],
76
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
77
+ contextWindow: 1_048_576,
78
+ maxTokens: 131_072,
79
+ },
80
+ {
81
+ id: 'longcat-2.5-preview-free',
82
+ name: 'LongCat 2.5 Preview (free)',
83
+ reasoning: true,
84
+ input: ['text', 'image'],
85
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
86
+ contextWindow: 1_000_000,
87
+ maxTokens: 131_072,
88
+ },
89
+ ]
90
+
91
+ /**
92
+ * Ids on the free lane whose *name* says nothing about it, so the free
93
+ * judgment can never be reached by name alone. `big-pickle` is the case that
94
+ * matters: free by pricing, invisible to `id.includes('free')`.
95
+ */
96
+ export const VERIFIED_FREE: Readonly<Record<string, true>> = {
97
+ 'big-pickle': true,
98
+ 'mimo-v2.6-flash-free': true,
99
+ 'ling-3.1-flash-free': true,
100
+ 'nemotron-3.5-lightning-free': true,
101
+ 'space-bunny-free': true,
102
+ 'fledge-alpha-free': true,
103
+ 'longcat-2.5-preview-free': true,
104
+ }
105
+
106
+ /**
107
+ * Ids upstream refuses on the anonymous lane. Each reason is the verbatim
108
+ * upstream response from a gate-shaped probe.
109
+ *
110
+ * `mimo-v2.5-free` is the case that proves this list earns its keep: retired
111
+ * from the live lane (gone from `/models`, `401 ModelError`) while models.dev
112
+ * still publishes it at zero cost — metadata alone would keep re-advertising
113
+ * a dead id.
114
+ *
115
+ * `exo-free`, `jev-1.13-free` and `ling-3.0-flash-fin-free` (all measured
116
+ * 2026-10-11, three identical probes each) are the same shape of fact from
117
+ * the opposite direction: Zen still lists all three live and models.dev still
118
+ * prices two of them at zero, so S1 and S2 both vouch for ids the chat lane
119
+ * refuses. `ling-3.0-flash-fin-free` is the one that reverses a decision —
120
+ * it used to flip 400/503 and was deliberately kept while it still showed up
121
+ * in the catalogue, but it now answers `400 Model is unavailable` on every
122
+ * request, which is a refusal and not an outage.
123
+ */
124
+ export const UNAVAILABLE: Readonly<Record<string, string>> = {
125
+ 'mimo-v2.5-free': '401 ModelError: not supported upstream (retired, measured 2026-10-08)',
126
+ 'exo-free': '410 ModelDeprecated: deprecated upstream (3/3 measured 2026-10-11)',
127
+ 'jev-1.13-free': '400 ModelProtocolUnsupported: not served on this protocol (3/3 measured 2026-10-11)',
128
+ 'ling-3.0-flash-fin-free': '400 Model is unavailable (3/3 measured 2026-10-11)',
129
+ // Zen spells this with hyphens; the dotted spelling never matched a live id.
130
+ 'nemotron-3-ultra-free': '401 ModelError: not supported upstream',
131
+ 'muse-spark-1.2-contributor-free': '403 RegionError: blocked in this region',
132
+ 'muse-spark-1.3-contributor-free': '403 RegionError: blocked in this region',
133
+ }
package/src/session.ts ADDED
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Per-conversation session identity (docs/PLAN.md §T3).
3
+ *
4
+ * Upstream does prompt-cache affinity per session id, so sharing one id across
5
+ * every conversation in a process makes unrelated sessions evict each other's
6
+ * cache. The previous implementation used a process-level constant.
7
+ *
8
+ * `options.sessionId` was **probed**, not assumed: a dump of the custom-API
9
+ * handler's runtime `options` (omp 18.6.1, 2026-10-05) shows a real per-session
10
+ * UUID (`01a10bfe-…`), mirrored in `options.metadata.user_id`. Neither is
11
+ * declared in the host's published `SimpleStreamOptions` type, so both are read
12
+ * through runtime narrowing rather than trusted casts.
13
+ *
14
+ * The id must be attached per *request*: `ProviderConfig.headers` is fixed at
15
+ * registration time and cannot vary by conversation, so the caller merges these
16
+ * onto the model it hands the engine — the model is rebuilt on every request,
17
+ * which is the seam that actually varies.
18
+ */
19
+
20
+ import { createHash } from 'node:crypto'
21
+
22
+ import type { SimpleStreamOptions } from '@earendil-works/pi-ai'
23
+
24
+ const CANONICAL_SESSION = /^ses_[0-9a-f]{12}[0-9A-Za-z]{14}$/
25
+ const BASE62 = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'
26
+
27
+ function base62Fixed(value: bigint, width: number): string {
28
+ let out = ''
29
+ let v = value
30
+ for (let i = 0; i < width; i++) {
31
+ out = BASE62[Number(v % 62n)] + out
32
+ v /= 62n
33
+ }
34
+ return out
35
+ }
36
+
37
+ /** OpenCode's canonical session shape, ported from opencode2dsh `ids.ts`. */
38
+ export function canonicalSessionID(signal: string): string {
39
+ if (CANONICAL_SESSION.test(signal)) return signal
40
+ const sum = createHash('sha256').update(`ses\0${signal}`).digest()
41
+ return `ses_${sum.subarray(0, 6).toString('hex')}${base62Fixed(BigInt(`0x${sum.subarray(6, 16).toString('hex')}`), 14)}`
42
+ }
43
+
44
+ /** Process-level fallback, used when the host gives us no session identity. */
45
+ export const PROCESS_SESSION = canonicalSessionID(`omp:${process.pid}:${new Date().toISOString().slice(0, 10)}`)
46
+
47
+ /**
48
+ * Resolve the signal to derive this request's session id from.
49
+ *
50
+ * Preference order: the host's own `sessionId`, then the `user_id` blob it
51
+ * mirrors into metadata, then the process constant. Returns null when nothing
52
+ * usable is present so the caller can log the fallback rather than pretend.
53
+ */
54
+ function sessionSignal(options: SimpleStreamOptions | undefined): string | null {
55
+ if (!options) return null
56
+
57
+ if ('sessionId' in options && typeof options.sessionId === 'string' && options.sessionId.length > 0) {
58
+ return options.sessionId
59
+ }
60
+
61
+ if ('metadata' in options) {
62
+ const metadata = options.metadata
63
+ if (typeof metadata === 'object' && metadata !== null && 'user_id' in metadata) {
64
+ const userId = metadata.user_id
65
+ if (typeof userId === 'string') {
66
+ try {
67
+ const parsed: unknown = JSON.parse(userId)
68
+ if (
69
+ typeof parsed === 'object' &&
70
+ parsed !== null &&
71
+ 'session_id' in parsed &&
72
+ typeof parsed.session_id === 'string' &&
73
+ parsed.session_id.length > 0
74
+ ) {
75
+ return parsed.session_id
76
+ }
77
+ } catch {
78
+ // Not JSON: fall through to the process constant.
79
+ }
80
+ }
81
+ }
82
+ }
83
+
84
+ return null
85
+ }
86
+
87
+ /**
88
+ * Canonical session id for this request, stable across every turn of one
89
+ * conversation and distinct between conversations in the same process.
90
+ */
91
+ export function sessionForRequest(options: SimpleStreamOptions | undefined): {
92
+ readonly id: string
93
+ readonly derived: boolean
94
+ } {
95
+ const signal = sessionSignal(options)
96
+ if (signal === null) return { id: PROCESS_SESSION, derived: false }
97
+ return { id: canonicalSessionID(`omp:${signal}`), derived: true }
98
+ }
99
+
100
+ /** Headers the upstream shape check and cache affinity both key on. */
101
+ export function sessionHeaders(sessionId: string): Record<string, string> {
102
+ return {
103
+ 'x-opencode-session': sessionId,
104
+ 'x-session-affinity': sessionId,
105
+ 'x-session-id': sessionId,
106
+ }
107
+ }