Skip to content

Commit a1c64cf

Browse files
committed
guard model context before launch
(cherry picked from commit 14ccef8)
1 parent 286f33e commit a1c64cf

13 files changed

Lines changed: 140 additions & 13 deletions

File tree

apps/daemon/src/integrations/provider-models.ts

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,7 @@ function openAiModelOption(item: unknown): ProviderModelOption | null {
141141
const obj = item as { id?: unknown; metadata?: unknown };
142142
const id = typeof obj?.id === 'string' ? obj.id : '';
143143
if (!id || !isOpenAiChatModelId(id)) return null;
144-
const metadata = extractModelMetadata(obj.metadata);
144+
const metadata = extractModelMetadata(obj);
145145
return {
146146
id,
147147
label: id,
@@ -151,16 +151,38 @@ function openAiModelOption(item: unknown): ProviderModelOption | null {
151151

152152
function extractModelMetadata(value: unknown): ModelMetadata | null {
153153
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
154-
const metadata = value as { cost?: unknown; capability?: unknown };
154+
const record = value as Record<string, unknown>;
155+
const metadata = (
156+
record.metadata && typeof record.metadata === 'object' && !Array.isArray(record.metadata)
157+
? record.metadata
158+
: record
159+
) as Record<string, unknown>;
155160
const cost = parseModelCost(metadata.cost);
156161
const capability = parseModelCapability(metadata.capability);
157-
if (!cost && !capability) return null;
162+
const contextWindowTokens = positiveInteger(
163+
metadata.contextWindowTokens ??
164+
metadata.context_window_tokens ??
165+
record.context_length ??
166+
record.contextWindow,
167+
);
168+
const maxOutputTokens = positiveInteger(
169+
metadata.maxOutputTokens ?? metadata.max_output_tokens ?? record.max_output_tokens,
170+
);
171+
if (!cost && !capability && !contextWindowTokens && !maxOutputTokens) return null;
158172
return {
159173
...(cost ? { cost } : {}),
160174
...(capability ? { capability } : {}),
175+
...(contextWindowTokens ? { contextWindowTokens } : {}),
176+
...(maxOutputTokens ? { maxOutputTokens } : {}),
161177
};
162178
}
163179

180+
function positiveInteger(value: unknown): number | null {
181+
return typeof value === 'number' && Number.isInteger(value) && value > 0
182+
? value
183+
: null;
184+
}
185+
164186
function parseModelCost(value: unknown): ModelCost | null {
165187
return value === 'low' ||
166188
value === 'medium' ||

apps/daemon/src/langfuse-bridge.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1100,6 +1100,7 @@ export async function reportRunCompletedFromDaemon(
11001100
...(stderr ? { stderr } : {}),
11011101
...(stdout ? { stdout } : {}),
11021102
diagnostics,
1103+
...(run.contextBudget ? { contextBudget: run.contextBudget } : {}),
11031104
},
11041105
message: {
11051106
messageId: run.assistantMessageId ?? '',

apps/daemon/src/runtimes/defs/amr.ts

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -198,13 +198,45 @@ function extractModelMetadata(item: unknown): ModelMetadata | null {
198198
const metadata = isRecord(item.metadata) ? item.metadata : item;
199199
const cost = parseModelCost(metadata.cost);
200200
const capability = parseModelCapability(metadata.capability);
201-
if (!cost && !capability) return null;
201+
const contextWindowTokens = extractPositiveModelLimit(item, [
202+
'contextWindowTokens',
203+
'context_window_tokens',
204+
'context_length',
205+
'contextLength',
206+
'context',
207+
]);
208+
const maxOutputTokens = extractPositiveModelLimit(item, [
209+
'maxOutputTokens',
210+
'max_output_tokens',
211+
'output',
212+
]);
213+
if (!cost && !capability && !contextWindowTokens && !maxOutputTokens) return null;
202214
return {
203215
...(cost ? { cost } : {}),
204216
...(capability ? { capability } : {}),
217+
...(contextWindowTokens ? { contextWindowTokens } : {}),
218+
...(maxOutputTokens ? { maxOutputTokens } : {}),
205219
};
206220
}
207221

222+
function extractPositiveModelLimit(
223+
item: Record<string, unknown>,
224+
keys: string[],
225+
): number | null {
226+
const metadata = isRecord(item.metadata) ? item.metadata : null;
227+
const limit = isRecord(item.limit) ? item.limit : null;
228+
for (const source of [metadata, limit, item]) {
229+
if (!source) continue;
230+
for (const key of keys) {
231+
const value = source[key];
232+
if (typeof value === 'number' && Number.isInteger(value) && value > 0) {
233+
return value;
234+
}
235+
}
236+
}
237+
return null;
238+
}
239+
208240
function withPriceDerivedCostMetadata(
209241
metadata: ModelMetadata | null,
210242
inputPriceUsdPerMillion: number | undefined,
@@ -462,14 +494,20 @@ function openCodeModelPrice(
462494
> | null {
463495
if (!isRecord(model)) return null;
464496
const inputPriceUsdPerMillion = extractInputPriceUsdPerMillion(model);
465-
if (inputPriceUsdPerMillion === undefined) return null;
466497
const outputPriceUsdPerMillion = extractOutputPriceUsdPerMillion(model);
467498
const metadata = withPriceDerivedCostMetadata(
468499
extractModelMetadata(model),
469500
inputPriceUsdPerMillion,
470501
);
502+
if (
503+
inputPriceUsdPerMillion === undefined &&
504+
outputPriceUsdPerMillion === undefined &&
505+
metadata === null
506+
) {
507+
return null;
508+
}
471509
return {
472-
inputPriceUsdPerMillion,
510+
...(inputPriceUsdPerMillion === undefined ? {} : { inputPriceUsdPerMillion }),
473511
...(outputPriceUsdPerMillion === undefined ? {} : { outputPriceUsdPerMillion }),
474512
...(metadata === null ? {} : { metadata }),
475513
};

apps/daemon/src/runtimes/model-context-budget.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,6 @@ export function compactTranscriptForSessionRollover(
132132
omittedMessageBlocks,
133133
};
134134
}
135-
136135
export function evaluateModelContextBudget({
137136
prompt,
138137
modelId,

apps/daemon/src/runtimes/models.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,19 @@ export function getRememberedLiveModels(agentId: string, scope?: string | null):
4545
return liveModelOrder.get(liveModelCacheKey(agentId, scope)) ?? [];
4646
}
4747

48+
export function getKnownModelOption(
49+
def: RuntimeAgentDef,
50+
modelId: string | null | undefined,
51+
scope?: string | null,
52+
): RuntimeModelOption | null {
53+
if (!modelId) return null;
54+
const live = getRememberedLiveModels(def.id, scope).find(
55+
(model) => model.id === modelId,
56+
);
57+
if (live) return live;
58+
return def.fallbackModels.find((model) => model.id === modelId) ?? null;
59+
}
60+
4861
export function preferFreshLiveModels(
4962
freshModels: RuntimeModelOption[],
5063
rememberedModels: RuntimeModelOption[],

apps/daemon/src/server.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,7 @@ import {
192192
spawnEnvForAgent,
193193
} from './agents.js';
194194
import {
195+
getKnownModelOption,
195196
getRememberedLiveModels,
196197
preferFreshLiveModels,
197198
rememberLiveModels,

apps/daemon/tests/amr-acp-integration.test.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -226,7 +226,12 @@ describe('AMR runtime def', () => {
226226
enabled: true,
227227
default: true,
228228
cost: { input: 0.14, output: 0.28 },
229-
metadata: { cost: 'low', capability: 'standard' },
229+
metadata: {
230+
cost: 'low',
231+
capability: 'standard',
232+
context_window_tokens: 204800,
233+
max_output_tokens: 32768,
234+
},
230235
},
231236
{ id: 'gpt-image-2' },
232237
{ id: 'deepseek-v4-flash' },
@@ -240,7 +245,12 @@ describe('AMR runtime def', () => {
240245
default: true,
241246
inputPriceUsdPerMillion: 0.14,
242247
outputPriceUsdPerMillion: 0.28,
243-
metadata: { cost: 'low', capability: 'standard' },
248+
metadata: {
249+
cost: 'low',
250+
capability: 'standard',
251+
contextWindowTokens: 204800,
252+
maxOutputTokens: 32768,
253+
},
244254
},
245255
{ id: 'deepseek-v3.2', label: 'deepseek-v3.2' },
246256
{ id: 'kimi-k2.7-code', label: 'kimi-k2.7-code', enabled: false },

apps/daemon/tests/runtimes/model-context-budget.test.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,6 @@ describe('model context budget', () => {
5555
error: { code: 'AGENT_PROMPT_TOO_LARGE' },
5656
});
5757
});
58-
5958
it('uses provider metadata and preserves output plus safety headroom', () => {
6059
const decision = evaluateModelContextBudget({
6160
prompt: 'short prompt',

apps/daemon/tests/runtimes/run-failure-telemetry-smoke.test.ts

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,20 @@ describe('run failure telemetry smoke', () => {
131131
expectedDetail: 'prompt_too_large',
132132
expectedDiagnosticSource: 'error_event',
133133
expectStderr: false,
134-
message: `od-failure-smoke-context ${'large-context '.repeat(4000)}`,
134+
message: `od-failure-smoke-context ${'large-context '.repeat(10_000)}`,
135+
},
136+
{
137+
id: 'model_context_budget',
138+
agentId: 'claude',
139+
config: { agentCliEnv: { claude: { CLAUDE_BIN: path.join(binDir, 'claude-auth') } } },
140+
model: 'claude-sonnet-4-5',
141+
expectedCode: 'AGENT_PROMPT_TOO_LARGE',
142+
expectedCategory: 'prompt_too_large',
143+
expectedDetail: 'prompt_too_large',
144+
expectedDiagnosticSource: 'error_event',
145+
expectedContextBudgetAction: 'blocked',
146+
expectStderr: false,
147+
message: `od-failure-smoke-model-context ${'x'.repeat(650_000)}`,
135148
},
136149
{
137150
id: 'hang_timeout',
@@ -151,6 +164,7 @@ describe('run failure telemetry smoke', () => {
151164
caseId: item.id,
152165
agentId: item.agentId,
153166
message: 'message' in item ? item.message : `od-failure-smoke-${item.id}`,
167+
...('model' in item ? { model: item.model } : {}),
154168
});
155169
const events = await readRunEvents(run.eventsLogPath);
156170
const errorCode = deriveRunErrorCode(run);
@@ -167,20 +181,33 @@ describe('run failure telemetry smoke', () => {
167181
signal: run.signal,
168182
});
169183

170-
expect(run.status).toBe('failed');
184+
expect(run.status, item.id).toBe('failed');
171185
expect('expectedCodes' in item ? item.expectedCodes : [item.expectedCode])
172186
.toContain(errorCode);
173187
expect(failure?.failure_category).toBe(item.expectedCategory);
174188
expect(failure?.failure_detail).toBe(item.expectedDetail);
175189
expect(diagnostics.diagnostic_source).toBe(item.expectedDiagnosticSource);
176190
expect(diagnostics.stderr_present).toBe(item.expectStderr);
191+
if ('expectedContextBudgetAction' in item) {
192+
expect(events).toContainEqual(expect.objectContaining({
193+
event: 'diagnostic',
194+
data: expect.objectContaining({
195+
type: 'model_context_budget',
196+
action: item.expectedContextBudgetAction,
197+
}),
198+
}));
199+
}
177200

178201
await finalizeAssistantMessage(started.url, run);
179202
const trace = await ingestion.waitForTrace(run.id);
180203
expect('expectedCodes' in item ? item.expectedCodes : [item.expectedCode])
181204
.toContain(trace.body.metadata.error_code);
182205
expect(trace.body.metadata.failure_category).toBe(item.expectedCategory);
183206
expect(trace.body.metadata.failure_detail).toBe(item.expectedDetail);
207+
if ('expectedContextBudgetAction' in item) {
208+
expect(trace.body.metadata.contextBudgetAction).toBe(item.expectedContextBudgetAction);
209+
expect(trace.body.metadata.contextWindowTokens).toBe(204_800);
210+
}
184211
if (item.expectStderr) {
185212
expect(trace.body.metadata.stderr.lineCount).toBeGreaterThan(0);
186213
} else {
@@ -479,6 +506,7 @@ async function createAndWaitForRun(url: string, input: {
479506
caseId: string;
480507
agentId: string;
481508
message: string;
509+
model?: string;
482510
}): Promise<RunStatus> {
483511
const projectId = `failure_smoke_${input.caseId}_${randomUUID()}`;
484512
const projectResponse = await fetch(`${url}/api/projects`, {
@@ -503,6 +531,7 @@ async function createAndWaitForRun(url: string, input: {
503531
assistantMessageId,
504532
clientRequestId: `client_${input.caseId}_${randomUUID()}`,
505533
agentId: input.agentId,
534+
...(input.model ? { model: input.model } : {}),
506535
message: input.message,
507536
currentPrompt: input.message,
508537
}),

apps/web/src/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -531,6 +531,8 @@ export type ModelCapability = 'standard' | 'advanced' | 'best_quality';
531531
export interface ModelMetadata {
532532
cost?: ModelCost;
533533
capability?: ModelCapability;
534+
contextWindowTokens?: number;
535+
maxOutputTokens?: number;
534536
}
535537

536538
export interface AgentModelOption {

0 commit comments

Comments
 (0)