Skip to content
This repository was archived by the owner on Aug 19, 2026. It is now read-only.

Commit 3f000fe

Browse files
authored
Merge pull request #19 from block/codex/repair-summon-ghost-fingerprint
[codex] Add UI sandbox model providers
2 parents df409db + 479c2b9 commit 3f000fe

13 files changed

Lines changed: 1146 additions & 174 deletions

README.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ The public package boundary is:
3333
```sh
3434
pnpm install
3535
cp apps/server/.env.example apps/server/.env
36-
# edit apps/server/.env and set ANTHROPIC_API_KEY
36+
# edit apps/server/.env and set ANTHROPIC_API_KEY, OPENAI_API_KEY, or GEMINI_API_KEY
3737
pnpm dev:gallery
3838
```
3939

@@ -114,8 +114,8 @@ registered host tools.
114114
`packages/sandbox-runtime`, `packages/server`, `packages/react` - private
115115
implementation workspaces published only through the public facades.
116116
- `examples/surface-gallery` - first-run live example app for OSS adopters.
117-
- `apps/server` - Anthropic-backed demo server, direction loading, validation
118-
retry feedback, and demo backing routes.
117+
- `apps/server` - multi-provider demo server for Anthropic, OpenAI, and Gemini,
118+
direction loading, validation retry feedback, and demo backing routes.
119119
- `apps/demo` - Vite maintainer workbench for generation, batch runs,
120120
adversarial checks, strict input, Ghost steering, diagnostics, and fatal
121121
sandbox testing.
@@ -169,4 +169,4 @@ pnpm eval-directions [--prompts N] [--directions id,id] [--seed N] [--dry]
169169
`pnpm test:safety` runs the Playwright Chromium and WebKit smoke suite for
170170
sandbox containment, bootstrap fatal checks, strict input, and generate-page
171171
boot. It starts only the Vite demo app and does not require
172-
`ANTHROPIC_API_KEY`.
172+
a model-provider API key.

apps/demo/generate.html

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,10 @@ <h2 id="scenario-active-title">Host Data Search</h2>
9292

9393
<section class="run-settings" aria-label="Run settings">
9494
<div class="settings-grid">
95+
<label>
96+
<span class="field-label">Provider</span>
97+
<select id="model-provider" class="pill-select" title="Model provider"></select>
98+
</label>
9599
<label>
96100
<span class="field-label">Direction</span>
97101
<select id="direction" class="pill-select" title="Design direction"></select>

