Skip to content

Commit 77f752d

Browse files
fix(pi): translate provider ids between canonical config and Pi's form
OpenCode and Pi now share one config, but a few auth-plugin providers are named differently on each harness (the model id after the slash is identical): canonical (OpenCode) Pi openai/<model> openai-codex/<model> google/<model> google-antigravity/<model> A model picked on one harness was unusable on the other: a config holding the canonical google/openai form (e.g. from OpenCode setup or hand-editing) made Pi's historian/dreamer/sidekick fail to resolve the model. Canonical = OpenCode; the shared config always stores the OpenCode form. Pi translates at its two edges via a new shared map (harness-provider-map.ts): - read: resolveModelRefForPi() at the single --model emit in subagent-runner buildArgs() — covers historian/dreamer/sidekick/recomp/fallbacks uniformly since every spawn routes through runner.run(). options.model stays canonical everywhere else (accounting, logging, fallback selection). Idempotent, so a config already in Pi form still works. - write: piModelRefToCanonical() in the Pi setup wizard, so a config written from the Pi side stays OpenCode-readable. OpenCode is untouched (canonical is its own form). Provider-prefix only; anthropic and every other provider pass through unchanged. Gate: Pi 519/0 (+map/buildArgs tests), CLI 207/0, map 8/0, tsc + biome clean. Co-authored-by: Alfonso [Magic Context] <288211368+alfonso-magic-context@users.noreply.github.com>
1 parent 0e1e573 commit 77f752d

5 files changed

Lines changed: 163 additions & 5 deletions

File tree

