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
53 changes: 21 additions & 32 deletions apps/sidecar/src/rpc/channel-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,17 @@ import {
updateChannel
} from "../services/channel/channel-manager";
import type { RpcHandler } from "./types";
import { asObject, asString, validateInput } from "./validation";
import { validateInput } from "./validation";
import {
channelIdParamsSchema,
channelCreateInputSchema,
channelDeleteParamsSchema,
channelUpdateParamsSchema,
fetchModelsInputSchema
connectionIdParamsSchema,
fetchModelsInputSchema,
oauthAnswerParamsSchema,
oauthCancelParamsSchema,
oauthSessionIdParamsSchema
} from "./schemas";
import {
answerConnectionOAuthPrompt,
Expand All @@ -41,52 +46,36 @@ export function createChannelHandlers(): Record<string, RpcHandler> {
return { ok: true };
},
[CHANNEL_IPC_CHANNELS.DECRYPT_KEY]: async (params) => {
const payload = asObject(params);
const channelId = asString(payload.channelId);
if (!channelId) {
throw new Error("缺少 channelId");
}
return decryptApiKey(channelId);
const input = validateInput(channelIdParamsSchema, params, CHANNEL_IPC_CHANNELS.DECRYPT_KEY);
return decryptApiKey(input.channelId);
},
[CHANNEL_IPC_CHANNELS.TEST]: async (params) => {
const payload = asObject(params);
const channelId = asString(payload.channelId);
if (!channelId) {
throw new Error("缺少 channelId");
}
return testChannel(channelId);
const input = validateInput(channelIdParamsSchema, params, CHANNEL_IPC_CHANNELS.TEST);
return testChannel(input.channelId);
},
[CHANNEL_IPC_CHANNELS.TEST_DIRECT]: async (params) =>
testChannelDirect(validateInput(fetchModelsInputSchema, params, CHANNEL_IPC_CHANNELS.TEST_DIRECT) as FetchModelsInput),
[CHANNEL_IPC_CHANNELS.FETCH_MODELS]: async (params) =>
fetchModels(validateInput(fetchModelsInputSchema, params, CHANNEL_IPC_CHANNELS.FETCH_MODELS) as FetchModelsInput),
[CHANNEL_IPC_CHANNELS.SYNC_MODELS]: async (params) => {
const payload = asObject(params);
const channelId = asString(payload.channelId);
if (!channelId) throw new Error("缺少 channelId");
return syncChannelModels(channelId);
const input = validateInput(channelIdParamsSchema, params, CHANNEL_IPC_CHANNELS.SYNC_MODELS);
return syncChannelModels(input.channelId);
},
[CHANNEL_IPC_CHANNELS.OAUTH_START]: async (params) => {
const connectionId = asString(asObject(params).connectionId);
if (!connectionId) throw new Error("缺少 connectionId");
return startConnectionOAuthLogin(connectionId);
const input = validateInput(connectionIdParamsSchema, params, CHANNEL_IPC_CHANNELS.OAUTH_START);
return startConnectionOAuthLogin(input.connectionId);
},
[CHANNEL_IPC_CHANNELS.OAUTH_STATUS]: async (params) => {
const sessionId = asString(asObject(params).sessionId);
if (!sessionId) throw new Error("缺少 sessionId");
return getConnectionOAuthSession(sessionId);
const input = validateInput(oauthSessionIdParamsSchema, params, CHANNEL_IPC_CHANNELS.OAUTH_STATUS);
return getConnectionOAuthSession(input.sessionId);
},
[CHANNEL_IPC_CHANNELS.OAUTH_ANSWER]: async (params) => {
const payload = asObject(params);
const sessionId = asString(payload.sessionId);
const promptId = asString(payload.promptId);
const value = asString(payload.value);
if (!sessionId || !promptId) throw new Error("缺少 OAuth prompt 参数");
return answerConnectionOAuthPrompt(sessionId, promptId, value ?? "");
const input = validateInput(oauthAnswerParamsSchema, params, CHANNEL_IPC_CHANNELS.OAUTH_ANSWER);
return answerConnectionOAuthPrompt(input.sessionId, input.promptId, input.value ?? "");
},
[CHANNEL_IPC_CHANNELS.OAUTH_CANCEL]: async (params) => {
const sessionId = asString(asObject(params).sessionId);
if (sessionId) cancelConnectionOAuthLogin(sessionId);
const input = validateInput(oauthCancelParamsSchema, params, CHANNEL_IPC_CHANNELS.OAUTH_CANCEL);
if (input.sessionId) cancelConnectionOAuthLogin(input.sessionId);
return { ok: true };
},
};
Expand Down
42 changes: 42 additions & 0 deletions apps/sidecar/src/rpc/schemas.channel-params.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { describe, expect, test } from "bun:test";
import {
channelIdParamsSchema,
connectionIdParamsSchema,
oauthAnswerParamsSchema,
oauthCancelParamsSchema,
oauthSessionIdParamsSchema
} from "./schemas";

