@vincemakes/kiso-code 0.30.0 → 0.31.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/dist/chat.js CHANGED
@@ -11,7 +11,7 @@ import { canonicalTargetPath, shellProgressPath } from "@vincemakes/kiso-tools-n
11
11
  import { canonicalizeUsage } from "@vincemakes/kiso-runtime";
12
12
  import { canonicalizeUsageForModel, requestBudget } from "@vincemakes/kiso-runtime/internal";
13
13
  import { dispatch } from "./dispatch.js";
14
- import { agentModel, body, bodyLog, configuredWindow, dock } from "./state.js";
14
+ import { agentBaseUrl, agentModel, body, bodyLog, configuredWindow, dock } from "./state.js";
15
15
  import { attachImages } from "./attachments.js";
16
16
  import { lookupModelMetadata } from "@vincemakes/kiso-runtime/internal";
17
17
  import { addDontAskAgainRule, askPanel, fixHintFor, pendingAsk, resolveUncertains } from "./trust-ui.js";
@@ -47,8 +47,10 @@ export function contextWindowTokens() {
47
47
  // window (and the microcompact threshold derived from it) without an
48
48
  // env var. agentModel is the same live binding the status row shows;
49
49
  // an unknown model keeps the 200k default — the registry never
50
- // guesses, so neither do we.
51
- const known = lookupModelMetadata(agentModel)?.capabilities.contextWindow;
50
+ // guesses, so neither do we. OR-1: the ENDPOINT narrows the row —
51
+ // gpt-5.5 is 1,050,000 at the first-party API and 272,000 at the
52
+ // subscription backend; the two are set together (setAgentModel).
53
+ const known = lookupModelMetadata(agentModel, agentBaseUrl)?.capabilities.contextWindow;
52
54
  if (known !== undefined && known !== null)
53
55
  return known;
54
56
  return DEFAULT_CONTEXT_WINDOW;
@@ -67,6 +69,14 @@ export function estimateCtxRatio(session) {
67
69
  * their continuation envelopes, the output reserve when known) over the
68
70
  * window. Still ~ (chars/4). The auto-compact policy does NOT read this
69
71
  * (see autoCompactRatio) — moving the policy's number is A1b's. */
72
+ /** OR-8 (owner, 2026-09-09): the status row names the effort next to the
73
+ * model — `gpt-5.6-sol · xhigh` — whenever the live binding carries one;
74
+ * the provider's default shows as the bare model, as before. The effort
75
+ * is the session's own (the durable profile), never the CLI's memory. */
76
+ export function statusModelLabel(session) {
77
+ const effort = session.reasoning?.effort;
78
+ return effort !== undefined && effort !== "default" ? `${agentModel} · ${effort}` : agentModel;
79
+ }
70
80
  export function displayCtxRatio(session) {
71
81
  return requestBudget(session.requestParts(), contextWindowTokens()).ratio;
72
82
  }
@@ -110,8 +120,12 @@ export function usageFromEvent(route, ev, prevTotal,
110
120
  // metadata entry (an unpriced model shows null, never a route-table
111
121
  // guess); omitted, the legacy route-keyed path stands (old callers,
112
122
  // old tests, unchanged bytes).
113
- model) {
114
- const c = model === undefined ? canonicalizeUsage(route ?? "adapter", ev) : canonicalizeUsageForModel(model, undefined, route ?? "adapter", ev);
123
+ model,
124
+ // OR-1: the live ENDPOINT — the same model id is priced at the
125
+ // first-party API and unpriced at the subscription backend; the
126
+ // session hands it over next to `provider` (one binding, one row).
127
+ endpoint) {
128
+ const c = model === undefined ? canonicalizeUsage(route ?? "adapter", ev) : canonicalizeUsageForModel(model, endpoint, route ?? "adapter", ev);
115
129
  const total = c.input + c.cacheRead + (c.cacheWrite ?? 0);
116
130
  let missed = null;
117
131
  // R-C item 4: min(prevTotal, total) is the part that could have been
@@ -121,7 +135,24 @@ model) {
121
135
  const m = Math.min(prevTotal, total) - c.cacheRead;
122
136
  missed = m > CACHE_MISS_FLOOR ? m : null;
123
137
  }
124
- return { usage: { in: c.input, out: c.output, cache: c.cacheRead, known: ev.known }, total, missed, costUsd: c.costUsd };
138
+ // OR-10 (owner, 2026-09-09): a backend whose cache figure cannot be
139
+ // observed has no cache figure. The ChatGPT backend answers
140
+ // `cached_tokens` 0 to a third-party client on every request, so its 0
141
+ // and "not reported" are one value, and a CH 0% built on it claims a
142
+ // measurement that never happened. The registry ROW says which
143
+ // endpoints these are; the status row, the recap and the usage line
144
+ // already render a null cache as nothing ("an unmeasured cache is not
145
+ // a 0% cache"), so this is one decision at the one place the endpoint
146
+ // is known. The trace ledger is written by the runtime from the
147
+ // backend's own words and keeps its 0 — the display's rule, not the
148
+ // record's.
149
+ const unobservable = model !== undefined && lookupModelMetadata(model, endpoint)?.capabilities.promptCaching === "unobservable";
150
+ return {
151
+ usage: { in: c.input, out: c.output, cache: unobservable ? null : c.cacheRead, known: ev.known },
152
+ total,
153
+ missed: unobservable ? null : missed,
154
+ costUsd: c.costUsd,
155
+ };
125
156
  }
