diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index b216c0fb6f3..e3626716b53 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,6 +2,9 @@ ## [Unreleased] +### Fixed + +- Classified zero-output Devin `invalid_argument` trailers as context overflow when the serialized message history is already large, routing cumulative tool-output payload failures through context maintenance—including artifact-backed shake rescue—instead of retrying the same rejected history. ## [17.0.5] - 2026-07-18 ### Changed @@ -18,6 +21,14 @@ - Fixed tool request failures (HTTP 400) on local grammar-constrained OpenAI-compatible backends (such as llama.cpp, LM Studio, and vLLM) by widening bare boolean subschemas into a value-accepting primitive union. - Fixed custom OAuth Anthropic-compatible endpoints receiving generated Claude Code fingerprint headers even when explicit header overrides were provided. - Fixed active sessions for plan-gated OpenAI Codex models (Sol/Luna) silently re-routing to sibling OAuth accounts when usage headroom changed, ensuring session stickiness is preserved as long as the preferred credential remains usable and eligible. +- Retry one transient OpenAI Responses stream truncation before replay-unsafe output, preventing recoverable transport truncations from surfacing as failed turns ([#5908](https://github.com/can1357/oh-my-pi/issues/5908)). +- Kept native Kimi Code K3 thinking enabled for named function selection by using generic required tool choice. +- Fixed `/login moonshot` validating China-platform API keys against the international host instead of honoring `MOONSHOT_BASE_URL` ([#5981](https://github.com/can1357/oh-my-pi/issues/5981)). +- Fixed Anthropic session credential stickiness suppressing usage-based re-ranking indefinitely: the session pin skipped ranking for up to 30 days (or process lifetime) even after the ≤1h prompt cache it protects was no longer guaranteed warm. The Anthropic skip is now gated on time since the session's last resolve (`ANTHROPIC_SESSION_STICKY_CACHE_WARM_MS`, 1h), while providers without a verified cache lifetime retain their existing stickiness. When ranking runs, the pinned account is only a tie-break rather than an absolute front-of-queue override, restoring proactive multi-account load balancing after long idle ([#5966](https://github.com/can1357/oh-my-pi/issues/5966)). +- Fixed clockless Anthropic usage windows outranking clocked sibling credentials by scoring their headroom over the full window duration ([#5960](https://github.com/can1357/oh-my-pi/issues/5960)). +- Fixed local grammar-constrained OpenAI-compatible backends (llama.cpp, LM Studio, vLLM) rejecting every tool request with `400 "Unable to generate parser for this template ... Unrecognized schema: true"`. The `task` tool's open `outputSchema` field normalizes to a bare boolean `true` subschema (issue #1179), which llama.cpp's JSON-schema→GBNF converter cannot compile. The chat-completions encoder now widens bare boolean subschemas into a value-accepting primitive union (and preserves closed-object `additionalProperties: false`) for these backends via the new `grammar` tool-schema flavor ([#5914](https://github.com/can1357/oh-my-pi/issues/5914)). +- Fixed custom OAuth Anthropic-compatible endpoints with explicit header overrides still receiving generated Claude Code fingerprint headers instead. ([#5888](https://github.com/can1357/oh-my-pi/issues/5888)) +- Fixed plan-gated OpenAI Codex models (Sol/Luna) silently re-routing an active session to a sibling OAuth account when usage headroom changed: `AuthStorage.#resolveOAuthSelection` now promotes the session-preferred credential back to the front of the ranked candidates whenever it is still usable, unblocked, and plan-eligible, so a session stays sticky unless the sticky account is blocked, exhausted, or ineligible for the current model ([#5203](https://github.com/can1357/oh-my-pi/issues/5203)). ## [17.0.4] - 2026-07-18 diff --git a/packages/ai/src/providers/devin.ts b/packages/ai/src/providers/devin.ts index c3f546e0bc1..8b1b8aaa5ba 100644 --- a/packages/ai/src/providers/devin.ts +++ b/packages/ai/src/providers/devin.ts @@ -78,6 +78,13 @@ const CONNECT_END_STREAM_FLAG = 0x02; * fails fast instead of consuming memory. */ const MAX_CONNECT_FRAME_PAYLOAD = 16 * 1024 * 1024; +/** + * Recovery heuristic for opaque Devin `invalid_argument` trailers. This is not + * asserted to be the backend's hard limit: small requests can hit the same + * intermittent error, while compactable message history this large is likely + * to benefit from the existing context-overflow maintenance path. + */ +const LARGE_HISTORY_RECOVERY_BYTES = 512 * 1024; export const streamDevin: StreamFunction<"devin-agent"> = ( model: Model<"devin-agent">, @@ -157,10 +164,14 @@ export const streamDevin: StreamFunction<"devin-agent"> = ( const auth = await fetchDevinAuthMetadata(apiKey, baseUrl, fetchImpl, options?.signal); const chatBaseUrl = auth.baseUrl ?? baseUrl; const request = buildDevinChatRequest(model, context, options, apiKey, auth.userJwt); - logger.debug("devin: sending chat request", { model: model.id, tools: context.tools?.length ?? 0 }); - const reqBytes = toBinary(GetChatMessageRequestSchema, request); const gz = gzipSync(reqBytes); + logger.debug("devin: sending chat request", { + model: model.id, + tools: context.tools?.length ?? 0, + requestBytes: reqBytes.byteLength, + compressedBytes: gz.byteLength, + }); const frame = Buffer.alloc(5 + gz.length); frame[0] = CONNECT_COMPRESSED_FLAG; frame.writeUInt32BE(gz.length, 1); @@ -223,7 +234,53 @@ export const streamDevin: StreamFunction<"devin-agent"> = ( if (flag & CONNECT_END_STREAM_FLAG) { const trailerBytes = flag & CONNECT_COMPRESSED_FLAG ? gunzipSync(payload) : payload; const trailerError = readConnectTrailerError(trailerBytes.toString("utf8").trim()); - if (trailerError) throw new AIError.ValidationError(trailerError); + if (trailerError) { + const error = new AIError.ValidationError(trailerError.formatted); + if ( + firstTokenTime === undefined && + trailerError.code.toLowerCase() === "invalid_argument" && + /\binternal error\b/i.test(trailerError.message) + ) { + // The full protobuf also contains the system prompt and tool + // schemas, which history maintenance cannot shrink. Re-encode + // only the repeated history field before choosing recovery. + let activeTailCount = 0; + const lastRole = context.messages.at(-1)?.role; + if (lastRole === "user" || lastRole === "developer") { + activeTailCount = 1; + // A trailing developer message can accompany the current user + // prompt. Earlier user-role records may instead be flushed + // execution history and must remain eligible for compaction. + if (lastRole === "developer") { + for (let i = context.messages.length - 2; i >= 0; i--) { + const role = context.messages[i].role; + if (role !== "user" && role !== "developer") break; + activeTailCount++; + } + } + } + const shrinkablePrompts = + activeTailCount > 0 + ? request.chatMessagePrompts.slice(0, -activeTailCount) + : request.chatMessagePrompts; + const historyBytes = toBinary( + GetChatMessageRequestSchema, + create(GetChatMessageRequestSchema, { + chatMessagePrompts: shrinkablePrompts, + }), + ).byteLength; + if (historyBytes >= LARGE_HISTORY_RECOVERY_BYTES) { + AIError.attach(error, AIError.create(AIError.Flag.ContextOverflow)); + logger.warn("devin: treating large-history invalid_argument as context overflow", { + model: model.id, + historyBytes, + requestBytes: reqBytes.byteLength, + compressedBytes: gz.byteLength, + }); + } + } + throw error; + } continue; } @@ -569,12 +626,18 @@ function buildChatMessagePrompts(messages: Message[], cascadeId: string): ChatMe return prompts; } +interface ConnectTrailerError { + code: string; + message: string; + formatted: string; +} + /** - * Parse a Connect end-of-stream JSON trailer and return a human-readable error - * string when it carries `{ error: { code, message } }`, else `null`. The trailer - * is untrusted server output, so the shape is checked with guards rather than asserted. + * Parse a Connect end-of-stream JSON trailer and return its structured error + * when it carries `{ error: { code, message } }`, else `null`. The trailer is + * untrusted server output, so the shape is checked with guards rather than asserted. */ -function readConnectTrailerError(text: string): string | null { +function readConnectTrailerError(text: string): ConnectTrailerError | null { if (text.length === 0) return null; let parsed: unknown; try { @@ -588,5 +651,9 @@ function readConnectTrailerError(text: string): string | null { const code = "code" in err && typeof err.code === "string" ? err.code : ""; const message = "message" in err && typeof err.message === "string" ? err.message : ""; if (!code && !message) return null; - return `Devin stream error${code ? ` ${code}` : ""}: ${message}`; + return { + code, + message, + formatted: `Devin stream error${code ? ` ${code}` : ""}: ${message}`, + }; } diff --git a/packages/ai/test/devin-large-request-recovery.test.ts b/packages/ai/test/devin-large-request-recovery.test.ts new file mode 100644 index 00000000000..da869df8c28 --- /dev/null +++ b/packages/ai/test/devin-large-request-recovery.test.ts @@ -0,0 +1,289 @@ +import { describe, expect, it } from "bun:test"; +import { create, toBinary } from "@bufbuild/protobuf"; +import * as AIError from "@oh-my-pi/pi-ai/error"; +import { streamDevin } from "@oh-my-pi/pi-ai/providers/devin"; +import type { Context, Model } from "@oh-my-pi/pi-ai/types"; +import { buildModel } from "@oh-my-pi/pi-catalog/build"; +import { GetChatMessageResponseSchema } from "@oh-my-pi/pi-catalog/discovery/devin-gen/exa/api_server_pb/api_server_pb"; +import { GetUserJwtResponseSchema } from "@oh-my-pi/pi-catalog/discovery/devin-gen/exa/auth_pb/auth_pb"; + +const CONNECT_END_STREAM_FLAG = 0x02; +const LARGE_TOOL_RESULT_BYTES = 160 * 1024; + +const devinModel: Model<"devin-agent"> = buildModel({ + id: "devin-test", + name: "Devin Test", + api: "devin-agent", + provider: "devin", + baseUrl: "https://server.codeium.com", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 1_000_000, + maxTokens: 64_000, +}); + +const largeReadContext: Context = { + messages: Array.from({ length: 4 }, (_, index) => ({ + role: "toolResult" as const, + toolCallId: `read-${index}`, + toolName: "read", + content: [{ type: "text" as const, text: String(index).repeat(LARGE_TOOL_RESULT_BYTES) }], + isError: false, + timestamp: index + 1, + })), +}; +const largeFixedContext: Context = { + systemPrompt: ["s".repeat(320 * 1024)], + messages: [{ role: "user", content: "small history", timestamp: 1 }], + tools: [ + { + name: "large_tool", + description: "d".repeat(320 * 1024), + parameters: { type: "object", properties: {} }, + }, + ], +}; + +function connectFrame(payload: Uint8Array, flag = 0): Uint8Array { + const frame = Buffer.alloc(5 + payload.length); + frame[0] = flag; + frame.writeUInt32BE(payload.length, 1); + frame.set(payload, 5); + return frame; +} + +function connectErrorFrame(code: string, message: string): Uint8Array { + const payload = Buffer.from(JSON.stringify({ error: { code, message } }), "utf8"); + return connectFrame(payload, CONNECT_END_STREAM_FLAG); +} + +async function runTrailerError(context: Context, code: string, message: string, leadingFrames: Uint8Array[] = []) { + const authPayload = toBinary(GetUserJwtResponseSchema, create(GetUserJwtResponseSchema, { userJwt: "jwt" })); + const trailer = connectErrorFrame(code, message); + const fetchImpl = (async (input: string | URL | Request) => { + if (String(input).includes("GetUserJwt")) return new Response(authPayload); + return new Response( + new ReadableStream({ + start(controller) { + for (const frame of leadingFrames) controller.enqueue(frame); + controller.enqueue(trailer); + controller.close(); + }, + }), + { status: 200 }, + ); + }) as typeof fetch; + + return streamDevin(devinModel, context, { apiKey: "token", fetch: fetchImpl }).result(); +} + +describe("streamDevin large request recovery", () => { + it("classifies an opaque invalid_argument on cumulative large read output as context overflow", async () => { + const result = await runTrailerError( + largeReadContext, + "invalid_argument", + "an internal error occurred (trace ID: large-request)", + ); + + expect(result.stopReason).toBe("error"); + expect(result.errorMessage).toContain("trace ID: large-request"); + expect(AIError.is(result.errorId, AIError.Flag.ContextOverflow)).toBe(true); + }); + + it("keeps the same opaque trailer transient for a small request", async () => { + const result = await runTrailerError( + { messages: [{ role: "user", content: "hi", timestamp: 1 }] }, + "invalid_argument", + "an internal error occurred (trace ID: small-request)", + ); + + expect(AIError.is(result.errorId, AIError.Flag.ContextOverflow)).toBe(false); + expect(AIError.is(result.errorId, AIError.Flag.Transient)).toBe(true); + }); + + it("does not compact a large system prompt and tool schema with small history", async () => { + const result = await runTrailerError( + largeFixedContext, + "invalid_argument", + "an internal error occurred (trace ID: fixed-request)", + ); + + expect(AIError.is(result.errorId, AIError.Flag.ContextOverflow)).toBe(false); + expect(AIError.is(result.errorId, AIError.Flag.Transient)).toBe(true); + }); + + it("does not compact a large history after partial output", async () => { + const partial = toBinary( + GetChatMessageResponseSchema, + create(GetChatMessageResponseSchema, { deltaText: "partial" }), + ); + const result = await runTrailerError( + largeReadContext, + "invalid_argument", + "an internal error occurred (trace ID: partial-output)", + [connectFrame(partial)], + ); + + expect(result.content).toContainEqual({ type: "text", text: "partial" }); + expect(AIError.is(result.errorId, AIError.Flag.ContextOverflow)).toBe(false); + expect(AIError.is(result.errorId, AIError.Flag.Transient)).toBe(true); + }); + + it("does not reinterpret a specific invalid argument on a large request", async () => { + const result = await runTrailerError(largeReadContext, "invalid_argument", "unknown chat model uid"); + + expect(AIError.is(result.errorId, AIError.Flag.ContextOverflow)).toBe(false); + }); + + it("keeps the trailer transient for a huge active turn user message with no prior history", async () => { + const result = await runTrailerError( + { + messages: [ + { + role: "user" as const, + content: "u".repeat(520 * 1024), + timestamp: 1, + }, + ], + }, + "invalid_argument", + "an internal error occurred (trace ID: huge-user-request)", + ); + + expect(result.stopReason).toBe("error"); + expect(result.errorMessage).toContain("trace ID: huge-user-request"); + expect(AIError.is(result.errorId, AIError.Flag.ContextOverflow)).toBe(false); + expect(AIError.is(result.errorId, AIError.Flag.Transient)).toBe(true); + }); + + it("classifies as context overflow if there is huge eligible prior history before the active turn user message", async () => { + const result = await runTrailerError( + { + messages: [ + { + role: "toolResult" as const, + toolCallId: "read-prior", + toolName: "read", + content: [{ type: "text" as const, text: "p".repeat(520 * 1024) }], + isError: false, + timestamp: 1, + }, + { + role: "user" as const, + content: "small user prompt", + timestamp: 2, + }, + ], + }, + "invalid_argument", + "an internal error occurred (trace ID: prior-history-overflow)", + ); + + expect(result.stopReason).toBe("error"); + expect(result.errorMessage).toContain("trace ID: prior-history-overflow"); + expect(AIError.is(result.errorId, AIError.Flag.ContextOverflow)).toBe(true); + }); + + it("classifies as context overflow if the request follows a large tool execution", async () => { + const result = await runTrailerError( + { + messages: [ + { + role: "user" as const, + content: "small user prompt", + timestamp: 1, + }, + { + role: "assistant" as const, + content: [ + { type: "text" as const, text: "running a tool" }, + { + type: "toolCall" as const, + id: "call-1", + name: "large_tool", + arguments: {}, + }, + ], + api: "devin-agent" as const, + provider: "devin" as const, + model: "devin-test", + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "toolUse" as const, + timestamp: 2, + }, + { + role: "toolResult" as const, + toolCallId: "call-1", + toolName: "large_tool", + content: [{ type: "text" as const, text: "t".repeat(520 * 1024) }], + isError: false, + timestamp: 3, + }, + ], + }, + "invalid_argument", + "an internal error occurred (trace ID: tool-execution-overflow)", + ); + + expect(result.stopReason).toBe("error"); + expect(result.errorMessage).toContain("trace ID: tool-execution-overflow"); + expect(AIError.is(result.errorId, AIError.Flag.ContextOverflow)).toBe(true); + }); + it("keeps prior user-role execution history eligible before the active prompt", async () => { + const result = await runTrailerError( + { + messages: [ + { + role: "user" as const, + content: "execution output: ".concat("x".repeat(520 * 1024)), + timestamp: 1, + }, + { + role: "user" as const, + content: "small active prompt", + timestamp: 2, + }, + ], + }, + "invalid_argument", + "an internal error occurred (trace ID: user-role-history)", + ); + + expect(result.stopReason).toBe("error"); + expect(AIError.is(result.errorId, AIError.Flag.ContextOverflow)).toBe(true); + }); + + it("keeps the trailer transient for a large current prompt split across multiple trailing user/developer messages", async () => { + const result = await runTrailerError( + { + messages: [ + { + role: "user" as const, + content: "u".repeat(520 * 1024), + timestamp: 1, + }, + { + role: "developer" as const, + content: "small notice/companion", + timestamp: 2, + }, + ], + }, + "invalid_argument", + "an internal error occurred (trace ID: split-active-prompt)", + ); + + expect(result.stopReason).toBe("error"); + expect(result.errorMessage).toContain("trace ID: split-active-prompt"); + expect(AIError.is(result.errorId, AIError.Flag.ContextOverflow)).toBe(false); + expect(AIError.is(result.errorId, AIError.Flag.Transient)).toBe(true); + }); +});