describe("channel RPC param schemas", () => {
test("channelIdParamsSchema accepts a non-empty channelId", () => {
expect(channelIdParamsSchema.parse({ channelId: "ch-1" })).toEqual({ channelId: "ch-1" });
});

test("channelIdParamsSchema rejects missing and empty channelId", () => {
expect(channelIdParamsSchema.safeParse({}).success).toBe(false);
expect(channelIdParamsSchema.safeParse({ channelId: "" }).success).toBe(false);
expect(channelIdParamsSchema.safeParse({ channelId: 42 }).success).toBe(false);
});

test("param schemas reject unknown keys like the sibling channel schemas", () => {
expect(channelIdParamsSchema.safeParse({ channelId: "ch-1", extra: true }).success).toBe(false);
expect(connectionIdParamsSchema.safeParse({ connectionId: "conn-1", channelId: "x" }).success).toBe(false);
});

test("oauthSessionIdParamsSchema requires a non-empty sessionId", () => {
expect(oauthSessionIdParamsSchema.parse({ sessionId: "s-1" }).sessionId).toBe("s-1");
expect(oauthSessionIdParamsSchema.safeParse({ sessionId: "" }).success).toBe(false);
});

test("oauthAnswerParamsSchema keeps value optional and ids required", () => {
expect(oauthAnswerParamsSchema.parse({ sessionId: "s-1", promptId: "p-1" }).value).toBeUndefined();
expect(oauthAnswerParamsSchema.parse({ sessionId: "s-1", promptId: "p-1", value: "yes" }).value).toBe("yes");
expect(oauthAnswerParamsSchema.safeParse({ sessionId: "s-1" }).success).toBe(false);
});

test("oauthCancelParamsSchema tolerates an absent sessionId (legacy no-op face)", () => {
expect(oauthCancelParamsSchema.parse({})).toEqual({});
expect(oauthCancelParamsSchema.parse({ sessionId: "s-1" }).sessionId).toBe("s-1");
expect(oauthCancelParamsSchema.safeParse({ sessionId: "" }).success).toBe(false);
});
});
14 changes: 14 additions & 0 deletions apps/sidecar/src/rpc/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1818,3 +1818,17 @@ export const fetchModelsInputSchema = z.object({
apiFamily: channelApiFamilySchema.optional(),
openaiApiMode: channelOpenAiApiModeSchema.optional()
}).strict();

export const channelIdParamsSchema = z.object({ channelId: idSchema }).strict();

export const connectionIdParamsSchema = z.object({ connectionId: idSchema }).strict();

export const oauthSessionIdParamsSchema = z.object({ sessionId: idSchema }).strict();

export const oauthAnswerParamsSchema = z.object({
sessionId: idSchema,
promptId: idSchema,
value: z.string().optional()
}).strict();

export const oauthCancelParamsSchema = z.object({ sessionId: idSchema.optional() }).strict();
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ import type { MemoryV2RecallItem } from "../../memory-v2/types";
import type { CollectedAppendContextEffect } from "../../workflow-hooks/hook-effects";
import { getPermissionDeniedSummary } from "../permissions/permission-denials";
import type { TraceRecorder } from "../trace/trace-recorder";
import { DEFAULT_CONTEXT_BUDGET, type ContextBudget } from "./context-budget";
import { buildMessageAttachmentBrief } from "./message-attachments";

