Skip to content

Commit 2f3dbf2

Browse files
Merge pull request #728 from corbitsdev/cl-7263-make-live-model-switching-refresh-every-model-scoped-runtime
Refresh permission identity and schemas on a live model switch
2 parents d3ceaf7 + 4b5aa52 commit 2f3dbf2

11 files changed

Lines changed: 259 additions & 15 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,11 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename
2222
- `ask_operator` no longer pre-authorizes a model-authored shell command when
2323
the operator picks any option, including Reject. Clarification choices
2424
cannot mint shell grants.
25+
- Live `/model` switches refresh inference, permission identity, grant
26+
persistence identity, and advertised tool schemas together. A grant for the
27+
previous model no longer covers the same action, new grants store under the
28+
new pair, and Kimi/Moonshot sessions get non-recursive `present` schemas
29+
immediately (canonical schemas restore when switching away).
2530

2631
## [0.3.10] - 2026-08-30
2732

docs/IMPLEMENTATION.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -303,7 +303,7 @@ Profiles supply per-project or named-profile overrides for `model` and `systemPr
303303

304304
Providers and credentials are read exclusively from settings files: the global `~/.corbits/settings.json` (definitions + credentials) and the per-repo `.corbits/settings.json` (selection only). There are no `OPENAI_COMPATIBLE_*` environment-variable overrides, and `index.ts` does not load `.env` files — a deliberately stale or exported key can no longer shadow the configured provider.
305305

306-
**Models-first connect.** There is no standalone `/login` command. `/model` opens on a flat **models-only** list (Recent, Favorites, then connected provider/model rows) built by `buildModelsFirstList` (`src/tui/model-picker.ts`); type-to-filter owns printable keys. **Alt+A** opens Connect via `addProviderSelectorChoices` (`src/tui/provider-setup.ts`), which lists every first-class kind including Custom — never bare `c` / Ctrl+A, and never in-list “connect →” rows. First-class API-key rows use a named-instance + auth-only form (instance name, key; catalog base URL is display-only); Custom keeps the full manual form. **Alt+F** toggles favorites; recent/favorite pairs live in global settings (`recentModels` / `favoriteModels`). **Alt+D** sets the default via `setDefaultModel` (global `defaultProvider` + that provider's `defaultModel`) plus `persistConnectedSelection` without switching the live session. First-class providers ship from `packages/first-class-providers` (corbits-agnostic defs) and `packages/opencode-go` (Go catalog, auth validate, multi-protocol endpoints, usage). OAuth providers open the existing browser login modal with a named account step; API-key providers share the same multi-instance naming and pre-seed models on save so selection works without restart. Both OAuth and API-key (including Custom) connects share `persistConnectedSelection` in `provider-setup-submit.ts` so project-local provider/model selection is written alongside global credentials. OpenCode Go forces `OPENCODE_GO_BASE_URL` when `opencodeGo` is set so subscription traffic is not billed as Zen PAYG.
306+
**Models-first connect.** There is no standalone `/login` command. `/model` opens on a flat **models-only** list (Recent, Favorites, then connected provider/model rows) built by `buildModelsFirstList` (`src/tui/model-picker.ts`); type-to-filter owns printable keys. Selecting a row runs `applyLiveModelSwitch` (`src/session/live-model-switch.ts`) so inference sources, permission-gate identity, grant persistence identity, and advertised tool schemas cut over together. **Alt+A** opens Connect via `addProviderSelectorChoices` (`src/tui/provider-setup.ts`), which lists every first-class kind including Custom — never bare `c` / Ctrl+A, and never in-list “connect →” rows. First-class API-key rows use a named-instance + auth-only form (instance name, key; catalog base URL is display-only); Custom keeps the full manual form. **Alt+F** toggles favorites; recent/favorite pairs live in global settings (`recentModels` / `favoriteModels`). **Alt+D** sets the default via `setDefaultModel` (global `defaultProvider` + that provider's `defaultModel`) plus `persistConnectedSelection` without switching the live session. First-class providers ship from `packages/first-class-providers` (corbits-agnostic defs) and `packages/opencode-go` (Go catalog, auth validate, multi-protocol endpoints, usage). OAuth providers open the existing browser login modal with a named account step; API-key providers share the same multi-instance naming and pre-seed models on save so selection works without restart. Both OAuth and API-key (including Custom) connects share `persistConnectedSelection` in `provider-setup-submit.ts` so project-local provider/model selection is written alongside global credentials. OpenCode Go forces `OPENCODE_GO_BASE_URL` when `opencodeGo` is set so subscription traffic is not billed as Zen PAYG.
307307

308308
**OpenCode Go multi-protocol.** Each Go model carries protocol metadata (`chat-completions`, `responses`, or `messages`). `buildGoSource` / `resolveGoEndpoint` pick the adapter and base URL per model (not a single provider-wide OpenAI route). When Go is the active provider, subscription usage is fetched for the status bar and omitted on auth/network failure.
309309

docs/PRODUCT.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ The TUI has an extensible slash-command framework. Built-ins include `/help` (sh
107107

108108
Providers are **models-first**: there is no standalone `/login` command. `/model` opens a **models-only list** (Recent, Favorites, then connected provider/model rows) — type-to-filter owns printable keys, so Connect is never a bare letter. **Alt+A** opens a dedicated add-provider selector over every first-class kind (OpenAI dual-path ChatGPT OAuth or API key, xAI, OpenCode Zen, Anthropic, Google, OpenCode Go, Z.AI Coding Plan, Custom), each annotated with its live account count and never filtered out for “already connected.” **Alt+F** toggles favorite on the highlighted model. **Alt+D** persists the highlighted pair as the default without switching the live session. Advanced provider drill-down (edit/delete/tiers) stays on the advanced surface, not a bare printable key while the model list is filtering. OAuth providers open their existing browser login with a named account step so multiple accounts per kind coexist (`codex/work`, …). API-key providers use the same named-instance step before the key (auth-only form: instance name + key + fixed catalog base URL), so personal and team keys land as distinct catalog rows (`openai/default`, `anthropic/work`, …); reusing a name re-keys that instance after confirm. Custom remains a free-form single endpoint (full manual form). Successful connect refreshes the catalog and reopens the model list focused on the new account’s default model. OpenCode Go routes each model by its protocol metadata (chat completions, OpenAI responses, or Anthropic messages) and can show subscription usage in the status bar when active (rolling 5h / weekly / monthly windows when the usage API responds; omitted on auth or network failure). When Go returns a quota or rate-limit error — including some HTTP 400 responses that carry limit payloads — Corbits classifies them so quota aborts cleanly and short provider rate limits remain retryable. On a free-tier or subscription quota hit, wait for the window to reset or use OpenCode Zen free models.
109109

110-
`/model` opens a dedicated full-screen modal — the single place agent configuration lives. The default view is models-only (Recent / Favorites / connected models); add-provider, tiers, and profiles remain reachable from the same surface without in-list “connect →” rows. A switch applies to the running session immediately (no restart), and can be saved as this project's default (written to the per-repo selection file). Recent and favorite model pairs are stored in global settings (no credentials).
110+
`/model` opens a dedicated full-screen modal — the single place agent configuration lives. The default view is models-only (Recent / Favorites / connected models); add-provider, tiers, and profiles remain reachable from the same surface without in-list “connect →” rows. A switch applies to the running session immediately (no restart): inference, permission identity, grant persistence identity, and advertised tool schemas cut over together, and the choice can be saved as this project's default (written to the per-repo selection file). Recent and favorite model pairs are stored in global settings (no credentials).
111111

112112
## Lifecycle Hooks
113113

src/exec/runner.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -357,7 +357,6 @@ export async function runExec(config: Config): Promise<ExecResult> {
357357
const sessionMode: SessionMode =
358358
resolveSessionMode(config.settings, localSettingsForMode) ?? "orchestrator";
359359

360-
const activeProviderModel = `${config.providerName}:${config.model}`;
361360
const seededApprovals = await loadSeededApprovals(config.cwd, sessionId);
362361

363362
const interactive = input.isTTY === true && output.isTTY === true;
@@ -377,7 +376,7 @@ export async function runExec(config: Config): Promise<ExecResult> {
377376
model: config.model,
378377
requestApproval: (request: PermissionRequest): Promise<ApprovalOutcome> =>
379378
promptPermission(request, interactive),
380-
persist: createApprovalPersist(config.cwd, activeProviderModel),
379+
persist: createApprovalPersist(config.cwd, () => `${config.providerName}:${config.model}`),
381380
approvalLog: createApprovalLog(sessionDir(config.cwd, sessionId)),
382381
interactive,
383382
skipPermissions: config.dangerouslySkipPermissions,

src/permission/gate.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -327,6 +327,10 @@ export interface PermissionGate {
327327
// TUI wires the toggle here so a switch takes effect on the next tool call —
328328
// including pre-gate sandboxes that read getSkipPermissions live.
329329
setSkipPermissions: (value: boolean) => void;
330+
// Point matching and newly minted provider-model grants at a different
331+
// providerName:model. A live `/model` switch calls this so a grant scoped to
332+
// the previous pair no longer auto-allows, and new grants tag the new pair.
333+
setProviderIdentity: (providerName: string, model: string) => void;
330334
registerMcpClient: (client: MCPClient) => void;
331335
unregisterMcpServer: (serverName: string) => void;
332336
}
@@ -359,7 +363,7 @@ export function createPermissionGate(options: PermissionGateOptions): Permission
359363
let skipPermissions = options.skipPermissions;
360364
// Own a private copy so evaluating a grant never mutates the caller's array.
361365
const approvals: Approval[] = [...options.approvals];
362-
const activeProviderModel =
366+
let activeProviderModel =
363367
providerName !== undefined && model !== undefined ? `${providerName}:${model}` : undefined;
364368
// Session grants live only in this array; persisted grants are seeded in via
365369
// options.approvals and re-routed to a store by the persist callback.
@@ -734,6 +738,9 @@ export function createPermissionGate(options: PermissionGateOptions): Permission
734738
setSkipPermissions: (value: boolean) => {
735739
skipPermissions = value;
736740
},
741+
setProviderIdentity: (nextProviderName: string, nextModel: string) => {
742+
activeProviderModel = `${nextProviderName}:${nextModel}`;
743+
},
737744
registerMcpClient,
738745
unregisterMcpServer,
739746
};
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test";
2+
import type { ToolCall, ToolDefinition } from "@intx/types/runtime";
3+
4+
import { presentDefinition } from "../agent/director.js";
5+
import { normalizeToolDefinitionsForProvider } from "../agent/tool-schema-normalize.js";
6+
import { createPermissionGate } from "../permission/gate.js";
7+
import * as permissionStore from "../permission/store.js";
8+
import type { Approval } from "../permission/types.js";
9+
import { createApprovalPersist } from "./runtime-assembly.js";
10+
import { applyLiveModelSwitch, providerModelKey, type LiveModelRef } from "./live-model-switch.js";
11+
12+
const MODEL_A: LiveModelRef = { providerName: "openai", model: "gpt-5" };
13+
const MODEL_B: LiveModelRef = { providerName: "anthropic", model: "claude-opus" };
14+
const MODEL_KIMI: LiveModelRef = { providerName: "moonshot", model: "kimi-k2" };
15+
16+
const canonicalDefs: readonly ToolDefinition[] = [presentDefinition];
17+
18+
function schemaHasRef(value: unknown): boolean {
19+
if (value === null || typeof value !== "object") return false;
20+
if (Array.isArray(value)) return value.some(schemaHasRef);
21+
const obj = value as Record<string, unknown>;
22+
if ("$ref" in obj) return true;
23+
return Object.values(obj).some(schemaHasRef);
24+
}
25+
26+
const shellCall = (command: string): ToolCall => ({
27+
id: "c",
28+
name: "run_shell",
29+
arguments: { command },
30+
});
31+
32+
const providerModelScope = {
33+
id: "provider-model",
34+
label: "",
35+
pattern: "npm *",
36+
grant: "provider-model" as const,
37+
};
38+
39+
function presentSchema(defs: readonly ToolDefinition[]): unknown {
40+
return defs.find((d) => d.name === "present")?.inputSchema;
41+
}
42+
43+
/**
44+
* Same collaborators the TUI `/model` handler wires through
45+
* `applyLiveModelSwitch`: live identity (persist reads it), permission gate,
46+
* inference rebuild, and canonical-then-family-gate advertise.
47+
*/
48+
function createProductionSwitch() {
49+
let identity: LiveModelRef = MODEL_A;
50+
let inference: LiveModelRef = MODEL_A;
51+
let advertised = normalizeToolDefinitionsForProvider(canonicalDefs, MODEL_A);
52+
53+
const persist = createApprovalPersist("/tmp/proj", () => providerModelKey(identity));
54+
const gate = createPermissionGate({
55+
approvals: [],
56+
requestApproval: async () => ({ allow: true, persist: providerModelScope }),
57+
persist,
58+
interactive: true,
59+
skipPermissions: false,
60+
auto: false,
61+
providerName: identity.providerName,
62+
model: identity.model,
63+
});
64+
65+
const switchTo = (next: LiveModelRef): void => {
66+
applyLiveModelSwitch(next, {
67+
applyIdentity: (ref) => {
68+
identity = ref;
69+
},
70+
setPermissionIdentity: (providerName, model) => {
71+
gate.setProviderIdentity(providerName, model);
72+
},
73+
rebuildInference: (ref) => {
74+
inference = ref;
75+
},
76+
refreshAdvertisedSchemas: (ref) => {
77+
advertised = normalizeToolDefinitionsForProvider(canonicalDefs, ref);
78+
},
79+
});
80+
};
81+
82+
return {
83+
gate,
84+
switchTo,
85+
identity: () => identity,
86+
inference: () => inference,
87+
advertised: () => advertised,
88+
};
89+
}
90+
91+
describe("applyLiveModelSwitch", () => {
92+
afterEach(() => {
93+
mock.restore();
94+
});
95+
96+
test("refreshes identity, permission, inference, and schemas as one operation", () => {
97+
const order: string[] = [];
98+
applyLiveModelSwitch(MODEL_B, {
99+
applyIdentity: () => {
100+
order.push("identity");
101+
},
102+
setPermissionIdentity: () => {
103+
order.push("permission");
104+
},
105+
rebuildInference: () => {
106+
order.push("inference");
107+
},
108+
refreshAdvertisedSchemas: () => {
109+
order.push("schemas");
110+
},
111+
});
112+
expect(order).toEqual(["identity", "permission", "inference", "schemas"]);
113+
});
114+
115+
test("a grant scoped to A does not cover the action after switching to B; new grants store under B", async () => {
116+
const saved: string[] = [];
117+
spyOn(permissionStore, "saveProviderModelApproval").mockImplementation(async (key: string) => {
118+
saved.push(key);
119+
});
120+
spyOn(permissionStore, "saveProjectApproval").mockResolvedValue(undefined);
121+
spyOn(permissionStore, "saveGlobalApproval").mockResolvedValue(undefined);
122+
123+
const session = createProductionSwitch();
124+
125+
expect((await session.gate.evaluate(shellCall("npm test"))).allowed).toBe(true);
126+
expect(saved).toEqual([providerModelKey(MODEL_A)]);
127+
const grantA = session.gate
128+
.getApprovals()
129+
.find((a: Approval) => a.providerModel === providerModelKey(MODEL_A));
130+
expect(grantA).toBeDefined();
131+
132+
session.switchTo(MODEL_B);
133+
134+
expect(session.identity()).toEqual(MODEL_B);
135+
expect(session.inference()).toEqual(MODEL_B);
136+
137+
expect((await session.gate.evaluate(shellCall("npm test"))).allowed).toBe(true);
138+
expect(saved).toEqual([providerModelKey(MODEL_A), providerModelKey(MODEL_B)]);
139+
expect(
140+
session.gate.getApprovals().some((a) => a.providerModel === providerModelKey(MODEL_B)),
141+
).toBe(true);
142+
});
143+
144+
test("non-kimi to kimi rewrites advertised present; switching away restores canonical", () => {
145+
const session = createProductionSwitch();
146+
expect(schemaHasRef(presentSchema(session.advertised()))).toBe(true);
147+
expect(presentSchema(session.advertised())).toBe(presentDefinition.inputSchema);
148+
149+
session.switchTo(MODEL_KIMI);
150+
151+
expect(session.inference()).toEqual(MODEL_KIMI);
152+
expect(schemaHasRef(presentSchema(session.advertised()))).toBe(false);
153+
expect(presentSchema(session.advertised())).not.toBe(presentDefinition.inputSchema);
154+
155+
session.switchTo(MODEL_A);
156+
157+
expect(session.inference()).toEqual(MODEL_A);
158+
expect(schemaHasRef(presentSchema(session.advertised()))).toBe(true);
159+
expect(presentSchema(session.advertised())).toBe(presentDefinition.inputSchema);
160+
});
161+
});

src/session/live-model-switch.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
/**
2+
* One cutover for every model-scoped runtime fact a live session reads.
3+
* `/model` (and tests) must go through this rather than refreshing inference
4+
* alone — a missed step is how provider-model grants and kimi wire schemas
5+
* went stale after a switch.
6+
*/
7+
8+
export interface LiveModelRef {
9+
providerName: string;
10+
model: string;
11+
}
12+
13+
export function providerModelKey(ref: LiveModelRef): string {
14+
return `${ref.providerName}:${ref.model}`;
15+
}
16+
17+
export interface LiveModelSwitchHandles {
18+
/** Session config / live identity that persist getters read. */
19+
applyIdentity: (next: LiveModelRef) => void;
20+
/** Permission-gate matching and mint identity. */
21+
setPermissionIdentity: (providerName: string, model: string) => void;
22+
/** Rebuild inference sources for the next turn. */
23+
rebuildInference: (next: LiveModelRef) => void;
24+
/**
25+
* Re-advertise family-gated tool schemas from canonical definitions.
26+
* Must not re-normalize an already-rewritten advertise set — switching
27+
* away from kimi would then keep the non-recursive present schema.
28+
*/
29+
refreshAdvertisedSchemas: (next: LiveModelRef) => void;
30+
}
31+
32+
export function applyLiveModelSwitch(next: LiveModelRef, handles: LiveModelSwitchHandles): void {
33+
handles.applyIdentity(next);
34+
handles.setPermissionIdentity(next.providerName, next.model);
35+
handles.rebuildInference(next);
36+
handles.refreshAdvertisedSchemas(next);
37+
}

src/session/runtime-assembly.test.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,7 @@ describe("createApprovalPersist", () => {
171171
undefined,
172172
);
173173

174-
const persist = createApprovalPersist("/tmp/proj", "openai:gpt-5");
174+
const persist = createApprovalPersist("/tmp/proj", () => "openai:gpt-5");
175175
const approval = { tool: "run_shell", pattern: "npm *" };
176176

177177
persist(approval, "project");
@@ -187,6 +187,22 @@ describe("createApprovalPersist", () => {
187187
expect(global).toHaveBeenCalledTimes(1);
188188
expect(providerModel).toHaveBeenCalledTimes(1);
189189
});
190+
191+
test("a live identity change stores the next provider-model grant under the new key", () => {
192+
const providerModel = spyOn(permissionStore, "saveProviderModelApproval").mockResolvedValue(
193+
undefined,
194+
);
195+
let identity = "openai:gpt-5";
196+
const persist = createApprovalPersist("/tmp/proj", () => identity);
197+
const approval = { tool: "run_shell", pattern: "npm *" };
198+
199+
persist(approval, "provider-model");
200+
identity = "anthropic:claude-opus";
201+
persist(approval, "provider-model");
202+
203+
expect(providerModel).toHaveBeenNthCalledWith(1, "openai:gpt-5", approval);
204+
expect(providerModel).toHaveBeenNthCalledWith(2, "anthropic:claude-opus", approval);
205+
});
190206
});
191207

192208
describe("skillDirsFromEnabledPlugins", () => {

0 commit comments

Comments
 (0)