packages/cli/src/commands/setup-pi.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { existsSync, mkdirSync, readFileSync } from "node:fs";
22
import { dirname } from "node:path";
3+
import { piModelRefToCanonical } from "@magic-context/core/shared/harness-provider-map";
34
import { parse as parseJsonc, stringify as stringifyJsonc } from "comment-json";
45
import { writeFileAtomic } from "../lib/atomic-write";
56
import {
@@ -151,14 +152,17 @@ export function writeMagicContextConfig(
151152
"https://raw.githubusercontent.com/cortexkit/magic-context/master/assets/magic-context.schema.json";
152153
}
153154

155+
// The Pi model picker yields Pi-native provider ids (openai-codex/...,
156+
// google-antigravity/...). The shared config is canonical (OpenCode) form so
157+
// OpenCode can read the same file; normalize before writing.
154158
config.historian = compactObject({
155159
...((config.historian as Record<string, unknown> | undefined) ?? {}),
156-
model: options.historianModel,
160+
model: piModelRefToCanonical(options.historianModel),
157161
thinking_level: options.historianThinkingLevel,
158162
});
159163
const dreamer = {
160164
...((config.dreamer as Record<string, unknown> | undefined) ?? {}),
161-
model: options.dreamerModel,
165+
model: options.dreamerModel ? piModelRefToCanonical(options.dreamerModel) : undefined,
162166
disable: options.dreamerEnabled ? undefined : true,
163167
enabled: undefined,
164168
// Dreamer v2 per-task schedules — only set when the user declined the
@@ -169,7 +173,10 @@ export function writeMagicContextConfig(
169173

170174
const sidekick = {
171175
...((config.sidekick as Record<string, unknown> | undefined) ?? {}),
172-
model: options.sidekickEnabled ? options.sidekickModel : undefined,
176+
model:
177+
options.sidekickEnabled && options.sidekickModel
178+
? piModelRefToCanonical(options.sidekickModel)
179+
: undefined,
173180
disable: options.sidekickEnabled ? undefined : true,
174181
enabled: undefined,
175182
};

packages/pi-plugin/src/subagent-runner.test.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,29 @@ describe("subagent-runner pure helpers", () => {
182182
expect(args.at(-1)).toBe("summarize this session");
183183
});
184184

185+
it("translates the canonical (OpenCode) provider to Pi's form at --model", () => {
186+
// Shared config stores canonical ids; Pi names two auth-plugin providers
187+
// differently. The spawned --model must carry Pi's form.
188+
expect(
189+
__test.buildArgs({ ...baseOptions, model: "openai/gpt-5.5" }),
190+
).toEqual(expect.arrayContaining(["--model", "openai-codex/gpt-5.5"]));
191+
expect(
192+
__test.buildArgs({
193+
...baseOptions,
194+
model: "google/antigravity-gemini-3.5-flash",
195+
}),
196+
).toEqual(
197+
expect.arrayContaining([
198+
"--model",
199+
"google-antigravity/antigravity-gemini-3.5-flash",
200+
]),
201+
);
202+
// Anthropic and other providers pass through unchanged.
203+
expect(
204+
__test.buildArgs({ ...baseOptions, model: "anthropic/claude-opus-4-8" }),
205+
).toEqual(expect.arrayContaining(["--model", "anthropic/claude-opus-4-8"]));
206+
});
207+
185208
it("passes prompt last without a -- sentinel", () => {
186209
const args = __test.buildArgs({
187210
...baseOptions,
@@ -934,8 +957,10 @@ describe("PiSubagentRunner spawn lifecycle", () => {
934957
expect(spawnImpl.mock.calls[0]?.[1]).toEqual(
935958
expect.arrayContaining(["--model", "anthropic/primary"]),
936959
);
960+
// The canonical (OpenCode) `openai/` provider is translated to Pi's
961+
// `openai-codex/` form at the spawn boundary.
937962
expect(spawnImpl.mock.calls[1]?.[1]).toEqual(
938-
expect.arrayContaining(["--model", "openai/fallback"]),
963+
expect.arrayContaining(["--model", "openai-codex/fallback"]),
939964
);
940965
});
941966

packages/pi-plugin/src/subagent-runner.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { fileURLToPath } from "node:url";
77
import { openDatabase } from "@magic-context/core/features/magic-context/storage";
88
import type { SubagentKind } from "@magic-context/core/features/magic-context/storage-subagent-invocations";
99
import { recordChildInvocation } from "@magic-context/core/features/magic-context/subagent-token-capture";
10+
import { resolveModelRefForPi } from "@magic-context/core/shared/harness-provider-map";
1011
import { sessionLog } from "@magic-context/core/shared/logger";
1112
import type {
1213
SubagentProgressEvent,
@@ -1071,7 +1072,13 @@ export function buildArgs(
10711072
// Pi's --models flag scopes the model picker list; it is not an ordered
10721073
// fallback chain. The runner implements fallback by spawning a fresh child
10731074
// per model, so each invocation receives exactly one --model.
1074-
args.push("--model", options.model);
1075+
//
1076+
// The shared config stores the canonical (OpenCode) provider form; Pi
1077+
// names a few auth-plugin providers differently (openai->openai-codex,
1078+
// google->google-antigravity). Translate to Pi's form HERE, at the only
1079+
// point the model reaches the spawned process, so options.model stays
1080+
// canonical everywhere else (accounting, logging, fallback selection).
1081+
args.push("--model", resolveModelRefForPi(options.model));
10751082
}
10761083

10771084
// Pass --thinking <level> only when explicitly configured.
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { describe, expect, it } from "bun:test";
2+
import { piModelRefToCanonical, resolveModelRefForPi } from "./harness-provider-map";
3+
4+
describe("harness-provider-map", () => {
5+
describe("resolveModelRefForPi (canonical -> Pi, used when spawning)", () => {
6+
it("maps the diverging auth-plugin providers, preserving the model id", () => {
7+
expect(resolveModelRefForPi("openai/gpt-5.5")).toBe("openai-codex/gpt-5.5");
8+
expect(resolveModelRefForPi("google/antigravity-gemini-3.5-flash")).toBe(
9+
"google-antigravity/antigravity-gemini-3.5-flash",
10+
);
11+
});
12+
13+
it("leaves anthropic and every other provider unchanged", () => {
14+
expect(resolveModelRefForPi("anthropic/claude-opus-4-8")).toBe(
15+
"anthropic/claude-opus-4-8",
16+
);
17+
expect(resolveModelRefForPi("cerebras/gpt-oss-120b")).toBe("cerebras/gpt-oss-120b");
18+
expect(resolveModelRefForPi("openrouter/openai/gpt-5.5")).toBe(
19+
"openrouter/openai/gpt-5.5",
20+
);
21+
});
22+
23+
it("is idempotent: a config already in Pi form still resolves to Pi form", () => {
24+
expect(resolveModelRefForPi("openai-codex/gpt-5.5")).toBe("openai-codex/gpt-5.5");
25+
expect(resolveModelRefForPi("google-antigravity/antigravity-gemini-3.1-pro")).toBe(
26+
"google-antigravity/antigravity-gemini-3.1-pro",
27+
);
28+
});
29+
30+
it("preserves model ids that themselves contain slashes", () => {
31+
expect(resolveModelRefForPi("openai/some/nested/id")).toBe(
32+
"openai-codex/some/nested/id",
33+
);
34+
});
35+
36+
it("passes through malformed refs (no slash, empty provider) unchanged", () => {
37+
expect(resolveModelRefForPi("gpt-5.5")).toBe("gpt-5.5");
38+
expect(resolveModelRefForPi("/gpt-5.5")).toBe("/gpt-5.5");
39+
expect(resolveModelRefForPi("")).toBe("");
40+
});
41+
});
42+
43+
describe("piModelRefToCanonical (Pi -> canonical, used by Pi setup write)", () => {
44+
it("normalizes Pi-native provider ids to the OpenCode form", () => {
45+
expect(piModelRefToCanonical("openai-codex/gpt-5.5")).toBe("openai/gpt-5.5");
46+
expect(piModelRefToCanonical("google-antigravity/antigravity-gemini-3.5-flash")).toBe(
47+
"google/antigravity-gemini-3.5-flash",
48+
);
49+
});
50+
51+
it("leaves already-canonical and unmapped providers unchanged", () => {
52+
expect(piModelRefToCanonical("anthropic/claude-opus-4-8")).toBe(
53+
"anthropic/claude-opus-4-8",
54+
);
55+
expect(piModelRefToCanonical("openai/gpt-5.5")).toBe("openai/gpt-5.5");
56+
});
57+
58+
it("round-trips with resolveModelRefForPi", () => {
59+
const piForm = "openai-codex/gpt-5.5";
60+
expect(resolveModelRefForPi(piModelRefToCanonical(piForm))).toBe(piForm);
61+
});
62+
});
63+
});
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
/**
2+
* Provider-id translation between the canonical (OpenCode) form stored in the
3+
* shared magic-context config and Pi's harness-native provider ids.
4+
*
5+
* OpenCode and Pi now share ONE config, but a few auth-plugin providers were
6+
* named differently on each side. The model id AFTER the slash is identical;
7+
* only the provider prefix differs:
8+
*
9+
* canonical (OpenCode) Pi
10+
* -------------------- -------------------
11+
* openai/<model> openai-codex/<model>
12+
* google/<model> google-antigravity/<model>
13+
* anthropic/<model> anthropic/<model> (same; every other provider too)
14+
*
15+
* Canonical = OpenCode: the config always stores the OpenCode form. Pi
16+
* translates at its edges:
17+
* - read: canonical -> Pi when spawning a configured model (subagent-runner).
18+
* - write: Pi -> canonical in the Pi setup wizard, so a config written from
19+
* the Pi side stays readable by OpenCode.
20+
*
21+
* OpenCode needs no translation (canonical IS the OpenCode form).
22+
*/
23+
24+
const CANONICAL_TO_PI_PROVIDER: Record<string, string> = {
25+
openai: "openai-codex",
26+
google: "google-antigravity",
27+
};
28+
29+
const PI_TO_CANONICAL_PROVIDER: Record<string, string> = {
30+
"openai-codex": "openai",
31+
"google-antigravity": "google",
32+
};
33+
34+
/** Remap only the provider prefix (text before the first "/"), preserving the
35+
* model id verbatim. No "/", empty provider, or unmapped provider -> unchanged. */
36+
function remapProviderPrefix(ref: string, map: Record<string, string>): string {
37+
if (typeof ref !== "string") return ref;
38+
const slash = ref.indexOf("/");
39+
if (slash <= 0) return ref;
40+
const provider = ref.slice(0, slash);
41+
const mapped = map[provider];
42+
return mapped ? `${mapped}${ref.slice(slash)}` : ref;
43+
}
44+
45+
/** Pi-native `provider/model` -> canonical (OpenCode). Identity when unmapped.
46+
* Used by the Pi setup wizard so configs it writes stay OpenCode-readable. */
47+
export function piModelRefToCanonical(ref: string): string {
48+
return remapProviderPrefix(ref, PI_TO_CANONICAL_PROVIDER);
49+
}
50+
51+
/** Canonical (OpenCode) `provider/model` -> Pi-native, for spawning a model on
52+
* Pi. Idempotent: normalizes any Pi-form prefix back to canonical first, so it
53+
* is safe on a config that already holds Pi-form ids (hand-edited or pre-fix). */
54+
export function resolveModelRefForPi(ref: string): string {
55+
return remapProviderPrefix(piModelRefToCanonical(ref), CANONICAL_TO_PI_PROVIDER);
56+
}

0 commit comments

Comments
 (0)