Skip to content
Open
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
28 changes: 28 additions & 0 deletions src/features/workspaces/ai/ai-inspector-view-model.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";

import type { AIInspectorEvent } from "#/features/workspaces/ai/ai-inspector";
import { getAIInspectorRunViews } from "#/features/workspaces/ai/ai-inspector-view-model";

describe("AI inspector run views", () => {
it("surfaces the requested and routed model for auto turns", () => {
const [run] = getAIInspectorRunViews([
{
id: "event-1",
runId: "run-1",
sequence: 1,
createdAt: 1,
type: "turn.started",
payload: {
modelId: "claude-haiku",
requestedModelId: "auto",
routingReason: "simple-chat",
system: "system prompt",
},
} satisfies AIInspectorEvent,
]);

expect(run.modelId).toBe("claude-haiku");
expect(run.requestedModelId).toBe("auto");
expect(run.routingReason).toBe("simple-chat");
});
});
2 changes: 2 additions & 0 deletions src/features/workspaces/ai/ai-inspector-view-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ function buildRunView(runId: string, events: AIInspectorEvent[]) {
case "turn.started":
run.startedAt = event.createdAt;
run.modelId = getString(payload.modelId);
run.requestedModelId = getString(payload.requestedModelId);
run.routingReason = getString(payload.routingReason);
run.system = getString(payload.system);
run.thread = payload.thread;
run.body = payload.body;
Expand Down
2 changes: 2 additions & 0 deletions src/features/workspaces/ai/ai-inspector-view-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ export interface AIInspectorRunView {
finishedAt?: number;
status: "running" | "completed" | "failed";
modelId?: string;
requestedModelId?: string;
routingReason?: string;
system?: string;
thread?: unknown;
body?: unknown;
Expand Down
4 changes: 4 additions & 0 deletions src/features/workspaces/ai/ai-thread-inspector-recorder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ export class AIThreadInspectorRecorder {
async recordTurnStarted(input: {
ctx: TurnContext;
modelId: string;
requestedModelId: string;
routingReason?: string;
system: string;
thread: AIThreadContext;
tools: unknown;
Expand All @@ -56,6 +58,8 @@ export class AIThreadInspectorRecorder {
continuation: input.ctx.continuation,
messages: summarizeInspectorMessages(input.ctx.messages),
modelId: input.modelId,
requestedModelId: input.requestedModelId,
routingReason: input.routingReason,
system: input.system,
thread: input.thread,
tools: await summarizeInspectorTools(input.tools),
Expand Down
4 changes: 4 additions & 0 deletions src/features/workspaces/ai/ai-thread-posthog-recorder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,8 @@ export class AIThreadPostHogRecorder {
recordTurnStarted(input: {
ctx: TurnContext;
modelId: WorkspaceAiChatModelId;
requestedModelId: WorkspaceAiChatModelId;
routingReason?: string;
thread: AIThreadContext;
tools?: unknown;
}) {
Expand Down Expand Up @@ -166,6 +168,8 @@ export class AIThreadPostHogRecorder {
properties: {
...turnTelemetryProperties(turn),
model_id: input.modelId,
requested_model_id: input.requestedModelId,
routing_reason: input.routingReason ?? null,
continuation: Boolean(input.ctx.continuation),
},
...this.serverEventRuntime,
Expand Down
8 changes: 8 additions & 0 deletions src/features/workspaces/ai/ai-thread-tcc-recorder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,8 @@ export class AIThreadTccRecorder {
ctx: TurnContext;
env: Cloudflare.Env;
modelId: WorkspaceAiChatModelId;
requestedModelId: WorkspaceAiChatModelId;
routingReason?: string;
system: string;
thread: AIThreadContext;
tools?: unknown;
Expand All @@ -94,6 +96,8 @@ export class AIThreadTccRecorder {
const metadata = createTccMetadata({
gatewayModel,
modelId: input.modelId,
requestedModelId: input.requestedModelId,
routingReason: input.routingReason,
thread: input.thread,
});

Expand Down Expand Up @@ -454,6 +458,8 @@ function getTccApiKey(env: Cloudflare.Env) {
function createTccMetadata(input: {
gatewayModel: string;
modelId: WorkspaceAiChatModelId;
requestedModelId: WorkspaceAiChatModelId;
routingReason?: string;
thread: AIThreadContext;
}) {
return {
Expand All @@ -466,6 +472,8 @@ function createTccMetadata(input: {
gateway_model: input.gatewayModel,
model_id: input.modelId,
mutation_mode: input.thread.promptScope.canMutate ? "mutate" : "view",
requested_model_id: input.requestedModelId,
...(input.routingReason ? { routing_reason: input.routingReason } : {}),
workspace_id: input.thread.workspaceId,
} satisfies Record<string, string>;
}
Expand Down
4 changes: 4 additions & 0 deletions src/features/workspaces/ai/ai-thread-telemetry-recorder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,11 @@ export class AIThreadTelemetryRecorder {

async recordTurnStarted(input: {
ctx: TurnContext;
/** Model the turn actually runs on (the routed model for Auto turns). */
modelId: WorkspaceAiChatModelId;
/** Model the user picked; "auto" when the router chose modelId. */
requestedModelId: WorkspaceAiChatModelId;
routingReason?: string;
system: string;
thread: AIThreadContext;
tools: unknown;
Expand Down
52 changes: 51 additions & 1 deletion src/features/workspaces/ai/ai-thread.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ import {
getWorkspaceAiGatewayProviderOptions,
getWorkspaceAiLanguageModel,
} from "#/features/workspaces/ai/ai-thread-runtime";
import {
extractWorkspaceAiRoutingSignals,
routeWorkspaceAiAutoModel,
} from "#/features/workspaces/ai/model-router";
import {
DEFAULT_WORKSPACE_AI_CHAT_MODEL_ID,
getWorkspaceAiChatModel,
Expand Down Expand Up @@ -69,7 +73,11 @@ type AIThreadRunSettlement =
};

interface AIThreadUsageContext {
/** Model actually run and metered; equals requestedModelId unless Auto routed. */
modelId: WorkspaceAiChatModelId;
/** Model the user picked; "auto" when the router chose modelId. */
requestedModelId: WorkspaceAiChatModelId;
routingReason?: string;
thread: AIThreadContext;
}

Expand Down Expand Up @@ -173,9 +181,14 @@ export function createAIThreadClass(getUserAIStore: () => typeof UserAIStore) {
void this.keepAliveWhile(() => this._maybeGenerateThreadTitle());
}

const modelId = resolveWorkspaceAiChatModelId(ctx.body?.modelId);
const requestedModelId = resolveWorkspaceAiChatModelId(ctx.body?.modelId);
const route = this._resolveAutoModelRoute(requestedModelId, ctx);
const modelId = route?.modelId ?? requestedModelId;

if (!ctx.continuation) {
// Enforcement is currently disabled inside this check. When it lands
// it must gate on the routed model's billing tier (modelId here), not
// the requested picker id, so Auto turns cannot dodge premium gating.
const access = await checkWorkspaceAiMessageAccess({
env: this.env,
modelId,
Expand All @@ -188,6 +201,8 @@ export function createAIThreadClass(getUserAIStore: () => typeof UserAIStore) {

this.activeUsageContext = {
modelId,
requestedModelId,
routingReason: route?.reason,
thread,
};
}
Expand All @@ -213,6 +228,8 @@ export function createAIThreadClass(getUserAIStore: () => typeof UserAIStore) {
await this.telemetry.recordTurnStarted({
ctx,
modelId,
requestedModelId,
routingReason: route?.reason,
system,
thread,
tools: activeTools,
Expand Down Expand Up @@ -293,6 +310,39 @@ export function createAIThreadClass(getUserAIStore: () => typeof UserAIStore) {
return this.telemetry.getInspectorSnapshot(this.name);
}

// Auto resolves to a concrete standard-tier model at turn time; every
// other picker id passes through untouched. Continuation turns reuse the
// id routed when the run started so a run never switches models
// mid-flight; if that state is gone (e.g. recovery), we route again from
// the current turn's signals.
private _resolveAutoModelRoute(
requestedModelId: WorkspaceAiChatModelId,
ctx: TurnContext,
): { modelId: WorkspaceAiChatModelId; reason: string } | undefined {
if (requestedModelId !== "auto") {
return undefined;
}

const activeContext = ctx.continuation ? this.activeUsageContext : undefined;

if (activeContext && activeContext.requestedModelId !== "auto") {
// The run started from an explicit picker choice; a continuation
// whose body lost the selection must not be re-routed.
return undefined;
}

if (activeContext) {
return {
modelId: activeContext.modelId,
reason: activeContext.routingReason ?? "turn-continuation",
};
}

const routed = routeWorkspaceAiAutoModel(extractWorkspaceAiRoutingSignals(ctx));

return { modelId: routed.modelId, reason: routed.reason };
}

private async _getThreadContext() {
const directory = await this.parentAgent(getUserAIStore());
return directory.getThreadContext(this.name);
Expand Down
Loading
Loading