126
157
  /** v2b: the spinner merged into the STATUS BAR (the v2a standalone glyph
127
158
  * is gone) — docked only, 200ms rotation between the request and the
@@ -636,7 +667,7 @@ submitTurn) {
636
667
  body.notice("stream interrupted — the draft above is abandoned");
637
668
  break;
638
669
  case "usage": {
639
- const delta = usageFromEvent(session.provider, ev, prevTotal, agentModel);
670
+ const delta = usageFromEvent(session.provider, ev, prevTotal, agentModel, session.baseUrl);
640
671
  usage = delta.usage;
641
672
  prevTotal = delta.total;
642
673
  missed = delta.missed;
@@ -785,6 +816,17 @@ submitTurn) {
785
816
  // is a sentence addressed to a human; it needs no edge.
786
817
  body.notice('answer truncated at max_tokens — say "continue" to finish');
787
818
  }
819
+ // OR-5 (the ChatGPT real leg, 2026-09-09): a run that ended in an
820
+ // ERROR terminal printed nothing but the recap — the vendor's 400
821
+ // lived in the durable log alone and the turn read as "took 1s".
822
+ // A failure that fires silently is indistinguishable from a
823
+ // crash, the one thing this product must never be: the mapped
824
+ // error is named here, before the recap, code and status and the
825
+ // provider's own words.
826
+ if (ev.outcome.kind === "error") {
827
+ const e = ev.outcome.error;
828
+ body.notice(`run failed — ${e.code}${e.status !== undefined ? ` ${e.status}` : ""}${e.retryable ? " (retryable)" : ""}: ${escapeTerminal(e.message)}`);
829
+ }
788
830
  bodyLog(renderRecap({
789
831
  seconds: Math.round((Date.now() - turnStart) / 1000),
790
832
  // R3g: the work terms are NOT passed — the fold line
@@ -1077,7 +1119,7 @@ export async function chat(session, faux, input, autoCompact, nav) {
1077
1119
  // TUI2-R1 (E): the meter rides the idle row — both fields omitted
1078
1120
  // when unknown, so a session that has not called the model paints
1079
1121
  // exactly the pre-round row.
1080
- dock.setStatus(idleStatus(getMode() === "plan" ? "plan (read-only)" : getMode(), agentModel, displayCtxRatio(session), {
1122
+ dock.setStatus(idleStatus(getMode() === "plan" ? "plan (read-only)" : getMode(), statusModelLabel(session), displayCtxRatio(session), {
1081
1123
  cacheHitPct: cacheHitPct(runUsage),
1082
1124
  costUsd: spentUsd,
1083
1125
  }));
package/dist/config.d.ts CHANGED
@@ -8,8 +8,9 @@
8
8
  *
9
9
  * Schema v1:
10
10
  * model?: string — a profile NAME (models.<name>) or "provider/model"
11
- * direct write (provider: "openai-compat" | "anthropic")
12
- * models?: { [name]: { kind: "openai-compat"|"anthropic",
11
+ * direct write (provider: "openai-compat" |
12
+ * "anthropic" | "openai-responses")
13
+ * models?: { [name]: { kind: "openai-compat"|"anthropic"|"openai-responses",
13
14
  * baseUrl?: string, model: string, apiKeyEnv: string } }
14
15
  * mode?: "manual"|"default"|"accept-edits"|"plan"|"bypass"
15
16
  * contextWindow?: number — tokens
@@ -27,7 +28,11 @@
27
28
  * ignored (forward compatibility).
28
29
  */