export interface ContextAssemblyInput {
Expand Down Expand Up @@ -71,7 +70,7 @@ export interface ContextAssemblyResult {
userMessageContentBlocks?: ContentBlockParam[];
sessionContext: string;
planContext?: string;
budget: ContextBudget;
budget: { total: number };
trace: {
includedMemoryIds: string[];
includedSessionMessageIds: string[];
Expand Down Expand Up @@ -341,7 +340,6 @@ Browser annotation bodies are the user's intent. URL, title, DOM locators, selec
userMessageForModel,
sessionContext: "",
budget: {
...DEFAULT_CONTEXT_BUDGET,
total: input.tokenBudget
},
trace: {
Expand Down
19 changes: 0 additions & 19 deletions apps/sidecar/src/services/agent-runtime/context/context-budget.ts

This file was deleted.

This file was deleted.

This file was deleted.

10 changes: 0 additions & 10 deletions apps/sidecar/src/services/agent/prompt/types.ts

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ describe("pi-ai provider retry policy", () => {
expect(isRetryablePiAiError(new PiAiProviderError("invalid key", { status: 401 }))).toBe(false);
});

test("uses 1/2/4/8/16 second backoff and caps Retry-After at 30 seconds", () => {
test("uses 1/2/4/8/16 second backoff and caps Retry-After at 120 seconds", () => {
const deterministicRandom = () => 0.5;
expect([0, 1, 2, 3, 4].map((index) =>
resolvePiAiRetryDelayMs(new Error("network"), index, deterministicRandom)
Expand All @@ -53,7 +53,12 @@ describe("pi-ai provider retry policy", () => {
new PiAiProviderError("busy", { retryAfterMs: 90_000 }),
0,
deterministicRandom,
)).toBe(30_000);
)).toBe(90_000);
expect(resolvePiAiRetryDelayMs(
new PiAiProviderError("busy", { retryAfterMs: 300_000 }),
0,
deterministicRandom,
)).toBe(120_000);
});

test("falls back for model-specific and credential failures but not malformed requests", () => {
Expand Down
13 changes: 4 additions & 9 deletions apps/sidecar/src/services/model-runtime/pi-ai-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,12 @@ import type {
NormalizedMessageParam,
NormalizedResponseBlock,
} from "@lume/agent-sdk";
import { MAX_RETRY_AFTER_DELAY_MS, parseRetryAfterHeader } from "@lume/agent-sdk";

type PiTextApi = "openai-completions" | "openai-responses" | "openai-codex-responses" | "anthropic-messages" | "google-generative-ai";

const DEFAULT_MAX_RETRIES = 5;
const RETRY_DELAYS_MS = [1_000, 2_000, 4_000, 8_000, 16_000] as const;
const MAX_RETRY_AFTER_MS = 30_000;

export interface PiAiProviderOptions {
apiType: ApiType;
Expand Down Expand Up @@ -80,7 +80,7 @@ export function resolvePiAiRetryDelayMs(error: unknown, retryIndex: number, rand
const base = RETRY_DELAYS_MS[Math.min(retryIndex, RETRY_DELAYS_MS.length - 1)] ?? RETRY_DELAYS_MS.at(-1)!;
const jittered = Math.round(base * (0.8 + random() * 0.4));
const retryAfter = typeof (error as { retryAfterMs?: unknown } | null)?.retryAfterMs === "number"
? Math.min(MAX_RETRY_AFTER_MS, Math.max(0, (error as { retryAfterMs: number }).retryAfterMs))
? Math.min(MAX_RETRY_AFTER_DELAY_MS, Math.max(0, (error as { retryAfterMs: number }).retryAfterMs))
: 0;
return Math.max(jittered, retryAfter);
}
Expand Down Expand Up @@ -288,12 +288,7 @@ function toResponse(message: AssistantMessage): CreateMessageResponse {
}

function retryAfterMs(headers: Record<string, string> | undefined): number | undefined {
const raw = headers?.["retry-after"] ?? headers?.["Retry-After"];
if (!raw) return undefined;
const seconds = Number(raw);
if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000);
const date = Date.parse(raw);
return Number.isFinite(date) ? Math.max(0, date - Date.now()) : undefined;
return parseRetryAfterHeader(headers?.["retry-after"] ?? headers?.["Retry-After"]);
}

function structuredOutputTransform(api: PiTextApi, schema: Record<string, unknown> | undefined) {
Expand Down Expand Up @@ -377,7 +372,7 @@ export class PiAiProvider implements LLMProvider {
signal: params.abortSignal,
maxTokens: params.maxTokens,
maxRetries: 0,
maxRetryDelayMs: MAX_RETRY_AFTER_MS,
maxRetryDelayMs: MAX_RETRY_AFTER_DELAY_MS,
sessionId: this.options.sessionId ?? params.promptCache?.routingKey,
cacheRetention: params.promptCache?.ttl === "5m" ? "short" : "none",
...(params.thinking?.type === "disabled" ? {} : { reasoning: params.effort ?? "medium" }),
Expand Down
3 changes: 3 additions & 0 deletions packages/sdk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -391,6 +391,9 @@ export {
isRateLimitError,
formatApiError,
getRetryDelay,
computeRetryDelay,
parseRetryAfterHeader,
MAX_RETRY_AFTER_DELAY_MS,
DEFAULT_RETRY_CONFIG,
} from './utils/retry.js'
export type { RetryConfig } from './utils/retry.js'
Expand Down
Loading