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
3 changes: 3 additions & 0 deletions .papercuts/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -430,6 +430,9 @@ symlink with this checkout's own npm ci. Full type-check and lint then passed.
- E2E chat-title expectations assume the deterministic chat-model route. On a Mac where the native Foundation Models helper reports `ready`, automatic titles come from Apple Intelligence instead, so `chat-message-queue` sidebar-title lookups fail locally while passing in CI; probe the helper or move it aside before treating those failures as regressions.
- `git add` on the tracked-but-ignored `.papercuts/troubleshooting.md` still needs `-f` after conflict resolution.

- Production provider 400s are untriageable from `logs/aiden.log` alone: the real error text survives only in `userData/pi-compaction-sessions/*.jsonl` (per-message `errorMessage`), because the diagnostic journal strips provider messages outside the development profile. Check the journals before assuming a classification.
- `@earendil-works/pi-ai` transports merge `model.headers` into every outgoing request and merge `options.headers` last — a per-conversation header can be attached once at runtime-model resolution instead of threading it through each call site.

## 2026-09-12 — Production provider-failure investigation

- The 0.40.0 production diagnostic log collapsed a concrete OpenCode Go 400 into duplicate `unknown` generation failures; correlate the Pi journal to recover historical provider causes. PR #110 improves future evidence but cannot reconstruct old redacted logs.
Expand Down
4 changes: 4 additions & 0 deletions main/services/advisor-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@ export interface AdvisorRuntimeDependencies {
providerId: string,
modelId: string,
signal?: AbortSignal,
conversationId?: string,
): Promise<ResolvedModelRuntime>;
recordUsage(message: AssistantMessage, runtime: ResolvedModelRuntime): Promise<void>;
recordUnreportedUsage(
Expand Down Expand Up @@ -541,6 +542,9 @@ export class AdvisorRuntime {
selection.providerId,
selection.modelId,
signal,
// Advisor dispatch is a one-shot call; a fresh id still satisfies
// gateway per-request attribution.
randomUUID(),
);
assertAdvisorRuntimeSelection(selection, runtime);
await preflightAdvisorRuntimeAuth(runtime, signal);
Expand Down
5 changes: 4 additions & 1 deletion main/services/bot-avatar-generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,10 @@ export async function generateBotAvatarSuggestion(
try {
controller.signal.throwIfAborted();
const runtime = await waitForBotAvatarBoundary(
resolveModelRuntime(input.providerId, input.model, controller.signal),
// One-shot generation has no conversation; the request id doubles as the
// gateway's per-request attribution id. Kept on one line: the source
// contract test asserts this call's shape.
resolveModelRuntime(input.providerId, input.model, controller.signal, input.requestId),
controller.signal,
);
controller.signal.throwIfAborted();
Expand Down
17 changes: 14 additions & 3 deletions main/services/bot-generation-preparation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ const workspace = {
};

const chat = {
id: "chat-1",
botId: bot.id,
workspaceId: workspace.workspaceId,
providerId: "provider-1",
Expand Down Expand Up @@ -61,8 +62,13 @@ function fixture(
calls.push(`workspace:${botId}`);
return workspace;
},
resolveRuntime: async (providerId: string, model: string) => {
calls.push(`runtime:${providerId}/${model}`);
resolveRuntime: async (
providerId: string,
model: string,
_signal?: AbortSignal,
conversationId?: string,
) => {
calls.push(`runtime:${providerId}/${model}:${conversationId ?? "none"}`);
return { provider: { id: providerId }, model: { id: model }, marker: "exact" };
},
...overrides,
Expand All @@ -73,7 +79,12 @@ function fixture(
test("prepares an exact main-only managed-home workspace and persisted runtime", async () => {
const { input, calls } = fixture();
const prepared = await prepareBotGeneration(input);
assert.deepEqual(calls, ["workspace:bot-1", "runtime:provider-1/model-1"]);
assert.deepEqual(calls, [
"workspace:bot-1",
// The chat id travels with the resolution so OpenCode attribution keys
// on the conversation.
`runtime:provider-1/model-1:${chat.id}`,
]);
assert.equal(prepared.managedWorkspace, workspace);
assert.deepEqual(prepared.workspace, {
id: workspace.workspaceId,
Expand Down
4 changes: 3 additions & 1 deletion main/services/bot-generation-preparation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import type { Chat, Workspace } from "./types.js";

type BotGenerationChat = Pick<
Chat,
"botId" | "workspaceId" | "providerId" | "model"
"id" | "botId" | "workspaceId" | "providerId" | "model"
>;

export interface RequestedBotGenerationTarget {
Expand All @@ -28,6 +28,7 @@ export interface PrepareBotGenerationInput<Runtime extends ExactBotRuntime> {
providerId: string,
model: string,
signal?: AbortSignal,
conversationId?: string,
): Promise<Runtime>;
signal?: AbortSignal;
}
Expand Down Expand Up @@ -125,6 +126,7 @@ export async function prepareBotGeneration<Runtime extends ExactBotRuntime>(
selection.providerId,
selection.model,
input.signal,
input.chat.id,
);
if (
runtime.provider.id !== selection.providerId ||
Expand Down
3 changes: 3 additions & 0 deletions main/services/chat-title.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ function assistantText(content: AssistantMessage["content"]): string {
}

async function generateWithChatModel(input: {
chatId: string;
firstMessage: Parameters<typeof buildChatTitlePrompt>[0] & {
attachments?: import("./types.js").Attachment[];
};
Expand All @@ -85,6 +86,7 @@ async function generateWithChatModel(input: {
input.selection.providerId,
input.selection.model,
input.signal,
input.chatId,
);
const promptContent: Array<TextContent | ImageContent> = [
{
Expand Down Expand Up @@ -218,6 +220,7 @@ async function generateFirstTurnTitle(input: {
abortController.signal,
)
: await generateWithChatModel({
chatId: input.chatId,
firstMessage,
selection: input.fallbackSelection,
signal: abortController.signal,
Expand Down
10 changes: 8 additions & 2 deletions main/services/context-lifecycle-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,11 @@ export type CompactChatResult =

export interface ContextLifecycleServiceDeps {
getCompactionEngine?(): Promise<CompactionEngine>;
resolveLocalModel?(providerId: string, model: string): Promise<ResolvedModelRuntime["model"]>;
resolveLocalModel?(
providerId: string,
model: string,
conversationId?: string,
): Promise<ResolvedModelRuntime["model"]>;
compactionEnabled?(): boolean;
compactionEligible?(chat: Chat): boolean | Promise<boolean>;
skillsEnabled?(): Promise<boolean>;
Expand All @@ -60,6 +64,7 @@ export interface ContextLifecycleServiceDeps {
providerId: string,
model: string,
signal?: AbortSignal,
conversationId?: string,
): Promise<ResolvedModelRuntime>;
resolveThinkingLevel(
chat: Chat,
Expand Down Expand Up @@ -148,12 +153,13 @@ export class ContextLifecycleService {
try {
if (engine === "vcc") {
if (!this.deps.resolveLocalModel) throw new Error("Offline metadata is unavailable.");
model = await this.deps.resolveLocalModel(chat.providerId, chat.model);
model = await this.deps.resolveLocalModel(chat.providerId, chat.model, chat.id);
} else {
runtime = await this.deps.resolveRuntime(
chat.providerId,
chat.model,
operationAbort.signal,
chat.id,
);
model = runtime.model;
}
Expand Down
10 changes: 9 additions & 1 deletion main/services/dictation-cleanup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// the original transcript so paste still succeeds.

import type { AssistantMessage, TextContent } from "@earendil-works/pi-ai";
import { randomUUID } from "node:crypto";
import { logger } from "../platform.js";
import { configStore } from "./config-store.js";
import {
Expand Down Expand Up @@ -37,7 +38,14 @@ export async function cleanupDictationTranscript(transcript: string): Promise<st
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), DICTATION_CLEANUP_TIMEOUT_MS);
try {
const runtime = await resolveModelRuntime(providerId, modelId, controller.signal);
const runtime = await resolveModelRuntime(
providerId,
modelId,
controller.signal,
// One-shot polish has no conversation; a fresh id still satisfies the
// gateway's per-request attribution requirement.
randomUUID(),
);
const result = await runtime.streams
.streamSimple(
runtime.model,
Expand Down
22 changes: 14 additions & 8 deletions main/services/llm-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -664,7 +664,7 @@ async function prepareGeneration(
};
const runtime =
botContext?.prepared.runtime ??
(await resolveModelRuntime(params.providerId, params.model, signal));
(await resolveModelRuntime(params.providerId, params.model, signal, chat.id));
const botBound = botContext !== undefined;
const botApprovedRoots = botContext
? await resolveBotRuntimeApprovedRoots(botContext.admission.authority)
Expand Down Expand Up @@ -1079,14 +1079,20 @@ async function prepareGeneration(
includeCodingTools: !botContext,
imageInspectionTool:
botContext && !supportsImages && botContext.admission.authority.visionProvider
? createVisionAnalysisTool({
attachments: chat.messages.flatMap((message) => message.attachments ?? []),
authority: {
providerId: botContext.admission.authority.visionProvider.sourceProviderId,
modelId: botContext.admission.authority.visionProvider.sourceModelId,
revalidateBeforeEffect: () => botContext.admission.revalidateBeforeEffect(),
? createVisionAnalysisTool(
{
attachments: chat.messages.flatMap((message) => message.attachments ?? []),
authority: {
providerId: botContext.admission.authority.visionProvider.sourceProviderId,
modelId: botContext.admission.authority.visionProvider.sourceModelId,
revalidateBeforeEffect: () => botContext.admission.revalidateBeforeEffect(),
},
},
})
{
resolveRuntime: (providerId, modelId, signal) =>
resolveBotModelRuntime(providerId, modelId, signal, params.chatId),
},
)
: undefined,
})
).filter((tool) => !options.excludeToolNames?.has(tool.name));
Expand Down
12 changes: 9 additions & 3 deletions main/services/model-runtime-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
resolveRuntimeBaseUrl,
resolveRuntimeHeaders,
} from "./generation-runtime.js";
import { withOpenCodeSessionAttribution } from "./opencode-session-attribution.js";
import type { RuntimeModelLimits } from "./models-catalog-core.js";
import type { StoredProvider } from "./types.js";

Expand Down Expand Up @@ -147,6 +148,7 @@ export async function resolveModelRuntimeWith(
providerId: string,
modelId: string,
signal?: AbortSignal,
conversationId?: string,
): Promise<ResolvedModelRuntime> {
if (providerId === OPENAI_CODEX_PROVIDER_ID) {
const model = await dependencies.codex.prepareRuntimeModel(modelId, signal);
Expand All @@ -166,12 +168,13 @@ export async function resolveModelRuntimeWith(
// legacy Aiden key for this path.
const nativeProvider = dependencies.native.getProvider(providerId);
if (nativeProvider) {
const model = dependencies.native.getModel(providerId, modelId);
if (!model) {
const resolvedModel = dependencies.native.getModel(providerId, modelId);
if (!resolvedModel) {
throw new Error(
`Model "${modelId}" is not available through Pi's ${nativeProvider.label} provider. Choose another model and try again.`,
);
}
const model = withOpenCodeSessionAttribution(resolvedModel, conversationId);
return {
provider: nativeProvider,
model,
Expand All @@ -197,7 +200,10 @@ export async function resolveModelRuntimeWith(
}

const limits = await dependencies.resolveRuntimeLimits(provider, modelId);
const model = buildModel(provider, modelId, limits);
const model = withOpenCodeSessionAttribution(
buildModel(provider, modelId, limits),
conversationId,
);
const headers = resolveRuntimeHeaders(provider);
const models = createModels();
models.setProvider(
Expand Down
19 changes: 15 additions & 4 deletions main/services/model-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
} from "./model-runtime-core.js";
import { catalogProviderSlug } from "./models-catalog-core.js";
import { modelsCatalog } from "./models-catalog.js";
import { withOpenCodeSessionAttribution } from "./opencode-session-attribution.js";
import { providerRegistry } from "./provider-registry.js";
import { providerConnectionSnapshot } from "./provider-credential-rotation-core.js";
import { secrets } from "./secrets.js";
Expand All @@ -35,6 +36,7 @@ export async function resolveModelRuntime(
providerId: string,
modelId: string,
signal?: AbortSignal,
conversationId?: string,
): Promise<ResolvedModelRuntime> {
// Ensure the one-release legacy key migration completes even when a
// scheduled/background generation runs before Provider Settings is opened.
Expand Down Expand Up @@ -82,6 +84,7 @@ export async function resolveModelRuntime(
providerId,
modelId,
signal,
conversationId,
);
}

Expand All @@ -90,8 +93,9 @@ export async function resolveBotModelRuntime(
providerId: string,
modelId: string,
signal?: AbortSignal,
conversationId?: string,
): Promise<ResolvedModelRuntime> {
const runtime = await resolveModelRuntime(providerId, modelId, signal);
const runtime = await resolveModelRuntime(providerId, modelId, signal, conversationId);
if (
runtime.provider.id === OPENAI_CODEX_PROVIDER_ID ||
!providerRegistry.isBuiltinProvider(runtime.provider.id)
Expand Down Expand Up @@ -134,11 +138,18 @@ export async function preflightBotModelAuth(
}

/** Offline model metadata only: no credential migration, auth, discovery or provider I/O. */
export async function resolveCompactionModelMetadata(providerId: string, modelId: string) {
export async function resolveCompactionModelMetadata(
providerId: string,
modelId: string,
conversationId?: string,
) {
const native = providerRegistry.getBuiltinModel(providerId, modelId);
if (native) return native;
if (native) return withOpenCodeSessionAttribution(native, conversationId);
const provider = await configStore.getProvider(providerId);
if (!provider || !provider.models.includes(modelId))
throw new Error("Saved model metadata is unavailable.");
return buildModel(provider, modelId, await modelsCatalog.runtimeLimits(provider, modelId));
return withOpenCodeSessionAttribution(
buildModel(provider, modelId, await modelsCatalog.runtimeLimits(provider, modelId)),
conversationId,
);
}
Loading