29
30
  import type { Mode } from "./mode.js";
30
- export type ProfileKind = "openai-compat" | "anthropic";
31
+ /** OR-1: `openai-responses` is a DIALECT, not a vendor — the same kind
32
+ * drives the first-party Responses API and the ChatGPT subscription
33
+ * backend, and which one it is follows the profile's baseUrl (and so
34
+ * the credential it resolves to). */
35
+ export type ProfileKind = "openai-compat" | "anthropic" | "openai-responses";
31
36
  export interface ModelProfile {
32
37
  readonly kind: ProfileKind;
33
38
  readonly baseUrl?: string;
@@ -82,13 +87,44 @@ export declare function loadUserConfig(): KisoConfig | null;
82
87
  export declare function loadProjectConfig(cwd: string, trusted: boolean): KisoConfig | null;
83
88
  /** Merge: project wins over user (each layer's own keys only). */
84
89
  export declare function mergeConfigs(user: KisoConfig | null, project: KisoConfig | null): KisoConfig;
85
- /** A profile is available when its apiKeyEnv var is set (the key exists). */
90
+ /** What a profile's sign-in actually IS. The two shapes are not
91
+ * interchangeable: an API key is a string an adapter holds, an OAuth
92
+ * sign-in is a token that expires and must be re-resolved per request. */
93
+ export type ProfileAuth = {
94
+ readonly type: "api-key";
95
+ readonly apiKey: string;
96
+ readonly source: "store" | "env" | "none";
97
+ } | {
98
+ readonly type: "oauth";
99
+ readonly providerId: string;
100
+ };
101
+ /** Where a profile's sign-in comes from, or why it has none. The sign-in
102
+ * plan's resolve rule: a stored credential OWNS the provider (an
103
+ * unusable stored one is an error, never a silent fall back to env);
104
+ * with nothing stored, the profile's env var applies; a keyless profile
105
+ * (no apiKeyEnv — PH-1c, finding PH-F19) is an unauthenticated endpoint.
106
+ *
107
+ * OR-1: a stored OAuth credential is USABLE only by an adapter that can
108
+ * drive one, which today is the Responses adapter's ChatGPT target. For
109
+ * every other kind it stays the same loud refusal it has always been —
110
+ * the caller is told which sign-in it has and which one it needs. */
111
+ export declare function authForProfile(name: string, p: ModelProfile): ProfileAuth;
112
+ /** The reason a profile is unavailable, for the surfaces that print it. */
113
+ export declare function unavailableReason(name: string, p: ModelProfile): string;
114
+ /** A profile is available when a sign-in resolves for it — of EITHER
115
+ * shape: an OAuth-only profile is available, and calling the key-only
116
+ * resolver here would have declared it broken. */
86
117
  export declare function profileAvailable(p: ModelProfile): boolean;
87
- /** The resolved runtime model — what the adapter is built from. */
118
+ /** The resolved runtime model — what the adapter is built from. Exactly
119
+ * one of the two sign-in fields is set: an OAuth profile has no key to
120
+ * hand over, and the caller must build the adapter from the token thunk
121
+ * instead (the compiler enforces the branch — `apiKey` is optional). */
88
122
  export interface ResolvedModel {
89
123
  readonly name: string;
90
124
  readonly profile: ModelProfile;
91
- readonly apiKey: string;
125
+ readonly apiKey?: string;
126
+ /** OR-1: the provider whose stored OAuth sign-in this profile uses. */
127
+ readonly oauthProviderId?: string;
92
128
  }
93
129
  /** "provider/model" direct write → a profile. */
94
130
  export declare function directWriteProfile(value: string): ModelProfile | null;
package/dist/config.js CHANGED
@@ -8,8 +8,9 @@
8
8
  *
9
9
  * Schema v1:
10
10
  * model?: string — a profile NAME (models.<name>) or "provider/model"
11
- * direct write (provider: "openai-compat" | "anthropic")
12
- * models?: { [name]: { kind: "openai-compat"|"anthropic",
11
+ * direct write (provider: "openai-compat" |
12
+ * "anthropic" | "openai-responses")
13
+ * models?: { [name]: { kind: "openai-compat"|"anthropic"|"openai-responses",
13
14
  * baseUrl?: string, model: string, apiKeyEnv: string } }
14
15
  * mode?: "manual"|"default"|"accept-edits"|"plan"|"bypass"
15
16
  * contextWindow?: number — tokens
@@ -29,7 +30,9 @@
29
30
  import { readFileSync } from "node:fs";
30
31
  import { join } from "node:path";
31
32
  import { kisoHome } from "./state.js";
32
- const KINDS = ["openai-compat", "anthropic"];
33
+ import { AuthError, getCredential, providerIdOf } from "./auth/credentials.js";
34
+ import { BUILTIN_MANIFESTS } from "@vincemakes/kiso-runtime/internal";
35
+ const KINDS = ["openai-compat", "anthropic", "openai-responses"];
33
36
  const MODES_LIST = ["manual", "default", "accept-edits", "plan", "bypass"];
34
37
  export class ConfigError extends Error {
35
38
  }
@@ -177,11 +180,81 @@ export function mergeConfigs(user, project) {
177
180
  ...(u.checks !== undefined || p.checks !== undefined ? { checks: { ...(u.checks ?? {}), ...(p.checks ?? {}) } } : {}),
178
181
  };
179
182
  }
180
- /** A profile is available when its apiKeyEnv var is set (the key exists). */
183
+ /** Where a profile's sign-in comes from, or why it has none. The sign-in
184
+ * plan's resolve rule: a stored credential OWNS the provider (an
185
+ * unusable stored one is an error, never a silent fall back to env);
186
+ * with nothing stored, the profile's env var applies; a keyless profile
187
+ * (no apiKeyEnv — PH-1c, finding PH-F19) is an unauthenticated endpoint.
188
+ *
189
+ * OR-1: a stored OAuth credential is USABLE only by an adapter that can
190
+ * drive one, which today is the Responses adapter's ChatGPT target. For
191
+ * every other kind it stays the same loud refusal it has always been —
192
+ * the caller is told which sign-in it has and which one it needs. */
193
+ export function authForProfile(name, p) {
194
+ const providerId = providerIdOf(p.kind, p.baseUrl);
195
+ if (providerId !== null) {
196
+ let stored;
197
+ try {
198
+ stored = getCredential(providerId);
199
+ }
200
+ catch (err) {
201
+ if (err instanceof AuthError)
202
+ throw new ConfigError(`model ${name}: ${err.message}`);
203
+ throw err;
204
+ }
205
+ if (stored !== undefined) {
206
+ if (stored.type === "api-key")
207
+ return { type: "api-key", apiKey: stored.key, source: "store" };
208
+ if (p.kind === "openai-responses")
209
+ return { type: "oauth", providerId };
210
+ throw new ConfigError(`model ${name}: signed in to ${providerId} with OAuth, but this profile's adapter needs an API key — run \`kiso login ${providerId}\` with a key, or \`kiso logout ${providerId}\` to use the env var ${p.apiKeyEnv ?? "(none configured)"}`);
211
+ }
212
+ }
213
+ // OR-2 (2026-09-09): a provider whose manifest takes NO API key (the ChatGPT
214
+ // backend: authMethods ["oauth"]) is never an "unauthenticated endpoint" —
215
+ // with nothing stored it is a profile waiting for its sign-in. Without this
216
+ // the keyless rule below handed it a placeholder key, `/model` said
217
+ // "available", and the adapter (an apiKey selects the first-party target)
218
+ // would have sent an unauthenticated request to the wrong URL.
219
+ if (providerId !== null && !manifestTakesKey(providerId)) {
220
+ throw new ConfigError(`model ${name}: unavailable — not signed in: run \`kiso login ${providerId}\` (this provider takes no API key)`);
221
+ }
222
+ if (p.apiKeyEnv === undefined)
223
+ return { type: "api-key", apiKey: "none", source: "none" };
224
+ const fromEnv = process.env[p.apiKeyEnv];
225
+ if (fromEnv !== undefined)
226
+ return { type: "api-key", apiKey: fromEnv, source: "env" };
227
+ const hint = providerId !== null ? `run \`kiso login ${providerId}\` or set the env var ${p.apiKeyEnv}` : `set the env var ${p.apiKeyEnv}`;
228
+ throw new ConfigError(`model ${name}: unavailable — no credential: ${hint} (configs never store keys, only the env-var name)`);
229
+ }
230
+ /** OR-2: does the provider's manifest admit an API key (or no auth at all)?
231
+ * An unknown id answers yes — the keyless-endpoint rule stays for custom
232
+ * origins; only a manifest that says OAuth-only takes the sign-in path. */
233
+ function manifestTakesKey(providerId) {
234
+ const m = BUILTIN_MANIFESTS.find((x) => x.id === providerId);
235
+ return m === undefined || m.authMethods.includes("api-key") || m.authMethods.includes("none");
236
+ }
237
+ /** The reason a profile is unavailable, for the surfaces that print it. */
238
+ export function unavailableReason(name, p) {
239
+ try {
240
+ authForProfile(name, p);
241
+ return "";
242
+ }
243
+ catch (err) {
244
+ return err instanceof Error ? err.message : String(err);
245
+ }
246
+ }
247
+ /** A profile is available when a sign-in resolves for it — of EITHER
248
+ * shape: an OAuth-only profile is available, and calling the key-only
249
+ * resolver here would have declared it broken. */
181
250
  export function profileAvailable(p) {
182
- // PH-1c (finding PH-F19): a keyless profile (no apiKeyEnv) is an
183
- // unauthenticated endpoint — always available.
184
- return p.apiKeyEnv === undefined || process.env[p.apiKeyEnv] !== undefined;
251
+ try {
252
+ authForProfile("?", p);
253
+ return true;
254
+ }
255
+ catch {
256
+ return false;
257
+ }
185
258
  }
186
259
  /** "provider/model" direct write → a profile. */
187
260
  export function directWriteProfile(value) {
@@ -189,11 +262,15 @@ export function directWriteProfile(value) {
189
262
  if (slash <= 0 || slash === value.length - 1)
190
263
  return null;
191
264
  const kind = value.slice(0, slash);
192
- if (kind !== "openai-compat" && kind !== "anthropic")
265
+ if (kind !== "openai-compat" && kind !== "anthropic" && kind !== "openai-responses")
193
266
  return null;
194
267
  return {
195
268
  kind,
196
269
  model: value.slice(slash + 1),
270
+ // OR-1: a direct-write `openai-responses/…` names no baseUrl, so it
271
+ // is the FIRST-PARTY target and its env var is OpenAI's. The
272
+ // ChatGPT backend needs a baseUrl and a stored sign-in, which is a
273
+ // configured profile's job, not a one-line direct write.
197
274
  apiKeyEnv: kind === "anthropic" ? "ANTHROPIC_API_KEY" : "OPENAI_API_KEY",
198
275
  };
199
276
  }
@@ -256,12 +333,10 @@ export function resolveModel(modelFlag, merged) {
256
333
  return null;
257
334
  }
258
335
  function resolveProfile(name, p) {
259
- if (!profileAvailable(p)) {
260
- throw new ConfigError(`model ${name}: unavailable — the env var ${p.apiKeyEnv} is not set (configs never store keys, only the env-var name)`);
261
- }
262
336
  // PH-1c (finding PH-F19): a keyless profile hands the adapter a
263
337
  // placeholder — the SDKs require SOME string; the endpoint ignores it.
264
- return { name, profile: p, apiKey: p.apiKeyEnv === undefined ? "none" : process.env[p.apiKeyEnv] };
338
+ const auth = authForProfile(name, p);
339
+ return auth.type === "oauth" ? { name, profile: p, oauthProviderId: auth.providerId } : { name, profile: p, apiKey: auth.apiKey };
265
340
  }
266
341
  /** Mode: env (KISO_MODE) beats config.mode; the --mode flag is applied by
267
342
  * main before this runs (flags are the top of the chain). */
package/dist/dispatch.js CHANGED
@@ -5,11 +5,74 @@
5
5
  */
6
6
  import { contextRows, contextUnavailableRows, displayVerb, escapeTerminal, helpRows, kUnit, modePickView, modelPickView, palette } from "@vincemakes/kiso-tui";
7
7
  import { newSessionId } from "./session-id.js";
8
- import { buildAdapter, resolveContinuationScope, resolveReasoning } from "@vincemakes/kiso-runtime/internal";
8
+ import { buildAdapter, lookupModelMetadata, resolveContinuationScope, resolveReasoning } from "@vincemakes/kiso-runtime/internal";
9
9
  import { MODES, MODE_NOTE, getMode, setMode } from "./mode.js";
10
10
  import { clipboardWrite, lastAnswer } from "./clipboard.js";
11
- import { agentModel, body, bodyLog, configModels, dock, readContextLedger, sessionsDir, setAgentModel, setCurrentModelName, setLastBinding } from "./state.js";
12
- import { directWriteProfile, profileAvailable } from "./config.js";
11
+ import { agentModel, body, bodyLog, configModels, dock, lastBinding, readContextLedger, sessionsDir, setAgentModel, setCurrentModelName, setLastBinding } from "./state.js";
12
+ import { authForProfile, directWriteProfile, profileAvailable, unavailableReason } from "./config.js";
13
+ /** The picker's CLI half (owner 2026-09-08): a profile's LEGAL effort levels
14
+ * and its default, from the registry, shown wherever the profile is listed
15
+ * — kiso never silently drops a level, and now it shows the ones it takes.
16
+ * A model the registry does not know shows `unknown` (nothing is guessed);
17
+ * it must not read `none`, which is a REAL level since the Responses rows. */
18
+ function effortNote(p) {
19
+ const reasoning = lookupModelMetadata(p.model, p.baseUrl)?.capabilities.reasoning ?? null;
20
+ const effort = reasoning?.effort ?? null;
21
+ if (effort === null)
22
+ return "effort: unknown";
23
+ return `effort: ${effort.levels.map((l) => (l === effort.default ? `[${l}]` : l)).join(" · ")}`;
24
+ }
25
+ /** OR-7 — the pick panel's second axis, as DATA. `effortNote` above stays
26
+ * the PRINTED list's string (a machine-readable surface whose bytes do
27
+ * not move); the panel gets the levels structured so its cursor can walk
28
+ * them, and so the bracket can mean "the cursor is here" rather than
29
+ * "the registry default".
30
+ *
31
+ * Where the cursor starts: the session's CURRENT effort when this model
32
+ * has that level, else the model's default, else nowhere — a row whose
33
+ * registry default is null (the model page states none) marks nothing,
34
+ * and enter on it applies the profile alone. Nothing is invented. */
35
+ function effortAxis(p) {
36
+ const reasoning = lookupModelMetadata(p.model, p.baseUrl)?.capabilities.reasoning ?? null;
37
+ if (reasoning === null)
38
+ return {};
39
+ const effort = reasoning.effort;
40
+ if (effort === null)
41
+ return {};
42
+ const levels = effort.levels;
43
+ const carried = lastBinding()?.reasoning?.effort;
44
+ const carriedIdx = carried === undefined || carried === "default" ? -1 : levels.indexOf(carried);
45
+ const defaultIdx = effort.default === null ? -1 : levels.indexOf(effort.default);
46
+ const level = carriedIdx >= 0 ? carriedIdx : defaultIdx;
47
+ // the forbidden set is per THINKING mode, and the panel applies its
48
+ // pick as thinking "default" — the same setting `/model <p> <e>`
49
+ // builds — so only pairs forbidden at that mode are dimmed here.
50
+ const disabled = (reasoning.forbidden ?? []).filter((f) => f.thinking === "default").map((f) => levels.indexOf(f.effort)).filter((i) => i >= 0);
51
+ return {
52
+ levels,
53
+ ...(level >= 0 ? { level } : {}),
54
+ ...(disabled.length > 0 ? { disabled } : {}),
55
+ // the sentence the coordination note asked for: said only when the
56
+ // cursor could NOT land where the previous selection asked.
57
+ ...(carried !== undefined && carried !== "default" && carriedIdx < 0 && defaultIdx >= 0
58
+ ? { levelNote: `effort ${carried} \u2192 ${levels[defaultIdx]}: the nearest this model supports` }
59
+ : {}),
60
+ };
61
+ }
62
+ /** What signs a profile in, for the listing: the env var's name, `oauth`
63
+ * for a subscription sign-in, or `no key` for an unauthenticated endpoint. */
64
+ function signInNote(p) {
65
+ try {
66
+ const auth = authForProfile("?", p);
67
+ if (auth.type === "oauth")
68
+ return "oauth";
69
+ }
70
+ catch {
71
+ // unavailable — the availability mark says so; the env name still names what would sign it in
72
+ }
73
+ return p.apiKeyEnv ?? "no key";
74
+ }
75
+ import { oauthTokenThunk } from "./auth/token.js";
13
76
  /** The ONE dispatcher — slash commands, exit, and turns. The recovery
14
77
  * replay routes through it too — a queued "/last" must never become a
15
78
  * user turn (v2c: the rl lives in main, so lines arrive earlier and the
@@ -290,13 +353,17 @@ export function dispatch(line, ctx) {
290
353
  // surface and this round moves no bytes on one.
291
354
  if (arg === "" && dock.active && ctx.input.panelAsk !== undefined) {
292
355
  const names = Object.keys(configModels);
356
+ let pickedLevel;
293
357
  const picked = await new Promise((resolve) => {
294
358
  ctx.input.panelAsk(modelPickView({
295
359
  header: `model — current: ${agentModel}`,
296
360
  options: names.map((name) => {
297
361
  const profile = configModels[name];
362
+ // the note keeps what tells two rows apart; the levels
363
+ // left it for the axis below (they were being cut off
364
+ // the end of the note column at 100 columns).
298
365
  const marks = [`profile: ${name}`, ...(profileAvailable(profile) ? [] : ["unavailable"]), ...(profile.model === agentModel ? ["current"] : [])];
299
- return { label: `${profile.kind}/${profile.model}`, note: marks.join(" · ") };
366
+ return { label: `${profile.kind}/${profile.model}`, note: marks.join(" · "), ...effortAxis(profile) };
300
367
  }),
301
368
  // PH-1a (finding PH-F4): the example must be a syntax
302
369
  // directWriteProfile actually ACCEPTS — the old
@@ -311,14 +378,30 @@ export function dispatch(line, ctx) {
311
378
  // DC-12 (design §4): a panel WAITING ON A HUMAN says ❯. The
312
379
  // else-branch keeps ▸ — it names the current tier, which is
313
380
  // what ▸ means everywhere else.
314
- ctx.isRunning() ? "❯ run paused" : `▸ ${getMode()}`), (v) => resolve(v.action === "picked" ? v.result : null));
381
+ ctx.isRunning() ? "❯ run paused" : `▸ ${getMode()}`), (v) => {
382
+ // OR-7: the level rides beside the result, so a pick
383
+ // with no second axis is byte-for-byte the old one.
384
+ if (v.action === "picked")
385
+ pickedLevel = v.level;
386
+ resolve(v.action === "picked" ? v.result : null);
387
+ });
315
388
  });
316
389
  ctx.paintIdle();
317
390
  if (picked === null) {
318
391
  ctx.input.prompt();
319
392
  return; // esc — nothing switched, nothing said
320
393
  }
321
- arg = "index" in picked ? names[picked.index] : picked.custom;
394
+ if ("index" in picked) {
395
+ // OR-7: the panel hands back both axes and the command
396
+ // string is where they meet — everything below is the
397
+ // typed `/model <profile> <effort>` path, unchanged, so
398
+ // the refusal wording and the durable revision are its.
399
+ const name = names[picked.index];
400
+ const level = pickedLevel === undefined ? undefined : effortAxis(configModels[name]).levels?.[pickedLevel];
401
+ arg = level === undefined ? name : `${name} ${level}`;
402
+ }
403
+ else
404
+ arg = picked.custom;
322
405
  }
323
406
  if (arg === "") {
324
407
  bodyLog(`model: ${agentModel}`);
@@ -329,7 +412,7 @@ export function dispatch(line, ctx) {
329
412
  else {
330
413
  for (const name of names) {
331
414
  const p = configModels[name];
332
- bodyLog(` ${name} → ${p.kind}/${p.model} · ${p.apiKeyEnv} ${profileAvailable(p) ? "(available)" : "(unavailable)"}`);
415
+ bodyLog(` ${name} → ${p.kind}/${p.model} · ${signInNote(p)} ${profileAvailable(p) ? "(available)" : "(unavailable)"} · ${effortNote(p)}`);
333
416
  }
334
417
  }
335
418
  bodyLog("switch with /model <profile-name|provider/model>");
@@ -347,13 +430,25 @@ export function dispatch(line, ctx) {
347
430
  bodyLog(`no such model profile: ${profName}`);
348
431
  }
349
432
  else if (!profileAvailable(profile)) {
350
- bodyLog(`model ${arg}: unavailable — the env var ${profile.apiKeyEnv} is not set (configs never store keys, only the env-var name)`);
433
+ // the PROFILE name, not the whole argument: what is
434
+ // unavailable is the credential, and an effort token
435
+ // riding along ("claude high: unavailable") reads as
436
+ // though the level were part of the problem. The panel
437
+ // began producing that token in OR-7; the typed path
438
+ // could always produce it too.
439
+ bodyLog(unavailableReason(profName, profile));
351
440
  }
352
441
  else {
442
+ const auth = authForProfile(profName, profile);
353
443
  const adapter = await buildAdapter(profile.kind, {
354
444
  // PH-1c (PH-F19): a keyless profile = an unauthenticated
355
445
  // endpoint — the placeholder satisfies the SDK's ctor.
356
- apiKey: profile.apiKeyEnv === undefined ? "none" : process.env[profile.apiKeyEnv],
446
+ // OR-1: an OAuth profile passes a token thunk instead
447
+ // — there is no key, and the adapter re-resolves the
448
+ // token per request.
449
+ ...(auth.type === "oauth"
450
+ ? { oauth: oauthTokenThunk(auth.providerId), ...(ctx.session.id !== undefined ? { promptCacheKey: ctx.session.id } : {}) }
451
+ : { apiKey: auth.apiKey }),
357
452
  ...(profile.baseUrl !== undefined ? { baseUrl: profile.baseUrl } : {}),
358
453
  ...(profile.promptCaching !== undefined ? { promptCaching: profile.promptCaching } : {}),
359
454
  });
@@ -370,7 +465,9 @@ export function dispatch(line, ctx) {
370
465
  let refused = null;
371
466
  if (effortTok !== undefined) {
372
467
  const candidate = { thinking: "default", effort: effortTok };
373
- const resolved = resolveReasoning(profile.model, candidate);
468
+ // OR-1: the endpoint narrows the row — the same model id answers
469
+ // differently at the first-party API and at the subscription backend
470
+ const resolved = resolveReasoning(profile.model, candidate, profile.baseUrl);
374
471
  if (resolved.ok)
375
472
  reasoning = candidate;
376
473
  else
@@ -386,13 +483,22 @@ export function dispatch(line, ctx) {
386
483
  adapter,
387
484
  model: profile.model,
388
485
  provider: profile.kind,
486
+ // OR-1: the endpoint is the fourth passenger — the cost
487
+ // path and the window lookup key on (model, endpoint).
488
+ ...(profile.baseUrl !== undefined ? { baseUrl: profile.baseUrl } : {}),
389
489
  ...(scope !== undefined ? { scope } : {}),
390
490
  ...(reasoning !== undefined ? { reasoning } : {}),
391
491
  };
392
492
  ctx.session.setModelBinding(binding);
393
493
  setLastBinding(binding);
394
- setAgentModel(profile.model);
494
+ setAgentModel(profile.model, profile.baseUrl);
395
495
  setCurrentModelName(arg);
496
+ // OR-7 (owner, 2026-09-09): the status row is repainted BY the
497
+ // switch — it used to keep the old model until the next
498
+ // recap repainted it, so "takes effect on the next turn"
499
+ // read as "did not take". Painted BEFORE the notice so the
500
+ // frame that carries the notice already carries the row.
501
+ ctx.paintIdle();
396
502
  body.notice(`model → ${profName} (${profile.model}${effortTok !== undefined ? ` · ${effortTok}` : ""}) — takes effect on the next turn`);
397
503
  }
398
504
  }