apps/demo/src/capabilities.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,9 +56,14 @@ const summonArgsSchema = z.object({
5656
type SearchResult = z.infer<typeof searchResultSchema>;
5757
type SummonArgs = z.infer<typeof summonArgsSchema>;
5858

59+
function providerPayload(modelProvider: string | null | undefined): { modelProvider?: string } {
60+
return modelProvider ? { modelProvider } : {};
61+
}
62+
5963
export interface DemoHandlerOptions {
6064
onLog?: (message: string) => void;
6165
onError?: (message: string) => void;
66+
modelProvider?: () => string | null;
6267
/**
6368
* Optional because only the single-prompt generate page owns the DOM and
6469
* streaming machinery needed to spawn sibling sandboxes.
@@ -220,7 +225,10 @@ export function createDemoCapabilityRegistry(
220225
const res = await fetch('/api/mock-search', {
221226
method: 'POST',
222227
headers: { 'Content-Type': 'application/json' },
223-
body: JSON.stringify({ query }),
228+
body: JSON.stringify({
229+
query,
230+
...providerPayload(opts.modelProvider?.()),
231+
}),
224232
signal,
225233
});
226234
if (!res.ok) throw new Error(`HTTP ${res.status}`);
@@ -265,7 +273,10 @@ export function createDemoCapabilityRegistry(
265273
const res = await fetch('/api/ai-call', {
266274
method: 'POST',
267275
headers: { 'Content-Type': 'application/json' },
268-
body: JSON.stringify({ prompt }),
276+
body: JSON.stringify({
277+
prompt,
278+
...providerPayload(opts.modelProvider?.()),
279+
}),
269280
signal,
270281
});
271282
if (!res.ok) throw new Error(`HTTP ${res.status}`);

apps/demo/src/generate-main.ts

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,15 @@ interface GhostRootInfo {
6767
defaultBaseDirectionId?: string | null;
6868
}
6969

70+
interface ModelProviderInfo {
71+
id: string;
72+
name: string;
73+
configured: boolean;
74+
model: string;
75+
utilityModel: string;
76+
missingEnv?: string;
77+
}
78+
7079
const layoutPresets = new Map<string, SummonLayout>([
7180
[
7281
'card-structured',
@@ -92,6 +101,7 @@ const scenarioActiveTitleEl = document.getElementById('scenario-active-title')!;
92101
const scenarioActiveDescEl = document.getElementById('scenario-active-desc')!;
93102
const scenarioActiveFingerprintEl = document.getElementById('scenario-active-fingerprint')!;
94103
const scenarioActiveGrantsEl = document.getElementById('scenario-active-grants')!;
104+
const modelProviderSel = document.getElementById('model-provider') as HTMLSelectElement;
95105
const directionSel = document.getElementById('direction') as HTMLSelectElement;
96106
const ghostTargetEl = document.getElementById('ghost-target') as HTMLInputElement;
97107
const ghostBaseDirectionSel = document.getElementById('ghost-base-direction') as HTMLSelectElement;
@@ -147,6 +157,9 @@ function readMode(): Mode {
147157
const checked = document.querySelector<HTMLInputElement>('input[name=mode]:checked');
148158
return (checked?.value as Mode) ?? 'static';
149159
}
160+
function readModelProviderId(): string | null {
161+
return modelProviderSel.value || defaultModelProviderId;
162+
}
150163
function readLayout(): SummonLayout | null {
151164
const layout = layoutPresets.get(layoutSel.value);
152165
return layout ? { id: layout.id, slots: layout.slots.map((slot) => ({ ...slot })) } : null;
@@ -169,6 +182,8 @@ function logLine(cls: string, text: string) {
169182

170183
let directions: DirectionInfo[] = [];
171184
let ghostRoots: GhostRootInfo[] = [];
185+
let modelProviders: ModelProviderInfo[] = [];
186+
let defaultModelProviderId: string | null = null;
172187
let showcaseScenarios: ShowcaseScenario[] = [...SHOWCASE_SCENARIOS];
173188
let currentEffectiveSurfacePlan: SurfacePlan | null = null;
174189
let currentShape: string | null = null;
@@ -539,6 +554,7 @@ function readActiveContract(): ActiveContract {
539554
...(readTokenOverrides() ? { tokenOverrides: readTokenOverrides() } : {}),
540555
...(readRepairOptions() ? { repair: readRepairOptions() } : {}),
541556
directionId: currentDirectionId,
557+
modelProvider: readModelProviderId(),
542558
};
543559
}
544560

@@ -604,9 +620,11 @@ function renderContractSummary() {
604620
const validation = currentValidationSummary ?? 'pending';
605621
const stream = currentStreamHealth ?? 'pending';
606622
const effective = currentEffectiveSurfacePlan ? planText(currentEffectiveSurfacePlan) : 'pending';
623+
const provider = modelProviders.find((item) => item.id === active.modelProvider);
607624
inspectorStatusEl.textContent = currentEffectiveSurfacePlan ? 'effective' : 'pending';
608625
contractSummaryEl.innerHTML = '';
609626
const rows = [
627+
['provider', 'Model provider', provider ? `${provider.name} · ${provider.model}` : 'server default', provider ? 'neutral' : 'pending'],
610628
['requested', 'Requested surface config', planText(requested), 'neutral'],
611629
['effective', 'Effective safety plan', effective, currentEffectiveSurfacePlan ? 'good' : 'pending'],
612630
['grants', 'Allowed host tools', `${active.capabilityNames.length}: ${hostTools}`, active.capabilityNames.length ? 'neutral' : 'pending'],
@@ -697,6 +715,38 @@ function parseAppliedTokenOverrides(value: unknown): Array<{ token: string; valu
697715
}
698716

699717
async function loadDirections(): Promise<void> {
718+
try {
719+
const res = await fetch('/api/model-providers');
720+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
721+
const payload = (await res.json()) as { defaultProvider?: unknown; providers?: unknown };
722+
defaultModelProviderId = typeof payload.defaultProvider === 'string' ? payload.defaultProvider : null;
723+
modelProviders = Array.isArray(payload.providers)
724+
? payload.providers.flatMap((provider): ModelProviderInfo[] => {
725+
if (!provider || typeof provider !== 'object') return [];
726+
const item = provider as Record<string, unknown>;
727+
if (
728+
typeof item.id !== 'string' ||
729+
typeof item.name !== 'string' ||
730+
typeof item.model !== 'string' ||
731+
typeof item.utilityModel !== 'string'
732+
) {
733+
return [];
734+
}
735+
return [{
736+
id: item.id,
737+
name: item.name,
738+
configured: item.configured === true,
739+
model: item.model,
740+
utilityModel: item.utilityModel,
741+
missingEnv: typeof item.missingEnv === 'string' ? item.missingEnv : undefined,
742+
}];
743+
})
744+
: [];
745+
} catch {
746+
modelProviders = [];
747+
defaultModelProviderId = null;
748+
}
749+
700750
try {
701751
const res = await fetch('/api/directions');
702752
if (!res.ok) throw new Error(`HTTP ${res.status}`);
@@ -711,6 +761,7 @@ async function loadDirections(): Promise<void> {
711761
} catch {
712762
ghostRoots = [];
713763
}
764+
populateModelProviderSelect();
714765
ghostBaseDirectionSel.innerHTML = '';
715766
for (const d of directions) {
716767
const opt = document.createElement('option');
@@ -754,6 +805,38 @@ async function loadDirections(): Promise<void> {
754805
updateGhostControls();
755806
}
756807

808+
function populateModelProviderSelect() {
809+
modelProviderSel.innerHTML = '';
810+
if (modelProviders.length === 0) {
811+
const opt = document.createElement('option');
812+
opt.value = '';
813+
opt.textContent = 'Server default';
814+
modelProviderSel.appendChild(opt);
815+
modelProviderSel.disabled = true;
816+
return;
817+
}
818+
819+
modelProviderSel.disabled = false;
820+
for (const provider of modelProviders) {
821+
const opt = document.createElement('option');
822+
opt.value = provider.id;
823+
opt.textContent = provider.configured
824+
? `${provider.name}`
825+
: `${provider.name} (missing key)`;
826+
opt.title = provider.configured
827+
? `${provider.model} for generation; ${provider.utilityModel} for utility calls`
828+
: `Set ${provider.missingEnv ?? 'the provider API key'}`;
829+
opt.disabled = !provider.configured;
830+
modelProviderSel.appendChild(opt);
831+
}
832+
833+
const defaultProvider = defaultModelProviderId
834+
? modelProviders.find((provider) => provider.id === defaultModelProviderId && provider.configured)
835+
: null;
836+
const firstConfigured = modelProviders.find((provider) => provider.configured);
837+
modelProviderSel.value = defaultProvider?.id ?? firstConfigured?.id ?? '';
838+
}
839+
757840
function ghostSelectionValue(rootId: string): string {
758841
return `ghost:${rootId}`;
759842
}
@@ -856,6 +939,7 @@ function respawn(
856939

857940
if (mode === 'interactive') {
858941
const registry = createScopedDemoRegistry({
942+
modelProvider: readModelProviderId,
859943
onLog: (m) => logLine('op-add', m),
860944
onError: (m) => logLine('op-error', m),
861945
// summon needs DOM access (spawns a sibling iframe) and the streaming
@@ -940,6 +1024,11 @@ directionSel.addEventListener('change', () => {
9401024
logLine('op-meta', `direction → ${currentDirectionId ?? 'default'}`);
9411025
});
9421026

1027+
modelProviderSel.addEventListener('change', () => {
1028+
clearEffectiveContractSummary();
1029+
logLine('op-meta', `provider → ${readModelProviderId() ?? 'server default'}`);
1030+
});
1031+
9431032
ghostTargetEl.addEventListener('change', () => {
9441033
const root = ghostRootFromSelection(currentDirectionId);
9451034
if (!root) return;
@@ -1221,6 +1310,7 @@ function applyLineTo(target: SandboxTarget, line: ProtocolLine, context: Surface
12211310

12221311
interface StreamOptions {
12231312
prompt: string;
1313+
modelProvider?: string | null;
12241314
directionId: string | null;
12251315
layout?: SummonLayout | null;
12261316
scriptPolicy?: ScriptPolicy;
@@ -1258,6 +1348,7 @@ async function streamGenerationInto(target: SandboxTarget, opts: StreamOptions):
12581348
headers: { 'Content-Type': 'application/json' },
12591349
body: JSON.stringify({
12601350
prompt: opts.prompt,
1351+
...(opts.modelProvider ? { modelProvider: opts.modelProvider } : {}),
12611352
...(ghostRootId
12621353
? {
12631354
ghost: {
@@ -1609,6 +1700,7 @@ async function generate(prompt: string) {
16091700
try {
16101701
const result = await streamGenerationInto(target, {
16111702
prompt,
1703+
modelProvider: active.modelProvider,
16121704
directionId: currentDirectionId,
16131705
layout: readLayout(),
16141706
scriptPolicy: active.scriptPolicy,
@@ -1668,6 +1760,7 @@ async function editArtifact(instruction: string) {
16681760
try {
16691761
const result = await streamGenerationInto(createParentTarget(active), {
16701762
prompt: instruction,
1763+
modelProvider: active.modelProvider,
16711764
directionId: currentDirectionId,
16721765
layout: readLayout(),
16731766
scriptPolicy: active.scriptPolicy,
@@ -1753,6 +1846,7 @@ function summonChild(childPrompt: string, title?: string) {
17531846
.map((intent) => intent.name)
17541847
.filter((name) => name !== 'summon');
17551848
const childRegistry = createScopedDemoRegistry({
1849+
modelProvider: readModelProviderId,
17561850
onLog: () => {},
17571851
onError: (m) => {
17581852
statusEl.textContent = `error: ${m.slice(0, 40)}`;
@@ -1834,6 +1928,7 @@ function summonChild(childPrompt: string, title?: string) {
18341928

18351929
void streamGenerationInto(childTarget, {
18361930
prompt: childPrompt,
1931+
modelProvider: readModelProviderId(),
18371932
directionId: currentDirectionId,
18381933
signal: abort.signal,
18391934
})

apps/demo/src/showcase.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ export interface ActiveContract {
4444
tokenOverrides?: Record<string, string>;
4545
repair?: RepairOptions;
4646
directionId?: string | null;
47+
modelProvider?: string | null;
4748
}
4849

4950
export const SHOWCASE_SCENARIOS: ShowcaseScenario[] = [

apps/server/.env.example

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,19 @@
1+
# Pick one configured provider for server-default generation.
2+
# The Generate workbench can override this per run when multiple keys are set.
3+
SUMMON_MODEL_PROVIDER=anthropic
4+
15
ANTHROPIC_API_KEY=sk-ant-...
6+
# OPENAI_API_KEY=sk-...
7+
# GEMINI_API_KEY=...
8+
9+
# Optional model overrides.
10+
# ANTHROPIC_MODEL=claude-sonnet-4-6
11+
# ANTHROPIC_SMALL_MODEL=claude-haiku-4-5
12+
# OPENAI_MODEL=gpt-5
13+
# OPENAI_SMALL_MODEL=gpt-5-mini
14+
# GEMINI_MODEL=gemini-2.5-pro
15+
# GEMINI_SMALL_MODEL=gemini-2.5-flash
16+
217
PORT=3001
318

419
# Optional Ghost steering roots for /generate.html.
@@ -7,12 +22,12 @@ PORT=3001
722
# Legacy .ghost/fingerprint.yml roots are still bridged for compatibility.
823
# SUMMON_GHOST_ROOTS=checkout=/absolute/path/to/checkout
924

10-
# Layer 3 — Claude Haiku capability inference. When set to "1", a small Haiku
25+
# Layer 3 — utility-model capability inference. When set to "1", a small model
1126
# call decides mode + narrows the intent pack per prompt. Falls back to the
1227
# regex heuristic on timeout/error. Leave unset to use regex only.
1328
# SUMMON_INFER_CAPABILITIES=1
1429

15-
# Shape classifier. Default-on — a Haiku call picks one of article / card /
30+
# Shape classifier. Default-on — a utility-model call picks one of article / card /
1631
# comparison / tracker per prompt so only the matching shape exemplar ships
1732
# in the system prompt. Set to "0" to disable (all shape exemplars ship).
1833
# SUMMON_INFER_SHAPE=0

0 commit comments

Comments
 (0)