Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
9 changes: 9 additions & 0 deletions packages/ai/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,15 @@
- 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.
- 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.
Comment thread
riverpilot marked this conversation as resolved.
Outdated
- 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

Expand Down
77 changes: 69 additions & 8 deletions packages/ai/src/providers/devin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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">,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -223,7 +234,47 @@ 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;
for (let i = context.messages.length - 1; i >= 0; i--) {
const role = context.messages[i].role;
if (role === "user" || role === "developer") {
Comment thread
riverpilot marked this conversation as resolved.
Outdated
activeTailCount++;
} else {
break;
}
}
const shrinkablePrompts =
activeTailCount > 0
? request.chatMessagePrompts.slice(0, -activeTailCount)
: request.chatMessagePrompts;
Comment thread
riverpilot marked this conversation as resolved.
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;
}

Expand Down Expand Up @@ -569,12 +620,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 {
Expand All @@ -588,5 +645,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}`,
};
}
252 changes: 252 additions & 0 deletions packages/ai/test/devin-large-request-recovery.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,252 @@
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<Uint8Array>({
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 () => {
Comment thread
riverpilot marked this conversation as resolved.
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: "running a tool",
toolCalls: [
{
id: "call-1",
name: "large_tool",
arguments: {},
},
],
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 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);
});
});
Loading