Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions docs/api/runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -11716,6 +11716,12 @@ Stable, caller-owned cli-bridge session id for harness-side resume. Defaults
Per-resume-turn inference cap before the worker settles on its last output.
Mirrors `routerToolsInlineExecutor.maxTurns`; default 200 (runaway backstop).

##### activityWindow?

> `optional` **activityWindow?**: `number`

Newest-last activity window `progress()` reports. Default 12 (matches `PiSeam`).

***

### ProviderSeam
Expand Down Expand Up @@ -13896,6 +13902,16 @@ False when the call was observed but its original arguments were unavailable.

> `readonly` `optional` **status?**: `"error"` \| `"ok"`

##### statusCaptured?

> `readonly` `optional` **statusCaptured?**: `boolean`

False when the source observed the call being MADE but never observed it finishing — so no
outcome is knowable, not even by default. Some wires (cli-bridge's OpenAI-shaped `tool_calls`
deltas) report the model's DECISION to call a tool and never report the call's result at all.
Without this marker such a call would project as `status: 'ok'` and be counted as a success in
every downstream error-rate read. Set it and the span carries NO status, which is the truth.

##### result?

> `readonly` `optional` **result?**: `unknown`
Expand Down
26 changes: 25 additions & 1 deletion src/runtime/supervise/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1145,6 +1145,21 @@ function killWithGrace(
*/
/** Resolve the bridge wire model for this spawn. Per-create matrix settings win, then the
* canonical profile's harness/model preferences, then the bridge's configured fallback. */
/**
* A profile's selected model with its provider attached, the way a harness addresses one.
*
* Returns undefined when the profile selects no model, and the id unchanged when it is already
* qualified or when the profile names no provider — there, the harness's own provider resolution
* IS the caller's declared intent rather than a gap to fill.
*/
function qualifyProviderModel(model: AgentProfile['model']): string | undefined {
const id = model?.default
if (!id) return undefined
const provider = model?.provider
if (!provider || id.includes('/')) return id
return `${provider}/${id}`
}

function bridgeCellModel(
seamModel: string | undefined,
ctx: ExecutorContext,
Expand All @@ -1156,7 +1171,16 @@ function bridgeCellModel(
const backend = create?.backend
const profileHarness = profile.harness === 'cli-base' ? undefined : profile.harness
const harness = backend?.type ?? profileHarness
const model = backend?.model?.model ?? profile.model?.default
// The PROVIDER rides with the model. A harness addresses a model as `provider/model`, so a wire
// id built from `model.default` alone loses it: `{provider:'tangle-router', default:'glm-5.2'}`
// becomes `pi/glm-5.2`, which routes to the right BACKEND and then hands pi a bare id it cannot
// place. pi falls back to its own default provider and dies with "No API key found for
// <that provider>" — a credential error naming a provider the caller never chose. Measured live;
// the same request with `pi/tangle-router/glm-5.2` returns 200.
//
// A per-cell `backend.model.model` override is left exactly as supplied: it is a caller-authored
// wire id, not a profile hint, and qualifying it would rewrite what the caller asked for.
const model = backend?.model?.model ?? qualifyProviderModel(profile.model)
if (!harness && !model) return seamModel
if (!harness) return model
if (model) return model.startsWith(`${harness}/`) ? model : `${harness}/${model}`
Expand Down
57 changes: 57 additions & 0 deletions tests/runtime/bridge-executor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -709,3 +709,60 @@ function isUsageStream(value: unknown): value is AsyncIterable<UsageEvent> {
typeof (value as { [Symbol.asyncIterator]?: unknown })[Symbol.asyncIterator] === 'function'
)
}

describe('profile-selected model keeps its provider', () => {
// A harness addresses a model as `provider/model`. Building the wire id from `model.default`
// alone dropped the provider: `{provider:'tangle-router', default:'glm-5.2'}` became
// `pi/glm-5.2`, which routes to the right BACKEND and then hands pi a bare id it cannot place.
// pi fell back to its own default provider and died with "No API key found for opencode" — a
// credential error naming a provider nobody chose. Measured live against a real cli-bridge:
// `pi/tangle-router/glm-5.2` returns 200, `pi/glm-5.2` does not.
async function wireModelFor(profile: Record<string, unknown>): Promise<unknown> {
const seen: Array<Record<string, unknown>> = []
bridgeHttpHandler = (payload) => {
seen.push(payload)
return sse('ok', 1, 2)
}
const executor = createExecutor({
backend: 'bridge',
bridgeUrl: 'http://bridge.test',
bridgeBearer: 'secret',
model: 'pi/seam-default',
})({ profile, harness: null } as unknown as AgentSpec, {
signal: new AbortController().signal,
seams: {},
})
const run = executor.execute('go', new AbortController().signal)
if (!isUsageStream(run)) throw new Error('bridge worker must stream usage')
for await (const _event of run) {
// drain
}
return seen[0]?.model
}

it('composes backend/provider/model when the profile names a provider', async () => {
expect(
await wireModelFor({
name: 'w',
harness: 'pi',
model: { provider: 'tangle-router', default: 'glm-5.2' },
}),
).toBe('pi/tangle-router/glm-5.2')
})

it('leaves a model with no declared provider to the harness own resolution', async () => {
expect(await wireModelFor({ name: 'w', harness: 'pi', model: { default: 'glm-5.2' } })).toBe(
'pi/glm-5.2',
)
})

it('does not double-qualify a model that already carries its provider', async () => {
expect(
await wireModelFor({
name: 'w',
harness: 'pi',
model: { provider: 'tangle-router', default: 'tangle-router/glm-5.2' },
}),
).toBe('pi/tangle-router/glm-5.2')
})
})
Loading