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.
- package/LICENSE +21 -0
- package/README.md +209 -2
- package/package.json +58 -4
- package/src/commands.ts +254 -0
- package/src/discovery.ts +260 -0
- package/src/doctor.ts +64 -0
- package/src/errors.ts +95 -0
- package/src/filters.ts +121 -0
- package/src/gate.ts +94 -0
- package/src/health.ts +332 -0
- package/src/host.d.ts +15 -0
- package/src/index.ts +254 -0
- package/src/metadata.ts +198 -0
- package/src/seed.ts +133 -0
- package/src/session.ts +107 -0
package/src/metadata.ts
ADDED
|
@@ -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
|
+
}
|