Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
14ccef8
guard model context before launch
Siri-Ray Jul 17, 2026
cf95984
fix(daemon): recognize dotted Claude model IDs
Siri-Ray Jul 21, 2026
4c9682f
fix(daemon): budget Claude model aliases
Siri-Ray Jul 21, 2026
394cbad
fix(daemon): enrich AMR limits with live prices
Siri-Ray Jul 21, 2026
43b6403
roll over near-limit agent sessions (#5816)
Siri-Ray Jul 21, 2026
0723666
fix(daemon): close context rollover budget gaps
Siri-Ray Jul 21, 2026
e47bab4
fix(daemon): budget rollover transcript against prompt headroom
Siri-Ray Jul 21, 2026
db41a28
fix(daemon): merge main into model context budget
Siri-Ray Jul 22, 2026
c36dd47
fix(daemon): preserve context budget smoke coverage after main merge
Siri-Ray Jul 22, 2026
feb4cc3
test(web): synchronize question form loading
Siri-Ray Jul 22, 2026
3f9d14d
merge: resolve model context budget conflict with main
Siri-Ray Jul 24, 2026
336125d
merge: resolve context budget conflict with Codex preflight
Siri-Ray Jul 24, 2026
a81037b
merge: stabilize rollover validation with current main
Siri-Ray Jul 29, 2026
ca60238
test(web): stabilize privacy consent click
Siri-Ray Jul 29, 2026
ed8ef95
merge: reconcile context budgeting with current main
Siri-Ray Jul 30, 2026
35b8497
merge: update model-context-budget branch with main
Siri-Ray Aug 6, 2026
1ca4dfe
merge: reconcile context budget analytics with main
Siri-Ray Aug 10, 2026
13d780b
merge: preserve context budget fields with run terminal telemetry
Siri-Ray Aug 10, 2026
a321de7
merge: reconcile context budget and publish analytics
Siri-Ray Aug 11, 2026
11d8104
merge: reconcile context rollover with current main
Siri-Ray Aug 11, 2026
2133fec
merge: reconcile context telemetry with append-only persistence
Siri-Ray Aug 17, 2026
4583df9
test(daemon): extend rollover test timeout
Siri-Ray Aug 18, 2026
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
28 changes: 25 additions & 3 deletions apps/daemon/src/integrations/provider-models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ function openAiModelOption(item: unknown): ProviderModelOption | null {
const obj = item as { id?: unknown; metadata?: unknown };
const id = typeof obj?.id === 'string' ? obj.id : '';
if (!id || !isOpenAiChatModelId(id)) return null;
const metadata = extractModelMetadata(obj.metadata);
const metadata = extractModelMetadata(obj);
return {
id,
label: id,
Expand All @@ -151,16 +151,38 @@ function openAiModelOption(item: unknown): ProviderModelOption | null {

function extractModelMetadata(value: unknown): ModelMetadata | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
const metadata = value as { cost?: unknown; capability?: unknown };
const record = value as Record<string, unknown>;
const metadata = (
record.metadata && typeof record.metadata === 'object' && !Array.isArray(record.metadata)
? record.metadata
: record
) as Record<string, unknown>;
const cost = parseModelCost(metadata.cost);
const capability = parseModelCapability(metadata.capability);
if (!cost && !capability) return null;
const contextWindowTokens = positiveInteger(
metadata.contextWindowTokens ??
metadata.context_window_tokens ??
record.context_length ??
record.contextWindow,
);
const maxOutputTokens = positiveInteger(
metadata.maxOutputTokens ?? metadata.max_output_tokens ?? record.max_output_tokens,
);
if (!cost && !capability && !contextWindowTokens && !maxOutputTokens) return null;
return {
...(cost ? { cost } : {}),
...(capability ? { capability } : {}),
...(contextWindowTokens ? { contextWindowTokens } : {}),
...(maxOutputTokens ? { maxOutputTokens } : {}),
};
}

function positiveInteger(value: unknown): number | null {
return typeof value === 'number' && Number.isInteger(value) && value > 0
? value
: null;
}

function parseModelCost(value: unknown): ModelCost | null {
return value === 'low' ||
value === 'medium' ||
Expand Down
10 changes: 10 additions & 0 deletions apps/daemon/src/langfuse-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,15 @@ interface DaemonRunRecord {
promptTelemetry?: PromptStackTelemetry;
projectAttachmentPaths?: string[];
projectMetadata?: Record<string, unknown> | null;
contextBudget?: {
action: string;
source: string;
estimatedPromptTokens: number;
contextWindowTokens?: number;
reservedOutputTokens?: number;
inputBudgetTokens?: number;
budgetRatio?: number;
};
}

interface TraceSafeManifestResult {
Expand Down Expand Up @@ -1077,6 +1086,7 @@ export async function reportRunCompletedFromDaemon(
...(stderr ? { stderr } : {}),
...(stdout ? { stdout } : {}),
diagnostics,
...(run.contextBudget ? { contextBudget: run.contextBudget } : {}),
},
message: {
messageId: run.assistantMessageId ?? '',
Expand Down
16 changes: 16 additions & 0 deletions apps/daemon/src/langfuse-trace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,15 @@ export interface RunSummary {
truncated: boolean;
};
diagnostics?: unknown;
contextBudget?: {
action: string;
source: string;
estimatedPromptTokens: number;
contextWindowTokens?: number;
reservedOutputTokens?: number;
inputBudgetTokens?: number;
budgetRatio?: number;
};
}

export interface MessageSummary {
Expand Down Expand Up @@ -1373,6 +1382,13 @@ export function buildTracePayload(ctx: ReportContext): unknown[] {
stderr: ctx.run.stderr,
stdout: ctx.run.stdout,
diagnostics: ctx.run.diagnostics,
contextBudgetAction: ctx.run.contextBudget?.action,
contextBudgetSource: ctx.run.contextBudget?.source,
estimatedPromptTokens: ctx.run.contextBudget?.estimatedPromptTokens,
contextWindowTokens: ctx.run.contextBudget?.contextWindowTokens,
reservedOutputTokens: ctx.run.contextBudget?.reservedOutputTokens,
inputBudgetTokens: ctx.run.contextBudget?.inputBudgetTokens,
contextBudgetRatio: ctx.run.contextBudget?.budgetRatio,
eventsSummary: ctx.eventsSummary,
tokens,
cost_usd: costBreakdown.cost_usd,
Expand Down
27 changes: 27 additions & 0 deletions apps/daemon/src/routes/runs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,16 @@ interface ChatRun {
retryAttemptCount?: number;
retryFinalResult?: string;
retrySuppressedReason?: string;
contextBudget?: {
action: 'unmeasured' | 'within_budget' | 'blocked' | 'rollover';
source: 'model_metadata' | 'known_model_family' | 'unknown';
estimatedPromptTokens: number;
contextWindowTokens?: number;
reservedOutputTokens?: number;
safetyMarginTokens?: number;
inputBudgetTokens?: number;
budgetRatio?: number;
};
artifactOutcome?: {
artifactCount: number;
artifactsCreated?: number;
Expand Down Expand Up @@ -1207,6 +1217,23 @@ export function registerRunRoutes(app: Express, ctx: RegisterRunRoutesDeps) {
asked_user_question: runAskedUserQuestion(run.events),
retry_attempt_count: run.retryAttemptCount ?? 0,
retry_final_result: run.retryFinalResult ?? 'not_attempted',
context_budget_action: run.contextBudget?.action ?? 'unmeasured',
context_budget_source: run.contextBudget?.source ?? 'unknown',
...(run.contextBudget?.estimatedPromptTokens !== undefined
? { estimated_prompt_tokens: run.contextBudget.estimatedPromptTokens }
: {}),
...(run.contextBudget?.contextWindowTokens !== undefined
? { context_window_tokens: run.contextBudget.contextWindowTokens }
: {}),
...(run.contextBudget?.reservedOutputTokens !== undefined
? { reserved_output_tokens: run.contextBudget.reservedOutputTokens }
: {}),
...(run.contextBudget?.inputBudgetTokens !== undefined
? { input_budget_tokens: run.contextBudget.inputBudgetTokens }
: {}),
...(run.contextBudget?.budgetRatio !== undefined
? { context_budget_ratio: run.contextBudget.budgetRatio }
: {}),
...(run.retrySuppressedReason
? { retry_suppressed_reason: run.retrySuppressedReason }
: {}),
Expand Down
44 changes: 41 additions & 3 deletions apps/daemon/src/runtimes/defs/amr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,13 +198,45 @@ function extractModelMetadata(item: unknown): ModelMetadata | null {
const metadata = isRecord(item.metadata) ? item.metadata : item;
const cost = parseModelCost(metadata.cost);
const capability = parseModelCapability(metadata.capability);
if (!cost && !capability) return null;
const contextWindowTokens = extractPositiveModelLimit(item, [
'contextWindowTokens',
'context_window_tokens',
'context_length',
'contextLength',
'context',
]);
const maxOutputTokens = extractPositiveModelLimit(item, [
'maxOutputTokens',
'max_output_tokens',
'output',
]);
if (!cost && !capability && !contextWindowTokens && !maxOutputTokens) return null;
return {
...(cost ? { cost } : {}),
...(capability ? { capability } : {}),
...(contextWindowTokens ? { contextWindowTokens } : {}),
...(maxOutputTokens ? { maxOutputTokens } : {}),
};
}

function extractPositiveModelLimit(
item: Record<string, unknown>,
keys: string[],
): number | null {
const metadata = isRecord(item.metadata) ? item.metadata : null;
const limit = isRecord(item.limit) ? item.limit : null;
for (const source of [metadata, limit, item]) {
if (!source) continue;
for (const key of keys) {
const value = source[key];
if (typeof value === 'number' && Number.isInteger(value) && value > 0) {
return value;
}
}
}
return null;
}

function withPriceDerivedCostMetadata(
metadata: ModelMetadata | null,
inputPriceUsdPerMillion: number | undefined,
Expand Down Expand Up @@ -462,14 +494,20 @@ function openCodeModelPrice(
> | null {
if (!isRecord(model)) return null;
const inputPriceUsdPerMillion = extractInputPriceUsdPerMillion(model);
if (inputPriceUsdPerMillion === undefined) return null;
const outputPriceUsdPerMillion = extractOutputPriceUsdPerMillion(model);
const metadata = withPriceDerivedCostMetadata(
extractModelMetadata(model),
inputPriceUsdPerMillion,
);
if (
inputPriceUsdPerMillion === undefined &&
outputPriceUsdPerMillion === undefined &&
metadata === null
) {
return null;
}
return {
inputPriceUsdPerMillion,
...(inputPriceUsdPerMillion === undefined ? {} : { inputPriceUsdPerMillion }),
Comment thread
Siri-Ray marked this conversation as resolved.
...(outputPriceUsdPerMillion === undefined ? {} : { outputPriceUsdPerMillion }),
...(metadata === null ? {} : { metadata }),
};
Expand Down
126 changes: 126 additions & 0 deletions apps/daemon/src/runtimes/model-context-budget.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import type { ModelMetadata } from '@open-design/contracts';
import type { RuntimePromptBudgetError } from './types.js';

export type ModelContextBudgetSource =
| 'model_metadata'
| 'known_model_family'
| 'unknown';

export type ModelContextBudgetAction =
| 'unmeasured'
| 'within_budget'
| 'blocked'
| 'rollover';

export interface ModelContextBudgetDecision {
action: ModelContextBudgetAction;
source: ModelContextBudgetSource;
modelId: string | null;
estimatedPromptTokens: number;
contextWindowTokens?: number;
reservedOutputTokens?: number;
safetyMarginTokens?: number;
inputBudgetTokens?: number;
budgetRatio?: number;
error?: RuntimePromptBudgetError;
}

const DEFAULT_OUTPUT_RESERVE_TOKENS = 8_192;
const MIN_SAFETY_MARGIN_TOKENS = 1_024;
const SAFETY_MARGIN_RATIO = 0.05;

export function estimatePromptTokens(prompt: string): number {
// UTF-8 bytes / 3 deliberately overestimates normal English/code (usually
// closer to 4 bytes per token) while remaining realistic for CJK text.
// This is a launch guard, not billing telemetry: a small false-positive
// margin is safer than forwarding a request the provider will reject.
return Math.ceil(Buffer.byteLength(prompt, 'utf8') / 3);
}

function positiveInteger(value: unknown): number | null {
return typeof value === 'number' && Number.isInteger(value) && value > 0
? value
: null;
}

function knownModelFamilyContextWindow(modelId: string | null): number | null {
if (!modelId) return null;
const normalized = modelId.toLowerCase();
// OpenCode/provider errors in the 0.15.0 incident report a 204,800-token
// ceiling for Claude-family routes. Keep this fallback narrow; every other
// model remains observation-only until its live catalog supplies metadata.
if (/(?:^|[/])claude[-_]/u.test(normalized)) return 204_800;
Comment thread
Siri-Ray marked this conversation as resolved.
Outdated
return null;
}

export function evaluateModelContextBudget({
prompt,
modelId,
Comment thread
Siri-Ray marked this conversation as resolved.
metadata,
}: {
prompt: string;
modelId: string | null | undefined;
metadata?: ModelMetadata | null;
}): ModelContextBudgetDecision {
const normalizedModel = typeof modelId === 'string' && modelId.trim()
? modelId.trim()
: null;
const estimatedPromptTokens = estimatePromptTokens(prompt);
const metadataWindow = positiveInteger(metadata?.contextWindowTokens);
const familyWindow = knownModelFamilyContextWindow(normalizedModel);
const contextWindowTokens = metadataWindow ?? familyWindow;
const source: ModelContextBudgetSource = metadataWindow
? 'model_metadata'
: familyWindow
? 'known_model_family'
: 'unknown';

if (!contextWindowTokens) {
return {
action: 'unmeasured',
source,
modelId: normalizedModel,
estimatedPromptTokens,
};
}

const declaredOutput = positiveInteger(metadata?.maxOutputTokens);
const reservedOutputTokens = Math.min(
declaredOutput ?? DEFAULT_OUTPUT_RESERVE_TOKENS,
Math.floor(contextWindowTokens * 0.25),
);
const safetyMarginTokens = Math.max(
MIN_SAFETY_MARGIN_TOKENS,
Math.ceil(contextWindowTokens * SAFETY_MARGIN_RATIO),
);
const inputBudgetTokens = Math.max(
1,
contextWindowTokens - reservedOutputTokens - safetyMarginTokens,
);
const budgetRatio = estimatedPromptTokens / inputBudgetTokens;
const blocked = estimatedPromptTokens > inputBudgetTokens;

return {
action: blocked ? 'blocked' : 'within_budget',
source,
modelId: normalizedModel,
estimatedPromptTokens,
contextWindowTokens,
reservedOutputTokens,
safetyMarginTokens,
inputBudgetTokens,
budgetRatio,
...(blocked
? {
error: {
code: 'AGENT_PROMPT_TOO_LARGE',
limit: inputBudgetTokens,
message:
`The composed prompt is estimated at ${estimatedPromptTokens} tokens, above the safe input budget of ${inputBudgetTokens} tokens for ${normalizedModel ?? 'the selected model'} ` +
`(context window ${contextWindowTokens}; ${reservedOutputTokens} reserved for output; ${safetyMarginTokens} safety margin). ` +
'Shorten the conversation, remove large attachments, or start a new conversation.',
},
}
: {}),
};
}
13 changes: 13 additions & 0 deletions apps/daemon/src/runtimes/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,19 @@ export function getRememberedLiveModels(agentId: string, scope?: string | null):
return liveModelOrder.get(liveModelCacheKey(agentId, scope)) ?? [];
}

export function getKnownModelOption(
def: RuntimeAgentDef,
modelId: string | null | undefined,
scope?: string | null,
): RuntimeModelOption | null {
if (!modelId) return null;
const live = getRememberedLiveModels(def.id, scope).find(
(model) => model.id === modelId,
);
if (live) return live;
return def.fallbackModels.find((model) => model.id === modelId) ?? null;
}

export function preferFreshLiveModels(
freshModels: RuntimeModelOption[],
rememberedModels: RuntimeModelOption[],
Expand Down
Loading
Loading