From c36ab3f93503cab8977b28eb1f84ed4385a57214 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 8 May 2026 10:22:45 -0400 Subject: [PATCH 001/189] fix(provider): align Gemini thinking controls (#26279) --- packages/opencode/src/provider/transform.ts | 36 ++-- .../opencode/test/provider/transform.test.ts | 169 ++++++++++-------- 2 files changed, 116 insertions(+), 89 deletions(-) diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 3f52f6a2aa..bd778dacc5 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -603,6 +603,29 @@ function anthropicAdaptiveEfforts(apiId: string): string[] | null { return null } +function googleThinkingLevelEfforts(apiId: string) { + const id = apiId.toLowerCase() + if (!id.includes("gemini-3")) return ["low", "high"] + if (id.includes("flash-image")) return ["minimal", "high"] + if (id.includes("pro-image")) return ["high"] + if (id.includes("flash")) return ["minimal", "low", "medium", "high"] + return ["low", "medium", "high"] +} + +function googleThinkingBudgetMax(apiId: string) { + const id = apiId.toLowerCase() + if (id.includes("2.5") && id.includes("pro") && !id.includes("flash")) return 32_768 + return 24_576 +} + +function googleSmallThinkingConfig(apiId: string) { + const levels = googleThinkingLevelEfforts(apiId) + if (apiId.toLowerCase().includes("gemini-3")) { + return { thinkingLevel: levels.includes("minimal") ? "minimal" : levels.includes("low") ? "low" : "high" } + } + return { thinkingBudget: googleThinkingBudgetMax(apiId) === 32_768 ? 128 : 0 } +} + export function variants(model: Provider.Model): Record> { if (!model.capabilities.reasoning) return {} @@ -908,18 +931,14 @@ export function variants(model: Provider.Model): Record [ + googleThinkingLevelEfforts(id).map((effort) => [ effort, { thinkingConfig: { @@ -1186,10 +1205,7 @@ export function smallOptions(model: Provider.Model) { } if (model.providerID === "google") { // gemini-3 uses thinkingLevel, gemini-2.5 uses thinkingBudget - if (model.api.id.includes("gemini-3")) { - return { thinkingConfig: { thinkingLevel: "minimal" } } - } - return { thinkingConfig: { thinkingBudget: 0 } } + return { thinkingConfig: googleSmallThinkingConfig(model.api.id) } } if (model.providerID === "openrouter" || model.providerID === "llmgateway") { if (model.api.id.includes("google")) { diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index c52a7bfa44..df21922b09 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -3292,89 +3292,74 @@ describe("ProviderTransform.variants", () => { }) }) - describe("@ai-sdk/google", () => { - test("gemini-2.5 returns high and max with thinkingConfig and thinkingBudget", () => { - const model = createMockModel({ - id: "google/gemini-2.5-pro", - providerID: "google", - api: { - id: "gemini-2.5-pro", - url: "https://generativelanguage.googleapis.com", - npm: "@ai-sdk/google", + for (const provider of [ + { name: "@ai-sdk/google", providerID: "google", url: "https://generativelanguage.googleapis.com" }, + { name: "@ai-sdk/google-vertex", providerID: "google-vertex", url: "https://vertexai.googleapis.com" }, + ]) { + describe(provider.name, () => { + for (const testCase of [ + { + apiId: "gemini-2.5-pro", + efforts: ["high", "max"], + expectedHigh: { thinkingConfig: { includeThoughts: true, thinkingBudget: 16_000 } }, + expectedMax: { thinkingConfig: { includeThoughts: true, thinkingBudget: 32_768 } }, }, - }) - const result = ProviderTransform.variants(model) - expect(Object.keys(result)).toEqual(["high", "max"]) - expect(result.high).toEqual({ - thinkingConfig: { - includeThoughts: true, - thinkingBudget: 16000, + { + apiId: "gemini-2.5-flash", + efforts: ["high", "max"], + expectedHigh: { thinkingConfig: { includeThoughts: true, thinkingBudget: 16_000 } }, + expectedMax: { thinkingConfig: { includeThoughts: true, thinkingBudget: 24_576 } }, }, - }) - expect(result.max).toEqual({ - thinkingConfig: { - includeThoughts: true, - thinkingBudget: 24576, + { + apiId: "gemini-3-pro-preview", + efforts: ["low", "medium", "high"], + expectedHigh: { thinkingConfig: { includeThoughts: true, thinkingLevel: "high" } }, }, - }) + { + apiId: "gemini-3.1-pro-preview", + efforts: ["low", "medium", "high"], + expectedHigh: { thinkingConfig: { includeThoughts: true, thinkingLevel: "high" } }, + }, + { + apiId: "gemini-3-flash-preview", + efforts: ["minimal", "low", "medium", "high"], + expectedHigh: { thinkingConfig: { includeThoughts: true, thinkingLevel: "high" } }, + }, + { + apiId: "gemini-3.1-flash-lite", + efforts: ["minimal", "low", "medium", "high"], + expectedHigh: { thinkingConfig: { includeThoughts: true, thinkingLevel: "high" } }, + }, + { + apiId: "gemini-3.1-flash-image-preview", + efforts: ["minimal", "high"], + expectedHigh: { thinkingConfig: { includeThoughts: true, thinkingLevel: "high" } }, + }, + { + apiId: "gemini-3-pro-image-preview", + efforts: ["high"], + expectedHigh: { thinkingConfig: { includeThoughts: true, thinkingLevel: "high" } }, + }, + ]) { + test(`${testCase.apiId} returns supported thinking controls`, () => { + const result = ProviderTransform.variants( + createMockModel({ + id: `${provider.providerID}/${testCase.apiId}`, + providerID: provider.providerID, + api: { + id: testCase.apiId, + url: provider.url, + npm: provider.name, + }, + }), + ) + expect(Object.keys(result)).toEqual(testCase.efforts) + expect(result.high).toEqual(testCase.expectedHigh) + if (testCase.expectedMax) expect(result.max).toEqual(testCase.expectedMax) + }) + } }) - - test("other gemini models return low and high with thinkingLevel", () => { - const model = createMockModel({ - id: "google/gemini-2.0-pro", - providerID: "google", - api: { - id: "gemini-2.0-pro", - url: "https://generativelanguage.googleapis.com", - npm: "@ai-sdk/google", - }, - }) - const result = ProviderTransform.variants(model) - expect(Object.keys(result)).toEqual(["low", "high"]) - expect(result.low).toEqual({ - thinkingConfig: { - includeThoughts: true, - thinkingLevel: "low", - }, - }) - expect(result.high).toEqual({ - thinkingConfig: { - includeThoughts: true, - thinkingLevel: "high", - }, - }) - }) - }) - - describe("@ai-sdk/google-vertex", () => { - test("gemini-2.5 returns high and max with thinkingConfig and thinkingBudget", () => { - const model = createMockModel({ - id: "google-vertex/gemini-2.5-pro", - providerID: "google-vertex", - api: { - id: "gemini-2.5-pro", - url: "https://vertexai.googleapis.com", - npm: "@ai-sdk/google-vertex", - }, - }) - const result = ProviderTransform.variants(model) - expect(Object.keys(result)).toEqual(["high", "max"]) - }) - - test("other vertex models return low and high with thinkingLevel", () => { - const model = createMockModel({ - id: "google-vertex/gemini-2.0-pro", - providerID: "google-vertex", - api: { - id: "gemini-2.0-pro", - url: "https://vertexai.googleapis.com", - npm: "@ai-sdk/google-vertex", - }, - }) - const result = ProviderTransform.variants(model) - expect(Object.keys(result)).toEqual(["low", "high"]) - }) - }) + } describe("@ai-sdk/cohere", () => { test("returns empty object", () => { @@ -3640,6 +3625,32 @@ describe("ProviderTransform.smallOptions - gpt-5 chat/search", () => { } }) +describe("ProviderTransform.smallOptions - google thinking controls", () => { + const createGoogleModel = (apiId: string) => + ({ + id: `google/${apiId}`, + providerID: "google", + api: { + id: apiId, + url: "https://generativelanguage.googleapis.com", + npm: "@ai-sdk/google", + }, + }) as any + + for (const testCase of [ + { id: "gemini-3-pro-preview", options: { thinkingConfig: { thinkingLevel: "low" } } }, + { id: "gemini-3-flash-preview", options: { thinkingConfig: { thinkingLevel: "minimal" } } }, + { id: "gemini-3.1-flash-image-preview", options: { thinkingConfig: { thinkingLevel: "minimal" } } }, + { id: "gemini-3-pro-image-preview", options: { thinkingConfig: { thinkingLevel: "high" } } }, + { id: "gemini-2.5-pro", options: { thinkingConfig: { thinkingBudget: 128 } } }, + { id: "gemini-2.5-flash", options: { thinkingConfig: { thinkingBudget: 0 } } }, + ]) { + test(`${testCase.id} returns supported small thinking options`, () => { + expect(ProviderTransform.smallOptions(createGoogleModel(testCase.id))).toEqual(testCase.options) + }) + } +}) + describe("ProviderTransform.providerOptions - ai-gateway-provider", () => { const createModel = (overrides: Partial = {}) => ({ From c818c9dcb629f05ca99d00f6efbc5095bd6d8a98 Mon Sep 17 00:00:00 2001 From: James Long Date: Fri, 8 May 2026 11:09:12 -0400 Subject: [PATCH 002/189] feat(core): allow external workspace creation (#26212) --- .../migration.sql | 1 + .../snapshot.json | 1459 +++++++++++++++++ .../cmd/tui/component/dialog-session-list.tsx | 1 + .../tui/component/dialog-workspace-create.tsx | 21 +- .../cli/cmd/tui/component/prompt/index.tsx | 2 + .../src/control-plane/adapters/index.ts | 20 +- .../src/control-plane/adapters/worktree.ts | 23 +- packages/opencode/src/control-plane/types.ts | 8 +- .../src/control-plane/workspace.sql.ts | 5 +- .../opencode/src/control-plane/workspace.ts | 98 +- .../src/server/routes/control/workspace.ts | 17 + .../instance/httpapi/groups/workspace.ts | 10 + .../instance/httpapi/handlers/workspace.ts | 7 +- packages/opencode/src/session/projectors.ts | 5 + packages/opencode/src/worktree/index.ts | 36 +- .../cmd/tui/dialog-workspace-create.test.ts | 49 +- .../test/control-plane/workspace.test.ts | 254 ++- .../test/server/httpapi-workspace.test.ts | 54 + packages/sdk/js/src/v2/gen/sdk.gen.ts | 31 + packages/sdk/js/src/v2/gen/types.gen.ts | 21 + packages/sdk/openapi.json | 62 +- 21 files changed, 2039 insertions(+), 145 deletions(-) create mode 100644 packages/opencode/migration/20260507164347_add_workspace_time/migration.sql create mode 100644 packages/opencode/migration/20260507164347_add_workspace_time/snapshot.json diff --git a/packages/opencode/migration/20260507164347_add_workspace_time/migration.sql b/packages/opencode/migration/20260507164347_add_workspace_time/migration.sql new file mode 100644 index 0000000000..bdeed6bce1 --- /dev/null +++ b/packages/opencode/migration/20260507164347_add_workspace_time/migration.sql @@ -0,0 +1 @@ +ALTER TABLE `workspace` ADD `time_used` integer NOT NULL; \ No newline at end of file diff --git a/packages/opencode/migration/20260507164347_add_workspace_time/snapshot.json b/packages/opencode/migration/20260507164347_add_workspace_time/snapshot.json new file mode 100644 index 0000000000..57da763bb9 --- /dev/null +++ b/packages/opencode/migration/20260507164347_add_workspace_time/snapshot.json @@ -0,0 +1,1459 @@ +{ + "version": "7", + "dialect": "sqlite", + "id": "630a93f2-c6c6-4191-a351-868d8f3a05d4", + "prevIds": ["27114226-085b-421a-9a40-29b88747e29a"], + "ddl": [ + { + "name": "account_state", + "entityType": "tables" + }, + { + "name": "account", + "entityType": "tables" + }, + { + "name": "control_account", + "entityType": "tables" + }, + { + "name": "workspace", + "entityType": "tables" + }, + { + "name": "project", + "entityType": "tables" + }, + { + "name": "message", + "entityType": "tables" + }, + { + "name": "part", + "entityType": "tables" + }, + { + "name": "permission", + "entityType": "tables" + }, + { + "name": "session_message", + "entityType": "tables" + }, + { + "name": "session", + "entityType": "tables" + }, + { + "name": "todo", + "entityType": "tables" + }, + { + "name": "session_share", + "entityType": "tables" + }, + { + "name": "event_sequence", + "entityType": "tables" + }, + { + "name": "event", + "entityType": "tables" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active_account_id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active_org_id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "access_token", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "refresh_token", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "token_expiry", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "access_token", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "refresh_token", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "token_expiry", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "''", + "generated": null, + "name": "name", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "branch", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "directory", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "extra", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_used", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "worktree", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "vcs", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_url", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_url_override", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_color", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_initialized", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "sandboxes", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "commands", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "message_id", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "part" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "part" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "permission" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "permission" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "permission" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "permission" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "parent_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "slug", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "directory", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "path", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "title", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "version", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "share_url", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_additions", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_deletions", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_files", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_diffs", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "revert", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "permission", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "agent", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "model", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_compacting", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_archived", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "content", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "status", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "priority", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "position", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "secret", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "aggregate_id", + "entityType": "columns", + "table": "event_sequence" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "seq", + "entityType": "columns", + "table": "event_sequence" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "owner_id", + "entityType": "columns", + "table": "event_sequence" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "aggregate_id", + "entityType": "columns", + "table": "event" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "seq", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "event" + }, + { + "columns": ["active_account_id"], + "tableTo": "account", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "nameExplicit": false, + "name": "fk_account_state_active_account_id_account_id_fk", + "entityType": "fks", + "table": "account_state" + }, + { + "columns": ["project_id"], + "tableTo": "project", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_workspace_project_id_project_id_fk", + "entityType": "fks", + "table": "workspace" + }, + { + "columns": ["session_id"], + "tableTo": "session", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_message_session_id_session_id_fk", + "entityType": "fks", + "table": "message" + }, + { + "columns": ["message_id"], + "tableTo": "message", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_part_message_id_message_id_fk", + "entityType": "fks", + "table": "part" + }, + { + "columns": ["project_id"], + "tableTo": "project", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_permission_project_id_project_id_fk", + "entityType": "fks", + "table": "permission" + }, + { + "columns": ["session_id"], + "tableTo": "session", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_message_session_id_session_id_fk", + "entityType": "fks", + "table": "session_message" + }, + { + "columns": ["project_id"], + "tableTo": "project", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_project_id_project_id_fk", + "entityType": "fks", + "table": "session" + }, + { + "columns": ["session_id"], + "tableTo": "session", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_todo_session_id_session_id_fk", + "entityType": "fks", + "table": "todo" + }, + { + "columns": ["session_id"], + "tableTo": "session", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_share_session_id_session_id_fk", + "entityType": "fks", + "table": "session_share" + }, + { + "columns": ["aggregate_id"], + "tableTo": "event_sequence", + "columnsTo": ["aggregate_id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_event_aggregate_id_event_sequence_aggregate_id_fk", + "entityType": "fks", + "table": "event" + }, + { + "columns": ["email", "url"], + "nameExplicit": false, + "name": "control_account_pk", + "entityType": "pks", + "table": "control_account" + }, + { + "columns": ["session_id", "position"], + "nameExplicit": false, + "name": "todo_pk", + "entityType": "pks", + "table": "todo" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "account_state_pk", + "table": "account_state", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "account_pk", + "table": "account", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "workspace_pk", + "table": "workspace", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "project_pk", + "table": "project", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "message_pk", + "table": "message", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "part_pk", + "table": "part", + "entityType": "pks" + }, + { + "columns": ["project_id"], + "nameExplicit": false, + "name": "permission_pk", + "table": "permission", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "session_message_pk", + "table": "session_message", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "session_pk", + "table": "session", + "entityType": "pks" + }, + { + "columns": ["session_id"], + "nameExplicit": false, + "name": "session_share_pk", + "table": "session_share", + "entityType": "pks" + }, + { + "columns": ["aggregate_id"], + "nameExplicit": false, + "name": "event_sequence_pk", + "table": "event_sequence", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "event_pk", + "table": "event", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "time_created", + "isExpression": false + }, + { + "value": "id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "message_session_time_created_id_idx", + "entityType": "indexes", + "table": "message" + }, + { + "columns": [ + { + "value": "message_id", + "isExpression": false + }, + { + "value": "id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "part_message_id_id_idx", + "entityType": "indexes", + "table": "part" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "part_session_idx", + "entityType": "indexes", + "table": "part" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_message_session_idx", + "entityType": "indexes", + "table": "session_message" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "type", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_message_session_type_idx", + "entityType": "indexes", + "table": "session_message" + }, + { + "columns": [ + { + "value": "time_created", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_message_time_created_idx", + "entityType": "indexes", + "table": "session_message" + }, + { + "columns": [ + { + "value": "project_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_project_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_workspace_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "parent_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_parent_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "todo_session_idx", + "entityType": "indexes", + "table": "todo" + } + ], + "renames": [] +} diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx index 542449f5df..195221b88f 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx @@ -107,6 +107,7 @@ export function DialogSessionList() { dialog, sdk, sync, + project, toast, onSelect: (selection) => { void warp(selection) diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx index d7e212ab15..538428e8f1 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx @@ -36,21 +36,14 @@ export type WorkspaceSelection = type WorkspaceSelectValue = WorkspaceSelection | { type: "existing-list" } type ExistingWorkspaceSelectValue = { workspace: Workspace } -export function recentConnectedWorkspaces(input: { - sessions: readonly { workspaceID?: string; time: { updated: number } }[] - get: (workspaceID: string) => WorkspaceInfo | undefined +export function recentConnectedWorkspaces(input: { + workspaces: readonly WorkspaceInfo[] status: (workspaceID: string) => string | undefined limit?: number omitWorkspaceID?: string }) { - const workspaces = input.sessions - .toSorted((a, b) => b.time.updated - a.time.updated) - .flatMap((session) => { - const workspace = session.workspaceID ? input.get(session.workspaceID) : undefined - return workspace && input.status(workspace.id) === "connected" ? [workspace] : [] - }) - .filter((workspace) => workspace.id !== input.omitWorkspaceID) - .filter((workspace, index, list) => list.findIndex((item) => item.id === workspace.id) === index) + const allWorkspaces = input.workspaces.filter((workspace) => input.status(workspace.id) === "connected") + const workspaces = allWorkspaces.toSorted((a, b) => Number(b.timeUsed) - Number(a.timeUsed)) const recent = workspaces.slice(0, input.limit ?? 3) return { recent, hasMore: recent.length < workspaces.length } @@ -83,10 +76,13 @@ export async function openWorkspaceSelect(input: { dialog: ReturnType sdk: ReturnType sync: ReturnType + project: ReturnType toast: ReturnType onSelect: (selection: WorkspaceSelection) => Promise | void }) { input.dialog.clear() + await input.sdk.client.experimental.workspace.syncList().catch(() => undefined) + await input.project.workspace.sync().catch(() => undefined) const adapters = await loadWorkspaceAdapters(input) if (!adapters) return input.dialog.replace(() => ) @@ -200,8 +196,7 @@ export function DialogWorkspaceSelect(props: { const list = adapters() if (!list) return [] const { recent, hasMore } = recentConnectedWorkspaces({ - sessions: sync.data.session, - get: project.workspace.get, + workspaces: project.workspace.list(), status: project.workspace.status, omitWorkspaceID: omittedWorkspaceID(), }) diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index cafb1ba373..c6bcb89924 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -610,6 +610,7 @@ export function Prompt(props: PromptProps) { dialog, sdk, sync, + project, toast, onSelect: (selection) => { void warpSession(selection) @@ -1036,6 +1037,7 @@ export function Prompt(props: PromptProps) { dialog, sdk, sync, + project, toast, onSelect: (selection) => { void warpSession(selection) diff --git a/packages/opencode/src/control-plane/adapters/index.ts b/packages/opencode/src/control-plane/adapters/index.ts index 963e2a2ed5..e5fa13714b 100644 --- a/packages/opencode/src/control-plane/adapters/index.ts +++ b/packages/opencode/src/control-plane/adapters/index.ts @@ -18,22 +18,18 @@ export function getAdapter(projectID: ProjectID, type: string): WorkspaceAdapter throw new Error(`Unknown workspace adapter: ${type}`) } -export async function listAdapters(projectID: ProjectID): Promise { - const builtin = await Promise.all( - Object.entries(BUILTIN).map(async ([type, adapter]) => { - return { - type, - name: adapter.name, - description: adapter.description, - } - }), - ) - const custom = [...(state.get(projectID)?.entries() ?? [])].map(([type, adapter]) => ({ +export function listAdapters(projectID: ProjectID): WorkspaceAdapterEntry[] { + return registeredAdapters(projectID).map(([type, adapter]) => ({ type, name: adapter.name, description: adapter.description, })) - return [...builtin, ...custom] +} + +export function registeredAdapters(projectID: ProjectID): [string, WorkspaceAdapter][] { + const adapters = new Map(Object.entries(BUILTIN)) + for (const [type, adapter] of state.get(projectID)?.entries() ?? []) adapters.set(type, adapter) + return [...adapters.entries()] } // Plugins can be loaded per-project so we need to scope them. If you diff --git a/packages/opencode/src/control-plane/adapters/worktree.ts b/packages/opencode/src/control-plane/adapters/worktree.ts index af8f5d8d43..605d114ace 100644 --- a/packages/opencode/src/control-plane/adapters/worktree.ts +++ b/packages/opencode/src/control-plane/adapters/worktree.ts @@ -3,14 +3,18 @@ import { type WorkspaceAdapter, WorkspaceInfo } from "../types" const WorktreeConfig = Schema.Struct({ name: WorkspaceInfo.fields.name, - branch: Schema.String, + branch: Schema.optional(Schema.NullOr(Schema.String)), directory: Schema.String, }) const decodeWorktreeConfig = Schema.decodeUnknownSync(WorktreeConfig) async function loadWorktree() { - const [{ AppRuntime }, { Worktree }] = await Promise.all([import("@/effect/app-runtime"), import("@/worktree")]) - return { AppRuntime, Worktree } + const [{ AppRuntime }, { Instance }, { Worktree }] = await Promise.all([ + import("@/effect/app-runtime"), + import("@/project/instance"), + import("@/worktree"), + ]) + return { AppRuntime, Instance, Worktree } } export const WorktreeAdapter: WorkspaceAdapter = { @@ -34,11 +38,22 @@ export const WorktreeAdapter: WorkspaceAdapter = { svc.createFromInfo({ name: config.name, directory: config.directory, - branch: config.branch, + branch: config.branch ?? config.name, }), ), ) }, + async list() { + const { AppRuntime, Instance, Worktree } = await loadWorktree() + return (await AppRuntime.runPromise(Worktree.Service.use((svc) => svc.list()))).map((info) => ({ + type: "worktree", + name: info.name, + branch: info.branch ?? null, + directory: info.directory, + extra: null, + projectID: Instance.project.id, + })) + }, async remove(info) { const { AppRuntime, Worktree } = await loadWorktree() const config = decodeWorktreeConfig(info) diff --git a/packages/opencode/src/control-plane/types.ts b/packages/opencode/src/control-plane/types.ts index 7f3aad7ed1..5acb5c827e 100644 --- a/packages/opencode/src/control-plane/types.ts +++ b/packages/opencode/src/control-plane/types.ts @@ -1,4 +1,4 @@ -import { Schema } from "effect" +import { Schema, Struct } from "effect" import { ProjectID } from "@/project/schema" import { WorkspaceID } from "./schema" import { zod } from "@/util/effect-zod" @@ -17,6 +17,11 @@ export const WorkspaceInfo = Schema.Struct({ .pipe(withStatics((s) => ({ zod: zod(s) }))) export type WorkspaceInfo = DeepMutable> +export const WorkspaceListedInfo = Schema.Struct(Struct.omit(WorkspaceInfo.fields, ["id"])) + .annotate({ identifier: "WorkspaceListedInfo" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type WorkspaceListedInfo = DeepMutable> + export const WorkspaceAdapterEntry = Schema.Struct({ type: Schema.String, name: Schema.String, @@ -40,6 +45,7 @@ export type WorkspaceAdapter = { description: string configure(info: WorkspaceInfo): WorkspaceInfo | Promise create(info: WorkspaceInfo, env: Record, from?: WorkspaceInfo): Promise + list?(): WorkspaceListedInfo[] | Promise remove(info: WorkspaceInfo): Promise target(info: WorkspaceInfo): Target | Promise } diff --git a/packages/opencode/src/control-plane/workspace.sql.ts b/packages/opencode/src/control-plane/workspace.sql.ts index a6a4ce2c86..1afaf7cbc9 100644 --- a/packages/opencode/src/control-plane/workspace.sql.ts +++ b/packages/opencode/src/control-plane/workspace.sql.ts @@ -1,4 +1,4 @@ -import { sqliteTable, text } from "drizzle-orm/sqlite-core" +import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core" import { ProjectTable } from "../project/project.sql" import type { ProjectID } from "../project/schema" import type { WorkspaceID } from "./schema" @@ -14,4 +14,7 @@ export const WorkspaceTable = sqliteTable("workspace", { .$type() .notNull() .references(() => ProjectTable.id, { onDelete: "cascade" }), + time_used: integer() + .notNull() + .$default(() => Date.now()), }) diff --git a/packages/opencode/src/control-plane/workspace.ts b/packages/opencode/src/control-plane/workspace.ts index f9bab469b7..03640576d6 100644 --- a/packages/opencode/src/control-plane/workspace.ts +++ b/packages/opencode/src/control-plane/workspace.ts @@ -17,7 +17,7 @@ import { Filesystem } from "@/util/filesystem" import { ProjectID } from "@/project/schema" import { Slug } from "@opencode-ai/core/util/slug" import { WorkspaceTable } from "./workspace.sql" -import { getAdapter } from "./adapters" +import { getAdapter, registeredAdapters } from "./adapters" import { type Target, type WorkspaceInfo, WorkspaceInfo as WorkspaceInfoSchema } from "./types" import { WorkspaceID } from "./schema" import { Session } from "@/session/session" @@ -35,8 +35,13 @@ import { Vcs } from "@/project/vcs" import { InstanceStore } from "@/project/instance-store" import { InstanceBootstrap } from "@/project/bootstrap" -export const Info = WorkspaceInfoSchema -export type Info = WorkspaceInfo +export const Info = Schema.Struct({ + ...WorkspaceInfoSchema.fields, + timeUsed: Schema.Number, +}) + .annotate({ identifier: "Workspace" }) + .pipe(withStatics((s) => ({ zod: effectZod(s) }))) +export type Info = WorkspaceInfo & { timeUsed: number } export const ConnectionStatus = Schema.Struct({ workspaceID: WorkspaceID, @@ -69,6 +74,7 @@ function fromRow(row: typeof WorkspaceTable.$inferSelect): Info { directory: row.directory, extra: row.extra, projectID: row.project_id, + timeUsed: row.time_used, } } @@ -150,6 +156,7 @@ export interface Interface { readonly create: (input: CreateInput) => Effect.Effect readonly sessionWarp: (input: SessionWarpInput) => Effect.Effect readonly list: (project: Project.Info) => Effect.Effect + readonly syncList: (project: Project.Info) => Effect.Effect readonly get: (id: WorkspaceID) => Effect.Effect readonly remove: (id: WorkspaceID) => Effect.Effect readonly status: () => Effect.Effect @@ -483,7 +490,19 @@ export const layer = Layer.effect( if (!Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) return const adapter = getAdapter(space.projectID, space.type) - const target = yield* EffectBridge.fromPromise(() => adapter.target(space)) + const target = yield* EffectBridge.fromPromise(() => adapter.target(space)).pipe( + Effect.catch((error) => + Effect.sync(() => { + setStatus(space.id, "error") + log.warn("workspace target failed", { + workspaceID: space.id, + error: errorData(error), + }) + return null + }), + ), + ) + if (!target) return if (target.type === "local") { setStatus(space.id, (yield* Effect.promise(() => Filesystem.exists(target.directory))) ? "connected" : "error") @@ -523,7 +542,13 @@ export const layer = Layer.effect( const id = WorkspaceID.ascending(input.id) const adapter = getAdapter(input.projectID, input.type) const config = yield* EffectBridge.fromPromise(() => - adapter.configure({ ...input, id, name: Slug.create(), directory: null, extra: input.extra ?? null }), + adapter.configure({ + ...input, + id, + name: Slug.create(), + directory: null, + extra: input.extra ?? null, + }), ) const info: Info = { @@ -534,6 +559,7 @@ export const layer = Layer.effect( directory: config.directory ?? null, extra: config.extra ?? null, projectID: input.projectID, + timeUsed: Date.now(), } yield* db((db) => { @@ -546,6 +572,7 @@ export const layer = Layer.effect( directory: info.directory, extra: info.extra, project_id: info.projectID, + time_used: info.timeUsed, }) .run() }) @@ -828,6 +855,63 @@ export const layer = Layer.effect( ) }) + const syncList = Effect.fn("Workspace.syncList")(function* (project: Project.Info) { + const names = new Set((yield* list(project)).map((workspace) => workspace.name)) + const discovered = yield* Effect.forEach( + registeredAdapters(project.id), + ([type, adapter]) => + adapter.list + ? EffectBridge.fromPromise(() => Promise.resolve(adapter.list?.() ?? [])).pipe( + Effect.catchCause((error) => + Effect.sync(() => { + log.warn("workspace adapter list failed", { type, error }) + return [] + }), + ), + ) + : Effect.succeed([]), + { concurrency: "unbounded" }, + ).pipe(Effect.map((items) => items.flat())) + + yield* Effect.forEach( + discovered, + (item) => + Effect.gen(function* () { + if (names.has(item.name)) return + names.add(item.name) + + const info: Info = { + id: WorkspaceID.ascending(), + type: item.type, + branch: item.branch, + name: item.name, + directory: item.directory, + extra: item.extra, + projectID: item.projectID, + timeUsed: Date.now(), + } + + yield* db((db) => { + db.insert(WorkspaceTable) + .values({ + id: info.id, + type: info.type, + branch: info.branch, + name: info.name, + directory: info.directory, + extra: info.extra, + project_id: info.projectID, + time_used: info.timeUsed, + }) + .run() + }) + + yield* startSync(info) + }), + { concurrency: 1 }, + ) + }) + const get = Effect.fn("Workspace.get")(function* (id: WorkspaceID) { const row = yield* db((db) => db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get()) if (!row) return @@ -916,13 +1000,10 @@ export const layer = Layer.effect( }) const startWorkspaceSyncing = Effect.fn("Workspace.startWorkspaceSyncing")(function* (projectID: ProjectID) { - // This session table join makes this query only return - // workspaces that have sessions const rows = yield* db((db) => db .selectDistinct({ workspace: WorkspaceTable }) .from(WorkspaceTable) - .innerJoin(SessionTable, eq(SessionTable.workspace_id, WorkspaceTable.id)) .where(eq(WorkspaceTable.project_id, projectID)) .all(), ) @@ -947,6 +1028,7 @@ export const layer = Layer.effect( create, sessionWarp, list, + syncList, get, remove, status, diff --git a/packages/opencode/src/server/routes/control/workspace.ts b/packages/opencode/src/server/routes/control/workspace.ts index 0c1bf252ed..799294b261 100644 --- a/packages/opencode/src/server/routes/control/workspace.ts +++ b/packages/opencode/src/server/routes/control/workspace.ts @@ -93,6 +93,23 @@ export const WorkspaceRoutes = lazy(() => return c.json(await AppRuntime.runPromise(Workspace.Service.use((svc) => svc.list(Instance.project)))) }, ) + .post( + "/sync-list", + describeRoute({ + summary: "Sync workspace list", + description: "Register missing workspaces returned by workspace adapters.", + operationId: "experimental.workspace.syncList", + responses: { + 204: { + description: "Workspace list synced", + }, + }, + }), + async (c) => { + await AppRuntime.runPromise(Workspace.Service.use((svc) => svc.syncList(Instance.project))) + return c.body(null, 204) + }, + ) .get( "/status", describeRoute({ diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/workspace.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/workspace.ts index 66422c13b6..1a9732800d 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/workspace.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/workspace.ts @@ -29,6 +29,7 @@ export class ApiWorkspaceWarpError extends Schema.ErrorClass listAdapters(instance.project.id)) + return yield* Effect.sync(() => listAdapters(instance.project.id)) }) const list = Effect.fn("WorkspaceHttpApi.list")(function* () { @@ -32,6 +32,10 @@ export const workspaceHandlers = HttpApiBuilder.group(InstanceHttpApi, "workspac .pipe(Effect.mapError(() => new HttpApiError.BadRequest({}))) }) + const syncList = Effect.fn("WorkspaceHttpApi.syncList")(function* () { + yield* workspace.syncList((yield* InstanceState.context).project) + }) + const status = Effect.fn("WorkspaceHttpApi.status")(function* () { const ids = new Set((yield* workspace.list((yield* InstanceState.context).project)).map((item) => item.id)) return (yield* workspace.status()).filter((item) => ids.has(item.workspaceID)) @@ -73,6 +77,7 @@ export const workspaceHandlers = HttpApiBuilder.group(InstanceHttpApi, "workspac .handle("adapters", adapters) .handle("list", list) .handle("create", create) + .handle("syncList", syncList) .handle("status", status) .handle("remove", remove) .handle("warp", warp) diff --git a/packages/opencode/src/session/projectors.ts b/packages/opencode/src/session/projectors.ts index 9819ad810f..93acd4546d 100644 --- a/packages/opencode/src/session/projectors.ts +++ b/packages/opencode/src/session/projectors.ts @@ -5,6 +5,7 @@ import { SyncEvent } from "@/sync" import * as Session from "./session" import { MessageV2 } from "./message-v2" import { SessionTable, MessageTable, PartTable } from "./session.sql" +import { WorkspaceTable } from "@/control-plane/workspace.sql" import { Log } from "@opencode-ai/core/util/log" import nextProjectors from "./projectors-next" @@ -69,6 +70,10 @@ export default [ db.insert(SessionTable) .values(Session.toRow(data.info as Session.Info)) .run() + + if (data.info.workspaceID) { + db.update(WorkspaceTable).set({ time_used: Date.now() }).where(eq(WorkspaceTable.id, data.info.workspaceID)).run() + } }), SyncEvent.project(Session.Event.Updated, (db, data) => { diff --git a/packages/opencode/src/worktree/index.ts b/packages/opencode/src/worktree/index.ts index f4e4d2721c..088dc9eb35 100644 --- a/packages/opencode/src/worktree/index.ts +++ b/packages/opencode/src/worktree/index.ts @@ -117,6 +117,13 @@ export const ResetFailedError = NamedError.create( }), ) +export const ListFailedError = NamedError.create( + "WorktreeListFailedError", + z.object({ + message: z.string(), + }), +) + function slugify(input: string) { return input .trim() @@ -149,6 +156,7 @@ export interface Interface { readonly makeWorktreeInfo: (name?: string) => Effect.Effect readonly createFromInfo: (info: Info, startCommand?: string) => Effect.Effect readonly create: (input?: CreateInput) => Effect.Effect + readonly list: () => Effect.Effect<(Omit & { branch?: string })[]> readonly remove: (input: RemoveInput) => Effect.Effect readonly reset: (input: ResetInput) => Effect.Effect } @@ -341,6 +349,32 @@ export const layer: Layer.Layer< return undefined }) + const list = Effect.fn("Worktree.list")(function* () { + const ctx = yield* InstanceState.context + if (ctx.project.vcs !== "git") { + return [] + } + + const result = yield* git(["worktree", "list", "--porcelain"], { cwd: ctx.worktree }) + if (result.code !== 0) { + throw new ListFailedError({ message: result.stderr || result.text || "Failed to read git worktrees" }) + } + + const primary = yield* canonical(ctx.worktree) + return yield* Effect.forEach(parseWorktreeList(result.text), (entry) => + Effect.gen(function* () { + if (!entry.path) return undefined + const directory = yield* canonical(entry.path) + if (directory === primary) return undefined + return { + name: pathSvc.basename(directory), + directory, + ...(entry.branch ? { branch: entry.branch.replace(/^refs\/heads\//, "") } : {}), + } + }), + ).pipe(Effect.map((items) => items.filter((item) => item !== undefined))) + }) + function stopFsmonitor(target: string) { return fs.exists(target).pipe( Effect.orDie, @@ -579,7 +613,7 @@ export const layer: Layer.Layer< return true }) - return Service.of({ makeWorktreeInfo, createFromInfo, create, remove, reset }) + return Service.of({ makeWorktreeInfo, createFromInfo, create, list, remove, reset }) }), ) diff --git a/packages/opencode/test/cli/cmd/tui/dialog-workspace-create.test.ts b/packages/opencode/test/cli/cmd/tui/dialog-workspace-create.test.ts index a32dc61125..00b480ca00 100644 --- a/packages/opencode/test/cli/cmd/tui/dialog-workspace-create.test.ts +++ b/packages/opencode/test/cli/cmd/tui/dialog-workspace-create.test.ts @@ -2,13 +2,13 @@ import { describe, expect, test } from "bun:test" import { recentConnectedWorkspaces } from "../../../../src/cli/cmd/tui/component/dialog-workspace-create" describe("recentConnectedWorkspaces", () => { - test("returns unique connected workspaces after filtering missing and inactive entries", () => { + test("returns connected workspaces sorted by time used", () => { const workspaces = [ - { id: "wrk_a", name: "alpha" }, - { id: "wrk_b", name: "beta" }, - { id: "wrk_c", name: "gamma" }, - { id: "wrk_d", name: "delta" }, - { id: "wrk_e", name: "epsilon" }, + { id: "wrk_a", name: "alpha", timeUsed: 700 }, + { id: "wrk_b", name: "beta", timeUsed: 800 }, + { id: "wrk_c", name: "gamma", timeUsed: 400 }, + { id: "wrk_d", name: "delta", timeUsed: 300 }, + { id: "wrk_e", name: "epsilon", timeUsed: 200 }, ] const status = { wrk_a: "connected", @@ -19,45 +19,10 @@ describe("recentConnectedWorkspaces", () => { } as const const { recent } = recentConnectedWorkspaces({ - sessions: [ - { time: { updated: 900 } }, - { workspaceID: "wrk_b", time: { updated: 800 } }, - { workspaceID: "wrk_a", time: { updated: 700 } }, - { workspaceID: "wrk_a", time: { updated: 600 } }, - { workspaceID: "wrk_missing", time: { updated: 500 } }, - { workspaceID: "wrk_c", time: { updated: 400 } }, - { workspaceID: "wrk_d", time: { updated: 300 } }, - { workspaceID: "wrk_e", time: { updated: 200 } }, - ], - get: (workspaceID) => workspaces.find((workspace) => workspace.id === workspaceID), + workspaces, status: (workspaceID) => status[workspaceID as keyof typeof status], }) expect(recent.map((workspace) => workspace.id)).toEqual(["wrk_a", "wrk_d", "wrk_e"]) }) - - test("omits the active workspace before limiting recent workspaces", () => { - const workspaces = [ - { id: "wrk_a", name: "alpha" }, - { id: "wrk_b", name: "beta" }, - { id: "wrk_c", name: "gamma" }, - { id: "wrk_d", name: "delta" }, - ] - - const { recent, hasMore } = recentConnectedWorkspaces({ - sessions: [ - { workspaceID: "wrk_a", time: { updated: 400 } }, - { workspaceID: "wrk_b", time: { updated: 300 } }, - { workspaceID: "wrk_c", time: { updated: 200 } }, - { workspaceID: "wrk_d", time: { updated: 100 } }, - ], - get: (workspaceID) => workspaces.find((workspace) => workspace.id === workspaceID), - status: () => "connected", - limit: 3, - omitWorkspaceID: "wrk_a", - }) - - expect(recent.map((workspace) => workspace.id)).toEqual(["wrk_b", "wrk_c", "wrk_d"]) - expect(hasMore).toBe(false) - }) }) diff --git a/packages/opencode/test/control-plane/workspace.test.ts b/packages/opencode/test/control-plane/workspace.test.ts index 0eba431e1a..c29a09eb14 100644 --- a/packages/opencode/test/control-plane/workspace.test.ts +++ b/packages/opencode/test/control-plane/workspace.test.ts @@ -28,7 +28,7 @@ import { registerAdapter } from "../../src/control-plane/adapters" import { WorkspaceID } from "../../src/control-plane/schema" import { WorkspaceTable } from "../../src/control-plane/workspace.sql" import type { Target, WorkspaceAdapter, WorkspaceInfo } from "../../src/control-plane/types" -import * as WorkspaceOld from "../../src/control-plane/workspace" +import * as Workspace from "../../src/control-plane/workspace" import { AppRuntime } from "@/effect/app-runtime" import { InstanceStore } from "@/project/instance-store" import { InstanceBootstrap } from "@/project/bootstrap" @@ -37,7 +37,7 @@ void Log.init({ print: false }) const testServerLayer = Layer.mergeAll( NodeHttpServer.layer(Http.createServer, { host: "127.0.0.1", port: 0 }), - WorkspaceOld.defaultLayer.pipe( + Workspace.defaultLayer.pipe( Layer.provide(InstanceStore.defaultLayer), Layer.provide(InstanceBootstrap.defaultLayer), ), @@ -64,6 +64,7 @@ type RecordedAdapter = { calls: { configure: WorkspaceInfo[] create: RecordedCreate[] + list: number remove: WorkspaceInfo[] target: WorkspaceInfo[] } @@ -125,23 +126,25 @@ async function initGitRepo(dir: string) { await $`git commit -m "base"`.cwd(dir).quiet() } -const runWorkspace = (effect: Effect.Effect) => AppRuntime.runPromise(effect) -const createWorkspace = (input: WorkspaceOld.CreateInput) => - runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.create(input))) -const warpWorkspaceSession = (input: WorkspaceOld.SessionWarpInput) => - runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.sessionWarp(input))) -const listWorkspaces = (project: Parameters[0]) => - runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.list(project))) -const getWorkspace = (id: WorkspaceID) => runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.get(id))) -const removeWorkspace = (id: WorkspaceID) => runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.remove(id))) -const workspaceStatus = () => runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.status())) +const runWorkspace = (effect: Effect.Effect) => AppRuntime.runPromise(effect) +const createWorkspace = (input: Workspace.CreateInput) => + runWorkspace(Workspace.Service.use((workspace) => workspace.create(input))) +const warpWorkspaceSession = (input: Workspace.SessionWarpInput) => + runWorkspace(Workspace.Service.use((workspace) => workspace.sessionWarp(input))) +const listWorkspaces = (project: Parameters[0]) => + runWorkspace(Workspace.Service.use((workspace) => workspace.list(project))) +const syncListWorkspaces = (project: Parameters[0]) => + runWorkspace(Workspace.Service.use((workspace) => workspace.syncList(project))) +const getWorkspace = (id: WorkspaceID) => runWorkspace(Workspace.Service.use((workspace) => workspace.get(id))) +const removeWorkspace = (id: WorkspaceID) => runWorkspace(Workspace.Service.use((workspace) => workspace.remove(id))) +const workspaceStatus = () => runWorkspace(Workspace.Service.use((workspace) => workspace.status())) const isWorkspaceSyncing = (id: WorkspaceID) => - runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.isSyncing(id))) + runWorkspace(Workspace.Service.use((workspace) => workspace.isSyncing(id))) const startWorkspaceSyncing = (projectID: ProjectID) => { - void runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.startWorkspaceSyncing(projectID))) + void runWorkspace(Workspace.Service.use((workspace) => workspace.startWorkspaceSyncing(projectID))) } const waitForWorkspaceSync = (workspaceID: WorkspaceID, state: Record, signal?: AbortSignal) => - runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.waitForSync(workspaceID, state, signal))) + runWorkspace(Workspace.Service.use((workspace) => workspace.waitForSync(workspaceID, state, signal))) function captureGlobalEvents() { const events: GlobalEvent[] = [] @@ -187,11 +190,13 @@ function recordedAdapter(input: { target: (info: WorkspaceInfo) => Target | Promise configure?: (info: WorkspaceInfo) => WorkspaceInfo | Promise create?: (info: WorkspaceInfo, env: Record, from?: WorkspaceInfo) => Promise + list?: () => Omit[] | Promise[]> remove?: (info: WorkspaceInfo) => Promise }): RecordedAdapter { const calls: RecordedAdapter["calls"] = { configure: [], create: [], + list: 0, remove: [], target: [], } @@ -213,6 +218,14 @@ function recordedAdapter(input: { }) await input.create?.(info, env, from) }, + ...(input.list + ? { + async list() { + calls.list += 1 + return input.list?.() ?? [] + }, + } + : {}), async remove(info) { calls.remove.push(structuredClone(info)) await input.remove?.(info) @@ -272,7 +285,7 @@ function serverUrl() { }) } -function workspaceInfo(projectID: ProjectID, type: string, input?: Partial): WorkspaceInfo { +function workspaceInfo(projectID: ProjectID, type: string, input?: Partial): Workspace.Info { return { id: input?.id ?? WorkspaceID.ascending(), type, @@ -281,10 +294,11 @@ function workspaceInfo(projectID: ProjectID, type: string, input?: Partial db .insert(WorkspaceTable) @@ -296,6 +310,7 @@ function insertWorkspace(info: WorkspaceInfo) { directory: info.directory, extra: info.extra, project_id: info.projectID, + time_used: info.timeUsed, }) .run(), ) @@ -348,11 +363,11 @@ function sessionUpdatedType() { return SyncEvent.versionedType(SessionNs.Event.Updated.type, SessionNs.Event.Updated.version) } -describe("workspace-old schemas and exports", () => { +describe("workspace schemas and exports", () => { test("keeps the historical event type names", () => { - expect(WorkspaceOld.Event.Ready.type).toBe("workspace.ready") - expect(WorkspaceOld.Event.Failed.type).toBe("workspace.failed") - expect(WorkspaceOld.Event.Status.type).toBe("workspace.status") + expect(Workspace.Event.Ready.type).toBe("workspace.ready") + expect(Workspace.Event.Failed.type).toBe("workspace.failed") + expect(Workspace.Event.Status.type).toBe("workspace.status") }) test("validates create input with workspace id, project id, branch, type, and extra", () => { @@ -364,13 +379,13 @@ describe("workspace-old schemas and exports", () => { extra: { nested: true }, } - expect(WorkspaceOld.CreateInput.zod.parse(input)).toEqual(input) - expect(() => WorkspaceOld.CreateInput.zod.parse({ ...input, id: "bad" })).toThrow() - expect(() => WorkspaceOld.CreateInput.zod.parse({ ...input, branch: 1 })).toThrow() + expect(Workspace.CreateInput.zod.parse(input)).toEqual(input) + expect(() => Workspace.CreateInput.zod.parse({ ...input, id: "bad" })).toThrow() + expect(() => Workspace.CreateInput.zod.parse({ ...input, branch: 1 })).toThrow() }) }) -describe("workspace-old CRUD", () => { +describe("workspace CRUD", () => { test("get returns undefined for a missing workspace", async () => { await withInstance(async () => { expect(await getWorkspace(WorkspaceID.ascending("wrk_missing_get"))).toBeUndefined() @@ -447,13 +462,22 @@ describe("workspace-old CRUD", () => { directory: targetDir, extra: { configured: true }, projectID: Instance.project.id, + timeUsed: info.timeUsed, }) expect(await getWorkspace(workspaceID)).toEqual(info) expect(await listWorkspaces(Instance.project)).toEqual([info]) expect(recorded.calls.configure).toHaveLength(1) expect(recorded.calls.configure[0]).toMatchObject({ id: workspaceID, type, directory: null }) expect(recorded.calls.create).toHaveLength(1) - expect(recorded.calls.create[0].info).toEqual(info) + expect(recorded.calls.create[0].info).toEqual({ + id: workspaceID, + type, + branch: "configured-branch", + name: "Configured Name", + directory: targetDir, + extra: { configured: true }, + projectID: Instance.project.id, + }) expect(JSON.parse(recorded.calls.create[0].env.OPENCODE_AUTH_CONTENT ?? "{}")).toEqual({ test: { type: "api", key: "secret" }, }) @@ -532,6 +556,120 @@ describe("workspace-old CRUD", () => { }) }) + test("syncList registers adapter-listed workspaces that are missing by name", async () => { + await withInstance(async (dir) => { + const type = unique("list-sync") + const existing = workspaceInfo(Instance.project.id, type, { + id: WorkspaceID.ascending("wrk_list_sync_existing"), + name: "existing", + directory: path.join(dir, "existing"), + }) + insertWorkspace(existing) + + const discovered = { + type, + name: "discovered", + branch: "feature/discovered", + directory: path.join(dir, "discovered"), + extra: { source: "adapter" }, + projectID: Instance.project.id, + } + const recorded = recordedAdapter({ + list() { + return [ + { + type, + name: existing.name, + branch: "ignored", + directory: path.join(dir, "ignored"), + extra: null, + projectID: Instance.project.id, + }, + discovered, + ] + }, + target(info) { + return { type: "local", directory: info.directory ?? dir } + }, + }) + registerAdapter(Instance.project.id, type, recorded.adapter) + + await syncListWorkspaces(Instance.project) + const synced = (await listWorkspaces(Instance.project)).filter((item) => item.name === discovered.name) + + expect(synced).toHaveLength(1) + expect(synced[0]).toMatchObject(discovered) + expect(synced[0]?.id).toStartWith("wrk_") + expect(await listWorkspaces(Instance.project)).toEqual(expect.arrayContaining([existing, synced[0]])) + expect(recorded.calls.list).toBe(1) + expect(recorded.calls.configure).toHaveLength(0) + expect(recorded.calls.create).toHaveLength(0) + expect(recorded.calls.target).toHaveLength(1) + }) + }) + + test("syncList calls every registered adapter with a list method", async () => { + await withInstance(async (dir) => { + const typeA = unique("list-sync-a") + const typeB = unique("list-sync-b") + const adapterA = recordedAdapter({ + list() { + return [ + { + type: typeA, + name: "adapter-a", + branch: null, + directory: path.join(dir, "adapter-a"), + extra: null, + projectID: Instance.project.id, + }, + ] + }, + target(info) { + return { type: "local", directory: info.directory ?? dir } + }, + }) + const adapterB = recordedAdapter({ + list() { + return [ + { + type: typeB, + name: "adapter-b", + branch: null, + directory: path.join(dir, "adapter-b"), + extra: null, + projectID: Instance.project.id, + }, + ] + }, + target(info) { + return { type: "local", directory: info.directory ?? dir } + }, + }) + const noList = recordedAdapter({ + target() { + return { type: "local", directory: dir } + }, + }) + registerAdapter(Instance.project.id, typeA, adapterA.adapter) + registerAdapter(Instance.project.id, typeB, adapterB.adapter) + registerAdapter(Instance.project.id, unique("list-sync-none"), noList.adapter) + + await syncListWorkspaces(Instance.project) + const synced = await listWorkspaces(Instance.project) + + expect( + synced + .filter((item) => item.type === typeA || item.type === typeB) + .map((item) => item.name) + .toSorted(), + ).toEqual(["adapter-a", "adapter-b"]) + expect(adapterA.calls.list).toBe(1) + expect(adapterB.calls.list).toBe(1) + expect(noList.calls.list).toBe(0) + }) + }) + it.live("remote create connects to routed event and history endpoints", () => { const calls: FetchCall[] = [] return Effect.gen(function* () { @@ -557,7 +695,7 @@ describe("workspace-old CRUD", () => { yield* provideTmpdirInstance( (dir) => Effect.gen(function* () { - const workspace = yield* WorkspaceOld.Service + const workspace = yield* Workspace.Service const type = unique("remote-create") const recorded = remoteAdapter(`${url}/base/?ignored=1#hash`, { directory: dir }) registerAdapter(Instance.project.id, type, recorded.adapter) @@ -754,7 +892,7 @@ describe("workspace-old CRUD", () => { yield* provideTmpdirInstance( () => Effect.gen(function* () { - const workspace = yield* WorkspaceOld.Service + const workspace = yield* Workspace.Service const sessionSvc = yield* SessionNs.Service const previousType = unique("warp-remote-source") const targetType = unique("warp-remote-target") @@ -805,7 +943,7 @@ describe("workspace-old CRUD", () => { }) }) -describe("workspace-old sync state", () => { +describe("workspace sync state", () => { test("startWorkspaceSyncing is disabled by the experimental workspace flag", async () => { await withInstance(async (dir) => { Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = false @@ -823,35 +961,29 @@ describe("workspace-old sync state", () => { }) }) - test("startWorkspaceSyncing starts only workspaces with sessions", async () => { + test("startWorkspaceSyncing starts all workspaces", async () => { await withInstance(async (dir) => { - const withSessionType = unique("with-session") - const withoutSessionType = unique("without-session") - const withSession = workspaceInfo(Instance.project.id, withSessionType) - const withoutSession = workspaceInfo(Instance.project.id, withoutSessionType) - const withSessionDir = path.join(dir, "with-session") - const withoutSessionDir = path.join(dir, "without-session") - await fs.mkdir(withSessionDir, { recursive: true }) - await fs.mkdir(withoutSessionDir, { recursive: true }) - insertWorkspace(withSession) - insertWorkspace(withoutSession) - registerAdapter(Instance.project.id, withSessionType, localAdapter(withSessionDir).adapter) - registerAdapter(Instance.project.id, withoutSessionType, localAdapter(withoutSessionDir).adapter) - attachSessionToWorkspace( - (await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({})))).id, - withSession.id, - ) + const firstType = unique("first") + const secondType = unique("second") + const first = workspaceInfo(Instance.project.id, firstType) + const second = workspaceInfo(Instance.project.id, secondType) + await fs.mkdir(path.join(dir, "first"), { recursive: true }) + await fs.mkdir(path.join(dir, "second"), { recursive: true }) + insertWorkspace(first) + insertWorkspace(second) + registerAdapter(Instance.project.id, firstType, localAdapter(path.join(dir, "first")).adapter) + registerAdapter(Instance.project.id, secondType, localAdapter(path.join(dir, "second")).adapter) startWorkspaceSyncing(Instance.project.id) await eventually(() => - workspaceStatus().then((status) => - expect(status.find((item) => item.workspaceID === withSession.id)?.status).toBe("connected"), - ), + workspaceStatus().then((status) => { + expect(status.find((item) => item.workspaceID === first.id)?.status).toBe("connected") + expect(status.find((item) => item.workspaceID === second.id)?.status).toBe("connected") + }), ) - expect((await workspaceStatus()).find((item) => item.workspaceID === withoutSession.id)?.status).toBeUndefined() - await removeWorkspace(withSession.id) - await removeWorkspace(withoutSession.id) + await removeWorkspace(first.id) + await removeWorkspace(second.id) }) }) @@ -907,7 +1039,7 @@ describe("workspace-old sync state", () => { ) expect( captured.events.filter( - (event) => event.workspace === info.id && event.payload.type === WorkspaceOld.Event.Status.type, + (event) => event.workspace === info.id && event.payload.type === Workspace.Event.Status.type, ), ).toHaveLength(1) await removeWorkspace(info.id) @@ -941,7 +1073,7 @@ describe("workspace-old sync state", () => { yield* provideTmpdirInstance( () => Effect.gen(function* () { - const workspace = yield* WorkspaceOld.Service + const workspace = yield* Workspace.Service const sessionSvc = yield* SessionNs.Service const captured = captureGlobalEvents() try { @@ -965,7 +1097,7 @@ describe("workspace-old sync state", () => { expect( captured.events .filter( - (event) => event.workspace === info.id && event.payload.type === WorkspaceOld.Event.Status.type, + (event) => event.workspace === info.id && event.payload.type === Workspace.Event.Status.type, ) .map((event) => event.payload.properties.status), ).toEqual(["disconnected", "connecting", "connected"]) @@ -998,7 +1130,7 @@ describe("workspace-old sync state", () => { yield* provideTmpdirInstance( () => Effect.gen(function* () { - const workspace = yield* WorkspaceOld.Service + const workspace = yield* Workspace.Service const sessionSvc = yield* SessionNs.Service const type = unique("remote-connect-fail") const info = workspaceInfo(Instance.project.id, type) @@ -1038,7 +1170,7 @@ describe("workspace-old sync state", () => { yield* provideTmpdirInstance( () => Effect.gen(function* () { - const workspace = yield* WorkspaceOld.Service + const workspace = yield* Workspace.Service const sessionSvc = yield* SessionNs.Service const type = unique("remote-history-fail") const info = workspaceInfo(Instance.project.id, type) @@ -1093,7 +1225,7 @@ describe("workspace-old sync state", () => { yield* provideTmpdirInstance( () => Effect.gen(function* () { - const workspace = yield* WorkspaceOld.Service + const workspace = yield* Workspace.Service const sessionSvc = yield* SessionNs.Service const captured = captureGlobalEvents() try { @@ -1160,7 +1292,7 @@ describe("workspace-old sync state", () => { yield* provideTmpdirInstance( () => Effect.gen(function* () { - const workspace = yield* WorkspaceOld.Service + const workspace = yield* Workspace.Service const sessionSvc = yield* SessionNs.Service const captured = captureGlobalEvents() try { @@ -1241,7 +1373,7 @@ describe("workspace-old sync state", () => { yield* provideTmpdirInstance( () => Effect.gen(function* () { - const workspace = yield* WorkspaceOld.Service + const workspace = yield* Workspace.Service const sessionSvc = yield* SessionNs.Service const captured = captureGlobalEvents() try { @@ -1280,7 +1412,7 @@ describe("workspace-old sync state", () => { }) }) -describe("workspace-old waitForSync", () => { +describe("workspace waitForSync", () => { test("returns immediately for an empty fence", async () => { await withInstance(async () => { await expect(waitForWorkspaceSync(WorkspaceID.ascending("wrk_wait_empty"), {})).resolves.toBeUndefined() diff --git a/packages/opencode/test/server/httpapi-workspace.test.ts b/packages/opencode/test/server/httpapi-workspace.test.ts index 9b38cb44a2..2e64081b8f 100644 --- a/packages/opencode/test/server/httpapi-workspace.test.ts +++ b/packages/opencode/test/server/httpapi-workspace.test.ts @@ -64,6 +64,36 @@ function localAdapter(directory: string): WorkspaceAdapter { } } +function listedAdapter(directory: string, type: string): WorkspaceAdapter { + return { + name: "Listed Test", + description: "List a local test workspace", + configure(info) { + return { ...info, name: "unused", directory } + }, + async create() {}, + async remove() {}, + list() { + return [ + { + type, + name: "listed-test", + branch: "listed/main", + directory, + extra: { listed: true }, + projectID: Instance.project.id, + }, + ] + }, + target() { + return { + type: "local" as const, + directory, + } + }, + } +} + function remoteAdapter(directory: string, url: string, headers?: HeadersInit): WorkspaceAdapter { return { name: "Remote Test", @@ -196,6 +226,30 @@ describe("workspace HttpApi", () => { }), ) + it.live("serves list sync endpoint", () => + Effect.gen(function* () { + Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true + const dir = yield* tmpdirScoped({ git: true }) + const project = yield* Project.use.fromDirectory(dir) + const type = `listed-${Math.random().toString(36).slice(2)}` + registerAdapter(project.project.id, type, listedAdapter(path.join(dir, ".listed"), type)) + + const response = yield* request(WorkspacePaths.syncList, dir, { method: "POST" }) + + expect(response.status).toBe(204) + const listed = yield* request(WorkspacePaths.list, dir) + expect(yield* Effect.promise(() => listed.json())).toMatchObject([ + { + type, + name: "listed-test", + branch: "listed/main", + directory: path.join(dir, ".listed"), + extra: { listed: true }, + }, + ]) + }), + ) + it.live("creates workspace with the TUI payload shape", () => Effect.gen(function* () { Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index ebedb1dd6b..03b02fcebf 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -36,6 +36,7 @@ import type { ExperimentalWorkspaceRemoveErrors, ExperimentalWorkspaceRemoveResponses, ExperimentalWorkspaceStatusResponses, + ExperimentalWorkspaceSyncListResponses, ExperimentalWorkspaceWarpErrors, ExperimentalWorkspaceWarpResponses, FileListResponses, @@ -949,6 +950,36 @@ export class Workspace extends HeyApiClient { }) } + /** + * Sync workspace list + * + * Register missing workspaces returned by workspace adapters. + */ + public syncList( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/experimental/workspace/sync-list", + ...options, + ...params, + }) + } + /** * Workspace status * diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 5a330c37b6..cb088ea946 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1755,6 +1755,7 @@ export type Workspace = { directory: string | null extra: unknown | null projectID: string + timeUsed: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" } export type WorkspaceWarpError = { @@ -6706,6 +6707,26 @@ export type ExperimentalWorkspaceCreateResponses = { export type ExperimentalWorkspaceCreateResponse = ExperimentalWorkspaceCreateResponses[keyof ExperimentalWorkspaceCreateResponses] +export type ExperimentalWorkspaceSyncListData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/experimental/workspace/sync-list" +} + +export type ExperimentalWorkspaceSyncListResponses = { + /** + * Workspace list synced + */ + 204: void +} + +export type ExperimentalWorkspaceSyncListResponse = + ExperimentalWorkspaceSyncListResponses[keyof ExperimentalWorkspaceSyncListResponses] + export type ExperimentalWorkspaceStatusData = { body?: never path?: never diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index fcd7a8547e..713c64cf5e 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -8417,6 +8417,43 @@ ] } }, + "/experimental/workspace/sync-list": { + "post": { + "tags": ["workspace"], + "operationId": "experimental.workspace.syncList", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Workspace list synced" + } + }, + "description": "Register missing workspaces returned by workspace adapters.", + "summary": "Sync workspace list", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.workspace.syncList({\n ...\n})" + } + ] + } + }, "/experimental/workspace/status": { "get": { "tags": ["workspace"], @@ -13672,9 +13709,32 @@ }, "projectID": { "type": "string" + }, + "timeUsed": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + }, + { + "type": "string", + "enum": ["Infinity", "-Infinity", "NaN"] + } + ] } }, - "required": ["id", "type", "name", "branch", "directory", "extra", "projectID"], + "required": ["id", "type", "name", "branch", "directory", "extra", "projectID", "timeUsed"], "additionalProperties": false }, "WorkspaceWarpError": { From df75bfe07cdeee16ad3cb555364fddfc69f0991f Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Fri, 8 May 2026 15:10:32 +0000 Subject: [PATCH 003/189] chore: generate --- packages/opencode/test/control-plane/workspace.test.ts | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/packages/opencode/test/control-plane/workspace.test.ts b/packages/opencode/test/control-plane/workspace.test.ts index c29a09eb14..e3de9cae71 100644 --- a/packages/opencode/test/control-plane/workspace.test.ts +++ b/packages/opencode/test/control-plane/workspace.test.ts @@ -37,10 +37,7 @@ void Log.init({ print: false }) const testServerLayer = Layer.mergeAll( NodeHttpServer.layer(Http.createServer, { host: "127.0.0.1", port: 0 }), - Workspace.defaultLayer.pipe( - Layer.provide(InstanceStore.defaultLayer), - Layer.provide(InstanceBootstrap.defaultLayer), - ), + Workspace.defaultLayer.pipe(Layer.provide(InstanceStore.defaultLayer), Layer.provide(InstanceBootstrap.defaultLayer)), SessionNs.defaultLayer, ) const it = testEffect(testServerLayer) @@ -1096,9 +1093,7 @@ describe("workspace sync state", () => { expect( captured.events - .filter( - (event) => event.workspace === info.id && event.payload.type === Workspace.Event.Status.type, - ) + .filter((event) => event.workspace === info.id && event.payload.type === Workspace.Event.Status.type) .map((event) => event.payload.properties.status), ).toEqual(["disconnected", "connecting", "connected"]) expect(calls.filter((call) => call.url.pathname === "/sync/global/event")).toHaveLength(1) From 799996db76b2a0206594b8b060e5aa2c7e457141 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Fri, 8 May 2026 10:48:19 -0500 Subject: [PATCH 004/189] fix: adjust tui retry dialog logic to be more provider specific and error case specific (#26366) --- .../src/cli/cmd/tui/routes/session/index.tsx | 35 ++++++++-- packages/opencode/src/session/processor.ts | 1 + packages/opencode/src/session/retry.ts | 14 +++- packages/opencode/src/session/status.ts | 2 + packages/opencode/test/session/retry.test.ts | 69 ++++++++++++++----- .../test/session/schema-decoding.test.ts | 2 + packages/sdk/js/src/v2/gen/types.gen.ts | 2 + script/zen-limit-server.ts | 37 ---------- 8 files changed, 98 insertions(+), 64 deletions(-) delete mode 100644 script/zen-limit-server.ts diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index f214540c96..40f3e4fbca 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -93,9 +93,29 @@ import { useBindings, useCommandShortcut } from "../../keymap" addDefaultParsers(parsers.parsers) -const GO_UPSELL_LAST_SEEN_AT = "go_upsell_last_seen_at" -const GO_UPSELL_DONT_SHOW = "go_upsell_dont_show" +const GO_UPSELL_FREE_TIER_LAST_SEEN_AT = "go_upsell_last_seen_at" +const GO_UPSELL_FREE_TIER_DONT_SHOW = "go_upsell_dont_show" +const GO_UPSELL_ACCOUNT_RATE_LIMIT_LAST_SEEN_AT = "go_upsell_account_rate_limit_last_seen_at" +const GO_UPSELL_ACCOUNT_RATE_LIMIT_DONT_SHOW = "go_upsell_account_rate_limit_dont_show" const GO_UPSELL_WINDOW = 86_400_000 // 24 hrs +const GO_UPSELL_PROVIDERS = new Set(["opencode", "opencode-go"]) + +function goUpsellKeys(action: SessionRetry.Retryable["action"]) { + if (!action) return + if (!GO_UPSELL_PROVIDERS.has(action.provider)) return + if (action.reason === "free_tier_limit") { + return { + lastSeenAt: GO_UPSELL_FREE_TIER_LAST_SEEN_AT, + dontShow: GO_UPSELL_FREE_TIER_DONT_SHOW, + } + } + if (action.reason === "account_rate_limit") { + return { + lastSeenAt: GO_UPSELL_ACCOUNT_RATE_LIMIT_LAST_SEEN_AT, + dontShow: GO_UPSELL_ACCOUNT_RATE_LIMIT_DONT_SHOW, + } + } +} const context = createContext<{ width: number @@ -263,14 +283,17 @@ export function Session() { if (!evt.properties.status.action) return if (dialog.stack.length > 0) return - const seen = kv.get(GO_UPSELL_LAST_SEEN_AT) + const keys = goUpsellKeys(evt.properties.status.action) + if (!keys) return + + const seen = kv.get(keys.lastSeenAt) if (typeof seen === "number" && Date.now() - seen < GO_UPSELL_WINDOW) return - if (kv.get(GO_UPSELL_DONT_SHOW)) return + if (kv.get(keys.dontShow)) return void DialogRetryAction.show(dialog, evt.properties.status.action).then((dontShowAgain) => { - if (dontShowAgain) kv.set(GO_UPSELL_DONT_SHOW, true) - kv.set(GO_UPSELL_LAST_SEEN_AT, Date.now()) + if (dontShowAgain) kv.set(keys.dontShow, true) + kv.set(keys.lastSeenAt, Date.now()) }) }) diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 66a2d47975..6e84db16e2 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -701,6 +701,7 @@ export const layer: Layer.Layer< ), Effect.retry( SessionRetry.policy({ + provider: input.model.providerID, parse, set: (info) => { // TODO(v2): Temporary dual-write while migrating session messages to v2 events. diff --git a/packages/opencode/src/session/retry.ts b/packages/opencode/src/session/retry.ts index 3bccee212d..1f73dee31f 100644 --- a/packages/opencode/src/session/retry.ts +++ b/packages/opencode/src/session/retry.ts @@ -7,10 +7,13 @@ export type Err = ReturnType export const GO_UPSELL_MESSAGE = "Free usage exceeded, subscribe to Go" export const GO_UPSELL_URL = "https://opencode.ai/go" +export type RetryReason = "free_tier_limit" | "account_rate_limit" | (string & {}) export type Retryable = { message: string action?: { + reason: RetryReason + provider: string title: string message: string label: string @@ -60,7 +63,7 @@ export function delay(attempt: number, error?: MessageV2.APIError) { return cap(Math.min(RETRY_INITIAL_DELAY * Math.pow(RETRY_BACKOFF_FACTOR, attempt - 1), RETRY_MAX_DELAY_NO_HEADERS)) } -export function retryable(error: Err) { +export function retryable(error: Err, provider: string) { // context overflow errors should not be retried if (MessageV2.ContextOverflowError.isInstance(error)) return undefined if (MessageV2.APIError.isInstance(error)) { @@ -72,6 +75,8 @@ export function retryable(error: Err) { return { message: GO_UPSELL_MESSAGE, action: { + reason: "free_tier_limit", + provider, title: "Free limit reached", message: "Subscribe to OpenCode Go for reliable access to the best open-source models, starting at $5/month.", label: "subscribe", @@ -97,12 +102,14 @@ export function retryable(error: Err) { return minutes > 0 ? unit(minutes, "minute") : "less than a minute" }) - const message = `${limitName} usage limit reached. It will reset in ${resetIn}. To continue using this model now, enable usage from your available balance` + const message = `${limitName ? `${limitName} usage limit` : "Usage limit"} reached. It will reset in ${resetIn}. To continue using this model now, enable usage from your available balance` const link = `https://opencode.ai/workspace/${workspace}/go` return { message: `${message} - ${link}`, action: { + reason: "account_rate_limit", + provider, title: "Go limit reached", message, label: "open settings", @@ -165,13 +172,14 @@ function parseJSON(value: unknown) { } export function policy(opts: { + provider: string parse: (error: unknown) => Err set: (input: { attempt: number; message: string; action?: Retryable["action"]; next: number }) => Effect.Effect }) { return Schedule.fromStepWithMetadata( Effect.succeed((meta: Schedule.InputMetadata) => { const error = opts.parse(meta.input) - const retry = retryable(error) + const retry = retryable(error, opts.provider) if (!retry) return Cause.done(meta.attempt) return Effect.gen(function* () { const wait = delay(meta.attempt, MessageV2.APIError.isInstance(error) ? error : undefined) diff --git a/packages/opencode/src/session/status.ts b/packages/opencode/src/session/status.ts index 1d6e96d935..abe51bcd61 100644 --- a/packages/opencode/src/session/status.ts +++ b/packages/opencode/src/session/status.ts @@ -17,6 +17,8 @@ export const Info = Schema.Union([ message: Schema.String, action: Schema.optional( Schema.Struct({ + reason: Schema.String, + provider: Schema.String, title: Schema.String, message: Schema.String, label: Schema.String, diff --git a/packages/opencode/test/session/retry.test.ts b/packages/opencode/test/session/retry.test.ts index 0b67294796..20de9ed9a7 100644 --- a/packages/opencode/test/session/retry.test.ts +++ b/packages/opencode/test/session/retry.test.ts @@ -13,6 +13,7 @@ import { provideTmpdirInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" const providerID = ProviderID.make("test") +const retryProvider = "test" const it = testEffect(Layer.mergeAll(SessionStatus.defaultLayer, CrossSpawnSpawner.defaultLayer)) function apiError(headers?: Record): MessageV2.APIError { @@ -92,6 +93,7 @@ describe("session.retry.delay", () => { const step = yield* Schedule.toStepWithMetadata( SessionRetry.policy({ + provider: "test", parse: (err) => MessageV2.APIError.Schema.parse(err), set: (info) => status.set(sessionID, { @@ -118,47 +120,47 @@ describe("session.retry.delay", () => { describe("session.retry.retryable", () => { test("maps too_many_requests json messages", () => { const error = wrap(JSON.stringify({ type: "error", error: { type: "too_many_requests" } })) - expect(SessionRetry.retryable(error)).toEqual({ message: "Too Many Requests" }) + expect(SessionRetry.retryable(error, retryProvider)).toEqual({ message: "Too Many Requests" }) }) test("maps overloaded provider codes", () => { const error = wrap(JSON.stringify({ code: "resource_exhausted" })) - expect(SessionRetry.retryable(error)).toEqual({ message: "Provider is overloaded" }) + expect(SessionRetry.retryable(error, retryProvider)).toEqual({ message: "Provider is overloaded" }) }) test("does not retry unknown json messages", () => { const error = wrap(JSON.stringify({ error: { message: "no_kv_space" } })) - expect(SessionRetry.retryable(error)).toBeUndefined() + expect(SessionRetry.retryable(error, retryProvider)).toBeUndefined() }) test("does not throw on numeric error codes", () => { const error = wrap(JSON.stringify({ type: "error", error: { code: 123 } })) - const result = SessionRetry.retryable(error) + const result = SessionRetry.retryable(error, retryProvider) expect(result).toBeUndefined() }) test("returns undefined for non-json message", () => { const error = wrap("not-json") - expect(SessionRetry.retryable(error)).toBeUndefined() + expect(SessionRetry.retryable(error, retryProvider)).toBeUndefined() }) test("retries plain text rate limit errors from Alibaba", () => { const msg = "Upstream error from Alibaba: Request rate increased too quickly. To ensure system stability, please adjust your client logic to scale requests more smoothly over time." const error = wrap(msg) - expect(SessionRetry.retryable(error)).toEqual({ message: msg }) + expect(SessionRetry.retryable(error, retryProvider)).toEqual({ message: msg }) }) test("retries plain text rate limit errors", () => { const msg = "Rate limit exceeded, please try again later" const error = wrap(msg) - expect(SessionRetry.retryable(error)).toEqual({ message: msg }) + expect(SessionRetry.retryable(error, retryProvider)).toEqual({ message: msg }) }) test("retries too many requests in plain text", () => { const msg = "Too many requests, please slow down" const error = wrap(msg) - expect(SessionRetry.retryable(error)).toEqual({ message: msg }) + expect(SessionRetry.retryable(error, retryProvider)).toEqual({ message: msg }) }) test("does not retry context overflow errors", () => { @@ -167,7 +169,7 @@ describe("session.retry.retryable", () => { responseBody: '{"error":{"code":"context_length_exceeded"}}', }).toObject() - expect(SessionRetry.retryable(error)).toBeUndefined() + expect(SessionRetry.retryable(error, retryProvider)).toBeUndefined() }) test("retries 500 errors even when isRetryable is false", () => { @@ -180,7 +182,7 @@ describe("session.retry.retryable", () => { }).toObject(), ) - expect(SessionRetry.retryable(error)).toEqual({ message: "Internal server error" }) + expect(SessionRetry.retryable(error, retryProvider)).toEqual({ message: "Internal server error" }) }) test("retries 502 bad gateway errors", () => { @@ -192,7 +194,7 @@ describe("session.retry.retryable", () => { }).toObject(), ) - expect(SessionRetry.retryable(error)).toEqual({ message: "Bad gateway" }) + expect(SessionRetry.retryable(error, retryProvider)).toEqual({ message: "Bad gateway" }) }) test("retries 503 service unavailable errors", () => { @@ -204,7 +206,7 @@ describe("session.retry.retryable", () => { }).toObject(), ) - expect(SessionRetry.retryable(error)).toEqual({ message: "Service unavailable" }) + expect(SessionRetry.retryable(error, retryProvider)).toEqual({ message: "Service unavailable" }) }) test("does not retry 4xx errors when isRetryable is false", () => { @@ -216,7 +218,7 @@ describe("session.retry.retryable", () => { }).toObject(), ) - expect(SessionRetry.retryable(error)).toBeUndefined() + expect(SessionRetry.retryable(error, retryProvider)).toBeUndefined() }) test("retries ZlibError decompression failures", () => { @@ -228,7 +230,7 @@ describe("session.retry.retryable", () => { }).toObject(), ) - const retryable = SessionRetry.retryable(error) + const retryable = SessionRetry.retryable(error, retryProvider) expect(retryable).toBeDefined() expect(retryable).toEqual({ message: "Response decompression failed" }) }) @@ -246,9 +248,11 @@ describe("session.retry.retryable", () => { }).toObject(), ) - expect(SessionRetry.retryable(error)).toEqual({ + expect(SessionRetry.retryable(error, "opencode")).toEqual({ message: SessionRetry.GO_UPSELL_MESSAGE, action: { + reason: "free_tier_limit", + provider: "opencode", title: "Free limit reached", message: "Subscribe to OpenCode Go for reliable access to the best open-source models, starting at $5/month.", label: "subscribe", @@ -280,10 +284,12 @@ describe("session.retry.retryable", () => { }).toObject(), ) - expect(SessionRetry.retryable(error)).toEqual({ + expect(SessionRetry.retryable(error, "opencode-go")).toEqual({ message: "5 hour usage limit reached. It will reset in 5 hours 23 minutes. To continue using this model now, enable usage from your available balance - https://opencode.ai/workspace/wrk_01K6XGM22R6FM8JVABE9XDQXGH/go", action: { + reason: "account_rate_limit", + provider: "opencode-go", title: "Go limit reached", message: "5 hour usage limit reached. It will reset in 5 hours 23 minutes. To continue using this model now, enable usage from your available balance", @@ -292,6 +298,33 @@ describe("session.retry.retryable", () => { }, }) }) + + test("maps Go subscription limits without limit metadata", () => { + const error = MessageV2.APIError.Schema.parse( + new MessageV2.APIError({ + message: "Subscription quota exceeded. You can continue using free models.", + isRetryable: true, + statusCode: 429, + responseHeaders: { + "retry-after": "900", + }, + responseBody: JSON.stringify({ + type: "error", + error: { + type: "GoUsageLimitError", + message: "Subscription quota exceeded. You can continue using free models.", + }, + metadata: { + workspace: "wrk_01K6XGM22R6FM8JVABE9XDQXGH", + }, + }), + }).toObject(), + ) + + expect(SessionRetry.retryable(error, "opencode-go")?.action?.message).toBe( + "Usage limit reached. It will reset in 15 minutes. To continue using this model now, enable usage from your available balance", + ) + }) }) describe("session.message-v2.fromError", () => { @@ -341,7 +374,7 @@ describe("session.message-v2.fromError", () => { }).toObject(), ) - const retryable = SessionRetry.retryable(error) + const retryable = SessionRetry.retryable(error, retryProvider) expect(retryable).toBeDefined() expect(retryable).toEqual({ message: "Connection reset by server" }) }) @@ -381,6 +414,6 @@ describe("session.message-v2.fromError", () => { expect(MessageV2.APIError.isInstance(result)).toBe(true) if (!MessageV2.APIError.isInstance(result)) throw new Error("expected APIError") expect(result.data.isRetryable).toBe(true) - expect(SessionRetry.retryable(result)).toEqual({ message: "An error occurred while processing your request." }) + expect(SessionRetry.retryable(result, retryProvider)).toEqual({ message: "An error occurred while processing your request." }) }) }) diff --git a/packages/opencode/test/session/schema-decoding.test.ts b/packages/opencode/test/session/schema-decoding.test.ts index 8bb94bdd8c..bee2184e5b 100644 --- a/packages/opencode/test/session/schema-decoding.test.ts +++ b/packages/opencode/test/session/schema-decoding.test.ts @@ -236,6 +236,8 @@ describe("SessionStatus.Info", () => { attempt: 1, message: "transient", action: { + reason: "free_tier_limit", + provider: "opencode", title: "Free limit reached", message: "Subscribe to OpenCode Go.", label: "subscribe", diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index cb088ea946..3f6b802bc6 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -267,6 +267,8 @@ export type SessionStatus = attempt: number message: string action?: { + reason: string + provider: string title: string message: string label: string diff --git a/script/zen-limit-server.ts b/script/zen-limit-server.ts deleted file mode 100644 index 3be1b5e111..0000000000 --- a/script/zen-limit-server.ts +++ /dev/null @@ -1,37 +0,0 @@ -const retryAfterSeconds = 15 * 60 - -// const response = { -// type: "error", -// error: { -// type: "FreeUsageLimitError", -// message: "Free usage exceeded, subscribe to Go https://opencode.ai/go", -// }, -// metadata: {}, -// } - -const response = { - type: "error", - error: { - type: "GoUsageLimitError", - message: "Subscription quota exceeded. You can continue using free models.", - }, - metadata: { - workspace: "wrk_01K6XGM22R6FM8JVABE9XDQXGH", - limit: "5 hour", - resetAt: retryAfterSeconds, - }, -} - -Bun.serve({ - port: 4141, - fetch() { - return Response.json(response, { - status: 429, - headers: { - "retry-after": String(retryAfterSeconds), - }, - }) - }, -}) - -console.log("Zen limit repro server listening on http://localhost:4141") From fc46cef5fd14610f516bfbc29f0c60bbcd344ddd Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Fri, 8 May 2026 15:49:37 +0000 Subject: [PATCH 005/189] chore: generate --- packages/opencode/test/session/retry.test.ts | 4 +++- packages/sdk/openapi.json | 8 +++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/opencode/test/session/retry.test.ts b/packages/opencode/test/session/retry.test.ts index 20de9ed9a7..9da45c9112 100644 --- a/packages/opencode/test/session/retry.test.ts +++ b/packages/opencode/test/session/retry.test.ts @@ -414,6 +414,8 @@ describe("session.message-v2.fromError", () => { expect(MessageV2.APIError.isInstance(result)).toBe(true) if (!MessageV2.APIError.isInstance(result)) throw new Error("expected APIError") expect(result.data.isRetryable).toBe(true) - expect(SessionRetry.retryable(result, retryProvider)).toEqual({ message: "An error occurred while processing your request." }) + expect(SessionRetry.retryable(result, retryProvider)).toEqual({ + message: "An error occurred while processing your request.", + }) }) }) diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 713c64cf5e..ab92482336 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -9441,6 +9441,12 @@ "action": { "type": "object", "properties": { + "reason": { + "type": "string" + }, + "provider": { + "type": "string" + }, "title": { "type": "string" }, @@ -9454,7 +9460,7 @@ "type": "string" } }, - "required": ["title", "message", "label"], + "required": ["reason", "provider", "title", "message", "label"], "additionalProperties": false }, "next": { From 83bb21648634f67ce33dbde8979c322dd64e0b34 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Fri, 8 May 2026 10:59:41 -0500 Subject: [PATCH 006/189] fix: ensure tools are always in same order (#26370) --- packages/opencode/src/session/llm.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index e76583f2d3..c7990d1b35 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -225,6 +225,7 @@ const live: Layer.Layer< execute: async () => ({ output: "", title: "", metadata: {} }), }) } + const sortedTools = Object.fromEntries(Object.entries(tools).toSorted(([a], [b]) => a.localeCompare(b))) // Wire up toolExecutor for DWS workflow models so that tool calls // from the workflow service are executed via opencode's tool system @@ -238,7 +239,7 @@ const live: Layer.Layer< workflowModel.sessionID = input.sessionID workflowModel.systemPrompt = system.join("\n") workflowModel.toolExecutor = async (toolName, argsJson, _requestID) => { - const t = tools[toolName] + const t = sortedTools[toolName] if (!t || !t.execute) { return { result: "", error: `Unknown tool: ${toolName}` } } @@ -260,7 +261,7 @@ const live: Layer.Layer< } const ruleset = Permission.merge(input.agent.permission ?? [], input.permission ?? []) - workflowModel.sessionPreapprovedTools = Object.keys(tools).filter((name) => { + workflowModel.sessionPreapprovedTools = Object.keys(sortedTools).filter((name) => { const match = ruleset.findLast((rule) => Wildcard.match(name, rule.permission)) return !match || match.action !== "ask" }) @@ -341,7 +342,7 @@ const live: Layer.Layer< }, async experimental_repairToolCall(failed) { const lower = failed.toolCall.toolName.toLowerCase() - if (lower !== failed.toolCall.toolName && tools[lower]) { + if (lower !== failed.toolCall.toolName && sortedTools[lower]) { l.info("repairing tool call", { tool: failed.toolCall.toolName, repaired: lower, @@ -364,8 +365,8 @@ const live: Layer.Layer< topP: params.topP, topK: params.topK, providerOptions: ProviderTransform.providerOptions(input.model, params.options), - activeTools: Object.keys(tools).filter((x) => x !== "invalid"), - tools, + activeTools: Object.keys(sortedTools).filter((x) => x !== "invalid"), + tools: sortedTools, toolChoice: input.toolChoice, maxOutputTokens: params.maxOutputTokens, abortSignal: input.abort, From 13b3117ca9e85b52837944ac51ea5d17fab570fc Mon Sep 17 00:00:00 2001 From: Rajvardhan Patil Date: Fri, 8 May 2026 22:09:11 +0530 Subject: [PATCH 007/189] fix(server): require auth for effect root routes (#26361) Co-authored-by: Rajvardhan Patil <243567420+RajvardhanPatil07@users.noreply.github.com> --- .../server/routes/instance/httpapi/server.ts | 5 +- .../test/server/httpapi-bridge.test.ts | 54 +++++++++++++++++++ 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index ef966036a9..54b02669d9 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -96,7 +96,10 @@ const cors = (corsOptions?: CorsOptions) => { global: true }, ) -const rootApiRoutes = HttpApiBuilder.layer(RootHttpApi).pipe(Layer.provide([controlHandlers, globalHandlers])) +const rootApiRoutes = HttpApiBuilder.layer(RootHttpApi).pipe( + Layer.provide([controlHandlers, globalHandlers]), + Layer.provide(authorizationRouterMiddleware.layer.pipe(Layer.provide(ServerAuth.Config.defaultLayer))), +) const instanceRouterLayer = authorizationRouterMiddleware .combine(instanceRouterMiddleware) .combine(workspaceRouterMiddleware) diff --git a/packages/opencode/test/server/httpapi-bridge.test.ts b/packages/opencode/test/server/httpapi-bridge.test.ts index 615899f2b4..de228d8b26 100644 --- a/packages/opencode/test/server/httpapi-bridge.test.ts +++ b/packages/opencode/test/server/httpapi-bridge.test.ts @@ -358,6 +358,60 @@ describe("HttpApi server", () => { expect(good.status).toBe(200) }) + test("requires credentials for root routes when auth is enabled", async () => { + const server = app({ password: "secret" }) + const auth = { authorization: authorization("opencode", "secret") } + const wrongAuth = { authorization: authorization("opencode", "wrong") } + + const [missingConfig, wrongConfig, goodConfig] = await Promise.all([ + server.request(GlobalPaths.config), + server.request(GlobalPaths.config, { headers: wrongAuth }), + server.request(GlobalPaths.config, { headers: auth }), + ]) + + expect(missingConfig.status).toBe(401) + expect(wrongConfig.status).toBe(401) + expect(goodConfig.status).toBe(200) + + const missingDispose = await server.request(GlobalPaths.dispose, { method: "POST" }) + expect(missingDispose.status).toBe(401) + + const missingUpgrade = await server.request(GlobalPaths.upgrade, { + method: "POST", + headers: { "content-type": "application/json" }, + body: "not-json", + }) + expect(missingUpgrade.status).toBe(401) + + const invalidUpgrade = await server.request(GlobalPaths.upgrade, { + method: "POST", + headers: { ...auth, "content-type": "application/json" }, + body: "not-json", + }) + expect(invalidUpgrade.status).toBe(400) + + const missingLog = await server.request(ControlPaths.log, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ service: "httpapi-auth-test", level: "info", message: "hello" }), + }) + expect(missingLog.status).toBe(401) + + const missingAuth = await server.request(ControlPaths.auth.replace(":providerID", "test"), { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ type: "api", key: "secret" }), + }) + expect(missingAuth.status).toBe(401) + + const invalidAuth = await server.request(ControlPaths.auth.replace(":providerID", "test"), { + method: "PUT", + headers: { ...auth, "content-type": "application/json" }, + body: JSON.stringify({ type: "api" }), + }) + expect(invalidAuth.status).toBe(400) + }) + test("accepts auth_token query credentials", async () => { await using tmp = await tmpdir({ git: true }) await Bun.write(`${tmp.path}/hello.txt`, "hello") From 3052a79b3250e09f9d5d0232795139116382b4dd Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 8 May 2026 13:06:00 -0400 Subject: [PATCH 008/189] refactor(server): clarify HttpApi route auth layers (#26372) --- .../src/server/routes/instance/httpapi/api.ts | 6 +++++- .../src/server/routes/instance/httpapi/server.ts | 13 ++++++++++--- .../opencode/test/server/httpapi-bridge.test.ts | 6 +++++- 3 files changed, 20 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/server/routes/instance/httpapi/api.ts b/packages/opencode/src/server/routes/instance/httpapi/api.ts index 1cf1584e3e..9b7af481cf 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/api.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/api.ts @@ -20,13 +20,17 @@ import { SyncApi } from "./groups/sync" import { TuiApi } from "./groups/tui" import { WorkspaceApi } from "./groups/workspace" import { V2Api } from "./groups/v2" +import { Authorization } from "./middleware/authorization" // SSE event schemas built from the same BusEvent/SyncEvent registries that // the Hono spec uses, so both specs emit identical Event/SyncEvent components. const EventSchema = Schema.Union(BusEvent.effectPayloads()).annotate({ identifier: "Event" }) const SyncEventSchemas = SyncEvent.effectPayloads() -export const RootHttpApi = HttpApi.make("opencode-root").addHttpApi(ControlApi).addHttpApi(GlobalApi) +export const RootHttpApi = HttpApi.make("opencode-root") + .addHttpApi(ControlApi) + .addHttpApi(GlobalApi) + .middleware(Authorization) export const InstanceHttpApi = HttpApi.make("opencode-instance") .addHttpApi(ConfigApi) diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index 54b02669d9..f60c4e6381 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -96,9 +96,16 @@ const cors = (corsOptions?: CorsOptions) => { global: true }, ) +// Route tree: +// - rootApiRoutes: typed /global/* and control routes; auth is declared by RootHttpApi. +// - eventApiRoutes/rawInstanceRoutes: raw instance routes; auth and workspace routing happen as router middleware. +// - instanceApiRoutes: schema routes; auth is declared on each group and workspace context is provided below. +// - uiRoute: raw catch-all fallback; auth is router middleware so public static assets can bypass it. +const authOnlyRouterLayer = authorizationRouterMiddleware.layer.pipe(Layer.provide(ServerAuth.Config.defaultLayer)) +const httpApiAuthLayer = authorizationLayer.pipe(Layer.provide(ServerAuth.Config.defaultLayer)) const rootApiRoutes = HttpApiBuilder.layer(RootHttpApi).pipe( Layer.provide([controlHandlers, globalHandlers]), - Layer.provide(authorizationRouterMiddleware.layer.pipe(Layer.provide(ServerAuth.Config.defaultLayer))), + Layer.provide(httpApiAuthLayer), ) const instanceRouterLayer = authorizationRouterMiddleware .combine(instanceRouterMiddleware) @@ -131,7 +138,7 @@ const instanceApiRoutes = HttpApiBuilder.layer(InstanceHttpApi).pipe( const rawInstanceRoutes = Layer.mergeAll(ptyConnectRoute).pipe(Layer.provide(instanceRouterLayer)) const instanceRoutes = Layer.mergeAll(rawInstanceRoutes, instanceApiRoutes).pipe( Layer.provide([ - authorizationLayer.pipe(Layer.provide(ServerAuth.Config.defaultLayer)), + httpApiAuthLayer, workspaceRoutingLayer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal)), instanceContextLayer, ]), @@ -143,7 +150,7 @@ const uiRoute = HttpRouter.use((router) => const client = yield* HttpClient.HttpClient yield* router.add("*", "/*", (request) => serveUIEffect(request, { fs, client })) }), -).pipe(Layer.provide(authorizationRouterMiddleware.layer.pipe(Layer.provide(ServerAuth.Config.defaultLayer)))) +).pipe(Layer.provide(authOnlyRouterLayer)) export function createRoutes(corsOptions?: CorsOptions) { return Layer.mergeAll(rootApiRoutes, eventApiRoutes, instanceRoutes, uiRoute).pipe( diff --git a/packages/opencode/test/server/httpapi-bridge.test.ts b/packages/opencode/test/server/httpapi-bridge.test.ts index de228d8b26..8476ece0e7 100644 --- a/packages/opencode/test/server/httpapi-bridge.test.ts +++ b/packages/opencode/test/server/httpapi-bridge.test.ts @@ -363,12 +363,16 @@ describe("HttpApi server", () => { const auth = { authorization: authorization("opencode", "secret") } const wrongAuth = { authorization: authorization("opencode", "wrong") } - const [missingConfig, wrongConfig, goodConfig] = await Promise.all([ + const [missingHealth, goodHealth, missingConfig, wrongConfig, goodConfig] = await Promise.all([ + server.request(GlobalPaths.health), + server.request(GlobalPaths.health, { headers: auth }), server.request(GlobalPaths.config), server.request(GlobalPaths.config, { headers: wrongAuth }), server.request(GlobalPaths.config, { headers: auth }), ]) + expect(missingHealth.status).toBe(401) + expect(goodHealth.status).toBe(200) expect(missingConfig.status).toBe(401) expect(wrongConfig.status).toBe(401) expect(goodConfig.status).toBe(200) From 4d43d584fe7b34d45d9c50f95026d1a40afcce0a Mon Sep 17 00:00:00 2001 From: Simon Klee Date: Fri, 8 May 2026 19:44:31 +0200 Subject: [PATCH 009/189] cli/run: switch to global event stream (#26383) --- packages/opencode/src/cli/cmd/run/runtime.ts | 1 + .../src/cli/cmd/run/stream.transport.ts | 132 +++-- .../opencode/src/cli/cmd/run/subagent-data.ts | 87 +++- .../test/cli/run/stream.transport.test.ts | 481 +++++++++++++++--- .../test/cli/run/subagent-data.test.ts | 128 +++++ 5 files changed, 723 insertions(+), 106 deletions(-) diff --git a/packages/opencode/src/cli/cmd/run/runtime.ts b/packages/opencode/src/cli/cmd/run/runtime.ts index d811106bd8..882ff2e6c7 100644 --- a/packages/opencode/src/cli/cmd/run/runtime.ts +++ b/packages/opencode/src/cli/cmd/run/runtime.ts @@ -487,6 +487,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput): Promise { const handle = await mod.createSessionTransport({ sdk: ctx.sdk, + directory: ctx.directory, sessionID: state.sessionID, thinking: input.thinking, limits: () => state.limits, diff --git a/packages/opencode/src/cli/cmd/run/stream.transport.ts b/packages/opencode/src/cli/cmd/run/stream.transport.ts index d4b73ce6fa..c528c3d374 100644 --- a/packages/opencode/src/cli/cmd/run/stream.transport.ts +++ b/packages/opencode/src/cli/cmd/run/stream.transport.ts @@ -1,8 +1,9 @@ -// SDK event subscription and prompt turn coordination. +// Global event subscription and prompt turn coordination. // -// Creates a long-lived event stream subscription and feeds every event -// through the session-data reducer. The reducer produces scrollback commits -// and footer patches, which get forwarded to the footer through stream.ts. +// Creates a long-lived global event stream subscription and feeds relevant +// events for the current session tree through the reducers. The reducers +// produce scrollback commits and footer patches, which get forwarded to the +// footer through stream.ts. // // Prompt turns are one-at-a-time: runPromptTurn() sends the prompt to the // SDK, arms a deferred Wait, and resolves when the session becomes idle. @@ -14,7 +15,7 @@ // The tick counter prevents stale idle events from resolving the wrong turn. // We also re-check live session status before resolving an idle event so a // delayed idle from an older turn cannot complete a newer busy turn. -import type { Event, OpencodeClient } from "@opencode-ai/sdk/v2" +import type { Event, GlobalEvent, OpencodeClient } from "@opencode-ai/sdk/v2" import { Context, Deferred, Effect, Exit, Layer, Scope, Stream } from "effect" import { makeRuntime } from "@/effect/run-service" import { @@ -62,6 +63,7 @@ type Trace = { type StreamInput = { sdk: OpencodeClient + directory?: string sessionID: string thinking: boolean limits: () => Record @@ -151,6 +153,40 @@ function isEvent(value: unknown): value is Event { return typeof type === "string" && !!properties && typeof properties === "object" } +function isGlobalEvent(value: unknown): value is GlobalEvent { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return false + } + + const payload = Reflect.get(value, "payload") + return !!payload && typeof payload === "object" +} + +function globalPayloadEvent(value: unknown): Event | undefined { + if (!isGlobalEvent(value)) { + return undefined + } + + const payload = value.payload + if (payload.type === "sync") { + return undefined + } + + return isEvent(payload) ? payload : undefined +} + +function isMatchingDisposeEvent(value: unknown, directory: string | undefined): boolean { + if (!directory || !isGlobalEvent(value)) { + return false + } + + if (value.directory !== directory) { + return false + } + + return value.payload.type === "server.instance.disposed" +} + function active(event: Event, sessionID: string): boolean { if (sid(event) !== sessionID) { return false @@ -371,7 +407,7 @@ function createLayer(input: StreamInput) { const events = yield* Scope.provide(scope)( Effect.acquireRelease( Effect.promise(() => - input.sdk.event.subscribe(undefined, { + input.sdk.global.event({ signal: abort.signal, }), ), @@ -397,7 +433,6 @@ function createLayer(input: StreamInput) { blockers: new Map(), } const recovering = new Set() - const currentSubagentState = () => { if (state.selectedSubagent && !state.subagent.tabs.has(state.selectedSubagent)) { state.selectedSubagent = undefined @@ -526,6 +561,38 @@ function createLayer(input: StreamInput) { Effect.orElseSucceed(() => []), ) + const bootstrapSubagentHistory = Effect.fn("RunStreamTransport.bootstrapSubagentHistory")( + function* (sessions: string[]) { + yield* Effect.forEach( + sessions, + (sessionID) => + messages(sessionID, SUBAGENT_CALL_BOOTSTRAP_LIMIT).pipe( + Effect.tap((messagesList) => + Effect.sync(() => { + if ( + !bootstrapSubagentCalls({ + data: state.subagent, + sessionID, + messages: messagesList, + thinking: input.thinking, + limits: input.limits(), + }) + ) { + return + } + + syncFooter([], undefined, currentSubagentState()) + }), + ), + ), + { + concurrency: 4, + discard: true, + }, + ) + }, + ) + const bootstrap = Effect.fn("RunStreamTransport.bootstrap")(function* () { const [messagesList, children, permissions, questions] = yield* Effect.all( [ @@ -566,33 +633,6 @@ function createLayer(input: StreamInput) { questions, }) - const sessions = [ - ...new Set( - listSubagentPermissions(state.subagent) - .filter((item) => item.tool && item.metadata?.input === undefined) - .map((item) => item.sessionID), - ), - ] - yield* Effect.forEach( - sessions, - (sessionID) => - messages(sessionID, SUBAGENT_CALL_BOOTSTRAP_LIMIT).pipe( - Effect.tap((messagesList) => - Effect.sync(() => { - bootstrapSubagentCalls({ - data: state.subagent, - sessionID, - messages: messagesList, - }) - }), - ), - ), - { - concurrency: "unbounded", - discard: true, - }, - ) - for (const request of [ ...state.data.permissions, ...listSubagentPermissions(state.subagent), @@ -605,6 +645,13 @@ function createLayer(input: StreamInput) { const snapshot = currentSubagentState() traceTabs(input.trace, [], snapshot.tabs) syncFooter([], undefined, snapshot) + + const sessions = [...state.subagent.tabs.keys()] + if (sessions.length === 0) { + return + } + + yield* bootstrapSubagentHistory(sessions).pipe(Effect.forkIn(scope, { startImmediately: true }), Effect.asVoid) }) const idle = Effect.fn("RunStreamTransport.idle")((fallback: boolean) => @@ -700,11 +747,22 @@ function createLayer(input: StreamInput) { return } - if (!isEvent(item)) { + if (isMatchingDisposeEvent(item, input.directory)) { + yield* fail(new Error("instance disposed")) + yield* closeScope() + return + } + + const event = globalPayloadEvent(item) + if (!event) { + return + } + + const sessionID = sid(event) + if (sessionID !== input.sessionID && (!sessionID || !state.subagent.tabs.has(sessionID))) { return } - const event = item input.trace?.write("recv.event", event) trackBlocker(event) @@ -754,7 +812,7 @@ function createLayer(input: StreamInput) { Effect.ensuring( Effect.gen(function* () { if (!abort.signal.aborted && !state.fault) { - yield* fail(new Error("session event stream closed")) + yield* fail(new Error("global event stream closed")) } closeStream() }), diff --git a/packages/opencode/src/cli/cmd/run/subagent-data.ts b/packages/opencode/src/cli/cmd/run/subagent-data.ts index e834ff74f0..e9dcd6538a 100644 --- a/packages/opencode/src/cli/cmd/run/subagent-data.ts +++ b/packages/opencode/src/cli/cmd/run/subagent-data.ts @@ -1,4 +1,4 @@ -import type { Event, Part, PermissionRequest, QuestionRequest, ToolPart } from "@opencode-ai/sdk/v2" +import type { Event, Message, Part, PermissionRequest, QuestionRequest, ToolPart } from "@opencode-ai/sdk/v2" import * as Locale from "@/util/locale" import { bootstrapSessionData, @@ -22,6 +22,10 @@ type SessionMessage = { parts: Part[] } +type BootstrapChildMessage = SessionMessage & { + info: Message +} + type Frame = { key: string commit: StreamCommit @@ -513,6 +517,70 @@ function applyChildEvent(input: { return changed || queueChanged(input.detail.data, before) } +function bootstrapChildEvent(input: { + detail: DetailState + event: Event + thinking: boolean + limits: Record +}) { + const out = reduceSessionData({ + data: input.detail.data, + event: input.event, + sessionID: input.detail.sessionID, + thinking: input.thinking, + limits: input.limits, + }) + + return appendCommits(input.detail, out.commits) +} + +function bootstrapChildMessages(input: { + detail: DetailState + messages: BootstrapChildMessage[] + thinking: boolean + limits: Record +}) { + let changed = false + + for (const message of input.messages) { + changed = + bootstrapChildEvent({ + detail: input.detail, + event: { + id: `bootstrap:message:${message.info.id}`, + type: "message.updated", + properties: { + sessionID: input.detail.sessionID, + info: message.info, + }, + }, + thinking: input.thinking, + limits: input.limits, + }) || changed + + for (const part of message.parts) { + changed = + bootstrapChildEvent({ + detail: input.detail, + event: { + id: `bootstrap:part:${part.id}`, + type: "message.part.updated", + properties: { + sessionID: input.detail.sessionID, + part, + time: 0, + }, + }, + thinking: input.thinking, + limits: input.limits, + }) || changed + } + } + + compactDetail(input.detail) + return changed +} + function knownSession(data: SubagentData, sessionID: string) { return data.tabs.has(sessionID) } @@ -634,7 +702,13 @@ export function bootstrapSubagentData(input: BootstrapSubagentInput) { return changed } -export function bootstrapSubagentCalls(input: { data: SubagentData; sessionID: string; messages: SessionMessage[] }) { +export function bootstrapSubagentCalls(input: { + data: SubagentData + sessionID: string + messages: BootstrapChildMessage[] + thinking: boolean + limits: Record +}) { if (!knownSession(input.data, input.sessionID) || input.messages.length === 0) { return false } @@ -648,9 +722,14 @@ export function bootstrapSubagentCalls(input: { data: SubagentData; sessionID: s permissions: detail.data.permissions, questions: detail.data.questions, }) - compactDetail(detail) + const changed = bootstrapChildMessages({ + detail, + messages: input.messages, + thinking: input.thinking, + limits: input.limits, + }) - return beforeCallCount !== detail.data.call.size || queueChanged(detail.data, before) + return changed || beforeCallCount !== detail.data.call.size || queueChanged(detail.data, before) } export function clearFinishedSubagents(data: SubagentData) { diff --git a/packages/opencode/test/cli/run/stream.transport.test.ts b/packages/opencode/test/cli/run/stream.transport.test.ts index dab5264bd6..9d832d1e34 100644 --- a/packages/opencode/test/cli/run/stream.transport.test.ts +++ b/packages/opencode/test/cli/run/stream.transport.test.ts @@ -1,9 +1,10 @@ import { afterEach, describe, expect, mock, spyOn, test } from "bun:test" -import { OpencodeClient } from "@opencode-ai/sdk/v2" +import { OpencodeClient, type GlobalEvent } from "@opencode-ai/sdk/v2" import { createSessionTransport } from "@/cli/cmd/run/stream.transport" import type { FooterApi, FooterEvent, RunFilePart, StreamCommit } from "@/cli/cmd/run/types" type EventStream = Awaited>["stream"] +type GlobalEventStream = Awaited>["stream"] type SdkEvent = EventStream extends AsyncGenerator ? T : never type SessionMessage = NonNullable>["data"]>[number] type SessionChild = NonNullable>["data"]>[number] @@ -81,12 +82,12 @@ function assistant(id: string) { } satisfies SdkEvent } -function feed() { - const list: SdkEvent[] = [] +function feed() { + const list: T[] = [] let done = false let wake: (() => void) | undefined - const stream: EventStream = (async function* () { + const wrapped = (async function* () { while (!done || list.length > 0) { if (list.length === 0) { await new Promise((resolve) => { @@ -105,8 +106,8 @@ function feed() { })() return { - stream, - push(value: SdkEvent) { + stream: wrapped, + push(value: T) { list.push(value) wake?.() wake = undefined @@ -119,6 +120,14 @@ function feed() { } } +function eventFeed() { + return feed() +} + +function globalFeed() { + return feed() +} + function emptyStream(): EventStream { return (async function* (): AsyncGenerator {})() } @@ -136,6 +145,18 @@ function sse(stream: EventStream) { return Promise.resolve({ stream }) } +function globalSse(stream: GlobalEventStream) { + return Promise.resolve({ stream }) +} + +function wrapGlobalStream(stream: EventStream): GlobalEventStream { + return (async function* () { + for await (const event of stream) { + yield globalEvent(event) + } + })() +} + function statusMap(busy: boolean): SessionStatusMap { if (busy) { return { "session-1": { type: "busy" } } @@ -235,10 +256,10 @@ function completedTool(input: { } } -function textPart(id: string, messageID: string, text: string): TextPart { +function textPart(id: string, messageID: string, text: string, sessionID = "session-1"): TextPart { return { id, - sessionID: "session-1", + sessionID, messageID, type: "text", text, @@ -298,6 +319,14 @@ function child(id: string): SessionChild { } } +function globalEvent(payload: GlobalEvent["payload"]): GlobalEvent { + return { + directory: "/tmp", + project: "project-1", + payload, + } +} + function footer(fn?: (commit: StreamCommit) => void) { const commits: StreamCommit[] = [] const events: FooterEvent[] = [] @@ -333,7 +362,9 @@ function footer(fn?: (commit: StreamCommit) => void) { function sdk( input: { stream?: EventStream + globalStream?: GlobalEventStream subscribe?: OpencodeClient["event"]["subscribe"] + globalEvent?: OpencodeClient["global"]["event"] promptAsync?: OpencodeClient["session"]["promptAsync"] status?: OpencodeClient["session"]["status"] messages?: OpencodeClient["session"]["messages"] @@ -345,6 +376,8 @@ function sdk( const client = new OpencodeClient() const subscribe: OpencodeClient["event"]["subscribe"] = input.subscribe ?? (() => sse(input.stream ?? emptyStream())) + const globalEvent: OpencodeClient["global"]["event"] = + input.globalEvent ?? (() => globalSse(input.globalStream ?? wrapGlobalStream(input.stream ?? emptyStream()))) const promptAsync: OpencodeClient["session"]["promptAsync"] = input.promptAsync ?? (() => ok(undefined)) const status: OpencodeClient["session"]["status"] = input.status ?? (() => ok({})) const messages: OpencodeClient["session"]["messages"] = input.messages ?? (() => ok([])) @@ -353,6 +386,7 @@ function sdk( const questions: OpencodeClient["question"]["list"] = input.questions ?? (() => ok([])) spyOn(client.event, "subscribe").mockImplementation(subscribe) + spyOn(client.global, "event").mockImplementation(globalEvent) spyOn(client.session, "promptAsync").mockImplementation(promptAsync) spyOn(client.session, "status").mockImplementation(status) spyOn(client.session, "messages").mockImplementation(messages) @@ -365,7 +399,7 @@ function sdk( describe("run stream transport", () => { test("bootstraps child tabs and resumed blocker input", async () => { - const src = feed() + const src = eventFeed() const ui = footer() const transport = await createSessionTransport({ sdk: sdk({ @@ -440,61 +474,67 @@ describe("run stream transport", () => { }) try { - expect(ui.events).toContainEqual({ - type: "stream.subagent", - state: { - tabs: [ - expect.objectContaining({ - sessionID: "child-1", - label: "Explore", - description: "Explore run folder", - status: "running", - }), - ], - details: {}, - permissions: [ - expect.objectContaining({ - id: "perm-1", - sessionID: "child-1", - metadata: { - input: { - filePath: "src/run/subagent-data.ts", - diff: "@@ -1 +1 @@", - }, - }, - }), - ], - questions: [], - }, + const boot = await waitFor(() => { + const item = ui.events.findLast((event) => event.type === "stream.subagent") + const state = item?.type === "stream.subagent" ? item.state : undefined + return state?.tabs.some((tab) => tab.sessionID === "child-1") && state.permissions.some((req) => req.id === "perm-1") + ? state + : undefined }) + expect(boot.tabs).toEqual([ + expect.objectContaining({ + sessionID: "child-1", + label: "Explore", + description: "Explore run folder", + status: "running", + }), + ]) + expect(boot.permissions).toEqual([ + expect.objectContaining({ + id: "perm-1", + sessionID: "child-1", + metadata: { + input: { + filePath: "src/run/subagent-data.ts", + diff: "@@ -1 +1 @@", + }, + }, + }), + ]) + transport.selectSubagent("child-1") - expect(ui.events).toContainEqual({ - type: "stream.subagent", - state: { - tabs: [ + const selected = await waitFor(() => { + const item = ui.events.findLast((event) => event.type === "stream.subagent") + const state = item?.type === "stream.subagent" ? item.state : undefined + const detail = state?.details["child-1"] + return detail?.commits.some((commit) => commit.kind === "tool" && commit.tool === "edit" && commit.phase === "start") + ? state + : undefined + }) + + expect(selected.details).toEqual({ + "child-1": { + sessionID: "child-1", + commits: [ expect.objectContaining({ - sessionID: "child-1", - label: "Explore", + kind: "tool", + tool: "edit", + phase: "start", }), ], - details: { - "child-1": { - sessionID: "child-1", - commits: [], - }, - }, - permissions: [ - expect.objectContaining({ - id: "perm-1", - }), - ], - questions: [], }, }) - expect(ui.events).toContainEqual({ + expect( + await waitFor(() => { + const item = ui.events.findLast((event) => event.type === "stream.view") + return item?.type === "stream.view" && item.view.type === "permission" && item.view.request.id === "perm-1" + ? item + : undefined + }), + ).toEqual({ type: "stream.view", view: { type: "permission", @@ -515,8 +555,265 @@ describe("run stream transport", () => { } }) + test("bootstraps child session output before selection", async () => { + const ui = footer() + const transport = await createSessionTransport({ + sdk: sdk({ + messages: async ({ sessionID }) => { + if (sessionID === "session-1") { + return ok([ + assistantMessage({ + sessionID: "session-1", + id: "msg-1", + parts: [ + completedTool({ + sessionID: "session-1", + messageID: "msg-1", + id: "task-1", + callID: "call-1", + tool: "task", + body: { + description: "Explore run.ts", + subagent_type: "explore", + }, + metadata: { + sessionId: "child-1", + }, + }), + ], + }), + ]) + } + + return sessionID === "child-1" + ? ok([ + assistantMessage({ + sessionID: "child-1", + id: "msg-child-1", + parts: [textPart("txt-child-1", "msg-child-1", "subagent summary", "child-1")], + }), + ]) + : ok([]) + }, + children: async () => ok([child("child-1")]), + }), + sessionID: "session-1", + thinking: true, + limits: () => ({}), + footer: ui.api, + }) + + try { + await waitFor(() => { + const item = ui.events.findLast((event) => event.type === "stream.subagent") + return item?.type === "stream.subagent" && item.state.tabs.some((tab) => tab.sessionID === "child-1") + ? item + : undefined + }) + + transport.selectSubagent("child-1") + + expect( + await waitFor(() => { + const item = ui.events.findLast((event) => event.type === "stream.subagent") + const detail = item?.type === "stream.subagent" ? item.state.details["child-1"] : undefined + return detail?.commits.some((commit) => commit.kind === "assistant" && commit.text === "subagent summary") + ? detail + : undefined + }), + ).toEqual({ + sessionID: "child-1", + commits: [ + expect.objectContaining({ + kind: "assistant", + text: "subagent summary", + }), + ], + }) + } finally { + await transport.close() + } + }) + + test("does not block startup on child history bootstrap", async () => { + const pending = defer>>>() + const ui = footer() + let transport: Awaited> | undefined + + const task = createSessionTransport({ + sdk: sdk({ + messages: async ({ sessionID }) => { + if (sessionID === "session-1") { + return ok([ + assistantMessage({ + sessionID: "session-1", + id: "msg-1", + parts: [ + runningTool({ + sessionID: "session-1", + messageID: "msg-1", + id: "task-1", + callID: "call-1", + tool: "task", + body: { + description: "Explore run.ts", + subagent_type: "explore", + }, + metadata: { + sessionId: "child-1", + }, + }), + ], + }), + ]) + } + + if (sessionID === "child-1") { + return pending.promise + } + + return ok([]) + }, + children: async () => ok([child("child-1")]), + }), + sessionID: "session-1", + thinking: true, + limits: () => ({}), + footer: ui.api, + }).then((item) => { + transport = item + return item + }) + + try { + const state = await waitFor(() => { + const item = ui.events.findLast((event) => event.type === "stream.subagent") + return item?.type === "stream.subagent" && item.state.tabs.some((tab) => tab.sessionID === "child-1") + ? item.state + : undefined + }) + + await waitFor(() => transport) + + expect(state).toEqual({ + tabs: [expect.objectContaining({ sessionID: "child-1", status: "running" })], + details: {}, + permissions: [], + questions: [], + }) + } finally { + pending.resolve(ok([])) + await task + await transport?.close() + } + }) + + test("streams selected subagent output from global events while it is running", async () => { + const global = globalFeed() + const ui = footer() + const transport = await createSessionTransport({ + sdk: sdk({ + globalStream: global.stream, + }), + sessionID: "session-1", + thinking: true, + limits: () => ({}), + footer: ui.api, + }) + + try { + global.push(globalEvent(assistant("msg-1"))) + global.push( + globalEvent( + toolUpdated( + runningTool({ + sessionID: "session-1", + messageID: "msg-1", + id: "task-1", + callID: "call-1", + tool: "task", + body: { + description: "Explore run.ts", + subagent_type: "explore", + }, + metadata: { + sessionId: "child-1", + }, + }), + ), + ), + ) + + await waitFor(() => { + const item = ui.events.findLast((event) => event.type === "stream.subagent") + return item?.type === "stream.subagent" && item.state.tabs.some((tab) => tab.sessionID === "child-1") + ? item + : undefined + }) + + transport.selectSubagent("child-1") + + global.push( + globalEvent({ + id: "evt-child-message", + type: "message.updated", + properties: { + sessionID: "child-1", + info: assistantMessage({ + sessionID: "child-1", + id: "msg-child-1", + parts: [], + }).info, + }, + }), + ) + global.push(globalEvent(textUpdated(textPart("txt-child-1", "msg-child-1", "hello", "child-1")))) + + expect( + await waitFor(() => { + const item = ui.events.findLast((event) => event.type === "stream.subagent") + const detail = item?.type === "stream.subagent" ? item.state.details["child-1"] : undefined + return detail?.commits.some((commit) => commit.kind === "assistant" && commit.text === "hello") + ? detail + : undefined + }), + ).toEqual({ + sessionID: "child-1", + commits: [ + expect.objectContaining({ + kind: "assistant", + text: "hello", + }), + ], + }) + + global.push(globalEvent(textUpdated(textPart("txt-child-1", "msg-child-1", "hello world", "child-1")))) + + expect( + await waitFor(() => { + const item = ui.events.findLast((event) => event.type === "stream.subagent") + const detail = item?.type === "stream.subagent" ? item.state.details["child-1"] : undefined + return detail?.commits.some((commit) => commit.kind === "assistant" && commit.text === "hello world") + ? detail + : undefined + }, 2_000), + ).toEqual({ + sessionID: "child-1", + commits: [ + expect.objectContaining({ + kind: "assistant", + text: "hello world", + }), + ], + }) + } finally { + global.close() + await transport.close() + } + }) + test("recovers pending questions from question.list when question.asked is missed", async () => { - const src = feed() + const src = eventFeed() const ui = footer() let questionCalls = 0 const request = { @@ -639,7 +936,7 @@ describe("run stream transport", () => { }) test("does not resurrect questions if question.list resolves after tool completion", async () => { - const src = feed() + const src = eventFeed() const ui = footer() const started = defer() const request = { @@ -736,6 +1033,12 @@ describe("run stream transport", () => { }), ), ) + await waitFor(() => { + const commit = ui.commits.findLast( + (item) => item.kind === "tool" && item.partID === "question-race-tool-1" && item.toolState === "completed", + ) + return commit ? true : undefined + }) pending.resolve(ok([request])) await Bun.sleep(50) @@ -756,7 +1059,7 @@ describe("run stream transport", () => { }) test("respects the includeFiles flag when building prompt payloads", async () => { - const src = feed() + const src = eventFeed() const ui = footer() const seen: unknown[] = [] const file: RunFilePart = { @@ -818,7 +1121,7 @@ describe("run stream transport", () => { }) test("falls back to session status polling when idle events are missing", async () => { - const src = feed() + const src = eventFeed() const ui = footer() let busy = true const transport = await createSessionTransport({ @@ -858,7 +1161,7 @@ describe("run stream transport", () => { }) test("flushes interrupted output when the active turn aborts", async () => { - const src = feed() + const src = eventFeed() const seen = defer() const ui = footer((commit) => { if (commit.kind === "assistant" && commit.phase === "progress") { @@ -927,7 +1230,7 @@ describe("run stream transport", () => { }) test("closes an active turn without rejecting it", async () => { - const src = feed() + const src = eventFeed() const ui = footer() const ready = defer() let aborted = false @@ -982,11 +1285,11 @@ describe("run stream transport", () => { const transport = await createSessionTransport({ sdk: sdk({ - subscribe: () => - sse( - (async function* (): AsyncGenerator { + globalEvent: () => + globalSse( + (async function* (): AsyncGenerator { await ready.promise - yield busy() + yield globalEvent(busy()) throw new Error("boom") })(), ), @@ -1018,8 +1321,56 @@ describe("run stream transport", () => { } }) + test("rejects the active turn when the backing instance is disposed", async () => { + const ui = footer() + const ready = defer() + + const transport = await createSessionTransport({ + sdk: sdk({ + globalEvent: () => + globalSse( + (async function* (): AsyncGenerator { + await ready.promise + yield globalEvent({ + id: "evt-disposed", + type: "server.instance.disposed", + properties: { + directory: "/tmp", + }, + }) + })(), + ), + promptAsync: async () => { + ready.resolve() + return ok(undefined) + }, + status: async () => ok({}), + }), + directory: "/tmp", + sessionID: "session-1", + thinking: true, + limits: () => ({}), + footer: ui.api, + }) + + try { + await expect( + transport.runPromptTurn({ + agent: undefined, + model: undefined, + variant: undefined, + prompt: { text: "hello", parts: [] }, + files: [], + includeFiles: false, + }), + ).rejects.toThrow("instance disposed") + } finally { + await transport.close() + } + }) + test("rejects concurrent turns", async () => { - const src = feed() + const src = eventFeed() const ui = footer() const transport = await createSessionTransport({ sdk: sdk({ diff --git a/packages/opencode/test/cli/run/subagent-data.test.ts b/packages/opencode/test/cli/run/subagent-data.test.ts index 8d53c5485d..8d2dad365b 100644 --- a/packages/opencode/test/cli/run/subagent-data.test.ts +++ b/packages/opencode/test/cli/run/subagent-data.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test" import type { Event } from "@opencode-ai/sdk/v2" import { entryBody } from "@/cli/cmd/run/entry.body" import { + bootstrapSubagentCalls, bootstrapSubagentData, clearFinishedSubagents, createSubagentData, @@ -10,6 +11,7 @@ import { } from "@/cli/cmd/run/subagent-data" type SessionMessage = Parameters[0]["messages"][number] +type ChildMessage = Parameters[0]["messages"][number] function visible(commits: Array[0]>) { return commits.flatMap((item) => { @@ -120,6 +122,65 @@ function question(id: string, sessionID: string) { } } +function childMessage(input: { + messageID: string + sessionID: string + role: "user" | "assistant" + parts: ChildMessage["parts"] +}) { + if (input.role === "user") { + return { + info: { + id: input.messageID, + sessionID: input.sessionID, + role: "user", + time: { + created: 1, + }, + agent: "test", + model: { + providerID: "openai", + modelID: "gpt-5", + }, + }, + parts: input.parts, + } satisfies ChildMessage + } + + return { + info: { + id: input.messageID, + sessionID: input.sessionID, + role: "assistant", + time: { + created: 2, + completed: 3, + }, + parentID: "msg-user-1", + providerID: "openai", + modelID: "gpt-5", + mode: "default", + agent: "explore", + path: { + cwd: "/tmp", + root: "/tmp", + }, + cost: 0, + tokens: { + input: 1, + output: 1, + reasoning: 0, + cache: { + read: 0, + write: 0, + }, + }, + finish: "stop", + }, + parts: input.parts, + } satisfies ChildMessage +} + describe("run subagent data", () => { test("bootstraps tabs and child blockers from parent task parts", () => { const data = createSubagentData() @@ -309,6 +370,73 @@ describe("run subagent data", () => { expect(snapshot.questions).toEqual([]) }) + test("replays bootstrapped child session messages into inspector commits", () => { + const data = createSubagentData() + + bootstrapSubagentData({ + data, + messages: [taskMessage("child-1", "completed")], + children: [{ id: "child-1" }], + permissions: [], + questions: [], + }) + + expect( + bootstrapSubagentCalls({ + data, + sessionID: "child-1", + messages: [ + childMessage({ + messageID: "msg-user-1", + sessionID: "child-1", + role: "user", + parts: [ + { + id: "txt-user-1", + messageID: "msg-user-1", + sessionID: "child-1", + type: "text", + text: "Inspect footer tabs", + time: { start: 1, end: 1 }, + }, + ], + }), + childMessage({ + messageID: "msg-assistant-1", + sessionID: "child-1", + role: "assistant", + parts: [ + { + id: "reason-1", + messageID: "msg-assistant-1", + sessionID: "child-1", + type: "reasoning", + text: "planning next steps", + time: { start: 2, end: 2 }, + }, + { + id: "txt-1", + messageID: "msg-assistant-1", + sessionID: "child-1", + type: "text", + text: "hello world", + time: { start: 2, end: 3 }, + }, + ], + }), + ], + thinking: true, + limits: {}, + }), + ).toBe(true) + + expect(visible(snapshotSubagentData(data).details["child-1"]?.commits ?? [])).toEqual([ + "› Inspect footer tabs", + "_Thinking:_ planning next steps", + "hello world", + ]) + }) + test("clears finished tabs on the next parent prompt", () => { const data = createSubagentData() From 9e7f7bf8e434646c568319fc44969777ab41b621 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Fri, 8 May 2026 17:46:57 +0000 Subject: [PATCH 010/189] chore: generate --- .../src/cli/cmd/run/stream.transport.ts | 63 ++++++++++--------- .../test/cli/run/stream.transport.test.ts | 7 ++- 2 files changed, 38 insertions(+), 32 deletions(-) diff --git a/packages/opencode/src/cli/cmd/run/stream.transport.ts b/packages/opencode/src/cli/cmd/run/stream.transport.ts index c528c3d374..22240ebf56 100644 --- a/packages/opencode/src/cli/cmd/run/stream.transport.ts +++ b/packages/opencode/src/cli/cmd/run/stream.transport.ts @@ -561,37 +561,37 @@ function createLayer(input: StreamInput) { Effect.orElseSucceed(() => []), ) - const bootstrapSubagentHistory = Effect.fn("RunStreamTransport.bootstrapSubagentHistory")( - function* (sessions: string[]) { - yield* Effect.forEach( - sessions, - (sessionID) => - messages(sessionID, SUBAGENT_CALL_BOOTSTRAP_LIMIT).pipe( - Effect.tap((messagesList) => - Effect.sync(() => { - if ( - !bootstrapSubagentCalls({ - data: state.subagent, - sessionID, - messages: messagesList, - thinking: input.thinking, - limits: input.limits(), - }) - ) { - return - } + const bootstrapSubagentHistory = Effect.fn("RunStreamTransport.bootstrapSubagentHistory")(function* ( + sessions: string[], + ) { + yield* Effect.forEach( + sessions, + (sessionID) => + messages(sessionID, SUBAGENT_CALL_BOOTSTRAP_LIMIT).pipe( + Effect.tap((messagesList) => + Effect.sync(() => { + if ( + !bootstrapSubagentCalls({ + data: state.subagent, + sessionID, + messages: messagesList, + thinking: input.thinking, + limits: input.limits(), + }) + ) { + return + } - syncFooter([], undefined, currentSubagentState()) - }), - ), + syncFooter([], undefined, currentSubagentState()) + }), ), - { - concurrency: 4, - discard: true, - }, - ) - }, - ) + ), + { + concurrency: 4, + discard: true, + }, + ) + }) const bootstrap = Effect.fn("RunStreamTransport.bootstrap")(function* () { const [messagesList, children, permissions, questions] = yield* Effect.all( @@ -651,7 +651,10 @@ function createLayer(input: StreamInput) { return } - yield* bootstrapSubagentHistory(sessions).pipe(Effect.forkIn(scope, { startImmediately: true }), Effect.asVoid) + yield* bootstrapSubagentHistory(sessions).pipe( + Effect.forkIn(scope, { startImmediately: true }), + Effect.asVoid, + ) }) const idle = Effect.fn("RunStreamTransport.idle")((fallback: boolean) => diff --git a/packages/opencode/test/cli/run/stream.transport.test.ts b/packages/opencode/test/cli/run/stream.transport.test.ts index 9d832d1e34..3358ae774d 100644 --- a/packages/opencode/test/cli/run/stream.transport.test.ts +++ b/packages/opencode/test/cli/run/stream.transport.test.ts @@ -477,7 +477,8 @@ describe("run stream transport", () => { const boot = await waitFor(() => { const item = ui.events.findLast((event) => event.type === "stream.subagent") const state = item?.type === "stream.subagent" ? item.state : undefined - return state?.tabs.some((tab) => tab.sessionID === "child-1") && state.permissions.some((req) => req.id === "perm-1") + return state?.tabs.some((tab) => tab.sessionID === "child-1") && + state.permissions.some((req) => req.id === "perm-1") ? state : undefined }) @@ -509,7 +510,9 @@ describe("run stream transport", () => { const item = ui.events.findLast((event) => event.type === "stream.subagent") const state = item?.type === "stream.subagent" ? item.state : undefined const detail = state?.details["child-1"] - return detail?.commits.some((commit) => commit.kind === "tool" && commit.tool === "edit" && commit.phase === "start") + return detail?.commits.some( + (commit) => commit.kind === "tool" && commit.tool === "edit" && commit.phase === "start", + ) ? state : undefined }) From daa3116f4bb93836fffc4ae077997c3990639794 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 8 May 2026 13:58:14 -0400 Subject: [PATCH 011/189] refactor(server): split HttpApi exercise harness (#26385) --- packages/opencode/script/httpapi-exercise.ts | 2015 +---------------- .../server/httpapi-exercise/assertions.ts | 64 + .../test/server/httpapi-exercise/backend.ts | 83 + .../test/server/httpapi-exercise/dsl.ts | 170 ++ .../server/httpapi-exercise/environment.ts | 41 + .../test/server/httpapi-exercise/index.ts | 1203 ++++++++++ .../test/server/httpapi-exercise/report.ts | 66 + .../test/server/httpapi-exercise/routing.ts | 44 + .../test/server/httpapi-exercise/runner.ts | 245 ++ .../test/server/httpapi-exercise/runtime.ts | 55 + .../test/server/httpapi-exercise/types.ts | 111 + 11 files changed, 2083 insertions(+), 2014 deletions(-) create mode 100644 packages/opencode/test/server/httpapi-exercise/assertions.ts create mode 100644 packages/opencode/test/server/httpapi-exercise/backend.ts create mode 100644 packages/opencode/test/server/httpapi-exercise/dsl.ts create mode 100644 packages/opencode/test/server/httpapi-exercise/environment.ts create mode 100644 packages/opencode/test/server/httpapi-exercise/index.ts create mode 100644 packages/opencode/test/server/httpapi-exercise/report.ts create mode 100644 packages/opencode/test/server/httpapi-exercise/routing.ts create mode 100644 packages/opencode/test/server/httpapi-exercise/runner.ts create mode 100644 packages/opencode/test/server/httpapi-exercise/runtime.ts create mode 100644 packages/opencode/test/server/httpapi-exercise/types.ts diff --git a/packages/opencode/script/httpapi-exercise.ts b/packages/opencode/script/httpapi-exercise.ts index 771e1e417e..5395a812f5 100644 --- a/packages/opencode/script/httpapi-exercise.ts +++ b/packages/opencode/script/httpapi-exercise.ts @@ -1,2014 +1 @@ -/** - * End-to-end exerciser for the legacy Hono instance routes and the Effect HttpApi routes. - * - * The goal is not to be a normal unit test file. This is a route-coverage and parity - * harness we can run while deleting Hono: every public route should eventually have a - * small scenario that proves the Effect route decodes requests, uses the right instance - * context, mutates storage when expected, and returns a compatible response shape. - * - * The script intentionally isolates `OPENCODE_DB` before importing modules that touch - * storage. Scenarios may create/delete sessions and reset the database after each run, - * so this must never point at a developer's real session database. - * - * DSL shape: - * - `http.get/post/...` starts a scenario for one OpenAPI route key. - * - `.seeded(...)` creates typed per-scenario state using Effect helpers on `ctx`. - * - `.at(...)` builds the request from that typed state. - * - `.json(...)` / `.jsonEffect(...)` assert response shape and optional side effects. - * - `.mutating()` tells parity mode to run Effect and Hono in separate isolated contexts - * so destructive routes compare equivalent fresh setups instead of sharing one DB. - */ -import { Cause, ConfigProvider, Effect, Layer } from "effect" -import { HttpRouter } from "effect/unstable/http" -import { OpenApi } from "effect/unstable/httpapi" -import { Flag } from "@opencode-ai/core/flag/flag" -import { TestLLMServer } from "../test/lib/llm-server" -import type { Config } from "../src/config/config" -import { MessageID, PartID, type SessionID } from "../src/session/schema" -import { ModelID, ProviderID } from "../src/provider/schema" -import type { MessageV2 } from "../src/session/message-v2" -import type { Worktree } from "../src/worktree" -import type { Project } from "../src/project/project" -import path from "path" - -const preserveExerciseGlobalRoot = !!process.env.OPENCODE_HTTPAPI_EXERCISE_GLOBAL -const exerciseGlobalRoot = - process.env.OPENCODE_HTTPAPI_EXERCISE_GLOBAL ?? - path.join(process.env.TMPDIR ?? "/tmp", `opencode-httpapi-global-${process.pid}`) -process.env.XDG_DATA_HOME = path.join(exerciseGlobalRoot, "data") -process.env.XDG_CONFIG_HOME = path.join(exerciseGlobalRoot, "config") -process.env.XDG_STATE_HOME = path.join(exerciseGlobalRoot, "state") -process.env.XDG_CACHE_HOME = path.join(exerciseGlobalRoot, "cache") -process.env.OPENCODE_DISABLE_SHARE = "true" -const exerciseConfigDirectory = path.join(exerciseGlobalRoot, "config", "opencode") -const exerciseDataDirectory = path.join(exerciseGlobalRoot, "data", "opencode") - -const preserveExerciseDatabase = !!process.env.OPENCODE_HTTPAPI_EXERCISE_DB -const exerciseDatabasePath = - process.env.OPENCODE_HTTPAPI_EXERCISE_DB ?? - path.join(process.env.TMPDIR ?? "/tmp", `opencode-httpapi-exercise-${process.pid}.db`) -process.env.OPENCODE_DB = exerciseDatabasePath -Flag.OPENCODE_DB = exerciseDatabasePath - -void (await import("@opencode-ai/core/util/log")).init({ print: false }) - -const OpenApiMethods = ["get", "post", "put", "delete", "patch"] as const -const Methods = ["GET", "POST", "PUT", "DELETE", "PATCH"] as const -const color = { - dim: "\x1b[2m", - green: "\x1b[32m", - red: "\x1b[31m", - yellow: "\x1b[33m", - cyan: "\x1b[36m", - reset: "\x1b[0m", -} - -type Method = (typeof Methods)[number] -type OpenApiMethod = (typeof OpenApiMethods)[number] -type Mode = "effect" | "parity" | "coverage" -type Backend = "effect" | "legacy" -type Comparison = "none" | "status" | "json" -type CaptureMode = "full" | "stream" -type ProjectOptions = { git?: boolean; config?: Partial; llm?: boolean } -type OpenApiSpec = { paths?: Record>> } -type JsonObject = Record - -type Options = { - mode: Mode - include: string | undefined - failOnMissing: boolean - failOnSkip: boolean -} - -type RequestSpec = { - path: string - headers?: Record - body?: unknown -} - -type CallResult = { - status: number - contentType: string - body: unknown - text: string -} - -type BackendApp = { - request(input: string | URL | Request, init?: RequestInit): Response | Promise -} - -/** Effect-native helpers available while setting up and asserting a scenario. */ -type ScenarioContext = { - directory: string | undefined - headers: (extra?: Record) => Record - file: (name: string, content: string) => Effect.Effect - session: (input?: { title?: string; parentID?: SessionID }) => Effect.Effect - sessionGet: (sessionID: SessionID) => Effect.Effect - project: () => Effect.Effect - message: (sessionID: SessionID, input?: { text?: string }) => Effect.Effect - messages: (sessionID: SessionID) => Effect.Effect - todos: (sessionID: SessionID, todos: TodoInfo[]) => Effect.Effect - worktree: (input?: { name?: string }) => Effect.Effect - worktreeRemove: (directory: string) => Effect.Effect - llmText: (value: string) => Effect.Effect - llmWait: (count: number) => Effect.Effect - tuiRequest: (request: { path: string; body: unknown }) => Effect.Effect -} - -/** Scenario context after `.seeded(...)`; `state` preserves the seed return type in the DSL. */ -type SeededContext = ScenarioContext & { - state: S -} - -type Scenario = ActiveScenario | TodoScenario -type ActiveScenario = { - kind: "active" - method: Method - path: string - name: string - project: ProjectOptions | undefined - seed: (ctx: ScenarioContext) => Effect.Effect - request: (ctx: ScenarioContext, state: unknown) => RequestSpec - expect: (ctx: ScenarioContext, state: unknown, result: CallResult) => Effect.Effect - compare: Comparison - capture: CaptureMode - mutates: boolean - reset: boolean -} - -/** Internal builder state stays generic until `.json(...)` erases it into `ActiveScenario`. */ -type BuilderState = { - method: Method - path: string - name: string - project: ProjectOptions | undefined - seed: (ctx: ScenarioContext) => Effect.Effect - request: (ctx: SeededContext) => RequestSpec - capture: CaptureMode - mutates: boolean - reset: boolean -} -type TodoScenario = { - kind: "todo" - method: Method - path: string - name: string - reason: string -} -type Result = - | { status: "pass"; scenario: ActiveScenario } - | { status: "fail"; scenario: ActiveScenario; message: string } - | { status: "skip"; scenario: TodoScenario } - -type SessionInfo = { id: SessionID; title: string; parentID?: SessionID } -type TodoInfo = { content: string; status: string; priority: string } -type MessageSeed = { info: MessageV2.User; part: MessageV2.TextPart } - -const original = { - OPENCODE_EXPERIMENTAL_HTTPAPI: Flag.OPENCODE_EXPERIMENTAL_HTTPAPI, - OPENCODE_SERVER_PASSWORD: Flag.OPENCODE_SERVER_PASSWORD, - OPENCODE_SERVER_USERNAME: Flag.OPENCODE_SERVER_USERNAME, -} - -type Runtime = { - PublicApi: (typeof import("../src/server/routes/instance/httpapi/public"))["PublicApi"] - ExperimentalHttpApiServer: (typeof import("../src/server/routes/instance/httpapi/server"))["ExperimentalHttpApiServer"] - Server: (typeof import("../src/server/server"))["Server"] - AppLayer: (typeof import("../src/effect/app-runtime"))["AppLayer"] - InstanceRef: (typeof import("../src/effect/instance-ref"))["InstanceRef"] - Instance: (typeof import("../src/project/instance"))["Instance"] - InstanceStore: (typeof import("../src/project/instance-store"))["InstanceStore"] - Session: (typeof import("../src/session/session"))["Session"] - Todo: (typeof import("../src/session/todo"))["Todo"] - Worktree: (typeof import("../src/worktree"))["Worktree"] - Project: (typeof import("../src/project/project"))["Project"] - Tui: typeof import("../src/server/shared/tui-control") - disposeAllInstances: (typeof import("../test/fixture/fixture"))["disposeAllInstances"] - tmpdir: (typeof import("../test/fixture/fixture"))["tmpdir"] - resetDatabase: (typeof import("../test/fixture/db"))["resetDatabase"] -} - -let runtimePromise: Promise | undefined - -function runtime() { - return (runtimePromise ??= (async () => { - const publicApi = await import("../src/server/routes/instance/httpapi/public") - const httpApiServer = await import("../src/server/routes/instance/httpapi/server") - const server = await import("../src/server/server") - const appRuntime = await import("../src/effect/app-runtime") - const instanceRef = await import("../src/effect/instance-ref") - const instance = await import("../src/project/instance") - const instanceStore = await import("../src/project/instance-store") - const session = await import("../src/session/session") - const todo = await import("../src/session/todo") - const worktree = await import("../src/worktree") - const project = await import("../src/project/project") - const tui = await import("../src/server/shared/tui-control") - const fixture = await import("../test/fixture/fixture") - const db = await import("../test/fixture/db") - return { - PublicApi: publicApi.PublicApi, - ExperimentalHttpApiServer: httpApiServer.ExperimentalHttpApiServer, - Server: server.Server, - AppLayer: appRuntime.AppLayer, - InstanceRef: instanceRef.InstanceRef, - Instance: instance.Instance, - InstanceStore: instanceStore.InstanceStore, - Session: session.Session, - Todo: todo.Todo, - Worktree: worktree.Worktree, - Project: project.Project, - Tui: tui, - disposeAllInstances: fixture.disposeAllInstances, - tmpdir: fixture.tmpdir, - resetDatabase: db.resetDatabase, - } - })()) -} - -class ScenarioBuilder { - private readonly state: BuilderState - - constructor(method: Method, path: string, name: string) { - this.state = { - method, - path, - name, - project: { git: true }, - seed: () => Effect.succeed(undefined as S), - request: (ctx) => ({ path, headers: ctx.headers() }), - capture: "full", - mutates: false, - reset: true, - } - } - - global() { - return this.clone({ project: undefined, request: () => ({ path: this.state.path }) }) - } - - inProject(project: ProjectOptions = { git: true }) { - return this.clone({ project }) - } - - withLlm() { - return this.clone({ project: { ...(this.state.project ?? { git: true }), llm: true } }) - } - - at(request: BuilderState["request"]) { - return this.clone({ request }) - } - - mutating() { - return this.clone({ mutates: true }) - } - - preserveDatabase() { - return this.clone({ reset: false }) - } - - stream() { - return this.clone({ capture: "stream" }) - } - - /** Assert a non-JSON or shape-only response. */ - ok(status = 200, compare: Comparison = "status") { - return this.done(compare, (_ctx, result) => - Effect.sync(() => { - if (result.status !== status) throw new Error(`expected ${status}, got ${result.status}: ${result.text}`) - }), - ) - } - - status( - status = 200, - inspect?: (ctx: SeededContext, result: CallResult) => Effect.Effect, - compare: Comparison = "status", - ) { - return this.done(compare, (ctx, result) => - Effect.gen(function* () { - if (result.status !== status) throw new Error(`expected ${status}, got ${result.status}: ${result.text}`) - if (inspect) yield* inspect(ctx, result) - }), - ) - } - - /** Assert JSON status/content-type plus an optional synchronous body check. */ - json(status = 200, inspect?: (body: unknown, ctx: SeededContext) => void, compare: Comparison = "json") { - return this.jsonEffect(status, inspect ? (body, ctx) => Effect.sync(() => inspect(body, ctx)) : undefined, compare) - } - - /** Assert JSON status/content-type plus optional Effect assertions, e.g. DB side effects. */ - jsonEffect( - status = 200, - inspect?: (body: unknown, ctx: SeededContext) => Effect.Effect, - compare: Comparison = "json", - ) { - return this.done(compare, (ctx, result) => - Effect.gen(function* () { - if (result.status !== status) throw new Error(`expected ${status}, got ${result.status}: ${result.text}`) - if (!looksJson(result)) - throw new Error(`expected JSON response, got ${result.contentType || "no content-type"}`) - if (inspect) yield* inspect(result.body, ctx) - }), - ) - } - - private clone(next: Partial>) { - const builder = new ScenarioBuilder(this.state.method, this.state.path, this.state.name) - Object.assign(builder.state, this.state, next) - return builder - } - - /** - * Seed typed state before the HTTP request. The returned value becomes `ctx.state` - * for `.at(...)` and assertions, giving stateful route tests type-safe setup. - */ - seeded(seed: (ctx: ScenarioContext) => Effect.Effect) { - const builder = new ScenarioBuilder(this.state.method, this.state.path, this.state.name) - Object.assign(builder.state, this.state, { seed }) - return builder - } - - private done( - compare: Comparison, - expect: (ctx: SeededContext, result: CallResult) => Effect.Effect, - ): ActiveScenario { - const state = this.state - return { - kind: "active", - method: state.method, - path: state.path, - name: state.name, - project: state.project, - seed: state.seed, - request: (ctx, seeded) => state.request({ ...ctx, state: seeded as S }), - expect: (ctx, seeded, result) => expect({ ...ctx, state: seeded as S }, result), - compare, - capture: state.capture, - mutates: state.mutates, - reset: state.reset, - } - } -} - -const http = { - get: (path: string, name: string) => new ScenarioBuilder("GET", path, name), - post: (path: string, name: string) => new ScenarioBuilder("POST", path, name), - put: (path: string, name: string) => new ScenarioBuilder("PUT", path, name), - patch: (path: string, name: string) => new ScenarioBuilder("PATCH", path, name), - delete: (path: string, name: string) => new ScenarioBuilder("DELETE", path, name), -} - -const pending = (method: Method, path: string, name: string, reason: string): TodoScenario => ({ - kind: "todo", - method, - path, - name, - reason, -}) - -function route(template: string, params: Record) { - return Object.entries(params).reduce( - (next, [key, value]) => next.replaceAll(`{${key}}`, value).replaceAll(`:${key}`, value), - template, - ) -} - -const scenarios: Scenario[] = [ - http - .get("/global/health", "global.health") - .global() - .json(200, (body) => { - object(body) - check(body.healthy === true, "server should report healthy") - }), - http - .get("/global/event", "global.event") - .global() - .stream() - .status( - 200, - (_ctx, result) => - Effect.sync(() => { - check(result.contentType.includes("text/event-stream"), "global event should be an SSE stream") - check(result.text.includes("server.connected"), "global event should emit initial connection event") - }), - "status", - ), - http.get("/global/config", "global.config.get").global().json(), - http - .patch("/global/config", "global.config.update") - .global() - .seeded(() => - Effect.promise(() => - Bun.write( - path.join(exerciseConfigDirectory, "opencode.jsonc"), - JSON.stringify({ username: "httpapi-global" }, null, 2), - ), - ), - ) - .at(() => ({ path: "/global/config", body: { username: "httpapi-global" } })) - .jsonEffect( - 200, - (body) => - Effect.gen(function* () { - object(body) - check(body.username === "httpapi-global", "global config update should return patched config") - const text = yield* Effect.promise(() => - Bun.file(path.join(exerciseConfigDirectory, "opencode.jsonc")).text(), - ) - check(text.includes('"username": "httpapi-global"'), "global config update should write isolated config file") - }), - "status", - ), - http - .post("/global/dispose", "global.dispose") - .global() - .mutating() - .json( - 200, - (body) => { - check(body === true, "global dispose should return true") - }, - "status", - ), - http.get("/path", "path.get").json(200, (body, ctx) => { - object(body) - check(body.directory === ctx.directory, "directory should resolve from x-opencode-directory") - check(body.worktree === ctx.directory, "worktree should resolve from x-opencode-directory") - }), - http.get("/vcs", "vcs.get").json(), - http - .get("/vcs/diff", "vcs.diff") - .at((ctx) => ({ path: "/vcs/diff?mode=git", headers: ctx.headers() })) - .json(200, array), - http.get("/command", "command.list").json(200, array, "status"), - http.get("/agent", "app.agents").json(200, array, "status"), - http.get("/skill", "app.skills").json(200, array, "status"), - http.get("/lsp", "lsp.status").json(200, array), - http.get("/formatter", "formatter.status").json(200, array), - http.get("/config", "config.get").json(200, undefined, "status"), - http - .patch("/config", "config.update") - .mutating() - .at((ctx) => ({ path: "/config", headers: ctx.headers(), body: { username: "httpapi-local" } })) - .json( - 200, - (body) => { - object(body) - check(body.username === "httpapi-local", "local config update should return patched config") - }, - "status", - ), - http - .patch("/config", "config.update.invalid") - .at((ctx) => ({ path: "/config", headers: ctx.headers(), body: { username: 1 } })) - .status(400), - http.get("/config/providers", "config.providers").json(), - http.get("/project", "project.list").json(200, array, "status"), - http.get("/project/current", "project.current").json( - 200, - (body, ctx) => { - object(body) - check(body.worktree === ctx.directory, "current project should resolve from scenario directory") - }, - "status", - ), - http - .patch("/project/{projectID}", "project.update") - .mutating() - .seeded((ctx) => ctx.project()) - .at((ctx) => ({ - path: route("/project/{projectID}", { projectID: ctx.state.id }), - headers: ctx.headers(), - body: { name: "HTTP API Project", commands: { start: "bun --version" } }, - })) - .json( - 200, - (body) => { - object(body) - check(body.name === "HTTP API Project", "project update should return patched name") - check( - isRecord(body.commands) && body.commands.start === "bun --version", - "project update should return patched command", - ) - }, - "status", - ), - http - .post("/project/git/init", "project.initGit") - .mutating() - .inProject({ git: false }) - .json( - 200, - (body, ctx) => { - object(body) - check(body.worktree === ctx.directory, "git init should return current project") - check(body.vcs === "git", "git init should mark the project as git-backed") - }, - "status", - ), - http.get("/provider", "provider.list").json(), - http.get("/provider/auth", "provider.auth").json(), - http - .post("/provider/{providerID}/oauth/authorize", "provider.oauth.authorize") - .at((ctx) => ({ - path: route("/provider/{providerID}/oauth/authorize", { providerID: "httpapi" }), - headers: ctx.headers(), - body: { method: "bad" }, - })) - .status(400), - http - .post("/provider/{providerID}/oauth/callback", "provider.oauth.callback") - .at((ctx) => ({ - path: route("/provider/{providerID}/oauth/callback", { providerID: "httpapi" }), - headers: ctx.headers(), - body: { method: "bad" }, - })) - .status(400), - http.get("/permission", "permission.list").json(200, array), - http - .post("/permission/{requestID}/reply", "permission.reply.invalid") - .at((ctx) => ({ - path: route("/permission/{requestID}/reply", { requestID: "per_httpapi" }), - headers: ctx.headers(), - body: { reply: "bad" }, - })) - .status(400), - http - .post("/permission/{requestID}/reply", "permission.reply") - .at((ctx) => ({ - path: route("/permission/{requestID}/reply", { requestID: "per_httpapi" }), - headers: ctx.headers(), - body: { reply: "once" }, - })) - .json(200, (body) => { - check(body === true, "permission reply should return true even when request is no longer pending") - }), - http.get("/question", "question.list").json(200, array), - http - .post("/question/{requestID}/reply", "question.reply.invalid") - .at((ctx) => ({ - path: route("/question/{requestID}/reply", { requestID: "que_httpapi_reply" }), - headers: ctx.headers(), - body: { answers: "Yes" }, - })) - .status(400), - http - .post("/question/{requestID}/reply", "question.reply") - .at((ctx) => ({ - path: route("/question/{requestID}/reply", { requestID: "que_httpapi_reply" }), - headers: ctx.headers(), - body: { answers: [["Yes"]] }, - })) - .json(200, (body) => { - check(body === true, "question reply should return true even when request is no longer pending") - }), - http - .post("/question/{requestID}/reject", "question.reject") - .at((ctx) => ({ - path: route("/question/{requestID}/reject", { requestID: "que_httpapi_reject" }), - headers: ctx.headers(), - })) - .json(200, (body) => { - check(body === true, "question reject should return true even when request is no longer pending") - }), - http - .get("/file", "file.list") - .seeded((ctx) => ctx.file("hello.txt", "hello\n")) - .at((ctx) => ({ path: `/file?${new URLSearchParams({ path: "." })}`, headers: ctx.headers() })) - .json(200, array), - http - .get("/file/content", "file.read") - .seeded((ctx) => ctx.file("hello.txt", "hello\n")) - .at((ctx) => ({ path: `/file/content?${new URLSearchParams({ path: "hello.txt" })}`, headers: ctx.headers() })) - .json(200, (body) => { - object(body) - check(body.content === "hello", `content should match seeded file: ${JSON.stringify(body)}`) - }), - http - .get("/file/content", "file.read.missing") - .at((ctx) => ({ path: `/file/content?${new URLSearchParams({ path: "missing.txt" })}`, headers: ctx.headers() })) - .json(200, (body) => { - object(body) - check(body.type === "text" && body.content === "", "missing file content should return an empty text result") - }), - http.get("/file/status", "file.status").json(200, array), - http - .get("/find", "find.text") - .seeded((ctx) => ctx.file("hello.txt", "hello\n")) - .at((ctx) => ({ path: `/find?${new URLSearchParams({ pattern: "hello" })}`, headers: ctx.headers() })) - .json(200, array), - http - .get("/find/file", "find.files") - .seeded((ctx) => ctx.file("hello.txt", "hello\n")) - .at((ctx) => ({ - path: `/find/file?${new URLSearchParams({ query: "hello", dirs: "false" })}`, - headers: ctx.headers(), - })) - .json(200, array), - http - .get("/find/symbol", "find.symbols") - .seeded((ctx) => ctx.file("hello.ts", "export const hello = 1\n")) - .at((ctx) => ({ path: `/find/symbol?${new URLSearchParams({ query: "hello" })}`, headers: ctx.headers() })) - .json(200, array), - http - .get("/event", "event.stream") - .stream() - .status( - 200, - (_ctx, result) => - Effect.sync(() => { - check(result.contentType.includes("text/event-stream"), "event should be an SSE stream") - check(result.text.includes("server.connected"), "event should emit initial connection event") - }), - "status", - ), - http.get("/mcp", "mcp.status").json(), - http - .post("/mcp", "mcp.add") - .mutating() - .at((ctx) => ({ - path: "/mcp", - headers: ctx.headers(), - body: { name: "httpapi-disabled", config: { type: "local", command: ["bun", "--version"], enabled: false } }, - })) - .json( - 200, - (body) => { - object(body) - object(body["httpapi-disabled"]) - check(body["httpapi-disabled"].status === "disabled", "disabled MCP server should be added without spawning") - }, - "status", - ), - http - .post("/mcp", "mcp.add.invalid") - .at((ctx) => ({ - path: "/mcp", - headers: ctx.headers(), - body: { name: "httpapi-invalid", config: { type: "invalid" } }, - })) - .status(400), - http - .post("/mcp/{name}/auth", "mcp.auth.start") - .at((ctx) => ({ path: route("/mcp/{name}/auth", { name: "httpapi-missing" }), headers: ctx.headers() })) - .json( - 400, - (body) => { - object(body) - check(typeof body.error === "string", "unsupported MCP OAuth response should include error") - }, - "status", - ), - http - .delete("/mcp/{name}/auth", "mcp.auth.remove") - .mutating() - .at((ctx) => ({ path: route("/mcp/{name}/auth", { name: "httpapi-missing" }), headers: ctx.headers() })) - .json(200, (body) => { - object(body) - check(body.success === true, "MCP auth removal should return success") - }), - http - .post("/mcp/{name}/auth/authenticate", "mcp.auth.authenticate") - .at((ctx) => ({ - path: route("/mcp/{name}/auth/authenticate", { name: "httpapi-missing" }), - headers: ctx.headers(), - })) - .json( - 400, - (body) => { - object(body) - check(typeof body.error === "string", "unsupported MCP OAuth authenticate response should include error") - }, - "status", - ), - http - .post("/mcp/{name}/auth/callback", "mcp.auth.callback") - .at((ctx) => ({ - path: route("/mcp/{name}/auth/callback", { name: "httpapi-missing" }), - headers: ctx.headers(), - body: { code: 1 }, - })) - .status(400), - http - .post("/mcp/{name}/connect", "mcp.connect") - .mutating() - .at((ctx) => ({ path: route("/mcp/{name}/connect", { name: "httpapi-missing" }), headers: ctx.headers() })) - .json(200, (body) => { - check(body === true, "missing MCP connect should remain a no-op success") - }), - http - .post("/mcp/{name}/disconnect", "mcp.disconnect") - .mutating() - .at((ctx) => ({ path: route("/mcp/{name}/disconnect", { name: "httpapi-missing" }), headers: ctx.headers() })) - .json(200, (body) => { - check(body === true, "missing MCP disconnect should remain a no-op success") - }), - http.get("/pty/shells", "pty.shells").json(200, array), - http.get("/pty", "pty.list").json(200, array), - http - .post("/pty", "pty.create") - .mutating() - .at((ctx) => ({ path: "/pty", headers: ctx.headers(), body: controlledPtyInput("HTTP API PTY") })) - .json( - 200, - (body, ctx) => { - object(body) - check(body.title === "HTTP API PTY", "PTY create should return requested title") - check(body.command === "/bin/sh", "PTY create should use controlled shell command") - check(body.cwd === ctx.directory, "PTY create should default cwd to scenario directory") - }, - "status", - ), - http - .post("/pty", "pty.create.invalid") - .at((ctx) => ({ path: "/pty", headers: ctx.headers(), body: { command: 1 } })) - .status(400), - http - .get("/pty/{ptyID}", "pty.get") - .at((ctx) => ({ path: route("/pty/{ptyID}", { ptyID: "pty_httpapi_missing" }), headers: ctx.headers() })) - .status(404), - http - .put("/pty/{ptyID}", "pty.update") - .mutating() - .at((ctx) => ({ - path: route("/pty/{ptyID}", { ptyID: "pty_httpapi_missing" }), - headers: ctx.headers(), - body: { size: { rows: 0, cols: 0 } }, - })) - .status(400), - http - .delete("/pty/{ptyID}", "pty.remove") - .mutating() - .at((ctx) => ({ path: route("/pty/{ptyID}", { ptyID: "pty_httpapi_missing" }), headers: ctx.headers() })) - .json(200, (body) => { - check(body === true, "PTY remove should return true") - }), - http - .get("/pty/{ptyID}/connect", "pty.connect") - .at((ctx) => ({ path: route("/pty/{ptyID}/connect", { ptyID: "pty_httpapi_missing" }), headers: ctx.headers() })) - .status(404, undefined, "none"), - http.get("/experimental/console", "experimental.console.get").json(), - http.get("/experimental/console/orgs", "experimental.console.listOrgs").json(), - http - .post("/experimental/console/switch", "experimental.console.switchOrg") - .at((ctx) => ({ - path: "/experimental/console/switch", - headers: ctx.headers(), - body: { accountID: "httpapi-account", orgID: "httpapi-org" }, - })) - .status(400, undefined, "none"), - http.get("/experimental/workspace/adapter", "experimental.workspace.adapter.list").json(200, array), - http.get("/experimental/workspace", "experimental.workspace.list").json(200, array), - http.get("/experimental/workspace/status", "experimental.workspace.status").json(200, array), - http - .post("/experimental/workspace", "experimental.workspace.create") - .at((ctx) => ({ path: "/experimental/workspace", headers: ctx.headers(), body: {} })) - .status(400), - http - .delete("/experimental/workspace/{id}", "experimental.workspace.remove") - .mutating() - .at((ctx) => ({ - path: route("/experimental/workspace/{id}", { id: "wrk_httpapi_missing" }), - headers: ctx.headers(), - })) - .status(200), - http - .post("/experimental/workspace/warp", "experimental.workspace.warp") - .at((ctx) => ({ - path: "/experimental/workspace/warp", - headers: ctx.headers(), - body: {}, - })) - .status(400), - http - .get("/experimental/tool", "tool.list") - .at((ctx) => ({ - path: `/experimental/tool?${new URLSearchParams({ provider: "opencode", model: "test" })}`, - headers: ctx.headers(), - })) - .json(200, array, "status"), - http.get("/experimental/tool/ids", "tool.ids").json(200, array), - http.get("/experimental/worktree", "worktree.list").json(200, array), - http - .post("/experimental/worktree", "worktree.create") - .mutating() - .at((ctx) => ({ path: "/experimental/worktree", headers: ctx.headers(), body: { name: "api-dsl" } })) - .jsonEffect( - 200, - (body, ctx) => - Effect.gen(function* () { - object(body) - check(typeof body.directory === "string", "created worktree should include directory") - yield* ctx.worktreeRemove(body.directory) - }), - "status", - ), - http - .post("/experimental/worktree", "worktree.create.invalid") - .at((ctx) => ({ path: "/experimental/worktree", headers: ctx.headers(), body: { name: 1 } })) - .status(400), - http - .delete("/experimental/worktree", "worktree.remove") - .mutating() - .seeded((ctx) => ctx.worktree({ name: "api-remove" })) - .at((ctx) => ({ path: "/experimental/worktree", headers: ctx.headers(), body: { directory: ctx.state.directory } })) - .json(200, (body) => { - check(body === true, "worktree remove should return true") - }), - http - .post("/experimental/worktree/reset", "worktree.reset") - .mutating() - .seeded((ctx) => ctx.worktree({ name: "api-reset" })) - .at((ctx) => ({ - path: "/experimental/worktree/reset", - headers: ctx.headers(), - body: { directory: ctx.state.directory }, - })) - .jsonEffect(200, (body, ctx) => - Effect.gen(function* () { - check(body === true, "worktree reset should return true") - yield* ctx.worktreeRemove(ctx.state.directory) - }), - ), - http.get("/experimental/session", "experimental.session.list").json(200, array), - http.get("/experimental/resource", "experimental.resource.list").json(), - http - .post("/sync/history", "sync.history.list") - .at((ctx) => ({ path: "/sync/history", headers: ctx.headers(), body: {} })) - .json(200, array), - http - .post("/sync/replay", "sync.replay") - .at((ctx) => ({ path: "/sync/replay", headers: ctx.headers(), body: { directory: ctx.directory, events: [] } })) - .status(400), - http - .post("/sync/start", "sync.start") - .mutating() - .preserveDatabase() - .json(200, (body) => { - check(body === true, "sync start should return true when no workspace sessions exist") - }), - http - .post("/instance/dispose", "instance.dispose") - .mutating() - .json(200, (body) => { - check(body === true, "instance dispose should return true") - }), - http - .post("/log", "app.log") - .global() - .at(() => ({ path: "/log", body: { service: "httpapi-exercise", level: "info", message: "route coverage" } })) - .json(200, (body) => { - check(body === true, "log route should return true") - }), - http - .put("/auth/{providerID}", "auth.set") - .global() - .at(() => ({ path: route("/auth/{providerID}", { providerID: "test" }), body: { type: "api", key: "test-key" } })) - .jsonEffect(200, (body) => - Effect.gen(function* () { - check(body === true, "auth set should return true") - const auth = yield* Effect.promise(() => Bun.file(path.join(exerciseDataDirectory, "auth.json")).json()) - object(auth) - check(isRecord(auth.test) && auth.test.key === "test-key", "auth set should write isolated auth file") - }), - ), - http - .delete("/auth/{providerID}", "auth.remove") - .global() - .seeded(() => - Effect.promise(() => - Bun.write( - path.join(exerciseDataDirectory, "auth.json"), - JSON.stringify({ test: { type: "api", key: "remove-me" } }), - ), - ), - ) - .at(() => ({ path: route("/auth/{providerID}", { providerID: "test" }) })) - .jsonEffect(200, (body) => - Effect.gen(function* () { - check(body === true, "auth remove should return true") - const auth = yield* Effect.promise(() => Bun.file(path.join(exerciseDataDirectory, "auth.json")).json()) - object(auth) - check(auth.test === undefined, "auth remove should delete provider from isolated auth file") - }), - ), - http - .get("/session", "session.list") - .seeded((ctx) => ctx.session({ title: "List me" })) - .at((ctx) => ({ path: "/session?roots=true", headers: ctx.headers() })) - .json(200, (body, ctx) => { - array(body) - check( - body.some((item) => isRecord(item) && item.id === ctx.state.id && item.title === "List me"), - "seeded session should be listed", - ) - }), - http - .get("/session/status", "session.status") - .seeded((ctx) => ctx.session({ title: "Status session" })) - .json(200, object), - http - .post("/session", "session.create") - .mutating() - .at((ctx) => ({ path: "/session", headers: ctx.headers(), body: { title: "Created session" } })) - .json( - 200, - (body, ctx) => { - object(body) - check(body.title === "Created session", "created session should use requested title") - check(body.directory === ctx.directory, "created session should use scenario directory") - }, - "status", - ), - http - .get("/session/{sessionID}", "session.get") - .seeded((ctx) => ctx.session({ title: "Get me" })) - .at((ctx) => ({ path: route("/session/{sessionID}", { sessionID: ctx.state.id }), headers: ctx.headers() })) - .json(200, (body, ctx) => { - object(body) - check(body.id === ctx.state.id, "should return requested session") - check(body.title === "Get me", "should preserve seeded title") - }), - http - .get("/session/{sessionID}", "session.get.missing") - .at((ctx) => ({ - path: route("/session/{sessionID}", { sessionID: "ses_httpapi_missing" }), - headers: ctx.headers(), - })) - .status(404), - http - .patch("/session/{sessionID}", "session.update") - .mutating() - .seeded((ctx) => ctx.session({ title: "Before rename" })) - .at((ctx) => ({ - path: route("/session/{sessionID}", { sessionID: ctx.state.id }), - headers: ctx.headers(), - body: { title: "After rename" }, - })) - .json( - 200, - (body) => { - object(body) - check(body.title === "After rename", "updated session should use new title") - }, - "status", - ), - http - .patch("/session/{sessionID}", "session.update.invalid") - .mutating() - .at((ctx) => ({ - path: route("/session/{sessionID}", { sessionID: "ses_httpapi_missing" }), - headers: ctx.headers(), - body: { title: 1 }, - })) - .status(400), - http - .delete("/session/{sessionID}", "session.delete") - .mutating() - .seeded((ctx) => ctx.session({ title: "Delete me" })) - .at((ctx) => ({ path: route("/session/{sessionID}", { sessionID: ctx.state.id }), headers: ctx.headers() })) - .jsonEffect(200, (body, ctx) => - Effect.gen(function* () { - check(body === true, "delete should return true") - check((yield* ctx.sessionGet(ctx.state.id)) === undefined, "deleted session should not remain in storage") - }), - ), - http - .get("/session/{sessionID}/children", "session.children") - .seeded((ctx) => - Effect.gen(function* () { - const parent = yield* ctx.session({ title: "Parent" }) - const child = yield* ctx.session({ title: "Child", parentID: parent.id }) - return { parent, child } - }), - ) - .at((ctx) => ({ - path: route("/session/{sessionID}/children", { sessionID: ctx.state.parent.id }), - headers: ctx.headers(), - })) - .json(200, (body, ctx) => { - array(body) - check( - body.some((item) => isRecord(item) && item.id === ctx.state.child.id && item.parentID === ctx.state.parent.id), - "children should include seeded child", - ) - }), - http - .get("/session/{sessionID}/todo", "session.todo") - .seeded((ctx) => - Effect.gen(function* () { - const session = yield* ctx.session({ title: "Todo session" }) - const todos = [{ content: "cover session todo", status: "pending", priority: "high" }] - yield* ctx.todos(session.id, todos) - return { session, todos } - }), - ) - .at((ctx) => ({ - path: route("/session/{sessionID}/todo", { sessionID: ctx.state.session.id }), - headers: ctx.headers(), - })) - .json(200, (body, ctx) => { - check(stable(body) === stable(ctx.state.todos), "todos should match seeded state") - }), - http - .get("/session/{sessionID}/diff", "session.diff") - .seeded((ctx) => ctx.session({ title: "Diff session" })) - .at((ctx) => ({ path: route("/session/{sessionID}/diff", { sessionID: ctx.state.id }), headers: ctx.headers() })) - .json(200, array), - http - .get("/session/{sessionID}/message", "session.messages") - .seeded((ctx) => ctx.session({ title: "Messages session" })) - .at((ctx) => ({ path: route("/session/{sessionID}/message", { sessionID: ctx.state.id }), headers: ctx.headers() })) - .json(200, (body) => { - array(body) - check(body.length === 0, "new session should have no messages") - }), - http - .get("/session/{sessionID}/message/{messageID}", "session.message") - .seeded((ctx) => - Effect.gen(function* () { - const session = yield* ctx.session({ title: "Message get session" }) - const message = yield* ctx.message(session.id, { text: "read me" }) - return { session, message } - }), - ) - .at((ctx) => ({ - path: route("/session/{sessionID}/message/{messageID}", { - sessionID: ctx.state.session.id, - messageID: ctx.state.message.info.id, - }), - headers: ctx.headers(), - })) - .json(200, (body, ctx) => { - object(body) - check(isRecord(body.info) && body.info.id === ctx.state.message.info.id, "should return requested message") - check( - Array.isArray(body.parts) && body.parts.some((part) => isRecord(part) && part.id === ctx.state.message.part.id), - "message should include seeded part", - ) - }), - http - .patch("/session/{sessionID}/message/{messageID}/part/{partID}", "part.update") - .mutating() - .seeded((ctx) => - Effect.gen(function* () { - const session = yield* ctx.session({ title: "Part update session" }) - const message = yield* ctx.message(session.id, { text: "before" }) - return { session, message } - }), - ) - .at((ctx) => ({ - path: route("/session/{sessionID}/message/{messageID}/part/{partID}", { - sessionID: ctx.state.session.id, - messageID: ctx.state.message.info.id, - partID: ctx.state.message.part.id, - }), - headers: ctx.headers(), - body: { ...ctx.state.message.part, text: "after" }, - })) - .json( - 200, - (body) => { - object(body) - check(body.type === "text" && body.text === "after", "updated part should be returned") - }, - "status", - ), - http - .delete("/session/{sessionID}/message/{messageID}/part/{partID}", "part.delete") - .mutating() - .seeded((ctx) => - Effect.gen(function* () { - const session = yield* ctx.session({ title: "Part delete session" }) - const message = yield* ctx.message(session.id, { text: "delete part" }) - return { session, message } - }), - ) - .at((ctx) => ({ - path: route("/session/{sessionID}/message/{messageID}/part/{partID}", { - sessionID: ctx.state.session.id, - messageID: ctx.state.message.info.id, - partID: ctx.state.message.part.id, - }), - headers: ctx.headers(), - })) - .jsonEffect(200, (body, ctx) => - Effect.gen(function* () { - check(body === true, "delete part should return true") - const messages = yield* ctx.messages(ctx.state.session.id) - check(messages[0]?.parts.length === 0, "deleted part should not remain on message") - }), - ), - http - .delete("/session/{sessionID}/message/{messageID}", "session.deleteMessage") - .mutating() - .seeded((ctx) => - Effect.gen(function* () { - const session = yield* ctx.session({ title: "Message delete session" }) - const message = yield* ctx.message(session.id, { text: "delete message" }) - return { session, message } - }), - ) - .at((ctx) => ({ - path: route("/session/{sessionID}/message/{messageID}", { - sessionID: ctx.state.session.id, - messageID: ctx.state.message.info.id, - }), - headers: ctx.headers(), - })) - .jsonEffect(200, (body, ctx) => - Effect.gen(function* () { - check(body === true, "delete message should return true") - check((yield* ctx.messages(ctx.state.session.id)).length === 0, "deleted message should not remain") - }), - ), - http - .post("/session/{sessionID}/fork", "session.fork") - .mutating() - .seeded((ctx) => ctx.session({ title: "Fork source" })) - .at((ctx) => ({ - path: route("/session/{sessionID}/fork", { sessionID: ctx.state.id }), - headers: ctx.headers(), - body: {}, - })) - .json( - 200, - (body) => { - object(body) - check(typeof body.id === "string", "fork should return a session") - }, - "status", - ), - http - .post("/session/{sessionID}/abort", "session.abort") - .mutating() - .seeded((ctx) => ctx.session({ title: "Abort session" })) - .at((ctx) => ({ path: route("/session/{sessionID}/abort", { sessionID: ctx.state.id }), headers: ctx.headers() })) - .json(200, (body) => { - check(body === true, "abort should return true") - }), - http - .post("/session/{sessionID}/abort", "session.abort.missing") - .at((ctx) => ({ - path: route("/session/{sessionID}/abort", { sessionID: "ses_httpapi_missing" }), - headers: ctx.headers(), - })) - .json(200, (body) => { - check(body === true, "missing session abort should remain a no-op success") - }), - http - .post("/session/{sessionID}/init", "session.init") - .preserveDatabase() - .withLlm() - .seeded((ctx) => - Effect.gen(function* () { - const session = yield* ctx.session({ title: "Init session" }) - const message = yield* ctx.message(session.id, { text: "initialize" }) - yield* ctx.llmText("initialized") - yield* ctx.llmText("initialized") - return { session, message } - }), - ) - .at((ctx) => ({ - path: route("/session/{sessionID}/init", { sessionID: ctx.state.session.id }), - headers: ctx.headers(), - body: { providerID: "test", modelID: "test-model", messageID: ctx.state.message.info.id }, - })) - .jsonEffect(200, (body, ctx) => - Effect.gen(function* () { - check(body === true, "init should return true") - yield* ctx.llmWait(1) - }), - ), - http - .post("/session/{sessionID}/message", "session.prompt") - .preserveDatabase() - .withLlm() - .seeded((ctx) => - Effect.gen(function* () { - const session = yield* ctx.session({ title: "LLM prompt session" }) - yield* ctx.llmText("fake assistant") - yield* ctx.llmText("fake assistant") - return session - }), - ) - .at((ctx) => ({ - path: route("/session/{sessionID}/message", { sessionID: ctx.state.id }), - headers: ctx.headers(), - body: { - agent: "build", - model: { providerID: "test", modelID: "test-model" }, - parts: [{ type: "text", text: "hello llm" }], - }, - })) - .jsonEffect( - 200, - (body, ctx) => - Effect.gen(function* () { - object(body) - check(isRecord(body.info) && body.info.role === "assistant", "prompt should return assistant message") - check( - Array.isArray(body.parts) && body.parts.some((part) => isRecord(part) && part.text === "fake assistant"), - "assistant message should use fake LLM text", - ) - yield* ctx.llmWait(1) - }), - "status", - ), - http - .post("/session/{sessionID}/prompt_async", "session.prompt_async") - .preserveDatabase() - .withLlm() - .seeded((ctx) => - Effect.gen(function* () { - const session = yield* ctx.session({ title: "Async prompt session" }) - yield* ctx.llmText("fake async assistant") - yield* ctx.llmText("fake async assistant") - return session - }), - ) - .at((ctx) => ({ - path: route("/session/{sessionID}/prompt_async", { sessionID: ctx.state.id }), - headers: ctx.headers(), - body: { - agent: "build", - model: { providerID: "test", modelID: "test-model" }, - parts: [{ type: "text", text: "hello async" }], - }, - })) - .status(204, (ctx) => - Effect.gen(function* () { - yield* ctx.llmWait(1) - }), - ), - http - .post("/session/{sessionID}/command", "session.command") - .preserveDatabase() - .withLlm() - .seeded((ctx) => - Effect.gen(function* () { - const session = yield* ctx.session({ title: "Command session" }) - yield* ctx.llmText("command done") - yield* ctx.llmText("command done") - return session - }), - ) - .at((ctx) => ({ - path: route("/session/{sessionID}/command", { sessionID: ctx.state.id }), - headers: ctx.headers(), - body: { command: "init", arguments: "", model: "test/test-model" }, - })) - .jsonEffect( - 200, - (body, ctx) => - Effect.gen(function* () { - object(body) - check(isRecord(body.info) && body.info.role === "assistant", "command should return assistant message") - yield* ctx.llmWait(1) - }), - "status", - ), - http - .post("/session/{sessionID}/shell", "session.shell") - .preserveDatabase() - .mutating() - .seeded((ctx) => ctx.session({ title: "Shell session" })) - .at((ctx) => ({ - path: route("/session/{sessionID}/shell", { sessionID: ctx.state.id }), - headers: ctx.headers(), - body: { agent: "build", model: { providerID: "test", modelID: "test-model" }, command: "printf shell-ok" }, - })) - .json( - 200, - (body) => { - object(body) - check(isRecord(body.info) && body.info.role === "assistant", "shell should return assistant message") - check( - Array.isArray(body.parts) && body.parts.some((part) => isRecord(part) && part.type === "tool"), - "shell should return a tool part", - ) - }, - "status", - ), - http - .post("/session/{sessionID}/summarize", "session.summarize") - .preserveDatabase() - .withLlm() - .seeded((ctx) => - Effect.gen(function* () { - const session = yield* ctx.session({ title: "Summarize session" }) - yield* ctx.message(session.id, { text: "summarize this work" }) - const summary = [ - "## Goal", - "- Exercise session summarize.", - "", - "## Constraints & Preferences", - "- Use fake LLM.", - "", - "## Progress", - "### Done", - "- Summary generated.", - "", - "### In Progress", - "- (none)", - "", - "### Blocked", - "- (none)", - "", - "## Key Decisions", - "- Keep route local.", - "", - "## Next Steps", - "- (none)", - "", - "## Critical Context", - "- Test fixture.", - "", - "## Relevant Files", - "- script/httpapi-exercise.ts: scenario", - ].join("\n") - yield* ctx.llmText(summary) - yield* ctx.llmText(summary) - return session - }), - ) - .at((ctx) => ({ - path: route("/session/{sessionID}/summarize", { sessionID: ctx.state.id }), - headers: ctx.headers(), - body: { providerID: "test", modelID: "test-model", auto: false }, - })) - .jsonEffect( - 200, - (body, ctx) => - Effect.gen(function* () { - check(body === true, "summarize should return true") - const messages = yield* ctx.messages(ctx.state.id) - check( - messages.some((message) => message.info.role === "assistant" && message.info.summary === true), - "summarize should create a summary assistant message", - ) - yield* ctx.llmWait(1) - }), - "status", - ), - http - .post("/session/{sessionID}/revert", "session.revert") - .mutating() - .seeded((ctx) => - Effect.gen(function* () { - const session = yield* ctx.session({ title: "Revert session" }) - const message = yield* ctx.message(session.id, { text: "revert me" }) - return { session, message } - }), - ) - .at((ctx) => ({ - path: route("/session/{sessionID}/revert", { sessionID: ctx.state.session.id }), - headers: ctx.headers(), - body: { messageID: ctx.state.message.info.id }, - })) - .json( - 200, - (body, ctx) => { - object(body) - check(body.id === ctx.state.session.id, "revert should return the session") - check( - isRecord(body.revert) && body.revert.messageID === ctx.state.message.info.id, - "revert should record reverted message", - ) - }, - "status", - ), - http - .post("/session/{sessionID}/unrevert", "session.unrevert") - .mutating() - .seeded((ctx) => ctx.session({ title: "Unrevert session" })) - .at((ctx) => ({ - path: route("/session/{sessionID}/unrevert", { sessionID: ctx.state.id }), - headers: ctx.headers(), - })) - .json( - 200, - (body, ctx) => { - object(body) - check(body.id === ctx.state.id, "unrevert should return the session") - }, - "status", - ), - http - .post("/session/{sessionID}/permissions/{permissionID}", "permission.respond") - .seeded((ctx) => ctx.session({ title: "Deprecated permission session" })) - .at((ctx) => ({ - path: route("/session/{sessionID}/permissions/{permissionID}", { - sessionID: ctx.state.id, - permissionID: "per_httpapi_deprecated", - }), - headers: ctx.headers(), - body: { response: "once" }, - })) - .json(200, (body) => { - check(body === true, "deprecated permission response should return true") - }), - http - .post("/session/{sessionID}/share", "session.share") - .mutating() - .seeded((ctx) => ctx.session({ title: "Share session" })) - .at((ctx) => ({ path: route("/session/{sessionID}/share", { sessionID: ctx.state.id }), headers: ctx.headers() })) - .json( - 200, - (body, ctx) => { - object(body) - check(body.id === ctx.state.id, "share should return the session") - }, - "status", - ), - http - .delete("/session/{sessionID}/share", "session.unshare") - .mutating() - .seeded((ctx) => ctx.session({ title: "Unshare session" })) - .at((ctx) => ({ path: route("/session/{sessionID}/share", { sessionID: ctx.state.id }), headers: ctx.headers() })) - .json( - 200, - (body, ctx) => { - object(body) - check(body.id === ctx.state.id, "unshare should return the session") - }, - "status", - ), - http - .post("/tui/append-prompt", "tui.appendPrompt") - .at((ctx) => ({ path: "/tui/append-prompt", headers: ctx.headers(), body: { text: "hello" } })) - .json(200, boolean, "status"), - http - .post("/tui/select-session", "tui.selectSession.invalid") - .at((ctx) => ({ path: "/tui/select-session", headers: ctx.headers(), body: { sessionID: "invalid" } })) - .status(400), - http.post("/tui/open-help", "tui.openHelp").json(200, boolean, "status"), - http.post("/tui/open-sessions", "tui.openSessions").json(200, boolean, "status"), - http.post("/tui/open-themes", "tui.openThemes").json(200, boolean, "status"), - http.post("/tui/open-models", "tui.openModels").json(200, boolean, "status"), - http.post("/tui/submit-prompt", "tui.submitPrompt").json(200, boolean, "status"), - http.post("/tui/clear-prompt", "tui.clearPrompt").json(200, boolean, "status"), - http - .post("/tui/execute-command", "tui.executeCommand") - .at((ctx) => ({ path: "/tui/execute-command", headers: ctx.headers(), body: { command: "agent_cycle" } })) - .json(200, boolean, "status"), - http - .post("/tui/show-toast", "tui.showToast") - .at((ctx) => ({ - path: "/tui/show-toast", - headers: ctx.headers(), - body: { title: "Exercise", message: "covered", variant: "info", duration: 1000 }, - })) - .json(200, boolean, "status"), - http - .post("/tui/publish", "tui.publish") - .at((ctx) => ({ - path: "/tui/publish", - headers: ctx.headers(), - body: { type: "tui.prompt.append", properties: { text: "published" } }, - })) - .json(200, boolean, "status"), - http - .post("/tui/select-session", "tui.selectSession") - .seeded((ctx) => ctx.session({ title: "TUI select" })) - .at((ctx) => ({ path: "/tui/select-session", headers: ctx.headers(), body: { sessionID: ctx.state.id } })) - .json(200, boolean, "status"), - http - .post("/tui/control/response", "tui.control.response") - .at((ctx) => ({ path: "/tui/control/response", headers: ctx.headers(), body: { ok: true } })) - .json(200, boolean, "status"), - http - .get("/tui/control/next", "tui.control.next") - .mutating() - .seeded((ctx) => ctx.tuiRequest({ path: "/tui/exercise", body: { text: "queued" } })) - .json( - 200, - (body) => { - object(body) - check(body.path === "/tui/exercise", "control next should return queued path") - object(body.body) - check(body.body.text === "queued", "control next should return queued body") - }, - "status", - ), - http - .post("/global/upgrade", "global.upgrade") - .global() - .at(() => ({ path: "/global/upgrade", body: { target: 1 } })) - .status(400), -] - -const main = Effect.gen(function* () { - yield* Effect.addFinalizer(() => cleanupExercisePaths) - const options = parseOptions(Bun.argv.slice(2)) - const modules = yield* Effect.promise(() => runtime()) - const effectRoutes = routeKeys(OpenApi.fromApi(modules.PublicApi)) - const honoRoutes = routeKeys(yield* Effect.promise(() => modules.Server.openapiHono())) - const selected = scenarios.filter((scenario) => matches(options, scenario)) - const missing = effectRoutes.filter((route) => !scenarios.some((scenario) => route === routeKey(scenario))) - const extra = scenarios.filter((scenario) => !effectRoutes.includes(routeKey(scenario))) - - printHeader(options, effectRoutes, honoRoutes, selected, missing, extra) - - const results = - options.mode === "coverage" - ? selected.map(coverageResult) - : yield* Effect.forEach(selected, runScenario(options), { concurrency: 1 }) - printResults(results, missing, extra) - - if (results.some((result) => result.status === "fail")) - return yield* Effect.fail(new Error("one or more scenarios failed")) - if (options.failOnSkip && results.some((result) => result.status === "skip")) - return yield* Effect.fail(new Error("one or more scenarios are skipped")) - if (options.failOnMissing && missing.length > 0) - return yield* Effect.fail(new Error("one or more routes have no scenario")) -}) - -function runScenario(options: Options) { - return (scenario: Scenario) => { - if (scenario.kind === "todo") return Effect.succeed({ status: "skip", scenario } as Result) - return runActive(options, scenario).pipe( - Effect.as({ status: "pass", scenario } as Result), - Effect.catchCause((cause) => Effect.succeed({ status: "fail" as const, scenario, message: Cause.pretty(cause) })), - Effect.scoped, - ) - } -} - -function runActive(options: Options, scenario: ActiveScenario) { - if (options.mode === "parity" && scenario.mutates && scenario.compare !== "none") { - return Effect.gen(function* () { - const effect = yield* runBackend("effect", scenario) - const legacy = yield* runBackend("legacy", scenario) - yield* compare(scenario, effect, legacy) - }) - } - - return withContext(scenario, (ctx) => - Effect.gen(function* () { - const effect = yield* call("effect", scenario, ctx) - yield* scenario.expect(ctx, ctx.state, effect) - if (options.mode === "parity" && scenario.compare !== "none") { - const legacy = yield* call("legacy", scenario, ctx) - yield* scenario.expect(ctx, ctx.state, legacy) - yield* compare(scenario, effect, legacy) - } - }), - ) -} - -function runBackend(backend: "effect" | "legacy", scenario: ActiveScenario) { - return withContext(scenario, (ctx) => - Effect.gen(function* () { - const result = yield* call(backend, scenario, ctx) - yield* scenario.expect(ctx, ctx.state, result) - return result - }), - ) -} - -function withContext(scenario: ActiveScenario, use: (ctx: SeededContext) => Effect.Effect) { - return Effect.acquireRelease( - Effect.gen(function* () { - const llm = scenario.project?.llm ? yield* TestLLMServer : undefined - const project = scenario.project - const dir = project - ? yield* Effect.promise(async () => (await runtime()).tmpdir(projectOptions(project, llm?.url))) - : undefined - return { dir, llm } - }), - (ctx) => Effect.promise(async () => void (await ctx.dir?.[Symbol.asyncDispose]())).pipe(Effect.ignore), - ).pipe( - Effect.flatMap((context) => - Effect.gen(function* () { - const modules = yield* Effect.promise(() => runtime()) - const path = context.dir?.path - const instance = path - ? yield* modules.InstanceStore.Service.use((store) => store.load({ directory: path })).pipe( - Effect.provide(modules.AppLayer), - Effect.catchCause((cause) => - Effect.sleep("100 millis").pipe( - Effect.andThen( - modules.InstanceStore.Service.use((store) => store.load({ directory: path })).pipe( - Effect.provide(modules.AppLayer), - ), - ), - Effect.catchCause(() => Effect.failCause(cause)), - ), - ), - ) - : undefined - const run = (effect: Effect.Effect) => - effect.pipe(Effect.provideService(modules.InstanceRef, instance), Effect.provide(modules.AppLayer)) - const directory = () => { - if (!context.dir?.path) throw new Error("scenario needs a project directory") - return context.dir.path - } - const llm = () => { - if (!context.llm) throw new Error("scenario needs fake LLM") - return context.llm - } - const base: ScenarioContext = { - directory: context.dir?.path, - headers: (extra) => ({ - ...(context.dir?.path ? { "x-opencode-directory": context.dir.path } : {}), - ...extra, - }), - file: (name, content) => - Effect.promise(() => { - return Bun.write(`${directory()}/${name}`, content) - }).pipe(Effect.asVoid), - session: (input) => - run(modules.Session.Service.use((svc) => svc.create({ title: input?.title, parentID: input?.parentID }))), - sessionGet: (sessionID) => - run(modules.Session.Service.use((svc) => svc.get(sessionID))).pipe( - Effect.catchCause(() => Effect.succeed(undefined)), - ), - project: () => - Effect.sync(() => { - if (!instance) throw new Error("scenario needs a project directory") - return instance.project - }), - message: (sessionID, input) => - Effect.gen(function* () { - const info: MessageV2.User = { - id: MessageID.ascending(), - sessionID, - role: "user", - time: { created: Date.now() }, - agent: "build", - model: { - providerID: ProviderID.opencode, - modelID: ModelID.make("test"), - }, - } - const part: MessageV2.TextPart = { - id: PartID.ascending(), - sessionID, - messageID: info.id, - type: "text", - text: input?.text ?? "hello", - } - yield* run( - modules.Session.Service.use((svc) => - Effect.gen(function* () { - yield* svc.updateMessage(info) - yield* svc.updatePart(part) - }), - ), - ) - return { info, part } - }), - messages: (sessionID) => run(modules.Session.Service.use((svc) => svc.messages({ sessionID }))), - todos: (sessionID, todos) => run(modules.Todo.Service.use((svc) => svc.update({ sessionID, todos }))), - worktree: (input) => run(modules.Worktree.Service.use((svc) => svc.create(input))), - worktreeRemove: (directory) => - run(modules.Worktree.Service.use((svc) => svc.remove({ directory })).pipe(Effect.ignore)), - llmText: (value) => Effect.suspend(() => llm().text(value)), - llmWait: (count) => Effect.suspend(() => llm().wait(count)), - tuiRequest: (request) => Effect.sync(() => modules.Tui.submitTuiRequest(request)), - } - const state = yield* scenario.seed(base) - return yield* use({ ...base, state }) - }).pipe(Effect.ensuring(context.llm ? context.llm.reset : Effect.void)), - ), - Effect.ensuring(scenario.reset ? resetState : Effect.void), - ) -} - -function projectOptions( - project: ProjectOptions, - llmUrl: string | undefined, -): { git?: boolean; config?: Partial } { - if (!project.llm || !llmUrl) return { git: project.git, config: project.config } - const fake = fakeLlmConfig(llmUrl) - return { - git: project.git, - config: { - ...fake, - ...project.config, - provider: { - ...fake.provider, - ...project.config?.provider, - }, - }, - } -} - -function fakeLlmConfig(url: string): Partial { - return { - model: "test/test-model", - small_model: "test/test-model", - provider: { - test: { - name: "Test", - id: "test", - env: [], - npm: "@ai-sdk/openai-compatible", - models: { - "test-model": { - id: "test-model", - name: "Test Model", - attachment: false, - reasoning: false, - temperature: false, - tool_call: true, - release_date: "2025-01-01", - limit: { context: 100000, output: 10000 }, - cost: { input: 0, output: 0 }, - options: {}, - }, - }, - options: { - apiKey: "test-key", - baseURL: url, - }, - }, - }, - } -} - -function controlledPtyInput(title: string | undefined) { - return { - command: "/bin/sh", - args: ["-c", "sleep 30"], - ...(title ? { title } : {}), - } -} - -function call(backend: Backend, scenario: ActiveScenario, ctx: SeededContext) { - return Effect.promise(async () => - capture(await app(await runtime(), backend).request(toRequest(scenario, ctx)), scenario.capture), - ) -} - -const appCache: Partial> = {} - -function app(modules: Runtime, backend: Backend) { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = backend === "effect" - Flag.OPENCODE_SERVER_PASSWORD = undefined - Flag.OPENCODE_SERVER_USERNAME = undefined - if (appCache[backend]) return appCache[backend] - if (backend === "legacy") { - const legacy = modules.Server.Legacy().app - return (appCache.legacy = { - request: (input, init) => legacy.request(input, init), - }) - } - - const handler = HttpRouter.toWebHandler( - modules.ExperimentalHttpApiServer.routes.pipe( - Layer.provide( - ConfigProvider.layer( - ConfigProvider.fromUnknown({ OPENCODE_SERVER_PASSWORD: undefined, OPENCODE_SERVER_USERNAME: undefined }), - ), - ), - ), - { disableLogger: true }, - ).handler - return (appCache.effect = { - request(input: string | URL | Request, init?: RequestInit) { - return handler( - input instanceof Request ? input : new Request(new URL(input, "http://localhost"), init), - modules.ExperimentalHttpApiServer.context, - ) - }, - }) -} - -function toRequest(scenario: ActiveScenario, ctx: SeededContext) { - const spec = scenario.request(ctx, ctx.state) - return new Request(new URL(spec.path, "http://localhost"), { - method: scenario.method, - headers: spec.body === undefined ? spec.headers : { "content-type": "application/json", ...spec.headers }, - body: spec.body === undefined ? undefined : JSON.stringify(spec.body), - }) -} - -async function capture(response: Response, mode: CaptureMode): Promise { - const text = mode === "stream" ? await captureStream(response) : await response.text() - return { - status: response.status, - contentType: response.headers.get("content-type") ?? "", - text, - body: parse(text), - } -} - -async function captureStream(response: Response) { - if (!response.body) return "" - const reader = response.body.getReader() - const read = reader.read().then( - (result) => ({ result }), - (error: unknown) => ({ error }), - ) - const winner = await Promise.race([read, Bun.sleep(1_000).then(() => ({ timeout: true }))]) - if ("timeout" in winner) { - await reader.cancel("timed out waiting for stream chunk").catch(() => undefined) - throw new Error("timed out waiting for stream chunk") - } - if ("error" in winner) throw winner.error - await reader.cancel().catch(() => undefined) - if (winner.result.done) return "" - return new TextDecoder().decode(winner.result.value) -} - -const cleanupExercisePaths = Effect.promise(async () => { - const fs = await import("fs/promises") - if (!preserveExerciseDatabase) { - await Promise.all( - [exerciseDatabasePath, `${exerciseDatabasePath}-wal`, `${exerciseDatabasePath}-shm`].map((file) => - fs.rm(file, { force: true }).catch(() => undefined), - ), - ) - } - if (!preserveExerciseGlobalRoot) - await fs.rm(exerciseGlobalRoot, { recursive: true, force: true }).catch(() => undefined) -}) - -function compare(scenario: ActiveScenario, effect: CallResult, legacy: CallResult) { - return Effect.sync(() => { - if (effect.status !== legacy.status) - throw new Error(`legacy returned ${legacy.status}, effect returned ${effect.status}`) - if (scenario.compare === "status") return - if (stable(effect.body) !== stable(legacy.body)) - throw new Error(`JSON parity mismatch\nlegacy: ${stable(legacy.body)}\neffect: ${stable(effect.body)}`) - }) -} - -const resetState = Effect.promise(async () => { - const modules = await runtime() - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original.OPENCODE_EXPERIMENTAL_HTTPAPI - Flag.OPENCODE_SERVER_PASSWORD = original.OPENCODE_SERVER_PASSWORD - Flag.OPENCODE_SERVER_USERNAME = original.OPENCODE_SERVER_USERNAME - await modules.disposeAllInstances() - await modules.resetDatabase() - await Bun.sleep(25) -}) - -function routeKeys(spec: OpenApiSpec) { - return Object.entries(spec.paths ?? {}) - .flatMap(([path, item]) => - OpenApiMethods.filter((method) => item[method]).map((method) => `${method.toUpperCase()} ${path}`), - ) - .sort() -} - -function routeKey(scenario: Scenario) { - return `${scenario.method} ${scenario.path}` -} - -function coverageResult(scenario: Scenario): Result { - if (scenario.kind === "todo") return { status: "skip", scenario } - return { status: "pass", scenario } -} - -function parseOptions(args: string[]): Options { - const mode = option(args, "--mode") ?? "effect" - if (mode !== "effect" && mode !== "parity" && mode !== "coverage") throw new Error(`invalid --mode ${mode}`) - return { - mode, - include: option(args, "--include"), - failOnMissing: args.includes("--fail-on-missing"), - failOnSkip: args.includes("--fail-on-skip"), - } -} - -function option(args: string[], name: string) { - const index = args.indexOf(name) - if (index === -1) return undefined - return args[index + 1] -} - -function matches(options: Options, scenario: Scenario) { - if (!options.include) return true - return ( - scenario.name.includes(options.include) || - scenario.path.includes(options.include) || - scenario.method.includes(options.include.toUpperCase()) - ) -} - -function printHeader( - options: Options, - effectRoutes: string[], - honoRoutes: string[], - selected: Scenario[], - missing: string[], - extra: Scenario[], -) { - console.log(`${color.cyan}HttpApi exerciser${color.reset}`) - console.log(`${color.dim}db=${exerciseDatabasePath}${color.reset}`) - console.log(`${color.dim}global=${exerciseGlobalRoot}${color.reset}`) - console.log( - `${color.dim}mode=${options.mode} selected=${selected.length} effectRoutes=${effectRoutes.length} missing=${missing.length} extra=${extra.length} onlyEffect=${effectRoutes.filter((route) => !honoRoutes.includes(route)).length} onlyHono=${honoRoutes.filter((route) => !effectRoutes.includes(route)).length}${color.reset}`, - ) - console.log("") -} - -function printResults(results: Result[], missing: string[], extra: Scenario[]) { - for (const result of results) { - if (result.status === "pass") { - console.log( - `${color.green}PASS${color.reset} ${pad(result.scenario.method, 6)} ${pad(result.scenario.path, 48)} ${result.scenario.name}`, - ) - continue - } - if (result.status === "skip") { - console.log( - `${color.yellow}SKIP${color.reset} ${pad(result.scenario.method, 6)} ${pad(result.scenario.path, 48)} ${result.scenario.name} ${color.dim}${result.scenario.reason}${color.reset}`, - ) - continue - } - console.log( - `${color.red}FAIL${color.reset} ${pad(result.scenario.method, 6)} ${pad(result.scenario.path, 48)} ${result.scenario.name}`, - ) - console.log(`${color.red}${indent(result.message)}${color.reset}`) - } - if (missing.length > 0) { - console.log("\nMissing scenarios") - for (const route of missing) console.log(`${color.red}MISS${color.reset} ${route}`) - } - if (extra.length > 0) { - console.log("\nExtra scenarios") - for (const scenario of extra) - console.log(`${color.yellow}EXTRA${color.reset} ${routeKey(scenario)} ${scenario.name}`) - } - console.log( - `\n${color.dim}summary pass=${results.filter((result) => result.status === "pass").length} fail=${results.filter((result) => result.status === "fail").length} skip=${results.filter((result) => result.status === "skip").length} missing=${missing.length} extra=${extra.length}${color.reset}`, - ) -} - -function parse(text: string): unknown { - if (!text) return undefined - try { - return JSON.parse(text) as unknown - } catch { - return text - } -} - -function looksJson(result: CallResult) { - return result.contentType.includes("application/json") || result.text.startsWith("{") || result.text.startsWith("[") -} - -function stable(value: unknown): string { - return JSON.stringify(sort(value)) -} - -function sort(value: unknown): unknown { - if (Array.isArray(value)) return value.map(sort) - if (!value || typeof value !== "object") return value - return Object.fromEntries( - Object.entries(value) - .sort(([left], [right]) => left.localeCompare(right)) - .map(([key, item]) => [key, sort(item)]), - ) -} - -function array(value: unknown): asserts value is unknown[] { - if (!Array.isArray(value)) throw new Error("expected array") -} - -function object(value: unknown): asserts value is JsonObject { - if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("expected object") -} - -function boolean(value: unknown): asserts value is boolean { - if (typeof value !== "boolean") throw new Error("expected boolean") -} - -function isRecord(value: unknown): value is JsonObject { - return !!value && typeof value === "object" && !Array.isArray(value) -} - -function check(value: boolean, message: string): asserts value { - if (!value) throw new Error(message) -} - -function message(error: unknown) { - if (error instanceof Error) return error.message - return String(error) -} - -function pad(value: string, size: number) { - return value.length >= size ? value : value + " ".repeat(size - value.length) -} - -function indent(value: string) { - return value - .split("\n") - .map((line) => ` ${line}`) - .join("\n") -} - -Effect.runPromise(main.pipe(Effect.provide(TestLLMServer.layer), Effect.scoped)).then( - () => process.exit(0), - (error: unknown) => { - console.error(`${color.red}${message(error)}${color.reset}`) - process.exit(1) - }, -) +await import("../test/server/httpapi-exercise/index") diff --git a/packages/opencode/test/server/httpapi-exercise/assertions.ts b/packages/opencode/test/server/httpapi-exercise/assertions.ts new file mode 100644 index 0000000000..c59acfb366 --- /dev/null +++ b/packages/opencode/test/server/httpapi-exercise/assertions.ts @@ -0,0 +1,64 @@ +import type { CallResult, JsonObject } from "./types" + +export function parse(text: string): unknown { + if (!text) return undefined + try { + return JSON.parse(text) as unknown + } catch { + return text + } +} + +export function looksJson(result: CallResult) { + return result.contentType.includes("application/json") || result.text.startsWith("{") || result.text.startsWith("[") +} + +export function stable(value: unknown): string { + return JSON.stringify(sort(value)) +} + +function sort(value: unknown): unknown { + if (Array.isArray(value)) return value.map(sort) + if (!value || typeof value !== "object") return value + return Object.fromEntries( + Object.entries(value) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, item]) => [key, sort(item)]), + ) +} + +export function array(value: unknown): asserts value is unknown[] { + if (!Array.isArray(value)) throw new Error("expected array") +} + +export function object(value: unknown): asserts value is JsonObject { + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("expected object") +} + +export function boolean(value: unknown): asserts value is boolean { + if (typeof value !== "boolean") throw new Error("expected boolean") +} + +export function isRecord(value: unknown): value is JsonObject { + return !!value && typeof value === "object" && !Array.isArray(value) +} + +export function check(value: boolean, message: string): asserts value { + if (!value) throw new Error(message) +} + +export function message(error: unknown) { + if (error instanceof Error) return error.message + return String(error) +} + +export function pad(value: string, size: number) { + return value.length >= size ? value : value + " ".repeat(size - value.length) +} + +export function indent(value: string) { + return value + .split("\n") + .map((line) => ` ${line}`) + .join("\n") +} diff --git a/packages/opencode/test/server/httpapi-exercise/backend.ts b/packages/opencode/test/server/httpapi-exercise/backend.ts new file mode 100644 index 0000000000..fb2ed1d8a3 --- /dev/null +++ b/packages/opencode/test/server/httpapi-exercise/backend.ts @@ -0,0 +1,83 @@ +import { Flag } from "@opencode-ai/core/flag/flag" +import { ConfigProvider, Effect, Layer } from "effect" +import { HttpRouter } from "effect/unstable/http" +import { parse } from "./assertions" +import { runtime, type Runtime } from "./runtime" +import type { ActiveScenario, Backend, BackendApp, CallResult, CaptureMode, SeededContext } from "./types" + +export function call(backend: Backend, scenario: ActiveScenario, ctx: SeededContext) { + return Effect.promise(async () => + capture(await app(await runtime(), backend).request(toRequest(scenario, ctx)), scenario.capture), + ) +} + +const appCache: Partial> = {} + +function app(modules: Runtime, backend: Backend) { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = backend === "effect" + Flag.OPENCODE_SERVER_PASSWORD = undefined + Flag.OPENCODE_SERVER_USERNAME = undefined + if (appCache[backend]) return appCache[backend] + if (backend === "legacy") { + const legacy = modules.Server.Legacy().app + return (appCache.legacy = { + request: (input, init) => legacy.request(input, init), + }) + } + + const handler = HttpRouter.toWebHandler( + modules.ExperimentalHttpApiServer.routes.pipe( + Layer.provide( + ConfigProvider.layer( + ConfigProvider.fromUnknown({ OPENCODE_SERVER_PASSWORD: undefined, OPENCODE_SERVER_USERNAME: undefined }), + ), + ), + ), + { disableLogger: true }, + ).handler + return (appCache.effect = { + request(input: string | URL | Request, init?: RequestInit) { + return handler( + input instanceof Request ? input : new Request(new URL(input, "http://localhost"), init), + modules.ExperimentalHttpApiServer.context, + ) + }, + }) +} + +function toRequest(scenario: ActiveScenario, ctx: SeededContext) { + const spec = scenario.request(ctx, ctx.state) + return new Request(new URL(spec.path, "http://localhost"), { + method: scenario.method, + headers: spec.body === undefined ? spec.headers : { "content-type": "application/json", ...spec.headers }, + body: spec.body === undefined ? undefined : JSON.stringify(spec.body), + }) +} + +async function capture(response: Response, mode: CaptureMode): Promise { + const text = mode === "stream" ? await captureStream(response) : await response.text() + return { + status: response.status, + contentType: response.headers.get("content-type") ?? "", + text, + body: parse(text), + } +} + +async function captureStream(response: Response) { + if (!response.body) return "" + const reader = response.body.getReader() + const read = reader.read().then( + (result) => ({ result }), + (error: unknown) => ({ error }), + ) + const winner = await Promise.race([read, Bun.sleep(1_000).then(() => ({ timeout: true }))]) + if ("timeout" in winner) { + await reader.cancel("timed out waiting for stream chunk").catch(() => undefined) + throw new Error("timed out waiting for stream chunk") + } + if ("error" in winner) throw winner.error + await reader.cancel().catch(() => undefined) + if (winner.result.done) return "" + return new TextDecoder().decode(winner.result.value) +} diff --git a/packages/opencode/test/server/httpapi-exercise/dsl.ts b/packages/opencode/test/server/httpapi-exercise/dsl.ts new file mode 100644 index 0000000000..5d8a3cacad --- /dev/null +++ b/packages/opencode/test/server/httpapi-exercise/dsl.ts @@ -0,0 +1,170 @@ +import { Effect } from "effect" +import { looksJson } from "./assertions" +import type { + ActiveScenario, + BuilderState, + CallResult, + Comparison, + Method, + ProjectOptions, + ScenarioContext, + SeededContext, + TodoScenario, +} from "./types" + +class ScenarioBuilder { + private readonly state: BuilderState + + constructor(method: Method, path: string, name: string) { + this.state = { + method, + path, + name, + project: { git: true }, + seed: () => Effect.succeed(undefined as S), + request: (ctx) => ({ path, headers: ctx.headers() }), + capture: "full", + mutates: false, + reset: true, + } + } + + global() { + return this.clone({ project: undefined, request: () => ({ path: this.state.path }) }) + } + + inProject(project: ProjectOptions = { git: true }) { + return this.clone({ project }) + } + + withLlm() { + return this.clone({ project: { ...(this.state.project ?? { git: true }), llm: true } }) + } + + at(request: BuilderState["request"]) { + return this.clone({ request }) + } + + mutating() { + return this.clone({ mutates: true }) + } + + preserveDatabase() { + return this.clone({ reset: false }) + } + + stream() { + return this.clone({ capture: "stream" }) + } + + /** Assert a non-JSON or shape-only response. */ + ok(status = 200, compare: Comparison = "status") { + return this.done(compare, (_ctx, result) => + Effect.sync(() => { + if (result.status !== status) throw new Error(`expected ${status}, got ${result.status}: ${result.text}`) + }), + ) + } + + status( + status = 200, + inspect?: (ctx: SeededContext, result: CallResult) => Effect.Effect, + compare: Comparison = "status", + ) { + return this.done(compare, (ctx, result) => + Effect.gen(function* () { + if (result.status !== status) throw new Error(`expected ${status}, got ${result.status}: ${result.text}`) + if (inspect) yield* inspect(ctx, result) + }), + ) + } + + /** Assert JSON status/content-type plus an optional synchronous body check. */ + json(status = 200, inspect?: (body: unknown, ctx: SeededContext) => void, compare: Comparison = "json") { + return this.jsonEffect(status, inspect ? (body, ctx) => Effect.sync(() => inspect(body, ctx)) : undefined, compare) + } + + /** Assert JSON status/content-type plus optional Effect assertions, e.g. DB side effects. */ + jsonEffect( + status = 200, + inspect?: (body: unknown, ctx: SeededContext) => Effect.Effect, + compare: Comparison = "json", + ) { + return this.done(compare, (ctx, result) => + Effect.gen(function* () { + if (result.status !== status) throw new Error(`expected ${status}, got ${result.status}: ${result.text}`) + if (!looksJson(result)) + throw new Error(`expected JSON response, got ${result.contentType || "no content-type"}`) + if (inspect) yield* inspect(result.body, ctx) + }), + ) + } + + private clone(next: Partial>) { + const builder = new ScenarioBuilder(this.state.method, this.state.path, this.state.name) + Object.assign(builder.state, this.state, next) + return builder + } + + /** + * Seed typed state before the HTTP request. The returned value becomes `ctx.state` + * for `.at(...)` and assertions, giving stateful route tests type-safe setup. + */ + seeded(seed: (ctx: ScenarioContext) => Effect.Effect) { + const builder = new ScenarioBuilder(this.state.method, this.state.path, this.state.name) + Object.assign(builder.state, this.state, { seed }) + return builder + } + + private done( + compare: Comparison, + expect: (ctx: SeededContext, result: CallResult) => Effect.Effect, + ): ActiveScenario { + const state = this.state + return { + kind: "active", + method: state.method, + path: state.path, + name: state.name, + project: state.project, + seed: state.seed, + request: (ctx, seeded) => state.request({ ...ctx, state: seeded as S }), + expect: (ctx, seeded, result) => expect({ ...ctx, state: seeded as S }, result), + compare, + capture: state.capture, + mutates: state.mutates, + reset: state.reset, + } + } +} + +export const http = { + get: (path: string, name: string) => new ScenarioBuilder("GET", path, name), + post: (path: string, name: string) => new ScenarioBuilder("POST", path, name), + put: (path: string, name: string) => new ScenarioBuilder("PUT", path, name), + patch: (path: string, name: string) => new ScenarioBuilder("PATCH", path, name), + delete: (path: string, name: string) => new ScenarioBuilder("DELETE", path, name), +} + +export const pending = (method: Method, path: string, name: string, reason: string): TodoScenario => ({ + kind: "todo", + method, + path, + name, + reason, +}) + +export function route(template: string, params: Record) { + return Object.entries(params).reduce( + (next, [key, value]) => next.replaceAll(`{${key}}`, value).replaceAll(`:${key}`, value), + template, + ) +} + +export function controlledPtyInput(title: string | undefined) { + return { + command: "/bin/sh", + args: ["-c", "sleep 30"], + ...(title ? { title } : {}), + } +} diff --git a/packages/opencode/test/server/httpapi-exercise/environment.ts b/packages/opencode/test/server/httpapi-exercise/environment.ts new file mode 100644 index 0000000000..7962f7df94 --- /dev/null +++ b/packages/opencode/test/server/httpapi-exercise/environment.ts @@ -0,0 +1,41 @@ +import { Flag } from "@opencode-ai/core/flag/flag" +import { Effect } from "effect" +import path from "path" + +const preserveExerciseGlobalRoot = !!process.env.OPENCODE_HTTPAPI_EXERCISE_GLOBAL +export const exerciseGlobalRoot = + process.env.OPENCODE_HTTPAPI_EXERCISE_GLOBAL ?? + path.join(process.env.TMPDIR ?? "/tmp", `opencode-httpapi-global-${process.pid}`) +process.env.XDG_DATA_HOME = path.join(exerciseGlobalRoot, "data") +process.env.XDG_CONFIG_HOME = path.join(exerciseGlobalRoot, "config") +process.env.XDG_STATE_HOME = path.join(exerciseGlobalRoot, "state") +process.env.XDG_CACHE_HOME = path.join(exerciseGlobalRoot, "cache") +process.env.OPENCODE_DISABLE_SHARE = "true" +export const exerciseConfigDirectory = path.join(exerciseGlobalRoot, "config", "opencode") +export const exerciseDataDirectory = path.join(exerciseGlobalRoot, "data", "opencode") + +const preserveExerciseDatabase = !!process.env.OPENCODE_HTTPAPI_EXERCISE_DB +export const exerciseDatabasePath = + process.env.OPENCODE_HTTPAPI_EXERCISE_DB ?? + path.join(process.env.TMPDIR ?? "/tmp", `opencode-httpapi-exercise-${process.pid}.db`) +process.env.OPENCODE_DB = exerciseDatabasePath +Flag.OPENCODE_DB = exerciseDatabasePath + +export const original = { + OPENCODE_EXPERIMENTAL_HTTPAPI: Flag.OPENCODE_EXPERIMENTAL_HTTPAPI, + OPENCODE_SERVER_PASSWORD: Flag.OPENCODE_SERVER_PASSWORD, + OPENCODE_SERVER_USERNAME: Flag.OPENCODE_SERVER_USERNAME, +} + +export const cleanupExercisePaths = Effect.promise(async () => { + const fs = await import("fs/promises") + if (!preserveExerciseDatabase) { + await Promise.all( + [exerciseDatabasePath, `${exerciseDatabasePath}-wal`, `${exerciseDatabasePath}-shm`].map((file) => + fs.rm(file, { force: true }).catch(() => undefined), + ), + ) + } + if (!preserveExerciseGlobalRoot) + await fs.rm(exerciseGlobalRoot, { recursive: true, force: true }).catch(() => undefined) +}) diff --git a/packages/opencode/test/server/httpapi-exercise/index.ts b/packages/opencode/test/server/httpapi-exercise/index.ts new file mode 100644 index 0000000000..67100f35e0 --- /dev/null +++ b/packages/opencode/test/server/httpapi-exercise/index.ts @@ -0,0 +1,1203 @@ +/** + * End-to-end exerciser for the legacy Hono instance routes and the Effect HttpApi routes. + * + * The goal is not to be a normal unit test file. This is a route-coverage and parity + * harness we can run while deleting Hono: every public route should eventually have a + * small scenario that proves the Effect route decodes requests, uses the right instance + * context, mutates storage when expected, and returns a compatible response shape. + * + * The script intentionally isolates `OPENCODE_DB` before importing modules that touch + * storage. Scenarios may create/delete sessions and reset the database after each run, + * so this must never point at a developer's real session database. + * + * DSL shape: + * - `http.get/post/...` starts a scenario for one OpenAPI route key. + * - `.seeded(...)` creates typed per-scenario state using Effect helpers on `ctx`. + * - `.at(...)` builds the request from that typed state. + * - `.json(...)` / `.jsonEffect(...)` assert response shape and optional side effects. + * - `.mutating()` tells parity mode to run Effect and Hono in separate isolated contexts + * so destructive routes compare equivalent fresh setups instead of sharing one DB. + */ +import { Effect } from "effect" +import { OpenApi } from "effect/unstable/httpapi" +import { TestLLMServer } from "../../lib/llm-server" +import path from "path" +import { array, boolean, check, isRecord, message, object, stable } from "./assertions" +import { controlledPtyInput, http, pending, route } from "./dsl" +import { + cleanupExercisePaths, + exerciseConfigDirectory, + exerciseDataDirectory, + exerciseDatabasePath, + exerciseGlobalRoot, +} from "./environment" +import { color, printHeader, printResults } from "./report" +import { coverageResult, matches, parseOptions, routeKey, routeKeys } from "./routing" +import { runScenario } from "./runner" +import { runtime } from "./runtime" +import { type Scenario } from "./types" + +void (await import("@opencode-ai/core/util/log")).init({ print: false }) + +const scenarios: Scenario[] = [ + http + .get("/global/health", "global.health") + .global() + .json(200, (body) => { + object(body) + check(body.healthy === true, "server should report healthy") + }), + http + .get("/global/event", "global.event") + .global() + .stream() + .status( + 200, + (_ctx, result) => + Effect.sync(() => { + check(result.contentType.includes("text/event-stream"), "global event should be an SSE stream") + check(result.text.includes("server.connected"), "global event should emit initial connection event") + }), + "status", + ), + http.get("/global/config", "global.config.get").global().json(), + http + .patch("/global/config", "global.config.update") + .global() + .seeded(() => + Effect.promise(() => + Bun.write( + path.join(exerciseConfigDirectory, "opencode.jsonc"), + JSON.stringify({ username: "httpapi-global" }, null, 2), + ), + ), + ) + .at(() => ({ path: "/global/config", body: { username: "httpapi-global" } })) + .jsonEffect( + 200, + (body) => + Effect.gen(function* () { + object(body) + check(body.username === "httpapi-global", "global config update should return patched config") + const text = yield* Effect.promise(() => + Bun.file(path.join(exerciseConfigDirectory, "opencode.jsonc")).text(), + ) + check(text.includes('"username": "httpapi-global"'), "global config update should write isolated config file") + }), + "status", + ), + http + .post("/global/dispose", "global.dispose") + .global() + .mutating() + .json( + 200, + (body) => { + check(body === true, "global dispose should return true") + }, + "status", + ), + http.get("/path", "path.get").json(200, (body, ctx) => { + object(body) + check(body.directory === ctx.directory, "directory should resolve from x-opencode-directory") + check(body.worktree === ctx.directory, "worktree should resolve from x-opencode-directory") + }), + http.get("/vcs", "vcs.get").json(), + http + .get("/vcs/diff", "vcs.diff") + .at((ctx) => ({ path: "/vcs/diff?mode=git", headers: ctx.headers() })) + .json(200, array), + http.get("/command", "command.list").json(200, array, "status"), + http.get("/agent", "app.agents").json(200, array, "status"), + http.get("/skill", "app.skills").json(200, array, "status"), + http.get("/lsp", "lsp.status").json(200, array), + http.get("/formatter", "formatter.status").json(200, array), + http.get("/config", "config.get").json(200, undefined, "status"), + http + .patch("/config", "config.update") + .mutating() + .at((ctx) => ({ path: "/config", headers: ctx.headers(), body: { username: "httpapi-local" } })) + .json( + 200, + (body) => { + object(body) + check(body.username === "httpapi-local", "local config update should return patched config") + }, + "status", + ), + http + .patch("/config", "config.update.invalid") + .at((ctx) => ({ path: "/config", headers: ctx.headers(), body: { username: 1 } })) + .status(400), + http.get("/config/providers", "config.providers").json(), + http.get("/project", "project.list").json(200, array, "status"), + http.get("/project/current", "project.current").json( + 200, + (body, ctx) => { + object(body) + check(body.worktree === ctx.directory, "current project should resolve from scenario directory") + }, + "status", + ), + http + .patch("/project/{projectID}", "project.update") + .mutating() + .seeded((ctx) => ctx.project()) + .at((ctx) => ({ + path: route("/project/{projectID}", { projectID: ctx.state.id }), + headers: ctx.headers(), + body: { name: "HTTP API Project", commands: { start: "bun --version" } }, + })) + .json( + 200, + (body) => { + object(body) + check(body.name === "HTTP API Project", "project update should return patched name") + check( + isRecord(body.commands) && body.commands.start === "bun --version", + "project update should return patched command", + ) + }, + "status", + ), + http + .post("/project/git/init", "project.initGit") + .mutating() + .inProject({ git: false }) + .json( + 200, + (body, ctx) => { + object(body) + check(body.worktree === ctx.directory, "git init should return current project") + check(body.vcs === "git", "git init should mark the project as git-backed") + }, + "status", + ), + http.get("/provider", "provider.list").json(), + http.get("/provider/auth", "provider.auth").json(), + http + .post("/provider/{providerID}/oauth/authorize", "provider.oauth.authorize") + .at((ctx) => ({ + path: route("/provider/{providerID}/oauth/authorize", { providerID: "httpapi" }), + headers: ctx.headers(), + body: { method: "bad" }, + })) + .status(400), + http + .post("/provider/{providerID}/oauth/callback", "provider.oauth.callback") + .at((ctx) => ({ + path: route("/provider/{providerID}/oauth/callback", { providerID: "httpapi" }), + headers: ctx.headers(), + body: { method: "bad" }, + })) + .status(400), + http.get("/permission", "permission.list").json(200, array), + http + .post("/permission/{requestID}/reply", "permission.reply.invalid") + .at((ctx) => ({ + path: route("/permission/{requestID}/reply", { requestID: "per_httpapi" }), + headers: ctx.headers(), + body: { reply: "bad" }, + })) + .status(400), + http + .post("/permission/{requestID}/reply", "permission.reply") + .at((ctx) => ({ + path: route("/permission/{requestID}/reply", { requestID: "per_httpapi" }), + headers: ctx.headers(), + body: { reply: "once" }, + })) + .json(200, (body) => { + check(body === true, "permission reply should return true even when request is no longer pending") + }), + http.get("/question", "question.list").json(200, array), + http + .post("/question/{requestID}/reply", "question.reply.invalid") + .at((ctx) => ({ + path: route("/question/{requestID}/reply", { requestID: "que_httpapi_reply" }), + headers: ctx.headers(), + body: { answers: "Yes" }, + })) + .status(400), + http + .post("/question/{requestID}/reply", "question.reply") + .at((ctx) => ({ + path: route("/question/{requestID}/reply", { requestID: "que_httpapi_reply" }), + headers: ctx.headers(), + body: { answers: [["Yes"]] }, + })) + .json(200, (body) => { + check(body === true, "question reply should return true even when request is no longer pending") + }), + http + .post("/question/{requestID}/reject", "question.reject") + .at((ctx) => ({ + path: route("/question/{requestID}/reject", { requestID: "que_httpapi_reject" }), + headers: ctx.headers(), + })) + .json(200, (body) => { + check(body === true, "question reject should return true even when request is no longer pending") + }), + http + .get("/file", "file.list") + .seeded((ctx) => ctx.file("hello.txt", "hello\n")) + .at((ctx) => ({ path: `/file?${new URLSearchParams({ path: "." })}`, headers: ctx.headers() })) + .json(200, array), + http + .get("/file/content", "file.read") + .seeded((ctx) => ctx.file("hello.txt", "hello\n")) + .at((ctx) => ({ path: `/file/content?${new URLSearchParams({ path: "hello.txt" })}`, headers: ctx.headers() })) + .json(200, (body) => { + object(body) + check(body.content === "hello", `content should match seeded file: ${JSON.stringify(body)}`) + }), + http + .get("/file/content", "file.read.missing") + .at((ctx) => ({ path: `/file/content?${new URLSearchParams({ path: "missing.txt" })}`, headers: ctx.headers() })) + .json(200, (body) => { + object(body) + check(body.type === "text" && body.content === "", "missing file content should return an empty text result") + }), + http.get("/file/status", "file.status").json(200, array), + http + .get("/find", "find.text") + .seeded((ctx) => ctx.file("hello.txt", "hello\n")) + .at((ctx) => ({ path: `/find?${new URLSearchParams({ pattern: "hello" })}`, headers: ctx.headers() })) + .json(200, array), + http + .get("/find/file", "find.files") + .seeded((ctx) => ctx.file("hello.txt", "hello\n")) + .at((ctx) => ({ + path: `/find/file?${new URLSearchParams({ query: "hello", dirs: "false" })}`, + headers: ctx.headers(), + })) + .json(200, array), + http + .get("/find/symbol", "find.symbols") + .seeded((ctx) => ctx.file("hello.ts", "export const hello = 1\n")) + .at((ctx) => ({ path: `/find/symbol?${new URLSearchParams({ query: "hello" })}`, headers: ctx.headers() })) + .json(200, array), + http + .get("/event", "event.stream") + .stream() + .status( + 200, + (_ctx, result) => + Effect.sync(() => { + check(result.contentType.includes("text/event-stream"), "event should be an SSE stream") + check(result.text.includes("server.connected"), "event should emit initial connection event") + }), + "status", + ), + http.get("/mcp", "mcp.status").json(), + http + .post("/mcp", "mcp.add") + .mutating() + .at((ctx) => ({ + path: "/mcp", + headers: ctx.headers(), + body: { name: "httpapi-disabled", config: { type: "local", command: ["bun", "--version"], enabled: false } }, + })) + .json( + 200, + (body) => { + object(body) + object(body["httpapi-disabled"]) + check(body["httpapi-disabled"].status === "disabled", "disabled MCP server should be added without spawning") + }, + "status", + ), + http + .post("/mcp", "mcp.add.invalid") + .at((ctx) => ({ + path: "/mcp", + headers: ctx.headers(), + body: { name: "httpapi-invalid", config: { type: "invalid" } }, + })) + .status(400), + http + .post("/mcp/{name}/auth", "mcp.auth.start") + .at((ctx) => ({ path: route("/mcp/{name}/auth", { name: "httpapi-missing" }), headers: ctx.headers() })) + .json( + 400, + (body) => { + object(body) + check(typeof body.error === "string", "unsupported MCP OAuth response should include error") + }, + "status", + ), + http + .delete("/mcp/{name}/auth", "mcp.auth.remove") + .mutating() + .at((ctx) => ({ path: route("/mcp/{name}/auth", { name: "httpapi-missing" }), headers: ctx.headers() })) + .json(200, (body) => { + object(body) + check(body.success === true, "MCP auth removal should return success") + }), + http + .post("/mcp/{name}/auth/authenticate", "mcp.auth.authenticate") + .at((ctx) => ({ + path: route("/mcp/{name}/auth/authenticate", { name: "httpapi-missing" }), + headers: ctx.headers(), + })) + .json( + 400, + (body) => { + object(body) + check(typeof body.error === "string", "unsupported MCP OAuth authenticate response should include error") + }, + "status", + ), + http + .post("/mcp/{name}/auth/callback", "mcp.auth.callback") + .at((ctx) => ({ + path: route("/mcp/{name}/auth/callback", { name: "httpapi-missing" }), + headers: ctx.headers(), + body: { code: 1 }, + })) + .status(400), + http + .post("/mcp/{name}/connect", "mcp.connect") + .mutating() + .at((ctx) => ({ path: route("/mcp/{name}/connect", { name: "httpapi-missing" }), headers: ctx.headers() })) + .json(200, (body) => { + check(body === true, "missing MCP connect should remain a no-op success") + }), + http + .post("/mcp/{name}/disconnect", "mcp.disconnect") + .mutating() + .at((ctx) => ({ path: route("/mcp/{name}/disconnect", { name: "httpapi-missing" }), headers: ctx.headers() })) + .json(200, (body) => { + check(body === true, "missing MCP disconnect should remain a no-op success") + }), + http.get("/pty/shells", "pty.shells").json(200, array), + http.get("/pty", "pty.list").json(200, array), + http + .post("/pty", "pty.create") + .mutating() + .at((ctx) => ({ path: "/pty", headers: ctx.headers(), body: controlledPtyInput("HTTP API PTY") })) + .json( + 200, + (body, ctx) => { + object(body) + check(body.title === "HTTP API PTY", "PTY create should return requested title") + check(body.command === "/bin/sh", "PTY create should use controlled shell command") + check(body.cwd === ctx.directory, "PTY create should default cwd to scenario directory") + }, + "status", + ), + http + .post("/pty", "pty.create.invalid") + .at((ctx) => ({ path: "/pty", headers: ctx.headers(), body: { command: 1 } })) + .status(400), + http + .get("/pty/{ptyID}", "pty.get") + .at((ctx) => ({ path: route("/pty/{ptyID}", { ptyID: "pty_httpapi_missing" }), headers: ctx.headers() })) + .status(404), + http + .put("/pty/{ptyID}", "pty.update") + .mutating() + .at((ctx) => ({ + path: route("/pty/{ptyID}", { ptyID: "pty_httpapi_missing" }), + headers: ctx.headers(), + body: { size: { rows: 0, cols: 0 } }, + })) + .status(400), + http + .delete("/pty/{ptyID}", "pty.remove") + .mutating() + .at((ctx) => ({ path: route("/pty/{ptyID}", { ptyID: "pty_httpapi_missing" }), headers: ctx.headers() })) + .json(200, (body) => { + check(body === true, "PTY remove should return true") + }), + http + .get("/pty/{ptyID}/connect", "pty.connect") + .at((ctx) => ({ path: route("/pty/{ptyID}/connect", { ptyID: "pty_httpapi_missing" }), headers: ctx.headers() })) + .status(404, undefined, "none"), + http.get("/experimental/console", "experimental.console.get").json(), + http.get("/experimental/console/orgs", "experimental.console.listOrgs").json(), + http + .post("/experimental/console/switch", "experimental.console.switchOrg") + .at((ctx) => ({ + path: "/experimental/console/switch", + headers: ctx.headers(), + body: { accountID: "httpapi-account", orgID: "httpapi-org" }, + })) + .status(400, undefined, "none"), + http.get("/experimental/workspace/adapter", "experimental.workspace.adapter.list").json(200, array), + http.get("/experimental/workspace", "experimental.workspace.list").json(200, array), + http.get("/experimental/workspace/status", "experimental.workspace.status").json(200, array), + http + .post("/experimental/workspace", "experimental.workspace.create") + .at((ctx) => ({ path: "/experimental/workspace", headers: ctx.headers(), body: {} })) + .status(400), + http + .delete("/experimental/workspace/{id}", "experimental.workspace.remove") + .mutating() + .at((ctx) => ({ + path: route("/experimental/workspace/{id}", { id: "wrk_httpapi_missing" }), + headers: ctx.headers(), + })) + .status(200), + http + .post("/experimental/workspace/warp", "experimental.workspace.warp") + .at((ctx) => ({ + path: "/experimental/workspace/warp", + headers: ctx.headers(), + body: {}, + })) + .status(400), + http + .get("/experimental/tool", "tool.list") + .at((ctx) => ({ + path: `/experimental/tool?${new URLSearchParams({ provider: "opencode", model: "test" })}`, + headers: ctx.headers(), + })) + .json(200, array, "status"), + http.get("/experimental/tool/ids", "tool.ids").json(200, array), + http.get("/experimental/worktree", "worktree.list").json(200, array), + http + .post("/experimental/worktree", "worktree.create") + .mutating() + .at((ctx) => ({ path: "/experimental/worktree", headers: ctx.headers(), body: { name: "api-dsl" } })) + .jsonEffect( + 200, + (body, ctx) => + Effect.gen(function* () { + object(body) + check(typeof body.directory === "string", "created worktree should include directory") + yield* ctx.worktreeRemove(body.directory) + }), + "status", + ), + http + .post("/experimental/worktree", "worktree.create.invalid") + .at((ctx) => ({ path: "/experimental/worktree", headers: ctx.headers(), body: { name: 1 } })) + .status(400), + http + .delete("/experimental/worktree", "worktree.remove") + .mutating() + .seeded((ctx) => ctx.worktree({ name: "api-remove" })) + .at((ctx) => ({ path: "/experimental/worktree", headers: ctx.headers(), body: { directory: ctx.state.directory } })) + .json(200, (body) => { + check(body === true, "worktree remove should return true") + }), + http + .post("/experimental/worktree/reset", "worktree.reset") + .mutating() + .seeded((ctx) => ctx.worktree({ name: "api-reset" })) + .at((ctx) => ({ + path: "/experimental/worktree/reset", + headers: ctx.headers(), + body: { directory: ctx.state.directory }, + })) + .jsonEffect(200, (body, ctx) => + Effect.gen(function* () { + check(body === true, "worktree reset should return true") + yield* ctx.worktreeRemove(ctx.state.directory) + }), + ), + http.get("/experimental/session", "experimental.session.list").json(200, array), + http.get("/experimental/resource", "experimental.resource.list").json(), + http + .post("/sync/history", "sync.history.list") + .at((ctx) => ({ path: "/sync/history", headers: ctx.headers(), body: {} })) + .json(200, array), + http + .post("/sync/replay", "sync.replay") + .at((ctx) => ({ path: "/sync/replay", headers: ctx.headers(), body: { directory: ctx.directory, events: [] } })) + .status(400), + http + .post("/sync/start", "sync.start") + .mutating() + .preserveDatabase() + .json(200, (body) => { + check(body === true, "sync start should return true when no workspace sessions exist") + }), + http + .post("/instance/dispose", "instance.dispose") + .mutating() + .json(200, (body) => { + check(body === true, "instance dispose should return true") + }), + http + .post("/log", "app.log") + .global() + .at(() => ({ path: "/log", body: { service: "httpapi-exercise", level: "info", message: "route coverage" } })) + .json(200, (body) => { + check(body === true, "log route should return true") + }), + http + .put("/auth/{providerID}", "auth.set") + .global() + .at(() => ({ path: route("/auth/{providerID}", { providerID: "test" }), body: { type: "api", key: "test-key" } })) + .jsonEffect(200, (body) => + Effect.gen(function* () { + check(body === true, "auth set should return true") + const auth = yield* Effect.promise(() => Bun.file(path.join(exerciseDataDirectory, "auth.json")).json()) + object(auth) + check(isRecord(auth.test) && auth.test.key === "test-key", "auth set should write isolated auth file") + }), + ), + http + .delete("/auth/{providerID}", "auth.remove") + .global() + .seeded(() => + Effect.promise(() => + Bun.write( + path.join(exerciseDataDirectory, "auth.json"), + JSON.stringify({ test: { type: "api", key: "remove-me" } }), + ), + ), + ) + .at(() => ({ path: route("/auth/{providerID}", { providerID: "test" }) })) + .jsonEffect(200, (body) => + Effect.gen(function* () { + check(body === true, "auth remove should return true") + const auth = yield* Effect.promise(() => Bun.file(path.join(exerciseDataDirectory, "auth.json")).json()) + object(auth) + check(auth.test === undefined, "auth remove should delete provider from isolated auth file") + }), + ), + http + .get("/session", "session.list") + .seeded((ctx) => ctx.session({ title: "List me" })) + .at((ctx) => ({ path: "/session?roots=true", headers: ctx.headers() })) + .json(200, (body, ctx) => { + array(body) + check( + body.some((item) => isRecord(item) && item.id === ctx.state.id && item.title === "List me"), + "seeded session should be listed", + ) + }), + http + .get("/session/status", "session.status") + .seeded((ctx) => ctx.session({ title: "Status session" })) + .json(200, object), + http + .post("/session", "session.create") + .mutating() + .at((ctx) => ({ path: "/session", headers: ctx.headers(), body: { title: "Created session" } })) + .json( + 200, + (body, ctx) => { + object(body) + check(body.title === "Created session", "created session should use requested title") + check(body.directory === ctx.directory, "created session should use scenario directory") + }, + "status", + ), + http + .get("/session/{sessionID}", "session.get") + .seeded((ctx) => ctx.session({ title: "Get me" })) + .at((ctx) => ({ path: route("/session/{sessionID}", { sessionID: ctx.state.id }), headers: ctx.headers() })) + .json(200, (body, ctx) => { + object(body) + check(body.id === ctx.state.id, "should return requested session") + check(body.title === "Get me", "should preserve seeded title") + }), + http + .get("/session/{sessionID}", "session.get.missing") + .at((ctx) => ({ + path: route("/session/{sessionID}", { sessionID: "ses_httpapi_missing" }), + headers: ctx.headers(), + })) + .status(404), + http + .patch("/session/{sessionID}", "session.update") + .mutating() + .seeded((ctx) => ctx.session({ title: "Before rename" })) + .at((ctx) => ({ + path: route("/session/{sessionID}", { sessionID: ctx.state.id }), + headers: ctx.headers(), + body: { title: "After rename" }, + })) + .json( + 200, + (body) => { + object(body) + check(body.title === "After rename", "updated session should use new title") + }, + "status", + ), + http + .patch("/session/{sessionID}", "session.update.invalid") + .mutating() + .at((ctx) => ({ + path: route("/session/{sessionID}", { sessionID: "ses_httpapi_missing" }), + headers: ctx.headers(), + body: { title: 1 }, + })) + .status(400), + http + .delete("/session/{sessionID}", "session.delete") + .mutating() + .seeded((ctx) => ctx.session({ title: "Delete me" })) + .at((ctx) => ({ path: route("/session/{sessionID}", { sessionID: ctx.state.id }), headers: ctx.headers() })) + .jsonEffect(200, (body, ctx) => + Effect.gen(function* () { + check(body === true, "delete should return true") + check((yield* ctx.sessionGet(ctx.state.id)) === undefined, "deleted session should not remain in storage") + }), + ), + http + .get("/session/{sessionID}/children", "session.children") + .seeded((ctx) => + Effect.gen(function* () { + const parent = yield* ctx.session({ title: "Parent" }) + const child = yield* ctx.session({ title: "Child", parentID: parent.id }) + return { parent, child } + }), + ) + .at((ctx) => ({ + path: route("/session/{sessionID}/children", { sessionID: ctx.state.parent.id }), + headers: ctx.headers(), + })) + .json(200, (body, ctx) => { + array(body) + check( + body.some((item) => isRecord(item) && item.id === ctx.state.child.id && item.parentID === ctx.state.parent.id), + "children should include seeded child", + ) + }), + http + .get("/session/{sessionID}/todo", "session.todo") + .seeded((ctx) => + Effect.gen(function* () { + const session = yield* ctx.session({ title: "Todo session" }) + const todos = [{ content: "cover session todo", status: "pending", priority: "high" }] + yield* ctx.todos(session.id, todos) + return { session, todos } + }), + ) + .at((ctx) => ({ + path: route("/session/{sessionID}/todo", { sessionID: ctx.state.session.id }), + headers: ctx.headers(), + })) + .json(200, (body, ctx) => { + check(stable(body) === stable(ctx.state.todos), "todos should match seeded state") + }), + http + .get("/session/{sessionID}/diff", "session.diff") + .seeded((ctx) => ctx.session({ title: "Diff session" })) + .at((ctx) => ({ path: route("/session/{sessionID}/diff", { sessionID: ctx.state.id }), headers: ctx.headers() })) + .json(200, array), + http + .get("/session/{sessionID}/message", "session.messages") + .seeded((ctx) => ctx.session({ title: "Messages session" })) + .at((ctx) => ({ path: route("/session/{sessionID}/message", { sessionID: ctx.state.id }), headers: ctx.headers() })) + .json(200, (body) => { + array(body) + check(body.length === 0, "new session should have no messages") + }), + http + .get("/session/{sessionID}/message/{messageID}", "session.message") + .seeded((ctx) => + Effect.gen(function* () { + const session = yield* ctx.session({ title: "Message get session" }) + const message = yield* ctx.message(session.id, { text: "read me" }) + return { session, message } + }), + ) + .at((ctx) => ({ + path: route("/session/{sessionID}/message/{messageID}", { + sessionID: ctx.state.session.id, + messageID: ctx.state.message.info.id, + }), + headers: ctx.headers(), + })) + .json(200, (body, ctx) => { + object(body) + check(isRecord(body.info) && body.info.id === ctx.state.message.info.id, "should return requested message") + check( + Array.isArray(body.parts) && body.parts.some((part) => isRecord(part) && part.id === ctx.state.message.part.id), + "message should include seeded part", + ) + }), + http + .patch("/session/{sessionID}/message/{messageID}/part/{partID}", "part.update") + .mutating() + .seeded((ctx) => + Effect.gen(function* () { + const session = yield* ctx.session({ title: "Part update session" }) + const message = yield* ctx.message(session.id, { text: "before" }) + return { session, message } + }), + ) + .at((ctx) => ({ + path: route("/session/{sessionID}/message/{messageID}/part/{partID}", { + sessionID: ctx.state.session.id, + messageID: ctx.state.message.info.id, + partID: ctx.state.message.part.id, + }), + headers: ctx.headers(), + body: { ...ctx.state.message.part, text: "after" }, + })) + .json( + 200, + (body) => { + object(body) + check(body.type === "text" && body.text === "after", "updated part should be returned") + }, + "status", + ), + http + .delete("/session/{sessionID}/message/{messageID}/part/{partID}", "part.delete") + .mutating() + .seeded((ctx) => + Effect.gen(function* () { + const session = yield* ctx.session({ title: "Part delete session" }) + const message = yield* ctx.message(session.id, { text: "delete part" }) + return { session, message } + }), + ) + .at((ctx) => ({ + path: route("/session/{sessionID}/message/{messageID}/part/{partID}", { + sessionID: ctx.state.session.id, + messageID: ctx.state.message.info.id, + partID: ctx.state.message.part.id, + }), + headers: ctx.headers(), + })) + .jsonEffect(200, (body, ctx) => + Effect.gen(function* () { + check(body === true, "delete part should return true") + const messages = yield* ctx.messages(ctx.state.session.id) + check(messages[0]?.parts.length === 0, "deleted part should not remain on message") + }), + ), + http + .delete("/session/{sessionID}/message/{messageID}", "session.deleteMessage") + .mutating() + .seeded((ctx) => + Effect.gen(function* () { + const session = yield* ctx.session({ title: "Message delete session" }) + const message = yield* ctx.message(session.id, { text: "delete message" }) + return { session, message } + }), + ) + .at((ctx) => ({ + path: route("/session/{sessionID}/message/{messageID}", { + sessionID: ctx.state.session.id, + messageID: ctx.state.message.info.id, + }), + headers: ctx.headers(), + })) + .jsonEffect(200, (body, ctx) => + Effect.gen(function* () { + check(body === true, "delete message should return true") + check((yield* ctx.messages(ctx.state.session.id)).length === 0, "deleted message should not remain") + }), + ), + http + .post("/session/{sessionID}/fork", "session.fork") + .mutating() + .seeded((ctx) => ctx.session({ title: "Fork source" })) + .at((ctx) => ({ + path: route("/session/{sessionID}/fork", { sessionID: ctx.state.id }), + headers: ctx.headers(), + body: {}, + })) + .json( + 200, + (body) => { + object(body) + check(typeof body.id === "string", "fork should return a session") + }, + "status", + ), + http + .post("/session/{sessionID}/abort", "session.abort") + .mutating() + .seeded((ctx) => ctx.session({ title: "Abort session" })) + .at((ctx) => ({ path: route("/session/{sessionID}/abort", { sessionID: ctx.state.id }), headers: ctx.headers() })) + .json(200, (body) => { + check(body === true, "abort should return true") + }), + http + .post("/session/{sessionID}/abort", "session.abort.missing") + .at((ctx) => ({ + path: route("/session/{sessionID}/abort", { sessionID: "ses_httpapi_missing" }), + headers: ctx.headers(), + })) + .json(200, (body) => { + check(body === true, "missing session abort should remain a no-op success") + }), + http + .post("/session/{sessionID}/init", "session.init") + .preserveDatabase() + .withLlm() + .seeded((ctx) => + Effect.gen(function* () { + const session = yield* ctx.session({ title: "Init session" }) + const message = yield* ctx.message(session.id, { text: "initialize" }) + yield* ctx.llmText("initialized") + yield* ctx.llmText("initialized") + return { session, message } + }), + ) + .at((ctx) => ({ + path: route("/session/{sessionID}/init", { sessionID: ctx.state.session.id }), + headers: ctx.headers(), + body: { providerID: "test", modelID: "test-model", messageID: ctx.state.message.info.id }, + })) + .jsonEffect(200, (body, ctx) => + Effect.gen(function* () { + check(body === true, "init should return true") + yield* ctx.llmWait(1) + }), + ), + http + .post("/session/{sessionID}/message", "session.prompt") + .preserveDatabase() + .withLlm() + .seeded((ctx) => + Effect.gen(function* () { + const session = yield* ctx.session({ title: "LLM prompt session" }) + yield* ctx.llmText("fake assistant") + yield* ctx.llmText("fake assistant") + return session + }), + ) + .at((ctx) => ({ + path: route("/session/{sessionID}/message", { sessionID: ctx.state.id }), + headers: ctx.headers(), + body: { + agent: "build", + model: { providerID: "test", modelID: "test-model" }, + parts: [{ type: "text", text: "hello llm" }], + }, + })) + .jsonEffect( + 200, + (body, ctx) => + Effect.gen(function* () { + object(body) + check(isRecord(body.info) && body.info.role === "assistant", "prompt should return assistant message") + check( + Array.isArray(body.parts) && body.parts.some((part) => isRecord(part) && part.text === "fake assistant"), + "assistant message should use fake LLM text", + ) + yield* ctx.llmWait(1) + }), + "status", + ), + http + .post("/session/{sessionID}/prompt_async", "session.prompt_async") + .preserveDatabase() + .withLlm() + .seeded((ctx) => + Effect.gen(function* () { + const session = yield* ctx.session({ title: "Async prompt session" }) + yield* ctx.llmText("fake async assistant") + yield* ctx.llmText("fake async assistant") + return session + }), + ) + .at((ctx) => ({ + path: route("/session/{sessionID}/prompt_async", { sessionID: ctx.state.id }), + headers: ctx.headers(), + body: { + agent: "build", + model: { providerID: "test", modelID: "test-model" }, + parts: [{ type: "text", text: "hello async" }], + }, + })) + .status(204, (ctx) => + Effect.gen(function* () { + yield* ctx.llmWait(1) + }), + ), + http + .post("/session/{sessionID}/command", "session.command") + .preserveDatabase() + .withLlm() + .seeded((ctx) => + Effect.gen(function* () { + const session = yield* ctx.session({ title: "Command session" }) + yield* ctx.llmText("command done") + yield* ctx.llmText("command done") + return session + }), + ) + .at((ctx) => ({ + path: route("/session/{sessionID}/command", { sessionID: ctx.state.id }), + headers: ctx.headers(), + body: { command: "init", arguments: "", model: "test/test-model" }, + })) + .jsonEffect( + 200, + (body, ctx) => + Effect.gen(function* () { + object(body) + check(isRecord(body.info) && body.info.role === "assistant", "command should return assistant message") + yield* ctx.llmWait(1) + }), + "status", + ), + http + .post("/session/{sessionID}/shell", "session.shell") + .preserveDatabase() + .mutating() + .seeded((ctx) => ctx.session({ title: "Shell session" })) + .at((ctx) => ({ + path: route("/session/{sessionID}/shell", { sessionID: ctx.state.id }), + headers: ctx.headers(), + body: { agent: "build", model: { providerID: "test", modelID: "test-model" }, command: "printf shell-ok" }, + })) + .json( + 200, + (body) => { + object(body) + check(isRecord(body.info) && body.info.role === "assistant", "shell should return assistant message") + check( + Array.isArray(body.parts) && body.parts.some((part) => isRecord(part) && part.type === "tool"), + "shell should return a tool part", + ) + }, + "status", + ), + http + .post("/session/{sessionID}/summarize", "session.summarize") + .preserveDatabase() + .withLlm() + .seeded((ctx) => + Effect.gen(function* () { + const session = yield* ctx.session({ title: "Summarize session" }) + yield* ctx.message(session.id, { text: "summarize this work" }) + const summary = [ + "## Goal", + "- Exercise session summarize.", + "", + "## Constraints & Preferences", + "- Use fake LLM.", + "", + "## Progress", + "### Done", + "- Summary generated.", + "", + "### In Progress", + "- (none)", + "", + "### Blocked", + "- (none)", + "", + "## Key Decisions", + "- Keep route local.", + "", + "## Next Steps", + "- (none)", + "", + "## Critical Context", + "- Test fixture.", + "", + "## Relevant Files", + "- test/server/httpapi-exercise/index.ts: scenario", + ].join("\n") + yield* ctx.llmText(summary) + yield* ctx.llmText(summary) + return session + }), + ) + .at((ctx) => ({ + path: route("/session/{sessionID}/summarize", { sessionID: ctx.state.id }), + headers: ctx.headers(), + body: { providerID: "test", modelID: "test-model", auto: false }, + })) + .jsonEffect( + 200, + (body, ctx) => + Effect.gen(function* () { + check(body === true, "summarize should return true") + const messages = yield* ctx.messages(ctx.state.id) + check( + messages.some((message) => message.info.role === "assistant" && message.info.summary === true), + "summarize should create a summary assistant message", + ) + yield* ctx.llmWait(1) + }), + "status", + ), + http + .post("/session/{sessionID}/revert", "session.revert") + .mutating() + .seeded((ctx) => + Effect.gen(function* () { + const session = yield* ctx.session({ title: "Revert session" }) + const message = yield* ctx.message(session.id, { text: "revert me" }) + return { session, message } + }), + ) + .at((ctx) => ({ + path: route("/session/{sessionID}/revert", { sessionID: ctx.state.session.id }), + headers: ctx.headers(), + body: { messageID: ctx.state.message.info.id }, + })) + .json( + 200, + (body, ctx) => { + object(body) + check(body.id === ctx.state.session.id, "revert should return the session") + check( + isRecord(body.revert) && body.revert.messageID === ctx.state.message.info.id, + "revert should record reverted message", + ) + }, + "status", + ), + http + .post("/session/{sessionID}/unrevert", "session.unrevert") + .mutating() + .seeded((ctx) => ctx.session({ title: "Unrevert session" })) + .at((ctx) => ({ + path: route("/session/{sessionID}/unrevert", { sessionID: ctx.state.id }), + headers: ctx.headers(), + })) + .json( + 200, + (body, ctx) => { + object(body) + check(body.id === ctx.state.id, "unrevert should return the session") + }, + "status", + ), + http + .post("/session/{sessionID}/permissions/{permissionID}", "permission.respond") + .seeded((ctx) => ctx.session({ title: "Deprecated permission session" })) + .at((ctx) => ({ + path: route("/session/{sessionID}/permissions/{permissionID}", { + sessionID: ctx.state.id, + permissionID: "per_httpapi_deprecated", + }), + headers: ctx.headers(), + body: { response: "once" }, + })) + .json(200, (body) => { + check(body === true, "deprecated permission response should return true") + }), + http + .post("/session/{sessionID}/share", "session.share") + .mutating() + .seeded((ctx) => ctx.session({ title: "Share session" })) + .at((ctx) => ({ path: route("/session/{sessionID}/share", { sessionID: ctx.state.id }), headers: ctx.headers() })) + .json( + 200, + (body, ctx) => { + object(body) + check(body.id === ctx.state.id, "share should return the session") + }, + "status", + ), + http + .delete("/session/{sessionID}/share", "session.unshare") + .mutating() + .seeded((ctx) => ctx.session({ title: "Unshare session" })) + .at((ctx) => ({ path: route("/session/{sessionID}/share", { sessionID: ctx.state.id }), headers: ctx.headers() })) + .json( + 200, + (body, ctx) => { + object(body) + check(body.id === ctx.state.id, "unshare should return the session") + }, + "status", + ), + http + .post("/tui/append-prompt", "tui.appendPrompt") + .at((ctx) => ({ path: "/tui/append-prompt", headers: ctx.headers(), body: { text: "hello" } })) + .json(200, boolean, "status"), + http + .post("/tui/select-session", "tui.selectSession.invalid") + .at((ctx) => ({ path: "/tui/select-session", headers: ctx.headers(), body: { sessionID: "invalid" } })) + .status(400), + http.post("/tui/open-help", "tui.openHelp").json(200, boolean, "status"), + http.post("/tui/open-sessions", "tui.openSessions").json(200, boolean, "status"), + http.post("/tui/open-themes", "tui.openThemes").json(200, boolean, "status"), + http.post("/tui/open-models", "tui.openModels").json(200, boolean, "status"), + http.post("/tui/submit-prompt", "tui.submitPrompt").json(200, boolean, "status"), + http.post("/tui/clear-prompt", "tui.clearPrompt").json(200, boolean, "status"), + http + .post("/tui/execute-command", "tui.executeCommand") + .at((ctx) => ({ path: "/tui/execute-command", headers: ctx.headers(), body: { command: "agent_cycle" } })) + .json(200, boolean, "status"), + http + .post("/tui/show-toast", "tui.showToast") + .at((ctx) => ({ + path: "/tui/show-toast", + headers: ctx.headers(), + body: { title: "Exercise", message: "covered", variant: "info", duration: 1000 }, + })) + .json(200, boolean, "status"), + http + .post("/tui/publish", "tui.publish") + .at((ctx) => ({ + path: "/tui/publish", + headers: ctx.headers(), + body: { type: "tui.prompt.append", properties: { text: "published" } }, + })) + .json(200, boolean, "status"), + http + .post("/tui/select-session", "tui.selectSession") + .seeded((ctx) => ctx.session({ title: "TUI select" })) + .at((ctx) => ({ path: "/tui/select-session", headers: ctx.headers(), body: { sessionID: ctx.state.id } })) + .json(200, boolean, "status"), + http + .post("/tui/control/response", "tui.control.response") + .at((ctx) => ({ path: "/tui/control/response", headers: ctx.headers(), body: { ok: true } })) + .json(200, boolean, "status"), + http + .get("/tui/control/next", "tui.control.next") + .mutating() + .seeded((ctx) => ctx.tuiRequest({ path: "/tui/exercise", body: { text: "queued" } })) + .json( + 200, + (body) => { + object(body) + check(body.path === "/tui/exercise", "control next should return queued path") + object(body.body) + check(body.body.text === "queued", "control next should return queued body") + }, + "status", + ), + http + .post("/global/upgrade", "global.upgrade") + .global() + .at(() => ({ path: "/global/upgrade", body: { target: 1 } })) + .status(400), +] + +const main = Effect.gen(function* () { + yield* Effect.addFinalizer(() => cleanupExercisePaths) + const options = parseOptions(Bun.argv.slice(2)) + const modules = yield* Effect.promise(() => runtime()) + const effectRoutes = routeKeys(OpenApi.fromApi(modules.PublicApi)) + const honoRoutes = routeKeys(yield* Effect.promise(() => modules.Server.openapiHono())) + const selected = scenarios.filter((scenario) => matches(options, scenario)) + const missing = effectRoutes.filter((route) => !scenarios.some((scenario) => route === routeKey(scenario))) + const extra = scenarios.filter((scenario) => !effectRoutes.includes(routeKey(scenario))) + + printHeader(options, effectRoutes, honoRoutes, selected, missing, extra, { + database: exerciseDatabasePath, + global: exerciseGlobalRoot, + }) + + const results = + options.mode === "coverage" + ? selected.map(coverageResult) + : yield* Effect.forEach(selected, runScenario(options), { concurrency: 1 }) + printResults(results, missing, extra) + + if (results.some((result) => result.status === "fail")) + return yield* Effect.fail(new Error("one or more scenarios failed")) + if (options.failOnSkip && results.some((result) => result.status === "skip")) + return yield* Effect.fail(new Error("one or more scenarios are skipped")) + if (options.failOnMissing && missing.length > 0) + return yield* Effect.fail(new Error("one or more routes have no scenario")) +}) + +Effect.runPromise(main.pipe(Effect.provide(TestLLMServer.layer), Effect.scoped)).then( + () => process.exit(0), + (error: unknown) => { + console.error(`${color.red}${message(error)}${color.reset}`) + process.exit(1) + }, +) diff --git a/packages/opencode/test/server/httpapi-exercise/report.ts b/packages/opencode/test/server/httpapi-exercise/report.ts new file mode 100644 index 0000000000..9ed764f4db --- /dev/null +++ b/packages/opencode/test/server/httpapi-exercise/report.ts @@ -0,0 +1,66 @@ +import { indent, pad } from "./assertions" +import type { Options, Result, Scenario } from "./types" + +export const color = { + dim: "\x1b[2m", + green: "\x1b[32m", + red: "\x1b[31m", + yellow: "\x1b[33m", + cyan: "\x1b[36m", + reset: "\x1b[0m", +} + +export function printHeader( + options: Options, + effectRoutes: string[], + honoRoutes: string[], + selected: Scenario[], + missing: string[], + extra: Scenario[], + paths: { database: string; global: string }, +) { + console.log(`${color.cyan}HttpApi exerciser${color.reset}`) + console.log(`${color.dim}db=${paths.database}${color.reset}`) + console.log(`${color.dim}global=${paths.global}${color.reset}`) + console.log( + `${color.dim}mode=${options.mode} selected=${selected.length} effectRoutes=${effectRoutes.length} missing=${missing.length} extra=${extra.length} onlyEffect=${effectRoutes.filter((route) => !honoRoutes.includes(route)).length} onlyHono=${honoRoutes.filter((route) => !effectRoutes.includes(route)).length}${color.reset}`, + ) + console.log("") +} + +export function printResults(results: Result[], missing: string[], extra: Scenario[]) { + for (const result of results) { + if (result.status === "pass") { + console.log( + `${color.green}PASS${color.reset} ${pad(result.scenario.method, 6)} ${pad(result.scenario.path, 48)} ${result.scenario.name}`, + ) + continue + } + if (result.status === "skip") { + console.log( + `${color.yellow}SKIP${color.reset} ${pad(result.scenario.method, 6)} ${pad(result.scenario.path, 48)} ${result.scenario.name} ${color.dim}${result.scenario.reason}${color.reset}`, + ) + continue + } + console.log( + `${color.red}FAIL${color.reset} ${pad(result.scenario.method, 6)} ${pad(result.scenario.path, 48)} ${result.scenario.name}`, + ) + console.log(`${color.red}${indent(result.message)}${color.reset}`) + } + if (missing.length > 0) { + console.log("\nMissing scenarios") + for (const route of missing) console.log(`${color.red}MISS${color.reset} ${route}`) + } + if (extra.length > 0) { + console.log("\nExtra scenarios") + for (const scenario of extra) + console.log(`${color.yellow}EXTRA${color.reset} ${routeKey(scenario)} ${scenario.name}`) + } + console.log( + `\n${color.dim}summary pass=${results.filter((result) => result.status === "pass").length} fail=${results.filter((result) => result.status === "fail").length} skip=${results.filter((result) => result.status === "skip").length} missing=${missing.length} extra=${extra.length}${color.reset}`, + ) +} + +function routeKey(scenario: Scenario) { + return `${scenario.method} ${scenario.path}` +} diff --git a/packages/opencode/test/server/httpapi-exercise/routing.ts b/packages/opencode/test/server/httpapi-exercise/routing.ts new file mode 100644 index 0000000000..1d33b03621 --- /dev/null +++ b/packages/opencode/test/server/httpapi-exercise/routing.ts @@ -0,0 +1,44 @@ +import { OpenApiMethods, type OpenApiSpec, type Options, type Result, type Scenario } from "./types" + +export function routeKeys(spec: OpenApiSpec) { + return Object.entries(spec.paths ?? {}) + .flatMap(([path, item]) => + OpenApiMethods.filter((method) => item[method]).map((method) => `${method.toUpperCase()} ${path}`), + ) + .sort() +} + +export function routeKey(scenario: Scenario) { + return `${scenario.method} ${scenario.path}` +} + +export function coverageResult(scenario: Scenario): Result { + if (scenario.kind === "todo") return { status: "skip", scenario } + return { status: "pass", scenario } +} + +export function parseOptions(args: string[]): Options { + const mode = option(args, "--mode") ?? "effect" + if (mode !== "effect" && mode !== "parity" && mode !== "coverage") throw new Error(`invalid --mode ${mode}`) + return { + mode, + include: option(args, "--include"), + failOnMissing: args.includes("--fail-on-missing"), + failOnSkip: args.includes("--fail-on-skip"), + } +} + +export function matches(options: Options, scenario: Scenario) { + if (!options.include) return true + return ( + scenario.name.includes(options.include) || + scenario.path.includes(options.include) || + scenario.method.includes(options.include.toUpperCase()) + ) +} + +function option(args: string[], name: string) { + const index = args.indexOf(name) + if (index === -1) return undefined + return args[index + 1] +} diff --git a/packages/opencode/test/server/httpapi-exercise/runner.ts b/packages/opencode/test/server/httpapi-exercise/runner.ts new file mode 100644 index 0000000000..2eb38b190b --- /dev/null +++ b/packages/opencode/test/server/httpapi-exercise/runner.ts @@ -0,0 +1,245 @@ +import { Flag } from "@opencode-ai/core/flag/flag" +import { Cause, Effect } from "effect" +import { TestLLMServer } from "../../lib/llm-server" +import type { Config } from "../../../src/config/config" +import { ModelID, ProviderID } from "../../../src/provider/schema" +import type { MessageV2 } from "../../../src/session/message-v2" +import { MessageID, PartID } from "../../../src/session/schema" +import { stable } from "./assertions" +import { call } from "./backend" +import { original } from "./environment" +import { runtime } from "./runtime" +import type { + ActiveScenario, + CallResult, + Options, + ProjectOptions, + Result, + Scenario, + ScenarioContext, + SeededContext, +} from "./types" + +export function runScenario(options: Options) { + return (scenario: Scenario) => { + if (scenario.kind === "todo") return Effect.succeed({ status: "skip", scenario } as Result) + return runActive(options, scenario).pipe( + Effect.as({ status: "pass", scenario } as Result), + Effect.catchCause((cause) => Effect.succeed({ status: "fail" as const, scenario, message: Cause.pretty(cause) })), + Effect.scoped, + ) + } +} + +function runActive(options: Options, scenario: ActiveScenario) { + if (options.mode === "parity" && scenario.mutates && scenario.compare !== "none") { + return Effect.gen(function* () { + const effect = yield* runBackend("effect", scenario) + const legacy = yield* runBackend("legacy", scenario) + yield* compare(scenario, effect, legacy) + }) + } + + return withContext(scenario, (ctx) => + Effect.gen(function* () { + const effect = yield* call("effect", scenario, ctx) + yield* scenario.expect(ctx, ctx.state, effect) + if (options.mode === "parity" && scenario.compare !== "none") { + const legacy = yield* call("legacy", scenario, ctx) + yield* scenario.expect(ctx, ctx.state, legacy) + yield* compare(scenario, effect, legacy) + } + }), + ) +} + +function runBackend(backend: "effect" | "legacy", scenario: ActiveScenario) { + return withContext(scenario, (ctx) => + Effect.gen(function* () { + const result = yield* call(backend, scenario, ctx) + yield* scenario.expect(ctx, ctx.state, result) + return result + }), + ) +} + +function withContext(scenario: ActiveScenario, use: (ctx: SeededContext) => Effect.Effect) { + return Effect.acquireRelease( + Effect.gen(function* () { + const llm = scenario.project?.llm ? yield* TestLLMServer : undefined + const project = scenario.project + const dir = project + ? yield* Effect.promise(async () => (await runtime()).tmpdir(projectOptions(project, llm?.url))) + : undefined + return { dir, llm } + }), + (ctx) => Effect.promise(async () => void (await ctx.dir?.[Symbol.asyncDispose]())).pipe(Effect.ignore), + ).pipe( + Effect.flatMap((context) => + Effect.gen(function* () { + const modules = yield* Effect.promise(() => runtime()) + const path = context.dir?.path + const instance = path + ? yield* modules.InstanceStore.Service.use((store) => store.load({ directory: path })).pipe( + Effect.provide(modules.AppLayer), + Effect.catchCause((cause) => + Effect.sleep("100 millis").pipe( + Effect.andThen( + modules.InstanceStore.Service.use((store) => store.load({ directory: path })).pipe( + Effect.provide(modules.AppLayer), + ), + ), + Effect.catchCause(() => Effect.failCause(cause)), + ), + ), + ) + : undefined + const run = (effect: Effect.Effect) => + effect.pipe(Effect.provideService(modules.InstanceRef, instance), Effect.provide(modules.AppLayer)) + const directory = () => { + if (!context.dir?.path) throw new Error("scenario needs a project directory") + return context.dir.path + } + const llm = () => { + if (!context.llm) throw new Error("scenario needs fake LLM") + return context.llm + } + const base: ScenarioContext = { + directory: context.dir?.path, + headers: (extra) => ({ + ...(context.dir?.path ? { "x-opencode-directory": context.dir.path } : {}), + ...extra, + }), + file: (name, content) => + Effect.promise(() => { + return Bun.write(`${directory()}/${name}`, content) + }).pipe(Effect.asVoid), + session: (input) => + run(modules.Session.Service.use((svc) => svc.create({ title: input?.title, parentID: input?.parentID }))), + sessionGet: (sessionID) => + run(modules.Session.Service.use((svc) => svc.get(sessionID))).pipe( + Effect.catchCause(() => Effect.succeed(undefined)), + ), + project: () => + Effect.sync(() => { + if (!instance) throw new Error("scenario needs a project directory") + return instance.project + }), + message: (sessionID, input) => + Effect.gen(function* () { + const info: MessageV2.User = { + id: MessageID.ascending(), + sessionID, + role: "user", + time: { created: Date.now() }, + agent: "build", + model: { + providerID: ProviderID.opencode, + modelID: ModelID.make("test"), + }, + } + const part: MessageV2.TextPart = { + id: PartID.ascending(), + sessionID, + messageID: info.id, + type: "text", + text: input?.text ?? "hello", + } + yield* run( + modules.Session.Service.use((svc) => + Effect.gen(function* () { + yield* svc.updateMessage(info) + yield* svc.updatePart(part) + }), + ), + ) + return { info, part } + }), + messages: (sessionID) => run(modules.Session.Service.use((svc) => svc.messages({ sessionID }))), + todos: (sessionID, todos) => run(modules.Todo.Service.use((svc) => svc.update({ sessionID, todos }))), + worktree: (input) => run(modules.Worktree.Service.use((svc) => svc.create(input))), + worktreeRemove: (directory) => + run(modules.Worktree.Service.use((svc) => svc.remove({ directory })).pipe(Effect.ignore)), + llmText: (value) => Effect.suspend(() => llm().text(value)), + llmWait: (count) => Effect.suspend(() => llm().wait(count)), + tuiRequest: (request) => Effect.sync(() => modules.Tui.submitTuiRequest(request)), + } + const state = yield* scenario.seed(base) + return yield* use({ ...base, state }) + }).pipe(Effect.ensuring(context.llm ? context.llm.reset : Effect.void)), + ), + Effect.ensuring(scenario.reset ? resetState : Effect.void), + ) +} + +function projectOptions( + project: ProjectOptions, + llmUrl: string | undefined, +): { git?: boolean; config?: Partial } { + if (!project.llm || !llmUrl) return { git: project.git, config: project.config } + const fake = fakeLlmConfig(llmUrl) + return { + git: project.git, + config: { + ...fake, + ...project.config, + provider: { + ...fake.provider, + ...project.config?.provider, + }, + }, + } +} + +function fakeLlmConfig(url: string): Partial { + return { + model: "test/test-model", + small_model: "test/test-model", + provider: { + test: { + name: "Test", + id: "test", + env: [], + npm: "@ai-sdk/openai-compatible", + models: { + "test-model": { + id: "test-model", + name: "Test Model", + attachment: false, + reasoning: false, + temperature: false, + tool_call: true, + release_date: "2025-01-01", + limit: { context: 100000, output: 10000 }, + cost: { input: 0, output: 0 }, + options: {}, + }, + }, + options: { + apiKey: "test-key", + baseURL: url, + }, + }, + }, + } +} + +function compare(scenario: ActiveScenario, effect: CallResult, legacy: CallResult) { + return Effect.sync(() => { + if (effect.status !== legacy.status) + throw new Error(`legacy returned ${legacy.status}, effect returned ${effect.status}`) + if (scenario.compare === "status") return + if (stable(effect.body) !== stable(legacy.body)) + throw new Error(`JSON parity mismatch\nlegacy: ${stable(legacy.body)}\neffect: ${stable(effect.body)}`) + }) +} + +const resetState = Effect.promise(async () => { + const modules = await runtime() + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original.OPENCODE_EXPERIMENTAL_HTTPAPI + Flag.OPENCODE_SERVER_PASSWORD = original.OPENCODE_SERVER_PASSWORD + Flag.OPENCODE_SERVER_USERNAME = original.OPENCODE_SERVER_USERNAME + await modules.disposeAllInstances() + await modules.resetDatabase() + await Bun.sleep(25) +}) diff --git a/packages/opencode/test/server/httpapi-exercise/runtime.ts b/packages/opencode/test/server/httpapi-exercise/runtime.ts new file mode 100644 index 0000000000..ef1c868208 --- /dev/null +++ b/packages/opencode/test/server/httpapi-exercise/runtime.ts @@ -0,0 +1,55 @@ +export type Runtime = { + PublicApi: (typeof import("../../../src/server/routes/instance/httpapi/public"))["PublicApi"] + ExperimentalHttpApiServer: (typeof import("../../../src/server/routes/instance/httpapi/server"))["ExperimentalHttpApiServer"] + Server: (typeof import("../../../src/server/server"))["Server"] + AppLayer: (typeof import("../../../src/effect/app-runtime"))["AppLayer"] + InstanceRef: (typeof import("../../../src/effect/instance-ref"))["InstanceRef"] + Instance: (typeof import("../../../src/project/instance"))["Instance"] + InstanceStore: (typeof import("../../../src/project/instance-store"))["InstanceStore"] + Session: (typeof import("../../../src/session/session"))["Session"] + Todo: (typeof import("../../../src/session/todo"))["Todo"] + Worktree: (typeof import("../../../src/worktree"))["Worktree"] + Project: (typeof import("../../../src/project/project"))["Project"] + Tui: typeof import("../../../src/server/shared/tui-control") + disposeAllInstances: (typeof import("../../fixture/fixture"))["disposeAllInstances"] + tmpdir: (typeof import("../../fixture/fixture"))["tmpdir"] + resetDatabase: (typeof import("../../fixture/db"))["resetDatabase"] +} + +let runtimePromise: Promise | undefined + +export function runtime() { + return (runtimePromise ??= (async () => { + const publicApi = await import("../../../src/server/routes/instance/httpapi/public") + const httpApiServer = await import("../../../src/server/routes/instance/httpapi/server") + const server = await import("../../../src/server/server") + const appRuntime = await import("../../../src/effect/app-runtime") + const instanceRef = await import("../../../src/effect/instance-ref") + const instance = await import("../../../src/project/instance") + const instanceStore = await import("../../../src/project/instance-store") + const session = await import("../../../src/session/session") + const todo = await import("../../../src/session/todo") + const worktree = await import("../../../src/worktree") + const project = await import("../../../src/project/project") + const tui = await import("../../../src/server/shared/tui-control") + const fixture = await import("../../fixture/fixture") + const db = await import("../../fixture/db") + return { + PublicApi: publicApi.PublicApi, + ExperimentalHttpApiServer: httpApiServer.ExperimentalHttpApiServer, + Server: server.Server, + AppLayer: appRuntime.AppLayer, + InstanceRef: instanceRef.InstanceRef, + Instance: instance.Instance, + InstanceStore: instanceStore.InstanceStore, + Session: session.Session, + Todo: todo.Todo, + Worktree: worktree.Worktree, + Project: project.Project, + Tui: tui, + disposeAllInstances: fixture.disposeAllInstances, + tmpdir: fixture.tmpdir, + resetDatabase: db.resetDatabase, + } + })()) +} diff --git a/packages/opencode/test/server/httpapi-exercise/types.ts b/packages/opencode/test/server/httpapi-exercise/types.ts new file mode 100644 index 0000000000..befbd6aedb --- /dev/null +++ b/packages/opencode/test/server/httpapi-exercise/types.ts @@ -0,0 +1,111 @@ +import type { Effect } from "effect" +import type { Config } from "../../../src/config/config" +import type { Project } from "../../../src/project/project" +import type { Worktree } from "../../../src/worktree" +import type { MessageV2 } from "../../../src/session/message-v2" +import type { SessionID } from "../../../src/session/schema" + +export const OpenApiMethods = ["get", "post", "put", "delete", "patch"] as const +export const Methods = ["GET", "POST", "PUT", "DELETE", "PATCH"] as const + +export type Method = (typeof Methods)[number] +export type OpenApiMethod = (typeof OpenApiMethods)[number] +export type Mode = "effect" | "parity" | "coverage" +export type Backend = "effect" | "legacy" +export type Comparison = "none" | "status" | "json" +export type CaptureMode = "full" | "stream" +export type ProjectOptions = { git?: boolean; config?: Partial; llm?: boolean } +export type OpenApiSpec = { paths?: Record>> } +export type JsonObject = Record + +export type Options = { + mode: Mode + include: string | undefined + failOnMissing: boolean + failOnSkip: boolean +} + +export type RequestSpec = { + path: string + headers?: Record + body?: unknown +} + +export type CallResult = { + status: number + contentType: string + body: unknown + text: string +} + +export type BackendApp = { + request(input: string | URL | Request, init?: RequestInit): Response | Promise +} + +/** Effect-native helpers available while setting up and asserting a scenario. */ +export type ScenarioContext = { + directory: string | undefined + headers: (extra?: Record) => Record + file: (name: string, content: string) => Effect.Effect + session: (input?: { title?: string; parentID?: SessionID }) => Effect.Effect + sessionGet: (sessionID: SessionID) => Effect.Effect + project: () => Effect.Effect + message: (sessionID: SessionID, input?: { text?: string }) => Effect.Effect + messages: (sessionID: SessionID) => Effect.Effect + todos: (sessionID: SessionID, todos: TodoInfo[]) => Effect.Effect + worktree: (input?: { name?: string }) => Effect.Effect + worktreeRemove: (directory: string) => Effect.Effect + llmText: (value: string) => Effect.Effect + llmWait: (count: number) => Effect.Effect + tuiRequest: (request: { path: string; body: unknown }) => Effect.Effect +} + +/** Scenario context after `.seeded(...)`; `state` preserves the seed return type in the DSL. */ +export type SeededContext = ScenarioContext & { + state: S +} + +export type Scenario = ActiveScenario | TodoScenario +export type ActiveScenario = { + kind: "active" + method: Method + path: string + name: string + project: ProjectOptions | undefined + seed: (ctx: ScenarioContext) => Effect.Effect + request: (ctx: ScenarioContext, state: unknown) => RequestSpec + expect: (ctx: ScenarioContext, state: unknown, result: CallResult) => Effect.Effect + compare: Comparison + capture: CaptureMode + mutates: boolean + reset: boolean +} + +export type BuilderState = { + method: Method + path: string + name: string + project: ProjectOptions | undefined + seed: (ctx: ScenarioContext) => Effect.Effect + request: (ctx: SeededContext) => RequestSpec + capture: CaptureMode + mutates: boolean + reset: boolean +} + +export type TodoScenario = { + kind: "todo" + method: Method + path: string + name: string + reason: string +} + +export type Result = + | { status: "pass"; scenario: ActiveScenario } + | { status: "fail"; scenario: ActiveScenario; message: string } + | { status: "skip"; scenario: TodoScenario } + +export type SessionInfo = { id: SessionID; title: string; parentID?: SessionID } +export type TodoInfo = { content: string; status: string; priority: string } +export type MessageSeed = { info: MessageV2.User; part: MessageV2.TextPart } From 75308ea47d12c5705f31e42e6d534d0ef3e96f58 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 8 May 2026 14:05:46 -0400 Subject: [PATCH 012/189] test(server): add HttpApi auth exercise mode (#26386) --- .../test/server/httpapi-exercise/backend.ts | 58 +++++++++++++++---- .../test/server/httpapi-exercise/dsl.ts | 26 +++++++++ .../test/server/httpapi-exercise/index.ts | 3 +- .../test/server/httpapi-exercise/routing.ts | 3 +- .../test/server/httpapi-exercise/runner.ts | 24 +++++++- .../test/server/httpapi-exercise/types.ts | 5 +- 6 files changed, 104 insertions(+), 15 deletions(-) diff --git a/packages/opencode/test/server/httpapi-exercise/backend.ts b/packages/opencode/test/server/httpapi-exercise/backend.ts index fb2ed1d8a3..c393383e03 100644 --- a/packages/opencode/test/server/httpapi-exercise/backend.ts +++ b/packages/opencode/test/server/httpapi-exercise/backend.ts @@ -5,22 +5,46 @@ import { parse } from "./assertions" import { runtime, type Runtime } from "./runtime" import type { ActiveScenario, Backend, BackendApp, CallResult, CaptureMode, SeededContext } from "./types" -export function call(backend: Backend, scenario: ActiveScenario, ctx: SeededContext) { +type CallOptions = { + auth?: { + password?: string + username?: string + } +} + +export function call( + backend: Backend, + scenario: ActiveScenario, + ctx: SeededContext, + options: CallOptions = {}, +) { return Effect.promise(async () => - capture(await app(await runtime(), backend).request(toRequest(scenario, ctx)), scenario.capture), + capture(await app(await runtime(), backend, options).request(toRequest(scenario, ctx)), scenario.capture), ) } -const appCache: Partial> = {} +export function callAuthProbe(backend: Backend, scenario: ActiveScenario) { + return Effect.promise(async () => + capture( + await app(await runtime(), backend, { auth: { password: "secret" } }).request(toAuthProbeRequest(scenario)), + scenario.capture, + ), + ) +} -function app(modules: Runtime, backend: Backend) { +const appCache: Partial> = {} + +function app(modules: Runtime, backend: Backend, options: CallOptions) { + const username = options.auth?.username + const password = options.auth?.password + const cacheKey = `${backend}:${username ?? ""}:${password ?? ""}` Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = backend === "effect" - Flag.OPENCODE_SERVER_PASSWORD = undefined - Flag.OPENCODE_SERVER_USERNAME = undefined - if (appCache[backend]) return appCache[backend] + Flag.OPENCODE_SERVER_PASSWORD = password + Flag.OPENCODE_SERVER_USERNAME = username + if (appCache[cacheKey]) return appCache[cacheKey] if (backend === "legacy") { const legacy = modules.Server.Legacy().app - return (appCache.legacy = { + return (appCache[cacheKey] = { request: (input, init) => legacy.request(input, init), }) } @@ -29,13 +53,13 @@ function app(modules: Runtime, backend: Backend) { modules.ExperimentalHttpApiServer.routes.pipe( Layer.provide( ConfigProvider.layer( - ConfigProvider.fromUnknown({ OPENCODE_SERVER_PASSWORD: undefined, OPENCODE_SERVER_USERNAME: undefined }), + ConfigProvider.fromUnknown({ OPENCODE_SERVER_PASSWORD: password, OPENCODE_SERVER_USERNAME: username }), ), ), ), { disableLogger: true }, ).handler - return (appCache.effect = { + return (appCache[cacheKey] = { request(input: string | URL | Request, init?: RequestInit) { return handler( input instanceof Request ? input : new Request(new URL(input, "http://localhost"), init), @@ -54,6 +78,20 @@ function toRequest(scenario: ActiveScenario, ctx: SeededContext) { }) } +function toAuthProbeRequest(scenario: ActiveScenario) { + return new Request(new URL(authProbePath(scenario.path), "http://localhost"), { + method: scenario.method, + headers: scenario.method === "GET" ? undefined : { "content-type": "application/json" }, + body: scenario.method === "GET" ? undefined : JSON.stringify({}), + }) +} + +function authProbePath(path: string) { + return path + .replace(/\{([^}]+)\}/g, (_match, key: string) => `auth_${key}`) + .replace(/:([^/]+)/g, (_match, key: string) => `auth_${key}`) +} + async function capture(response: Response, mode: CaptureMode): Promise { const text = mode === "stream" ? await captureStream(response) : await response.text() return { diff --git a/packages/opencode/test/server/httpapi-exercise/dsl.ts b/packages/opencode/test/server/httpapi-exercise/dsl.ts index 5d8a3cacad..326207049f 100644 --- a/packages/opencode/test/server/httpapi-exercise/dsl.ts +++ b/packages/opencode/test/server/httpapi-exercise/dsl.ts @@ -2,6 +2,7 @@ import { Effect } from "effect" import { looksJson } from "./assertions" import type { ActiveScenario, + AuthPolicy, BuilderState, CallResult, Comparison, @@ -21,11 +22,13 @@ class ScenarioBuilder { path, name, project: { git: true }, + // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- The unseeded builder state is intentionally undefined until `.seeded(...)` narrows it. seed: () => Effect.succeed(undefined as S), request: (ctx) => ({ path, headers: ctx.headers() }), capture: "full", mutates: false, reset: true, + auth: "protected", } } @@ -57,6 +60,26 @@ class ScenarioBuilder { return this.clone({ capture: "stream" }) } + protected() { + return this.auth("protected") + } + + public() { + return this.auth("public") + } + + publicBypass() { + return this.auth("public-bypass") + } + + ticketBypass() { + return this.auth("ticket-bypass") + } + + private auth(auth: AuthPolicy) { + return this.clone({ auth }) + } + /** Assert a non-JSON or shape-only response. */ ok(status = 200, compare: Comparison = "status") { return this.done(compare, (_ctx, result) => @@ -128,12 +151,15 @@ class ScenarioBuilder { name: state.name, project: state.project, seed: state.seed, + // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- `.seeded(...)` preserves the paired request/state type inside the builder. request: (ctx, seeded) => state.request({ ...ctx, state: seeded as S }), + // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- `.seeded(...)` preserves the paired assertion/state type inside the builder. expect: (ctx, seeded, result) => expect({ ...ctx, state: seeded as S }, result), compare, capture: state.capture, mutates: state.mutates, reset: state.reset, + auth: state.auth, } } } diff --git a/packages/opencode/test/server/httpapi-exercise/index.ts b/packages/opencode/test/server/httpapi-exercise/index.ts index 67100f35e0..32d9af464b 100644 --- a/packages/opencode/test/server/httpapi-exercise/index.ts +++ b/packages/opencode/test/server/httpapi-exercise/index.ts @@ -23,7 +23,7 @@ import { OpenApi } from "effect/unstable/httpapi" import { TestLLMServer } from "../../lib/llm-server" import path from "path" import { array, boolean, check, isRecord, message, object, stable } from "./assertions" -import { controlledPtyInput, http, pending, route } from "./dsl" +import { controlledPtyInput, http, route } from "./dsl" import { cleanupExercisePaths, exerciseConfigDirectory, @@ -1192,6 +1192,7 @@ const main = Effect.gen(function* () { return yield* Effect.fail(new Error("one or more scenarios are skipped")) if (options.failOnMissing && missing.length > 0) return yield* Effect.fail(new Error("one or more routes have no scenario")) + return undefined }) Effect.runPromise(main.pipe(Effect.provide(TestLLMServer.layer), Effect.scoped)).then( diff --git a/packages/opencode/test/server/httpapi-exercise/routing.ts b/packages/opencode/test/server/httpapi-exercise/routing.ts index 1d33b03621..39bda11209 100644 --- a/packages/opencode/test/server/httpapi-exercise/routing.ts +++ b/packages/opencode/test/server/httpapi-exercise/routing.ts @@ -19,7 +19,8 @@ export function coverageResult(scenario: Scenario): Result { export function parseOptions(args: string[]): Options { const mode = option(args, "--mode") ?? "effect" - if (mode !== "effect" && mode !== "parity" && mode !== "coverage") throw new Error(`invalid --mode ${mode}`) + if (mode !== "effect" && mode !== "parity" && mode !== "coverage" && mode !== "auth") + throw new Error(`invalid --mode ${mode}`) return { mode, include: option(args, "--include"), diff --git a/packages/opencode/test/server/httpapi-exercise/runner.ts b/packages/opencode/test/server/httpapi-exercise/runner.ts index 2eb38b190b..18ef991807 100644 --- a/packages/opencode/test/server/httpapi-exercise/runner.ts +++ b/packages/opencode/test/server/httpapi-exercise/runner.ts @@ -6,7 +6,7 @@ import { ModelID, ProviderID } from "../../../src/provider/schema" import type { MessageV2 } from "../../../src/session/message-v2" import { MessageID, PartID } from "../../../src/session/schema" import { stable } from "./assertions" -import { call } from "./backend" +import { call, callAuthProbe } from "./backend" import { original } from "./environment" import { runtime } from "./runtime" import type { @@ -32,6 +32,8 @@ export function runScenario(options: Options) { } function runActive(options: Options, scenario: ActiveScenario) { + if (options.mode === "auth") return runAuth(scenario) + if (options.mode === "parity" && scenario.mutates && scenario.compare !== "none") { return Effect.gen(function* () { const effect = yield* runBackend("effect", scenario) @@ -53,6 +55,21 @@ function runActive(options: Options, scenario: ActiveScenario) { ) } +function runAuth(scenario: ActiveScenario) { + return Effect.gen(function* () { + const effect = yield* callAuthProbe("effect", scenario) + const legacy = yield* callAuthProbe("legacy", scenario) + if (scenario.auth === "protected") { + if (effect.status !== 401) throw new Error(`effect auth expected 401, got ${effect.status}`) + if (legacy.status !== 401) throw new Error(`legacy auth expected 401, got ${legacy.status}`) + return + } + + if (effect.status === 401) throw new Error("effect auth expected public access, got 401") + if (legacy.status === 401) throw new Error("legacy auth expected public access, got 401") + }) +} + function runBackend(backend: "effect" | "legacy", scenario: ActiveScenario) { return withContext(scenario, (ctx) => Effect.gen(function* () { @@ -73,7 +90,10 @@ function withContext(scenario: ActiveScenario, use: (ctx: SeededContext Effect.promise(async () => void (await ctx.dir?.[Symbol.asyncDispose]())).pipe(Effect.ignore), + (ctx) => + Effect.promise(async () => { + await ctx.dir?.[Symbol.asyncDispose]() + }).pipe(Effect.ignore), ).pipe( Effect.flatMap((context) => Effect.gen(function* () { diff --git a/packages/opencode/test/server/httpapi-exercise/types.ts b/packages/opencode/test/server/httpapi-exercise/types.ts index befbd6aedb..c725739b4e 100644 --- a/packages/opencode/test/server/httpapi-exercise/types.ts +++ b/packages/opencode/test/server/httpapi-exercise/types.ts @@ -10,10 +10,11 @@ export const Methods = ["GET", "POST", "PUT", "DELETE", "PATCH"] as const export type Method = (typeof Methods)[number] export type OpenApiMethod = (typeof OpenApiMethods)[number] -export type Mode = "effect" | "parity" | "coverage" +export type Mode = "effect" | "parity" | "coverage" | "auth" export type Backend = "effect" | "legacy" export type Comparison = "none" | "status" | "json" export type CaptureMode = "full" | "stream" +export type AuthPolicy = "protected" | "public" | "public-bypass" | "ticket-bypass" export type ProjectOptions = { git?: boolean; config?: Partial; llm?: boolean } export type OpenApiSpec = { paths?: Record>> } export type JsonObject = Record @@ -79,6 +80,7 @@ export type ActiveScenario = { capture: CaptureMode mutates: boolean reset: boolean + auth: AuthPolicy } export type BuilderState = { @@ -91,6 +93,7 @@ export type BuilderState = { capture: CaptureMode mutates: boolean reset: boolean + auth: AuthPolicy } export type TodoScenario = { From fed221e0b0c3c027d3671228653abf47d4ff0f81 Mon Sep 17 00:00:00 2001 From: Dax Date: Fri, 8 May 2026 14:50:06 -0400 Subject: [PATCH 013/189] fix(skill): allow missing descriptions (#26391) --- packages/opencode/src/skill/index.ts | 13 ++++---- packages/opencode/test/session/system.test.ts | 6 ++++ packages/opencode/test/skill/skill.test.ts | 31 +++++++++++++++++++ packages/sdk/js/src/v2/gen/types.gen.ts | 2 +- 4 files changed, 45 insertions(+), 7 deletions(-) diff --git a/packages/opencode/src/skill/index.ts b/packages/opencode/src/skill/index.ts index a4e3fb6d93..696ab887a7 100644 --- a/packages/opencode/src/skill/index.ts +++ b/packages/opencode/src/skill/index.ts @@ -27,7 +27,7 @@ const SKILL_PATTERN = "**/SKILL.md" export const Info = Schema.Struct({ name: Schema.String, - description: Schema.String, + description: Schema.optional(Schema.String), location: Schema.String, content: Schema.String, }).pipe(withStatics((s) => ({ zod: zod(s) }))) @@ -93,7 +93,7 @@ const add = Effect.fnUntraced(function* (state: State, match: string, bus: Bus.I if (!md) return - const parsed = z.object({ name: z.string(), description: z.string() }).safeParse(md.data) + const parsed = z.object({ name: z.string(), description: z.string().optional() }).safeParse(md.data) if (!parsed.success) return if (state.skills[parsed.data.name]) { @@ -269,12 +269,13 @@ export const defaultLayer = layer.pipe( ) export function fmt(list: Info[], opts: { verbose: boolean }) { - if (list.length === 0) return "No skills are currently available." + const described = list.filter((skill) => skill.description !== undefined) + if (described.length === 0) return "No skills are currently available." if (opts.verbose) { return [ "", - ...list - .sort((a, b) => a.name.localeCompare(b.name)) + ...described + .toSorted((a, b) => a.name.localeCompare(b.name)) .flatMap((skill) => [ " ", ` ${skill.name}`, @@ -288,7 +289,7 @@ export function fmt(list: Info[], opts: { verbose: boolean }) { return [ "## Available Skills", - ...list + ...described .toSorted((a, b) => a.name.localeCompare(b.name)) .map((skill) => `- **${skill.name}**: ${skill.description}`), ].join("\n") diff --git a/packages/opencode/test/session/system.test.ts b/packages/opencode/test/session/system.test.ts index 6e5439da58..1cf9026725 100644 --- a/packages/opencode/test/session/system.test.ts +++ b/packages/opencode/test/session/system.test.ts @@ -26,6 +26,11 @@ const skills: Skill.Info[] = [ location: "/tmp/middle-skill/SKILL.md", content: "# middle-skill", }, + { + name: "manual-skill", + location: "/tmp/manual-skill/SKILL.md", + content: "# manual-skill", + }, ] const build: Agent.Info = { @@ -68,6 +73,7 @@ describe("session.system", () => { expect(alpha).toBeGreaterThan(-1) expect(middle).toBeGreaterThan(alpha) expect(zeta).toBeGreaterThan(middle) + expect(output).not.toContain("manual-skill") }), ) }) diff --git a/packages/opencode/test/skill/skill.test.ts b/packages/opencode/test/skill/skill.test.ts index bfcb0dcd67..d73750b083 100644 --- a/packages/opencode/test/skill/skill.test.ts +++ b/packages/opencode/test/skill/skill.test.ts @@ -163,6 +163,37 @@ Just some content without YAML frontmatter. ), ) + it.live("discovers skills without descriptions", () => + provideTmpdirInstance( + (dir) => + Effect.gen(function* () { + yield* Effect.promise(() => + Bun.write( + path.join(dir, ".opencode", "skill", "manual-skill", "SKILL.md"), + `--- +name: manual-skill +--- + +# Manual Skill + +Instructions here. +`, + ), + ) + + const skill = yield* Skill.Service + const list = yield* skill.all() + expect(list.length).toBe(1) + const item = list.find((x) => x.name === "manual-skill") + expect(item).toBeDefined() + expect(item!.description).toBeUndefined() + expect(Skill.fmt(list, { verbose: false })).toBe("No skills are currently available.") + expect(Skill.fmt(list, { verbose: true })).toBe("No skills are currently available.") + }), + { git: true }, + ), + ) + it.live("discovers skills from .claude/skills/ directory", () => provideTmpdirInstance( (dir) => diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 3f6b802bc6..08051ba917 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -4195,7 +4195,7 @@ export type AppSkillsResponses = { */ 200: Array<{ name: string - description: string + description?: string location: string content: string }> From 9ca4be62bde7f542f6eac8045899c14bf22a416e Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Fri, 8 May 2026 18:51:43 +0000 Subject: [PATCH 014/189] chore: generate --- packages/sdk/openapi.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index ab92482336..6cb4ce1d5a 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -2265,7 +2265,7 @@ "type": "string" } }, - "required": ["name", "description", "location", "content"], + "required": ["name", "location", "content"], "additionalProperties": false }, "description": "List of skills" From 6e47ae769ed39461b8bce8249a6bf5f2109252ab Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Fri, 8 May 2026 15:00:51 -0400 Subject: [PATCH 015/189] fix(cli): forward signals from npm shim (#26259) Co-authored-by: Aiden Cline --- packages/opencode/bin/opencode | 44 ++++++++++++++++++++++++---------- 1 file changed, 32 insertions(+), 12 deletions(-) diff --git a/packages/opencode/bin/opencode b/packages/opencode/bin/opencode index a7674ce2f8..a7101f42b0 100755 --- a/packages/opencode/bin/opencode +++ b/packages/opencode/bin/opencode @@ -5,31 +5,51 @@ const fs = require("fs") const path = require("path") const os = require("os") +const forwardedSignals = ["SIGINT", "SIGTERM", "SIGHUP"] + function run(target) { - const result = childProcess.spawnSync(target, process.argv.slice(2), { + const child = childProcess.spawn(target, process.argv.slice(2), { stdio: "inherit", }) - if (result.error) { - console.error(result.error.message) + + child.on("error", (error) => { + console.error(error.message) process.exit(1) + }) + + const forwarders = {} + for (const signal of forwardedSignals) { + forwarders[signal] = () => { + try { + child.kill(signal) + } catch { + // The child may have already exited. + } + } + process.on(signal, forwarders[signal]) } - const code = typeof result.status === "number" ? result.status : 0 - process.exit(code) + + child.on("exit", (code, signal) => { + for (const forwardedSignal of forwardedSignals) { + process.removeListener(forwardedSignal, forwarders[forwardedSignal]) + } + + if (signal) { + process.kill(process.pid, signal) + return + } + + process.exit(typeof code === "number" ? code : 0) + }) } const envPath = process.env.OPENCODE_BIN_PATH -if (envPath) { - run(envPath) -} const scriptPath = fs.realpathSync(__filename) const scriptDir = path.dirname(scriptPath) // const cached = path.join(scriptDir, ".opencode") -if (fs.existsSync(cached)) { - run(cached) -} const platformMap = { darwin: "darwin", @@ -166,7 +186,7 @@ function findBinary(startDir) { } } -const resolved = findBinary(scriptDir) +const resolved = envPath || (fs.existsSync(cached) ? cached : findBinary(scriptDir)) if (!resolved) { console.error( "It seems that your package manager failed to install the right version of the opencode CLI for your platform. You can try manually installing " + From 40d5ea1cf1b8029b1163a367275b3d2d9574e46c Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Sat, 9 May 2026 01:50:08 +0530 Subject: [PATCH 016/189] feat(core): add scout agent for repo research (#24149) Co-authored-by: Dax Raad --- packages/core/src/flag/flag.ts | 1 + packages/core/src/global.ts | 4 + packages/opencode/src/acp/agent.ts | 6 + packages/opencode/src/agent/agent.ts | 116 ++++++++- packages/opencode/src/agent/prompt/scout.txt | 36 +++ packages/opencode/src/cli/cmd/github.ts | 14 +- packages/opencode/src/config/config.ts | 5 + packages/opencode/src/config/permission.ts | 3 + packages/opencode/src/config/reference.ts | 27 ++ packages/opencode/src/tool/codesearch.ts | 63 +++++ packages/opencode/src/tool/codesearch.txt | 12 + packages/opencode/src/tool/mcp-websearch.ts | 5 + packages/opencode/src/tool/registry.ts | 13 + packages/opencode/src/tool/repo_clone.ts | 209 +++++++++++++++ packages/opencode/src/tool/repo_clone.txt | 5 + packages/opencode/src/tool/repo_overview.ts | 238 ++++++++++++++++++ packages/opencode/src/tool/repo_overview.txt | 4 + packages/opencode/src/util/repository.ts | 139 ++++++++++ packages/opencode/test/agent/agent.test.ts | 126 ++++++++-- .../opencode/test/cli/github-remote.test.ts | 10 + packages/opencode/test/session/prompt.test.ts | 2 + .../test/session/snapshot-tool-race.test.ts | 2 + packages/opencode/test/tool/registry.test.ts | 29 +++ .../opencode/test/tool/repo_clone.test.ts | 226 +++++++++++++++++ .../opencode/test/tool/repo_overview.test.ts | 150 +++++++++++ packages/sdk/js/src/v2/gen/types.gen.ts | 31 +++ packages/web/src/content/docs/agents.mdx | 12 +- packages/web/src/content/docs/ar/agents.mdx | 12 +- packages/web/src/content/docs/bs/agents.mdx | 12 +- packages/web/src/content/docs/da/agents.mdx | 12 +- packages/web/src/content/docs/de/agents.mdx | 8 + packages/web/src/content/docs/es/agents.mdx | 12 +- packages/web/src/content/docs/fr/agents.mdx | 12 +- packages/web/src/content/docs/it/agents.mdx | 12 +- packages/web/src/content/docs/ja/agents.mdx | 12 +- packages/web/src/content/docs/ko/agents.mdx | 12 +- packages/web/src/content/docs/nb/agents.mdx | 12 +- packages/web/src/content/docs/pl/agents.mdx | 12 +- .../web/src/content/docs/pt-br/agents.mdx | 12 +- packages/web/src/content/docs/ru/agents.mdx | 12 +- packages/web/src/content/docs/th/agents.mdx | 12 +- packages/web/src/content/docs/tr/agents.mdx | 12 +- .../web/src/content/docs/zh-cn/agents.mdx | 12 +- .../web/src/content/docs/zh-tw/agents.mdx | 12 +- 44 files changed, 1622 insertions(+), 66 deletions(-) create mode 100644 packages/opencode/src/agent/prompt/scout.txt create mode 100644 packages/opencode/src/config/reference.ts create mode 100644 packages/opencode/src/tool/codesearch.ts create mode 100644 packages/opencode/src/tool/codesearch.txt create mode 100644 packages/opencode/src/tool/repo_clone.ts create mode 100644 packages/opencode/src/tool/repo_clone.txt create mode 100644 packages/opencode/src/tool/repo_overview.ts create mode 100644 packages/opencode/src/tool/repo_overview.txt create mode 100644 packages/opencode/src/util/repository.ts create mode 100644 packages/opencode/test/tool/repo_clone.test.ts create mode 100644 packages/opencode/test/tool/repo_overview.test.ts diff --git a/packages/core/src/flag/flag.ts b/packages/core/src/flag/flag.ts index f55c14bd05..9aa4a568da 100644 --- a/packages/core/src/flag/flag.ts +++ b/packages/core/src/flag/flag.ts @@ -76,6 +76,7 @@ export const Flag = { OPENCODE_EXPERIMENTAL_LSP_TY: truthy("OPENCODE_EXPERIMENTAL_LSP_TY"), OPENCODE_EXPERIMENTAL_LSP_TOOL: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_LSP_TOOL"), OPENCODE_EXPERIMENTAL_PLAN_MODE: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_PLAN_MODE"), + OPENCODE_EXPERIMENTAL_SCOUT: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_SCOUT"), OPENCODE_EXPERIMENTAL_MARKDOWN: !falsy("OPENCODE_EXPERIMENTAL_MARKDOWN"), OPENCODE_ENABLE_PARALLEL: truthy("OPENCODE_ENABLE_PARALLEL") || truthy("OPENCODE_EXPERIMENTAL_PARALLEL"), OPENCODE_MODELS_URL: process.env["OPENCODE_MODELS_URL"], diff --git a/packages/core/src/global.ts b/packages/core/src/global.ts index 6560d308c1..5f9799c252 100644 --- a/packages/core/src/global.ts +++ b/packages/core/src/global.ts @@ -20,6 +20,7 @@ const paths = { data, bin: path.join(cache, "bin"), log: path.join(data, "log"), + repos: path.join(data, "repos"), cache, config, state, @@ -37,6 +38,7 @@ await Promise.all([ fs.mkdir(Path.tmp, { recursive: true }), fs.mkdir(Path.log, { recursive: true }), fs.mkdir(Path.bin, { recursive: true }), + fs.mkdir(Path.repos, { recursive: true }), ]) export class Service extends Context.Service()("@opencode/Global") {} @@ -50,6 +52,7 @@ export interface Interface { readonly tmp: string readonly bin: string readonly log: string + readonly repos: string } export function make(input: Partial = {}): Interface { @@ -62,6 +65,7 @@ export function make(input: Partial = {}): Interface { tmp: Path.tmp, bin: Path.bin, log: Path.log, + repos: Path.repos, ...input, } } diff --git a/packages/opencode/src/acp/agent.ts b/packages/opencode/src/acp/agent.ts index ad930680d1..1d941c6b92 100644 --- a/packages/opencode/src/acp/agent.ts +++ b/packages/opencode/src/acp/agent.ts @@ -1619,6 +1619,8 @@ function toToolKind(toolName: string): ToolKind { case "grep": case "glob": + case "repo_clone": + case "repo_overview": case "context7_resolve_library_id": case "context7_get_library_docs": return "search" @@ -1642,6 +1644,10 @@ function toLocations(toolName: string, input: Record): { path: stri case "glob": case "grep": return input["path"] ? [{ path: input["path"] }] : [] + case "repo_clone": + return input["path"] ? [{ path: input["path"] }] : [] + case "repo_overview": + return input["path"] ? [{ path: input["path"] }] : [] case ShellID.ToolID: return [] default: diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index b38b0cc5dd..8584682412 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -10,11 +10,13 @@ import { ProviderTransform } from "@/provider/transform" import PROMPT_GENERATE from "./generate.txt" import PROMPT_COMPACTION from "./prompt/compaction.txt" import PROMPT_EXPLORE from "./prompt/explore.txt" +import PROMPT_SCOUT from "./prompt/scout.txt" import PROMPT_SUMMARY from "./prompt/summary.txt" import PROMPT_TITLE from "./prompt/title.txt" import { Permission } from "@/permission" import { mergeDeep, pipe, sortBy, values } from "remeda" import { Global } from "@opencode-ai/core/global" +import { Flag } from "@opencode-ai/core/flag/flag" import path from "path" import { Plugin } from "@/plugin" import { Skill } from "../skill" @@ -25,6 +27,9 @@ import * as OtelTracer from "@effect/opentelemetry/Tracer" import { zod } from "@/util/effect-zod" import { withStatics, type DeepMutable } from "@/util/schema" +type ReferenceEntry = NonNullable[string] +type ResolvedReference = { kind: "git"; repository: string; branch?: string } | { kind: "local"; path: string } + export const Info = Schema.Struct({ name: Schema.String, description: Schema.optional(Schema.String), @@ -86,6 +91,10 @@ export const layer = Layer.effect( path.join(Global.Path.tmp, "*"), ...skillDirs.map((dir) => path.join(dir, "*")), ] + const readonlyExternalDirectory = { + "*": "ask", + ...Object.fromEntries(whitelistedDirs.map((dir) => [dir, "allow"])), + } satisfies Record const defaults = Permission.fromConfig({ "*": "allow", @@ -97,6 +106,8 @@ export const layer = Layer.effect( question: "deny", plan_enter: "deny", plan_exit: "deny", + repo_clone: "deny", + repo_overview: "deny", // mirrors github.com/github/gitignore Node.gitignore pattern for .env files read: { "*": "allow", @@ -174,10 +185,7 @@ export const layer = Layer.effect( webfetch: "allow", websearch: "allow", read: "allow", - external_directory: { - "*": "ask", - ...Object.fromEntries(whitelistedDirs.map((dir) => [dir, "allow"])), - }, + external_directory: readonlyExternalDirectory, }), user, ), @@ -187,6 +195,37 @@ export const layer = Layer.effect( mode: "subagent", native: true, }, + ...(Flag.OPENCODE_EXPERIMENTAL_SCOUT + ? { + scout: { + name: "scout", + permission: Permission.merge( + defaults, + Permission.fromConfig({ + "*": "deny", + grep: "allow", + glob: "allow", + webfetch: "allow", + websearch: "allow", + codesearch: "allow", + read: "allow", + repo_clone: "allow", + repo_overview: "allow", + external_directory: { + ...readonlyExternalDirectory, + [path.join(Global.Path.repos, "*")]: "allow", + }, + }), + user, + ), + description: `Docs and dependency-source specialist. Use this when you need to inspect external documentation, clone dependency repositories into the managed cache, and research library implementation details without modifying the user's workspace.`, + prompt: PROMPT_SCOUT, + options: {}, + mode: "subagent" as const, + native: true, + }, + } + : {}), compaction: { name: "compaction", mode: "primary", @@ -264,6 +303,75 @@ export const layer = Layer.effect( item.permission = Permission.merge(item.permission, Permission.fromConfig(value.permission ?? {})) } + function referencePath(value: string) { + if (value.startsWith("~/")) return path.join(Global.Path.home, value.slice(2)) + return path.isAbsolute(value) + ? value + : path.resolve(ctx.worktree === "/" ? ctx.directory : ctx.worktree, value) + } + + function resolveReference(reference: ReferenceEntry): ResolvedReference { + if (typeof reference === "string") { + if (reference.startsWith(".") || reference.startsWith("/") || reference.startsWith("~")) { + return { kind: "local", path: referencePath(reference) } + } + return { kind: "git", repository: reference } + } + if ("path" in reference) return { kind: "local", path: referencePath(reference.path) } + return { kind: "git", repository: reference.repository, branch: reference.branch } + } + + function referencePrompt(name: string, reference: ResolvedReference) { + if (reference.kind === "local") { + return [ + PROMPT_SCOUT, + `You are Scout reference @${name}. This reference points to a local directory outside or alongside the current workspace.`, + `Local directory: ${reference.path}`, + `When invoked, inspect this directory as the primary reference source. Prefer repo_overview with path ${JSON.stringify(reference.path)} before broader searches. Do not edit files.`, + ].join("\n\n") + } + + return [ + PROMPT_SCOUT, + `You are Scout reference @${name}. This reference points to a git repository.`, + `Repository: ${reference.repository}`, + ...(reference.branch ? [`Branch/ref: ${reference.branch}`] : []), + `When invoked, clone or refresh this repository with repo_clone, then inspect the cached repository as the primary reference source. Do not edit files.`, + ].join("\n\n") + } + + if (Flag.OPENCODE_EXPERIMENTAL_SCOUT) { + for (const [name, reference] of Object.entries(cfg.reference ?? {})) { + if (agents[name]) continue + const resolved = resolveReference(reference) + const localPath = resolved.kind === "local" ? resolved.path : undefined + agents[name] = { + name, + description: + resolved.kind === "local" + ? `Scout reference for local directory ${resolved.path}` + : `Scout reference for repository ${resolved.repository}`, + permission: Permission.merge( + agents.scout.permission, + Permission.fromConfig( + localPath + ? { + external_directory: { + [localPath]: "allow", + [path.join(localPath, "*")]: "allow", + }, + } + : {}, + ), + ), + prompt: referencePrompt(name, resolved), + options: { reference }, + mode: "subagent", + native: false, + } + } + } + // Ensure Truncate.GLOB is allowed unless explicitly configured for (const name in agents) { const agent = agents[name] diff --git a/packages/opencode/src/agent/prompt/scout.txt b/packages/opencode/src/agent/prompt/scout.txt new file mode 100644 index 0000000000..c315cc5a6b --- /dev/null +++ b/packages/opencode/src/agent/prompt/scout.txt @@ -0,0 +1,36 @@ +You are `scout`, a read-only research agent for external libraries, dependency source, and documentation. + +Your purpose is to investigate code outside the local workspace and return evidence-backed findings without modifying the user's workspace. + +Use this agent when asked to: +- inspect dependency repositories or library source +- compare local code against upstream implementations +- research public GitHub repositories the environment can clone +- explain how a library or framework works by reading its source and docs +- investigate third-party APIs, workflows, or behavior outside the current workspace + +Working style: +1. When the task involves a GitHub repository or dependency source, use `repo_clone` first. +2. After cloning, use `Glob`, `Grep`, and `Read` to inspect the cloned repository. +3. Use `WebFetch` for official documentation pages when source alone is not enough. +4. Prefer direct code and documentation evidence over assumptions. +5. If multiple external repositories are relevant, inspect each one before drawing conclusions. + +Research standards: +- cite exact absolute file paths and line references whenever possible +- separate what is verified from what is inferred +- if the answer depends on branch state, note that you are reading the repository's current default clone state unless the caller specifies otherwise +- if a repository cannot be cloned or accessed, say so explicitly and continue with whatever evidence is still available +- call out uncertainty clearly instead of smoothing over gaps + +Output expectations: +- start with the direct answer +- then explain the evidence repository by repository or source by source +- include file references when relevant +- keep the explanation organized and easy to scan + +Constraints: +- do not modify files or run tools that change the user's workspace +- return absolute file paths for cloned-repo findings in your final response + +Complete the user's research request efficiently and report your findings clearly. diff --git a/packages/opencode/src/cli/cmd/github.ts b/packages/opencode/src/cli/cmd/github.ts index ea5b35ef78..a6754ec2df 100644 --- a/packages/opencode/src/cli/cmd/github.ts +++ b/packages/opencode/src/cli/cmd/github.ts @@ -32,6 +32,7 @@ import { SessionPrompt } from "@/session/prompt" import { Git } from "@/git" import { setTimeout as sleep } from "node:timers/promises" import { Process } from "@/util/process" +import { parseGitHubRemote } from "@/util/repository" import { Effect } from "effect" type GitHubAuthor = { @@ -151,18 +152,7 @@ const SUPPORTED_EVENTS = [...USER_EVENTS, ...REPO_EVENTS] as const type UserEvent = (typeof USER_EVENTS)[number] type RepoEvent = (typeof REPO_EVENTS)[number] -// Parses GitHub remote URLs in various formats: -// - https://github.com/owner/repo.git -// - https://github.com/owner/repo -// - git@github.com:owner/repo.git -// - git@github.com:owner/repo -// - ssh://git@github.com/owner/repo.git -// - ssh://git@github.com/owner/repo -export function parseGitHubRemote(url: string): { owner: string; repo: string } | null { - const match = url.match(/^(?:(?:https?|ssh):\/\/)?(?:git@)?github\.com[:/]([^/]+)\/([^/]+?)(?:\.git)?$/) - if (!match) return null - return { owner: match[1], repo: match[2] } -} +export { parseGitHubRemote } /** * Extracts displayable text from assistant response parts. diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index fcdb4e7b1c..ebf6ecc591 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -37,6 +37,7 @@ import { ConfigPaths } from "./paths" import { ConfigPermission } from "./permission" import { ConfigPlugin } from "./plugin" import { ConfigProvider } from "./provider" +import { ConfigReference } from "./reference" import { ConfigServer } from "./server" import { ConfigSkills } from "./skills" import { ConfigVariable } from "./variable" @@ -142,6 +143,9 @@ export const Info = Schema.Struct({ description: "Command configuration, see https://opencode.ai/docs/commands", }), skills: Schema.optional(ConfigSkills.Info).annotate({ description: "Additional skill folder paths" }), + reference: Schema.optional(ConfigReference.Info).annotate({ + description: "Named git or local directory references that can be @ mentioned as Scout-backed subagents", + }), watcher: Schema.optional( Schema.Struct({ ignore: Schema.optional(Schema.mutable(Schema.Array(Schema.String))), @@ -201,6 +205,7 @@ export const Info = Schema.Struct({ // subagent general: Schema.optional(ConfigAgent.Info), explore: Schema.optional(ConfigAgent.Info), + scout: Schema.optional(ConfigAgent.Info), // specialized title: Schema.optional(ConfigAgent.Info), summary: Schema.optional(ConfigAgent.Info), diff --git a/packages/opencode/src/config/permission.ts b/packages/opencode/src/config/permission.ts index 9513951c29..5326b007bd 100644 --- a/packages/opencode/src/config/permission.ts +++ b/packages/opencode/src/config/permission.ts @@ -35,6 +35,9 @@ const InputObject = Schema.StructWithRest( question: Schema.optional(Action), webfetch: Schema.optional(Action), websearch: Schema.optional(Action), + codesearch: Schema.optional(Action), + repo_clone: Schema.optional(Rule), + repo_overview: Schema.optional(Rule), lsp: Schema.optional(Rule), doom_loop: Schema.optional(Action), skill: Schema.optional(Rule), diff --git a/packages/opencode/src/config/reference.ts b/packages/opencode/src/config/reference.ts new file mode 100644 index 0000000000..eea3d998c1 --- /dev/null +++ b/packages/opencode/src/config/reference.ts @@ -0,0 +1,27 @@ +export * as ConfigReference from "./reference" + +import { Schema } from "effect" +import { zod } from "@/util/effect-zod" +import { withStatics } from "@/util/schema" + +const Git = Schema.Struct({ + repository: Schema.String.annotate({ + description: "Git repository URL, host/path reference, or GitHub owner/repo shorthand", + }), + branch: Schema.optional(Schema.String).annotate({ + description: "Branch or ref Scout should clone and inspect", + }), +}) + +const Local = Schema.Struct({ + path: Schema.String.annotate({ + description: "Absolute path, ~/ path, or workspace-relative path to a local reference directory", + }), +}) + +export const Entry = Schema.Union([Schema.String, Git, Local]).annotate({ identifier: "ReferenceConfigEntry" }) + +export const Info = Schema.Record(Schema.String, Entry) + .annotate({ identifier: "ReferenceConfig" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type Info = Schema.Schema.Type diff --git a/packages/opencode/src/tool/codesearch.ts b/packages/opencode/src/tool/codesearch.ts new file mode 100644 index 0000000000..4616d5900a --- /dev/null +++ b/packages/opencode/src/tool/codesearch.ts @@ -0,0 +1,63 @@ +import { Effect, Schema } from "effect" +import { HttpClient } from "effect/unstable/http" +import * as Tool from "./tool" +import * as McpWebSearch from "./mcp-websearch" +import DESCRIPTION from "./codesearch.txt" + +export const Parameters = Schema.Struct({ + query: Schema.String.annotate({ + description: + "Search query to find relevant context for APIs, Libraries, and SDKs. For example, 'React useState hook examples', 'Python pandas dataframe filtering', 'Express.js middleware', 'Next js partial prerendering configuration'", + }), + tokensNum: Schema.Number.check(Schema.isGreaterThanOrEqualTo(1000)) + .check(Schema.isLessThanOrEqualTo(50000)) + .pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed(5000))) + .annotate({ + description: + "Number of tokens to return (1000-50000). Default is 5000 tokens. Adjust this value based on how much context you need - use lower values for focused queries and higher values for comprehensive documentation.", + }), +}) + +export const CodeSearchTool = Tool.define( + "codesearch", + Effect.gen(function* () { + const http = yield* HttpClient.HttpClient + + return { + description: DESCRIPTION, + parameters: Parameters, + execute: (params: { query: string; tokensNum: number }, ctx: Tool.Context) => + Effect.gen(function* () { + yield* ctx.ask({ + permission: "codesearch", + patterns: [params.query], + always: ["*"], + metadata: { + query: params.query, + tokensNum: params.tokensNum, + }, + }) + + const result = yield* McpWebSearch.call( + http, + McpWebSearch.EXA_URL, + "get_code_context_exa", + McpWebSearch.CodeArgs, + { + query: params.query, + tokensNum: params.tokensNum, + }, + "30 seconds", + ) + + return { + output: + result ?? + "No code snippets or documentation found. Please try a different query, be more specific about the library or programming concept, or check the spelling of framework names.", + title: `Code search: ${params.query}`, + metadata: {}, + } + }).pipe(Effect.orDie), + } + }), +) diff --git a/packages/opencode/src/tool/codesearch.txt b/packages/opencode/src/tool/codesearch.txt new file mode 100644 index 0000000000..4187f08d12 --- /dev/null +++ b/packages/opencode/src/tool/codesearch.txt @@ -0,0 +1,12 @@ +- Search and get relevant context for any programming task using Exa Code API +- Provides the highest quality and freshest context for libraries, SDKs, and APIs +- Use this tool for ANY question or task related to programming +- Returns comprehensive code examples, documentation, and API references +- Optimized for finding specific programming patterns and solutions + +Usage notes: + - Adjustable token count (1000-50000) for focused or comprehensive results + - Default 5000 tokens provides balanced context for most queries + - Use lower values for specific questions, higher values for comprehensive documentation + - Supports queries about frameworks, libraries, APIs, and programming concepts + - Examples: 'React useState hook examples', 'Python pandas dataframe filtering', 'Express.js middleware' diff --git a/packages/opencode/src/tool/mcp-websearch.ts b/packages/opencode/src/tool/mcp-websearch.ts index 208924cba5..42b864c6fa 100644 --- a/packages/opencode/src/tool/mcp-websearch.ts +++ b/packages/opencode/src/tool/mcp-websearch.ts @@ -48,6 +48,11 @@ export const SearchArgs = Schema.Struct({ contextMaxCharacters: Schema.optional(Schema.Number), }) +export const CodeArgs = Schema.Struct({ + query: Schema.String, + tokensNum: Schema.Number, +}) + export const ParallelSearchArgs = Schema.Struct({ objective: Schema.String, search_queries: Schema.Array(Schema.String), diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index b288bf7ae5..c8a91c1de1 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -22,6 +22,9 @@ import { Plugin } from "../plugin" import { Provider } from "@/provider/provider" import { ProviderID, type ModelID } from "../provider/schema" import { WebSearchTool } from "./websearch" +import { CodeSearchTool } from "./codesearch" +import { RepoCloneTool } from "./repo_clone" +import { RepoOverviewTool } from "./repo_overview" import { Flag } from "@opencode-ai/core/flag/flag" import * as Log from "@opencode-ai/core/util/log" import { LspTool } from "./lsp" @@ -44,6 +47,7 @@ import { Instruction } from "../session/instruction" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Bus } from "../bus" import { Agent } from "../agent/agent" +import { Git } from "@/git" import { Skill } from "../skill" import { Permission } from "@/permission" @@ -86,6 +90,7 @@ export const layer: Layer.Layer< | Skill.Service | Session.Service | Provider.Service + | Git.Service | LSP.Service | Instruction.Service | AppFileSystem.Service @@ -113,6 +118,9 @@ export const layer: Layer.Layer< const plan = yield* PlanExitTool const webfetch = yield* WebFetchTool const websearch = yield* WebSearchTool + const codesearch = yield* CodeSearchTool + const repoClone = yield* RepoCloneTool + const repoOverview = yield* RepoOverviewTool const shell = yield* ShellTool const globtool = yield* GlobTool const writetool = yield* WriteTool @@ -212,6 +220,9 @@ export const layer: Layer.Layer< fetch: Tool.init(webfetch), todo: Tool.init(todo), search: Tool.init(websearch), + code: Tool.init(codesearch), + repo_clone: Tool.init(repoClone), + repo_overview: Tool.init(repoOverview), skill: Tool.init(skilltool), patch: Tool.init(patchtool), question: Tool.init(question), @@ -234,6 +245,7 @@ export const layer: Layer.Layer< tool.fetch, tool.todo, tool.search, + ...(Flag.OPENCODE_EXPERIMENTAL_SCOUT ? [tool.code, tool.repo_clone, tool.repo_overview] : []), tool.skill, tool.patch, ...(Flag.OPENCODE_EXPERIMENTAL_LSP_TOOL ? [tool.lsp] : []), @@ -348,6 +360,7 @@ export const defaultLayer = Layer.suspend(() => Layer.provide(Agent.defaultLayer), Layer.provide(Session.defaultLayer), Layer.provide(Provider.defaultLayer), + Layer.provide(Git.defaultLayer), Layer.provide(LSP.defaultLayer), Layer.provide(Instruction.defaultLayer), Layer.provide(AppFileSystem.defaultLayer), diff --git a/packages/opencode/src/tool/repo_clone.ts b/packages/opencode/src/tool/repo_clone.ts new file mode 100644 index 0000000000..969a3e66dd --- /dev/null +++ b/packages/opencode/src/tool/repo_clone.ts @@ -0,0 +1,209 @@ +import path from "path" +import { Effect, Schema } from "effect" +import { AppFileSystem } from "@opencode-ai/core/filesystem" +import { Flock } from "@opencode-ai/core/util/flock" +import { Git } from "@/git" +import DESCRIPTION from "./repo_clone.txt" +import * as Tool from "./tool" +import { parseRepositoryReference, repositoryCachePath, sameRepositoryReference } from "@/util/repository" + +export const Parameters = Schema.Struct({ + repository: Schema.String.annotate({ + description: "Repository to clone, as a git URL, host/path reference, or GitHub owner/repo shorthand", + }), + refresh: Schema.optional(Schema.Boolean).annotate({ + description: "When true, fetches the latest remote state into the managed cache", + }), + branch: Schema.optional(Schema.String).annotate({ + description: "Branch or ref to clone and inspect", + }), +}) + +type Metadata = { + repository: string + host: string + remote: string + localPath: string + status: "cached" | "cloned" | "refreshed" + head?: string + branch?: string +} + +function statusForRepository(input: { reuse: boolean; refresh?: boolean; branchMatches?: boolean }) { + if (!input.reuse) return "cloned" as const + if (input.branchMatches === false) return "refreshed" as const + if (input.refresh) return "refreshed" as const + return "cached" as const +} + +function resetTarget(input: { + requestedBranch?: string + remoteHead: { code: number; stdout: string } + branch: { code: number; stdout: string } +}) { + if (input.requestedBranch) return `origin/${input.requestedBranch}` + if (input.remoteHead.code === 0 && input.remoteHead.stdout) { + return input.remoteHead.stdout.replace(/^refs\/remotes\//, "") + } + if (input.branch.code === 0 && input.branch.stdout) { + return `origin/${input.branch.stdout}` + } + return "HEAD" +} + +function validateBranch(branch: string) { + if (!/^[A-Za-z0-9/_.-]+$/.test(branch) || branch.startsWith("-") || branch.includes("..")) { + throw new Error( + "Branch must contain only alphanumeric characters, /, _, ., and -, and cannot start with - or contain ..", + ) + } +} + +export const RepoCloneTool = Tool.define( + "repo_clone", + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const git = yield* Git.Service + + return { + description: DESCRIPTION, + parameters: Parameters, + execute: (params: Schema.Schema.Type, ctx: Tool.Context) => + Effect.gen(function* () { + const reference = parseRepositoryReference(params.repository) + if (!reference) + throw new Error("Repository must be a git URL, host/path reference, or GitHub owner/repo shorthand") + if (reference.protocol === "file:") throw new Error("Local file repositories are not supported") + if (params.branch) validateBranch(params.branch) + + const repository = reference.label + const remote = reference.remote + const localPath = repositoryCachePath(reference) + const cloneTarget = parseRepositoryReference(remote) ?? reference + + yield* ctx.ask({ + permission: "repo_clone", + patterns: [repository], + always: [repository], + metadata: { + repository, + remote, + path: localPath, + refresh: Boolean(params.refresh), + branch: params.branch, + }, + }) + + return yield* Effect.acquireUseRelease( + Effect.promise((signal) => Flock.acquire(`repo-clone:${localPath}`, { signal })), + () => + Effect.gen(function* () { + yield* fs.ensureDir(path.dirname(localPath)).pipe(Effect.orDie) + + const exists = yield* fs.existsSafe(localPath) + const hasGitDir = yield* fs.existsSafe(path.join(localPath, ".git")) + const origin = hasGitDir + ? yield* git.run(["config", "--get", "remote.origin.url"], { cwd: localPath }) + : undefined + const originReference = + origin?.exitCode === 0 ? parseRepositoryReference(origin.text().trim()) : undefined + const reuse = + hasGitDir && Boolean(originReference && sameRepositoryReference(originReference, cloneTarget)) + if (exists && !reuse) { + yield* fs.remove(localPath, { recursive: true }).pipe(Effect.orDie) + } + + const currentBranch = hasGitDir ? yield* git.branch(localPath) : undefined + const status = statusForRepository({ + reuse, + refresh: params.refresh, + branchMatches: params.branch ? currentBranch === params.branch : undefined, + }) + + if (status === "cloned") { + const clone = yield* git.run( + [ + "clone", + "--depth", + "100", + ...(params.branch ? ["--branch", params.branch] : []), + "--", + remote, + localPath, + ], + { cwd: path.dirname(localPath) }, + ) + if (clone.exitCode !== 0) { + throw new Error( + clone.stderr.toString().trim() || clone.text().trim() || `Failed to clone ${repository}`, + ) + } + } + + if (status === "refreshed") { + const fetch = yield* git.run(["fetch", "--all", "--prune"], { cwd: localPath }) + if (fetch.exitCode !== 0) { + throw new Error( + fetch.stderr.toString().trim() || fetch.text().trim() || `Failed to refresh ${repository}`, + ) + } + + if (params.branch) { + const checkout = yield* git.run(["checkout", "-B", params.branch, `origin/${params.branch}`], { + cwd: localPath, + }) + if (checkout.exitCode !== 0) { + throw new Error( + checkout.stderr.toString().trim() || + checkout.text().trim() || + `Failed to checkout ${params.branch}`, + ) + } + } + + const remoteHead = yield* git.run(["symbolic-ref", "refs/remotes/origin/HEAD"], { cwd: localPath }) + const branch = yield* git.run(["symbolic-ref", "--quiet", "--short", "HEAD"], { cwd: localPath }) + const target = resetTarget({ + requestedBranch: params.branch, + remoteHead: { code: remoteHead.exitCode, stdout: remoteHead.text().trim() }, + branch: { code: branch.exitCode, stdout: branch.text().trim() }, + }) + + const reset = yield* git.run(["reset", "--hard", target], { cwd: localPath }) + if (reset.exitCode !== 0) { + throw new Error( + reset.stderr.toString().trim() || reset.text().trim() || `Failed to reset ${repository}`, + ) + } + } + + const head = yield* git.run(["rev-parse", "HEAD"], { cwd: localPath }) + const branch = yield* git.branch(localPath) + const headText = head.exitCode === 0 ? head.text().trim() : undefined + + return { + title: repository, + metadata: { + repository, + host: reference.host, + remote, + localPath, + status, + head: headText, + branch, + }, + output: [ + `Repository ready: ${repository}`, + `Status: ${status}`, + `Local path: ${localPath}`, + ...(branch ? [`Branch: ${branch}`] : []), + ...(headText ? [`HEAD: ${headText}`] : []), + ].join("\n"), + } + }), + (lock) => Effect.promise(() => lock.release()).pipe(Effect.ignore), + ) + }).pipe(Effect.orDie), + } satisfies Tool.DefWithoutID + }), +) diff --git a/packages/opencode/src/tool/repo_clone.txt b/packages/opencode/src/tool/repo_clone.txt new file mode 100644 index 0000000000..7944015506 --- /dev/null +++ b/packages/opencode/src/tool/repo_clone.txt @@ -0,0 +1,5 @@ +- Clone or refresh a repository into OpenCode's managed cache under the data directory +- Accepts git URLs, forge host/path references, or GitHub owner/repo shorthand +- Returns the cached absolute local path so other tools can explore the cloned source +- Use this before Read, Glob, or Grep when the code you need lives outside the current workspace +- This tool is intended for dependency and documentation research workflows, not for modifying the user's workspace diff --git a/packages/opencode/src/tool/repo_overview.ts b/packages/opencode/src/tool/repo_overview.ts new file mode 100644 index 0000000000..bb788dc084 --- /dev/null +++ b/packages/opencode/src/tool/repo_overview.ts @@ -0,0 +1,238 @@ +import path from "path" +import { Effect, Schema } from "effect" +import { AppFileSystem } from "@opencode-ai/core/filesystem" +import { Git } from "@/git" +import { assertExternalDirectoryEffect } from "./external-directory" +import DESCRIPTION from "./repo_overview.txt" +import * as Tool from "./tool" +import { parseRepositoryReference, repositoryCachePath } from "@/util/repository" +import { Instance } from "@/project/instance" + +export const Parameters = Schema.Struct({ + repository: Schema.optional(Schema.String).annotate({ + description: "Cached repository to inspect, as a git URL, host/path reference, or GitHub owner/repo shorthand", + }), + path: Schema.optional(Schema.String).annotate({ + description: "Directory path to inspect instead of a cached repository", + }), + depth: Schema.optional(Schema.Number).annotate({ + description: "Maximum structure depth to include. Defaults to 3.", + }) +}) + +type Metadata = { + path: string + repository?: string + branch?: string + head?: string + package_manager?: string + ecosystems: string[] + dependency_files: string[] + entrypoints: string[] + depth: number + truncated: boolean +} + +const IGNORED_DIRS = new Set([".git", "node_modules", "__pycache__", ".venv", "dist", "build", ".next", "target", "vendor"]) +const STRUCTURE_LIMIT = 200 +const DEPENDENCY_FILES = [ + "package.json", + "package-lock.json", + "bun.lock", + "bun.lockb", + "pnpm-lock.yaml", + "yarn.lock", + "requirements.txt", + "pyproject.toml", + "go.mod", + "Cargo.toml", + "Gemfile", + "build.gradle", + "build.gradle.kts", + "pom.xml", + "composer.json", +] + +function packageManager(files: Set) { + if (files.has("bun.lock") || files.has("bun.lockb")) return "bun" + if (files.has("pnpm-lock.yaml")) return "pnpm" + if (files.has("yarn.lock")) return "yarn" + if (files.has("package-lock.json")) return "npm" +} + +function ecosystems(files: Set) { + return [ + ...(files.has("package.json") ? ["Node.js"] : []), + ...(files.has("pyproject.toml") || files.has("requirements.txt") ? ["Python"] : []), + ...(files.has("go.mod") ? ["Go"] : []), + ...(files.has("Cargo.toml") ? ["Rust"] : []), + ...(files.has("Gemfile") ? ["Ruby"] : []), + ...(files.has("build.gradle") || files.has("build.gradle.kts") || files.has("pom.xml") ? ["Java/Kotlin"] : []), + ...(files.has("composer.json") ? ["PHP"] : []), + ] +} + +function commonEntrypoints(files: Set) { + return ["index.ts", "index.tsx", "index.js", "index.mjs", "main.ts", "main.js", "src/index.ts", "src/index.tsx", "src/index.js", "src/main.ts", "src/main.js"].filter((file) => files.has(file)) +} + +export const RepoOverviewTool = Tool.define( + "repo_overview", + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const git = yield* Git.Service + + const resolveTarget = Effect.fn("RepoOverviewTool.resolveTarget")(function* (params: Schema.Schema.Type) { + if (params.path) { + const full = path.isAbsolute(params.path) ? params.path : path.resolve(Instance.directory, params.path) + return { path: full, repository: params.repository } + } + + if (!params.repository) throw new Error("Either repository or path is required") + + const parsed = parseRepositoryReference(params.repository) + if (!parsed) throw new Error("Repository must be a git URL, host/path reference, or GitHub owner/repo shorthand") + + const repository = parsed.label + return { + repository, + path: repositoryCachePath(parsed), + } + }) + + const structure = Effect.fn("RepoOverviewTool.structure")(function* (root: string, depth: number) { + let truncated = false + const lines: string[] = [] + + const visit: (dir: string, level: number) => Effect.Effect = Effect.fnUntraced(function* (dir: string, level: number) { + if (level >= depth || lines.length >= STRUCTURE_LIMIT) { + truncated = truncated || lines.length >= STRUCTURE_LIMIT + return + } + + const entries = yield* fs.readDirectoryEntries(dir).pipe(Effect.orElseSucceed(() => [])) + const sorted = yield* Effect.forEach( + entries, + Effect.fnUntraced(function* (entry) { + if (IGNORED_DIRS.has(entry.name)) return undefined + const full = path.join(dir, entry.name) + const info = yield* fs.stat(full).pipe(Effect.catch(() => Effect.succeed(undefined))) + if (!info) return undefined + return { name: entry.name, full, directory: info.type === "Directory" } + }), + { concurrency: 16 }, + ).pipe( + Effect.map((items) => + items + .filter((item): item is { name: string; full: string; directory: boolean } => Boolean(item)) + .sort((a, b) => Number(b.directory) - Number(a.directory) || a.name.localeCompare(b.name)), + ), + ) + + for (const entry of sorted) { + if (lines.length >= STRUCTURE_LIMIT) { + truncated = true + return + } + + lines.push(`${" ".repeat(level)}${entry.name}${entry.directory ? "/" : ""}`) + if (entry.directory) yield* visit(entry.full, level + 1) + } + }) + + yield* visit(root, 0) + return { lines, truncated } + }) + + return { + description: DESCRIPTION, + parameters: Parameters, + execute: (params: Schema.Schema.Type, ctx: Tool.Context) => + Effect.gen(function* () { + const target = yield* resolveTarget(params) + const depth = !params.depth || !Number.isInteger(params.depth) || params.depth < 1 || params.depth > 6 ? 3 : params.depth + + yield* assertExternalDirectoryEffect(ctx, target.path, { kind: "directory" }) + yield* ctx.ask({ + permission: "repo_overview", + patterns: [target.repository ?? target.path], + always: [target.repository ?? target.path], + metadata: { + repository: target.repository, + path: target.path, + depth, + }, + }) + + const info = yield* fs.stat(target.path).pipe(Effect.catch(() => Effect.succeed(undefined))) + if (!info) { + if (target.repository) throw new Error(`Repository is not cloned: ${target.repository}. Use repo_clone first.`) + throw new Error(`Directory not found: ${target.path}`) + } + if (info.type !== "Directory") throw new Error(`Path is not a directory: ${target.path}`) + + const entries = yield* fs.readDirectoryEntries(target.path).pipe(Effect.orElseSucceed(() => [])) + const topLevel = new Set(entries.map((entry) => entry.name)) + const dependencyFiles = DEPENDENCY_FILES.filter((file) => topLevel.has(file)) + const packageJson = topLevel.has("package.json") + ? (yield* fs.readJson(path.join(target.path, "package.json")).pipe(Effect.orElseSucceed(() => ({})))) as Record + : {} + + const entrypoints = [ + ...(typeof packageJson.main === "string" ? [`main: ${packageJson.main}`] : []), + ...(typeof packageJson.module === "string" ? [`module: ${packageJson.module}`] : []), + ...(typeof packageJson.types === "string" ? [`types: ${packageJson.types}`] : []), + ...(typeof packageJson.bin === "string" ? [`bin: ${packageJson.bin}`] : []), + ...(packageJson.bin && typeof packageJson.bin === "object" && !Array.isArray(packageJson.bin) + ? Object.keys(packageJson.bin as Record).map((name) => `bin: ${name}`) + : []), + ...(packageJson.exports && typeof packageJson.exports === "object" && !Array.isArray(packageJson.exports) + ? Object.keys(packageJson.exports as Record).slice(0, 10).map((name) => `exports: ${name}`) + : []), + ] + + const common = commonEntrypoints(new Set([ + ...topLevel, + ...entries + .filter((entry) => entry.name === "src") + .flatMap(() => ["src/index.ts", "src/index.tsx", "src/index.js", "src/main.ts", "src/main.js"]), + ])) + const structureResult = yield* structure(target.path, depth) + const branch = yield* git.branch(target.path) + const head = yield* git.run(["rev-parse", "HEAD"], { cwd: target.path }) + const headText = head.exitCode === 0 ? head.text().trim() : undefined + + const metadata: Metadata = { + path: target.path, + repository: target.repository, + branch, + head: headText, + package_manager: packageManager(topLevel), + ecosystems: ecosystems(topLevel), + dependency_files: dependencyFiles, + entrypoints: [...entrypoints, ...common.map((file) => `file: ${file}`)], + depth, + truncated: structureResult.truncated, + } + + return { + title: target.repository ?? path.basename(target.path), + metadata, + output: [ + `Path: ${target.path}`, + ...(target.repository ? [`Repository: ${target.repository}`] : []), + ...(branch ? [`Branch: ${branch}`] : []), + ...(headText ? [`HEAD: ${headText}`] : []), + ...(metadata.ecosystems.length ? [`Ecosystems: ${metadata.ecosystems.join(", ")}`] : []), + ...(metadata.package_manager ? [`Package manager: ${metadata.package_manager}`] : []), + ...(metadata.dependency_files.length ? [`Dependency files: ${metadata.dependency_files.join(", ")}`] : []), + ...(metadata.entrypoints.length ? ["Likely entrypoints:", ...metadata.entrypoints.map((entry) => `- ${entry}`)] : []), + "Top-level structure:", + ...structureResult.lines, + ...(structureResult.truncated ? ["(Structure truncated)"] : []), + ].join("\n"), + } + }).pipe(Effect.orDie), + } satisfies Tool.DefWithoutID + }), +) diff --git a/packages/opencode/src/tool/repo_overview.txt b/packages/opencode/src/tool/repo_overview.txt new file mode 100644 index 0000000000..2109838746 --- /dev/null +++ b/packages/opencode/src/tool/repo_overview.txt @@ -0,0 +1,4 @@ +- Summarize the structure and likely entrypoints of a cloned repository or local directory +- Accepts either a cached repository reference or a directory path +- Reports detected ecosystems, dependency files, package manager, likely entrypoints, and a compact structure tree +- Use this after repo_clone to orient quickly before deeper Read, Glob, or Grep investigation diff --git a/packages/opencode/src/util/repository.ts b/packages/opencode/src/util/repository.ts new file mode 100644 index 0000000000..279e5af0e4 --- /dev/null +++ b/packages/opencode/src/util/repository.ts @@ -0,0 +1,139 @@ +import path from "path" +import { fileURLToPath } from "url" +import { Global } from "@opencode-ai/core/global" + +export type Reference = { + host: string + path: string + segments: string[] + owner?: string + repo: string + remote: string + label: string + protocol?: string +} + +function normalize(input: string) { + return input.trim().replace(/^git\+/, "").replace(/#.*$/, "").replace(/\/+$/, "") +} + +function trimGitSuffix(input: string) { + return input.replace(/\.git$/, "") +} + +function parts(input: string) { + return input + .split("/") + .map((item) => trimGitSuffix(item.trim())) + .filter(Boolean) +} + +function safeHost(input: string) { + return Boolean(input) && !input.startsWith("-") && !/[\s/\\]/.test(input) +} + +function safeSegment(input: string) { + return input !== "." && input !== ".." && !input.includes(":") && !/[\s/\\]/.test(input) +} + +function hostLike(input: string) { + return input.includes(".") || input.includes(":") || input === "localhost" +} + +function withSlash(input: string) { + return input.endsWith("/") ? input : `${input}/` +} + +function githubRemote(pathname: string) { + const base = process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL + if (!base) return `https://github.com/${pathname}.git` + return new URL(`${pathname}.git`, withSlash(base)).href +} + +function build(input: { host: string; segments: string[]; remote?: string; protocol?: string }) { + const segments = input.segments.map(trimGitSuffix).filter(Boolean) + if (!safeHost(input.host) || !segments.length || segments.some((segment) => !safeSegment(segment))) return null + const pathname = segments.join("/") + const repo = segments[segments.length - 1] + const host = input.host.toLowerCase() + return { + host, + path: pathname, + segments, + owner: segments.length === 2 ? segments[0] : undefined, + repo, + remote: input.remote ?? (host === "github.com" ? githubRemote(pathname) : `https://${host}/${pathname}.git`), + label: host === "github.com" && segments.length === 2 ? pathname : `${host}/${pathname}`, + protocol: input.protocol, + } satisfies Reference +} + +function buildFile(input: { url: URL; remote: string }) { + const filePath = path.normalize(fileURLToPath(input.url)) + const segments = filePath.split(/[\\/]+/).filter(Boolean) + if (!segments.length) return null + return { + host: "file", + path: filePath, + segments: segments.map((segment) => segment.replace(/:$/, "")), + owner: undefined, + repo: trimGitSuffix(segments[segments.length - 1]), + remote: input.remote, + label: filePath, + protocol: "file:", + } satisfies Reference +} + +export function parseRepositoryReference(input: string) { + const cleaned = normalize(input) + if (!cleaned) return null + + const githubPrefixed = cleaned.match(/^github:([^/\s]+)\/([^/\s]+)$/) + if (githubPrefixed) return build({ host: "github.com", segments: [githubPrefixed[1], githubPrefixed[2]] }) + + if (!cleaned.includes("://")) { + const scp = cleaned.match(/^(?:[^@/\s]+@)?([^:/\s]+):(.+)$/) + if (scp) return build({ host: scp[1], segments: parts(scp[2]), remote: cleaned }) + + const direct = parts(cleaned) + if (direct.length >= 2 && hostLike(direct[0])) { + return build({ host: direct[0], segments: direct.slice(1) }) + } + + if (direct.length === 2) { + return build({ host: "github.com", segments: direct }) + } + } + + try { + const url = new URL(cleaned) + if (url.protocol === "file:") return buildFile({ url, remote: cleaned }) + const pathname = parts(url.pathname) + const host = url.host + return build({ + host, + segments: pathname, + remote: host === "github.com" ? githubRemote(pathname.join("/")) : cleaned, + protocol: url.protocol, + }) + } catch { + return null + } +} + +export function parseGitHubRemote(input: string) { + const cleaned = normalize(input) + if (!cleaned.includes("://") && !cleaned.match(/^(?:[^@/\s]+@)?github\.com:/)) return null + + const parsed = parseRepositoryReference(cleaned) + if (!parsed || parsed.host !== "github.com" || !parsed.owner || parsed.segments.length !== 2) return null + return { owner: parsed.owner, repo: parsed.repo } +} + +export function repositoryCachePath(input: Reference) { + return path.join(Global.Path.repos, ...input.host.split(":"), ...input.segments) +} + +export function sameRepositoryReference(left: Reference, right: Reference) { + return left.host === right.host && left.path === right.path +} diff --git a/packages/opencode/test/agent/agent.test.ts b/packages/opencode/test/agent/agent.test.ts index 6996e54b47..7f29860cfe 100644 --- a/packages/opencode/test/agent/agent.test.ts +++ b/packages/opencode/test/agent/agent.test.ts @@ -2,11 +2,11 @@ import { afterEach, test, expect } from "bun:test" import { Effect } from "effect" import path from "path" import { disposeAllInstances, provideInstance, tmpdir } from "../fixture/fixture" -import { Instance } from "../../src/project/instance" import { WithInstance } from "../../src/project/with-instance" import { Agent } from "../../src/agent/agent" -import { Permission } from "../../src/permission" import { Global } from "@opencode-ai/core/global" +import { Flag } from "@opencode-ai/core/flag/flag" +import { Permission } from "../../src/permission" // Helper to evaluate permission for a tool with wildcard pattern function evalPerm(agent: Agent.Info | undefined, permission: string): Permission.Action | undefined { @@ -18,25 +18,38 @@ function load(dir: string, fn: (svc: Agent.Interface) => Effect.Effect) { return Effect.runPromise(provideInstance(dir)(Agent.Service.use(fn)).pipe(Effect.provide(Agent.defaultLayer))) } +async function withExperimentalScout(enabled: boolean, fn: () => Promise) { + const original = Flag.OPENCODE_EXPERIMENTAL_SCOUT + Flag.OPENCODE_EXPERIMENTAL_SCOUT = enabled + try { + await fn() + } finally { + Flag.OPENCODE_EXPERIMENTAL_SCOUT = original + } +} + afterEach(async () => { await disposeAllInstances() }) test("returns default native agents when no config", async () => { - await using tmp = await tmpdir() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const agents = await load(tmp.path, (svc) => svc.list()) - const names = agents.map((a) => a.name) - expect(names).toContain("build") - expect(names).toContain("plan") - expect(names).toContain("general") - expect(names).toContain("explore") - expect(names).toContain("compaction") - expect(names).toContain("title") - expect(names).toContain("summary") - }, + await withExperimentalScout(false, async () => { + await using tmp = await tmpdir() + await WithInstance.provide({ + directory: tmp.path, + fn: async () => { + const agents = await load(tmp.path, (svc) => svc.list()) + const names = agents.map((a) => a.name) + expect(names).toContain("build") + expect(names).toContain("plan") + expect(names).toContain("general") + expect(names).toContain("explore") + expect(names).not.toContain("scout") + expect(names).toContain("compaction") + expect(names).toContain("title") + expect(names).toContain("summary") + }, + }) }) }) @@ -51,6 +64,8 @@ test("build agent has correct default properties", async () => { expect(build?.native).toBe(true) expect(evalPerm(build, "edit")).toBe("allow") expect(evalPerm(build, "bash")).toBe("allow") + expect(evalPerm(build, "repo_clone")).toBe("deny") + expect(evalPerm(build, "repo_overview")).toBe("deny") }, }) }) @@ -102,6 +117,85 @@ test("explore agent asks for external directories and allows whitelisted externa }) }) +test("scout agent allows repo cloning and repo cache reads", async () => { + await withExperimentalScout(true, async () => { + await using tmp = await tmpdir() + await WithInstance.provide({ + directory: tmp.path, + fn: async () => { + const scout = await load(tmp.path, (svc) => svc.get("scout")) + expect(scout).toBeDefined() + expect(scout?.mode).toBe("subagent") + expect(evalPerm(scout, "repo_clone")).toBe("allow") + expect(evalPerm(scout, "repo_overview")).toBe("allow") + expect(evalPerm(scout, "edit")).toBe("deny") + expect( + Permission.evaluate( + "external_directory", + path.join(Global.Path.repos, "github.com", "owner", "repo", "README.md"), + scout!.permission, + ).action, + ).toBe("allow") + }, + }) + }) +}) + +test("reference config creates scout-backed subagents", async () => { + await withExperimentalScout(true, async () => { + await using tmp = await tmpdir({ + config: { + reference: { + effect: "github.com/effect/effect-smol", + effectFull: { + repository: "Effect-TS/effect", + branch: "main", + }, + localdocs: "../docs", + localdocsFull: { + path: "../local-docs", + }, + }, + }, + }) + await WithInstance.provide({ + directory: tmp.path, + fn: async () => { + const effect = await load(tmp.path, (svc) => svc.get("effect")) + const effectFull = await load(tmp.path, (svc) => svc.get("effectFull")) + const local = await load(tmp.path, (svc) => svc.get("localdocs")) + const localFull = await load(tmp.path, (svc) => svc.get("localdocsFull")) + + expect(effect).toBeDefined() + expect(effect?.mode).toBe("subagent") + expect(effect?.prompt).toContain("Repository: github.com/effect/effect-smol") + expect(evalPerm(effect, "repo_clone")).toBe("allow") + + expect(effectFull).toBeDefined() + expect(effectFull?.mode).toBe("subagent") + expect(effectFull?.prompt).toContain("Repository: Effect-TS/effect") + expect(effectFull?.prompt).toContain("Branch/ref: main") + expect(evalPerm(effectFull, "repo_clone")).toBe("allow") + + expect(local).toBeDefined() + expect(local?.mode).toBe("subagent") + expect(local?.prompt).toContain(`Local directory: ${path.resolve(tmp.path, "../docs")}`) + expect( + Permission.evaluate( + "external_directory", + path.join(path.resolve(tmp.path, "../docs"), "README.md"), + local!.permission, + ).action, + ).toBe("allow") + + expect(localFull).toBeDefined() + expect(localFull?.mode).toBe("subagent") + expect(localFull?.prompt).toContain(`Local directory: ${path.resolve(tmp.path, "../local-docs")}`) + }, + }) + }) +}) + test("general agent denies todo tools", async () => { await using tmp = await tmpdir() await WithInstance.provide({ diff --git a/packages/opencode/test/cli/github-remote.test.ts b/packages/opencode/test/cli/github-remote.test.ts index 80102d986e..ed37b92d41 100644 --- a/packages/opencode/test/cli/github-remote.test.ts +++ b/packages/opencode/test/cli/github-remote.test.ts @@ -25,6 +25,16 @@ test("parses ssh:// URL without .git suffix", () => { expect(parseGitHubRemote("ssh://git@github.com/sst/opencode")).toEqual({ owner: "sst", repo: "opencode" }) }) +test("parses git protocol URLs from package metadata", () => { + expect(parseGitHubRemote("git://github.com/facebook/react.git")).toEqual({ owner: "facebook", repo: "react" }) + expect(parseGitHubRemote("git+https://github.com/facebook/react.git")).toEqual({ owner: "facebook", repo: "react" }) + expect(parseGitHubRemote("git+ssh://git@github.com/facebook/react.git")).toEqual({ owner: "facebook", repo: "react" }) +}) + +test("parses npm-style github shorthand", () => { + expect(parseGitHubRemote("github:facebook/react")).toBeNull() +}) + test("parses http URL", () => { expect(parseGitHubRemote("http://github.com/owner/repo")).toEqual({ owner: "owner", repo: "repo" }) }) diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index c5170f3464..3b0009d2b3 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -15,6 +15,7 @@ import { Permission } from "../../src/permission" import { Plugin } from "../../src/plugin" import { Provider as ProviderSvc } from "@/provider/provider" import { Env } from "../../src/env" +import { Git } from "../../src/git" import { ModelID, ProviderID } from "../../src/provider/schema" import { Question } from "../../src/question" import { Todo } from "../../src/session/todo" @@ -178,6 +179,7 @@ function makeHttp() { Layer.provide(Skill.defaultLayer), Layer.provide(FetchHttpClient.layer), Layer.provide(CrossSpawnSpawner.defaultLayer), + Layer.provide(Git.defaultLayer), Layer.provide(Ripgrep.defaultLayer), Layer.provide(Format.defaultLayer), Layer.provideMerge(todo), diff --git a/packages/opencode/test/session/snapshot-tool-race.test.ts b/packages/opencode/test/session/snapshot-tool-race.test.ts index ab5a3ab7ed..82b88a72fd 100644 --- a/packages/opencode/test/session/snapshot-tool-race.test.ts +++ b/packages/opencode/test/session/snapshot-tool-race.test.ts @@ -30,6 +30,7 @@ import { TestLLMServer } from "../lib/llm-server" // Same layer setup as prompt-effect.test.ts import { NodeFileSystem } from "@effect/platform-node" import { Agent as AgentSvc } from "../../src/agent/agent" +import { Git } from "../../src/git" import { Bus } from "../../src/bus" import { Command } from "../../src/command" import { Config } from "@/config/config" @@ -128,6 +129,7 @@ function makeHttp() { Layer.provide(Skill.defaultLayer), Layer.provide(FetchHttpClient.layer), Layer.provide(CrossSpawnSpawner.defaultLayer), + Layer.provide(Git.defaultLayer), Layer.provide(Ripgrep.defaultLayer), Layer.provide(Format.defaultLayer), Layer.provideMerge(todo), diff --git a/packages/opencode/test/tool/registry.test.ts b/packages/opencode/test/tool/registry.test.ts index c33981ddff..e42d4d59b3 100644 --- a/packages/opencode/test/tool/registry.test.ts +++ b/packages/opencode/test/tool/registry.test.ts @@ -4,6 +4,7 @@ import fs from "fs/promises" import { Effect, Layer } from "effect" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { ToolRegistry } from "@/tool/registry" +import { Flag } from "@opencode-ai/core/flag/flag" import { disposeAllInstances, TestInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" import { TestConfig } from "../fixture/config" @@ -15,6 +16,7 @@ import { Skill } from "@/skill" import { Agent } from "@/agent/agent" import { Session } from "@/session/session" import { Provider } from "@/provider/provider" +import { Git } from "@/git" import { LSP } from "@/lsp/lsp" import { Instruction } from "@/session/instruction" import { Bus } from "@/bus" @@ -25,6 +27,7 @@ import * as Truncate from "@/tool/truncate" import { InstanceState } from "@/effect/instance-state" const node = CrossSpawnSpawner.defaultLayer +const originalExperimentalScout = Flag.OPENCODE_EXPERIMENTAL_SCOUT const configLayer = TestConfig.layer({ directories: () => InstanceState.directory.pipe(Effect.map((dir) => [path.join(dir, ".opencode")])), }) @@ -38,6 +41,7 @@ const registryLayer = ToolRegistry.layer.pipe( Layer.provide(Agent.defaultLayer), Layer.provide(Session.defaultLayer), Layer.provide(Provider.defaultLayer), + Layer.provide(Git.defaultLayer), Layer.provide(LSP.defaultLayer), Layer.provide(Instruction.defaultLayer), Layer.provide(AppFileSystem.defaultLayer), @@ -52,10 +56,35 @@ const registryLayer = ToolRegistry.layer.pipe( const it = testEffect(Layer.mergeAll(registryLayer, node)) afterEach(async () => { + Flag.OPENCODE_EXPERIMENTAL_SCOUT = originalExperimentalScout await disposeAllInstances() }) describe("tool.registry", () => { + it.instance("hides repo research tools unless experimental", () => + Effect.gen(function* () { + Flag.OPENCODE_EXPERIMENTAL_SCOUT = false + const registry = yield* ToolRegistry.Service + const ids = yield* registry.ids() + + expect(ids).not.toContain("codesearch") + expect(ids).not.toContain("repo_clone") + expect(ids).not.toContain("repo_overview") + }), + ) + + it.instance("shows repo research tools when experimental scout is enabled", () => + Effect.gen(function* () { + Flag.OPENCODE_EXPERIMENTAL_SCOUT = true + const registry = yield* ToolRegistry.Service + const ids = yield* registry.ids() + + expect(ids).toContain("codesearch") + expect(ids).toContain("repo_clone") + expect(ids).toContain("repo_overview") + }), + ) + it.instance("loads tools from .opencode/tool (singular)", () => Effect.gen(function* () { const test = yield* TestInstance diff --git a/packages/opencode/test/tool/repo_clone.test.ts b/packages/opencode/test/tool/repo_clone.test.ts new file mode 100644 index 0000000000..e32c6ee439 --- /dev/null +++ b/packages/opencode/test/tool/repo_clone.test.ts @@ -0,0 +1,226 @@ +import { afterEach, describe, expect } from "bun:test" +import path from "path" +import { pathToFileURL } from "node:url" +import { Cause, Effect, Exit, Layer } from "effect" +import { AppFileSystem } from "@opencode-ai/core/filesystem" +import { Agent } from "../../src/agent/agent" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { Git } from "../../src/git" +import { Global } from "@opencode-ai/core/global" +import { MessageID, SessionID } from "../../src/session/schema" +import { Truncate } from "../../src/tool/truncate" +import { RepoCloneTool } from "../../src/tool/repo_clone" +import { disposeAllInstances, provideTmpdirInstance, tmpdirScoped } from "../fixture/fixture" +import { testEffect } from "../lib/effect" + +afterEach(async () => { + await disposeAllInstances() +}) + +const ctx = { + sessionID: SessionID.make("ses_test"), + messageID: MessageID.make(""), + callID: "", + agent: "scout", + abort: AbortSignal.any([]), + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, +} + +const it = testEffect( + Layer.mergeAll( + Agent.defaultLayer, + AppFileSystem.defaultLayer, + CrossSpawnSpawner.defaultLayer, + Git.defaultLayer, + Truncate.defaultLayer, + ), +) + +const init = Effect.fn("RepoCloneToolTest.init")(function* () { + const info = yield* RepoCloneTool + return yield* info.init() +}) + +const git = Effect.fn("RepoCloneToolTest.git")(function* (cwd: string, args: string[]) { + return yield* Effect.promise(async () => { + const proc = Bun.spawn(["git", ...args], { + cwd, + stdout: "pipe", + stderr: "pipe", + }) + const [stdout, stderr, code] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]) + if (code !== 0) { + throw new Error(stderr.trim() || stdout.trim() || `git ${args.join(" ")} failed`) + } + return stdout.trim() + }) +}) + +const githubBase = (url: string, self: Effect.Effect) => + Effect.acquireUseRelease( + Effect.sync(() => { + const previous = process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL + process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL = url + return previous + }), + () => self, + (previous) => + Effect.sync(() => { + if (previous) process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL = previous + else delete process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL + }), + ) + +describe("tool.repo_clone", () => { + it.live("clones a repo into the managed cache and reuses it on subsequent calls", () => + provideTmpdirInstance((_dir) => + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const source = yield* tmpdirScoped({ git: true }) + const remoteRoot = yield* tmpdirScoped() + const remoteDir = path.join(remoteRoot, "owner") + const remoteRepo = path.join(remoteDir, "repo.git") + + yield* Effect.promise(() => Bun.write(path.join(source, "README.md"), "v1\n")) + yield* git(source, ["add", "."]) + yield* git(source, ["commit", "-m", "add readme"]) + yield* fs.makeDirectory(remoteDir, { recursive: true }).pipe(Effect.orDie) + yield* git(remoteRoot, ["clone", "--bare", source, remoteRepo]) + + const tool = yield* init() + const cloned = yield* githubBase(`file://${remoteRoot}/`, tool.execute({ repository: "owner/repo" }, ctx)) + const cached = yield* githubBase( + `file://${remoteRoot}/`, + tool.execute({ repository: "https://github.com/owner/repo.git" }, ctx), + ) + + expect(cloned.metadata.status).toBe("cloned") + expect(cloned.metadata.localPath).toBe(path.join(Global.Path.repos, "github.com", "owner", "repo")) + expect(cached.metadata.status).toBe("cached") + expect(yield* fs.readFileString(path.join(cloned.metadata.localPath, "README.md"))).toBe("v1\n") + }), + ), + ) + + it.live("refresh updates an existing cached clone", () => + provideTmpdirInstance((_dir) => + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const source = yield* tmpdirScoped({ git: true }) + const remoteRoot = yield* tmpdirScoped() + const remoteDir = path.join(remoteRoot, "owner") + const remoteRepo = path.join(remoteDir, "repo.git") + + yield* Effect.promise(() => Bun.write(path.join(source, "README.md"), "v1\n")) + yield* git(source, ["add", "."]) + yield* git(source, ["commit", "-m", "add readme"]) + yield* fs.makeDirectory(remoteDir, { recursive: true }).pipe(Effect.orDie) + yield* git(remoteRoot, ["clone", "--bare", source, remoteRepo]) + + const branch = yield* git(source, ["branch", "--show-current"]) + yield* git(source, ["remote", "add", "origin", remoteRepo]) + yield* git(source, ["push", "-u", "origin", `${branch}:${branch}`]) + + const tool = yield* init() + const first = yield* githubBase(`file://${remoteRoot}/`, tool.execute({ repository: "owner/repo" }, ctx)) + + yield* Effect.promise(() => Bun.write(path.join(source, "README.md"), "v2\n")) + yield* git(source, ["add", "."]) + yield* git(source, ["commit", "-m", "update readme"]) + yield* git(source, ["push", "origin", `${branch}:${branch}`]) + + const refreshed = yield* githubBase( + `file://${remoteRoot}/`, + tool.execute({ repository: "owner/repo", refresh: true }, ctx), + ) + + expect(first.metadata.status).toBe("cloned") + expect(refreshed.metadata.status).toBe("refreshed") + expect(yield* fs.readFileString(path.join(first.metadata.localPath, "README.md"))).toBe("v2\n") + }), + ), + ) + + it.live("clones a configured branch", () => + provideTmpdirInstance((_dir) => + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const source = yield* tmpdirScoped({ git: true }) + const remoteRoot = yield* tmpdirScoped() + const remoteDir = path.join(remoteRoot, "owner") + const remoteRepo = path.join(remoteDir, "repo.git") + + yield* Effect.promise(() => Bun.write(path.join(source, "README.md"), "main\n")) + yield* git(source, ["add", "."]) + yield* git(source, ["commit", "-m", "add readme"]) + yield* git(source, ["checkout", "-b", "docs"]) + yield* Effect.promise(() => Bun.write(path.join(source, "DOCS.md"), "docs\n")) + yield* git(source, ["add", "."]) + yield* git(source, ["commit", "-m", "add docs"]) + yield* fs.makeDirectory(remoteDir, { recursive: true }).pipe(Effect.orDie) + yield* git(remoteRoot, ["clone", "--bare", source, remoteRepo]) + + const tool = yield* init() + const result = yield* githubBase( + `file://${remoteRoot}/`, + tool.execute({ repository: "owner/repo", branch: "docs" }, ctx), + ) + + expect(result.metadata.status).toBe("cloned") + expect(result.metadata.branch).toBe("docs") + expect(yield* fs.readFileString(path.join(result.metadata.localPath, "DOCS.md"))).toBe("docs\n") + }), + ), + ) + + it.live("rejects invalid repository inputs", () => + provideTmpdirInstance((_dir) => + Effect.gen(function* () { + const tool = yield* init() + const inputs = [ + { repository: "not-a-repo", message: "git URL" }, + { repository: "git@github.com:../../../etc/passwd", message: "git URL" }, + { repository: "-u:foo/bar", message: "git URL" }, + { repository: pathToFileURL(path.join(_dir, "local.git")).href, message: "Local file" }, + ] + + yield* Effect.forEach( + inputs, + (input) => + Effect.gen(function* () { + const result = yield* tool.execute({ repository: input.repository }, ctx).pipe(Effect.exit) + + expect(Exit.isFailure(result)).toBe(true) + if (Exit.isFailure(result)) { + const error = Cause.squash(result.cause) + expect(error instanceof Error ? error.message : String(error)).toContain(input.message) + } + }), + { discard: true }, + ) + }), + ), + ) + + it.live("rejects local file repository URLs", () => + provideTmpdirInstance((_dir) => + Effect.gen(function* () { + const source = yield* tmpdirScoped({ git: true }) + const tool = yield* init() + const result = yield* tool.execute({ repository: pathToFileURL(source).href }, ctx).pipe(Effect.exit) + + expect(Exit.isFailure(result)).toBe(true) + if (Exit.isFailure(result)) { + const error = Cause.squash(result.cause) + expect(error instanceof Error ? error.message : String(error)).toContain("Local file") + } + }), + ), + ) +}) diff --git a/packages/opencode/test/tool/repo_overview.test.ts b/packages/opencode/test/tool/repo_overview.test.ts new file mode 100644 index 0000000000..b4214b7af4 --- /dev/null +++ b/packages/opencode/test/tool/repo_overview.test.ts @@ -0,0 +1,150 @@ +import { afterEach, describe, expect } from "bun:test" +import path from "path" +import { Cause, Effect, Exit, Layer } from "effect" +import { AppFileSystem } from "@opencode-ai/core/filesystem" +import { Agent } from "../../src/agent/agent" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { Git } from "../../src/git" +import { Global } from "@opencode-ai/core/global" +import { MessageID, SessionID } from "../../src/session/schema" +import { Truncate } from "../../src/tool/truncate" +import { RepoOverviewTool } from "../../src/tool/repo_overview" +import { disposeAllInstances, provideTmpdirInstance, tmpdirScoped } from "../fixture/fixture" +import { testEffect } from "../lib/effect" + +afterEach(async () => { + await disposeAllInstances() +}) + +const ctx = { + sessionID: SessionID.make("ses_test"), + messageID: MessageID.make(""), + callID: "", + agent: "scout", + abort: AbortSignal.any([]), + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, +} + +const it = testEffect( + Layer.mergeAll( + Agent.defaultLayer, + AppFileSystem.defaultLayer, + CrossSpawnSpawner.defaultLayer, + Git.defaultLayer, + Truncate.defaultLayer, + ), +) + +const init = Effect.fn("RepoOverviewToolTest.init")(function* () { + const info = yield* RepoOverviewTool + return yield* info.init() +}) + +describe("tool.repo_overview", () => { + it.live("summarizes a local repository path", () => + provideTmpdirInstance((_dir) => + Effect.gen(function* () { + const repo = yield* tmpdirScoped({ git: true }) + const fs = yield* AppFileSystem.Service + yield* fs.writeWithDirs( + path.join(repo, "package.json"), + JSON.stringify( + { + name: "example-repo", + main: "dist/index.js", + module: "dist/index.mjs", + types: "dist/index.d.ts", + exports: { + ".": "./dist/index.js", + "./server": "./dist/server.js", + }, + bin: { + example: "./bin/example.js", + }, + }, + null, + 2, + ), + ) + yield* fs.writeWithDirs(path.join(repo, "bun.lock"), "") + yield* fs.writeWithDirs(path.join(repo, "README.md"), "# Example\n") + yield* fs.writeWithDirs(path.join(repo, "src", "index.ts"), "export const value = 1\n") + + const tool = yield* init() + const result = yield* tool.execute({ path: repo }, ctx) + + expect(result.metadata.path).toBe(repo) + expect(result.metadata.ecosystems).toContain("Node.js") + expect(result.metadata.package_manager).toBe("bun") + expect(result.metadata.dependency_files).toEqual(expect.arrayContaining(["package.json", "bun.lock"])) + expect(result.metadata.entrypoints).toEqual( + expect.arrayContaining([ + "main: dist/index.js", + "module: dist/index.mjs", + "types: dist/index.d.ts", + "exports: .", + "exports: ./server", + "bin: example", + "file: src/index.ts", + ]), + ) + expect(result.output).toContain("Top-level structure:") + expect(result.output).toContain("src/") + expect(result.output).toContain("README.md") + }), + ), + ) + + it.live("resolves a cached repository from repository shorthand", () => + provideTmpdirInstance((_dir) => + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const cached = path.join(Global.Path.repos, "github.com", "owner", "repo") + yield* fs.writeWithDirs(path.join(cached, "package.json"), JSON.stringify({ name: "cached-repo" }, null, 2)) + yield* fs.writeWithDirs(path.join(cached, "README.md"), "cached\n") + + const tool = yield* init() + const result = yield* tool.execute({ repository: "owner/repo" }, ctx) + + expect(result.metadata.path).toBe(cached) + expect(result.metadata.repository).toBe("owner/repo") + expect(result.output).toContain("Repository: owner/repo") + expect(result.output).toContain(`Path: ${cached}`) + }), + ), + ) + + it.live("fails clearly when a repository is not cloned", () => + provideTmpdirInstance((_dir) => + Effect.gen(function* () { + const tool = yield* init() + const result = yield* tool.execute({ repository: "missing/repo" }, ctx).pipe(Effect.exit) + + expect(Exit.isFailure(result)).toBe(true) + if (Exit.isFailure(result)) { + const error = Cause.squash(result.cause) + expect(error instanceof Error ? error.message : String(error)).toContain("Use repo_clone first") + } + }), + ), + ) + + it.live("resolves cached repositories from host/path references", () => + provideTmpdirInstance((_dir) => + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const cached = path.join(Global.Path.repos, "gitlab.com", "group", "repo") + yield* fs.writeWithDirs(path.join(cached, "README.md"), "cached\n") + + const tool = yield* init() + const result = yield* tool.execute({ repository: "gitlab.com/group/repo" }, ctx) + + expect(result.metadata.path).toBe(cached) + expect(result.metadata.repository).toBe("gitlab.com/group/repo") + expect(result.output).toContain("Repository: gitlab.com/group/repo") + }), + ), + ) +}) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 08051ba917..ba1b671c64 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -901,6 +901,32 @@ export type ServerConfig = { cors?: Array } +export type ReferenceConfigEntry = + | string + | { + /** + * Git repository URL, host/path reference, or GitHub owner/repo shorthand + */ + repository: string + /** + * Branch or ref Scout should clone and inspect + */ + branch?: string + } + | { + /** + * Absolute path, ~/ path, or workspace-relative path to a local reference directory + */ + path: string + } + +/** + * Named git or local directory references that can be @ mentioned as Scout-backed subagents + */ +export type ReferenceConfig = { + [key: string]: ReferenceConfigEntry +} + export type PermissionActionConfig = "ask" | "allow" | "deny" export type PermissionObjectConfig = { @@ -924,6 +950,9 @@ export type PermissionConfig = question?: PermissionActionConfig webfetch?: PermissionActionConfig websearch?: PermissionActionConfig + codesearch?: PermissionActionConfig + repo_clone?: PermissionRuleConfig + repo_overview?: PermissionRuleConfig lsp?: PermissionRuleConfig doom_loop?: PermissionActionConfig skill?: PermissionRuleConfig @@ -1127,6 +1156,7 @@ export type Config = { paths?: Array urls?: Array } + reference?: ReferenceConfig watcher?: { ignore?: Array } @@ -1162,6 +1192,7 @@ export type Config = { build?: AgentConfig general?: AgentConfig explore?: AgentConfig + scout?: AgentConfig title?: AgentConfig summary?: AgentConfig compaction?: AgentConfig diff --git a/packages/web/src/content/docs/agents.mdx b/packages/web/src/content/docs/agents.mdx index d7c85bc517..53048b7927 100644 --- a/packages/web/src/content/docs/agents.mdx +++ b/packages/web/src/content/docs/agents.mdx @@ -36,13 +36,13 @@ look at these below. Subagents are specialized assistants that primary agents can invoke for specific tasks. You can also manually invoke them by **@ mentioning** them in your messages. -OpenCode comes with two built-in subagents, **General** and **Explore**. We'll look at this below. +OpenCode comes with three built-in subagents, **General**, **Explore**, and **Scout**. We'll look at this below. --- ## Built-in -OpenCode comes with two built-in primary agents and two built-in subagents. +OpenCode comes with two built-in primary agents and three built-in subagents. --- @@ -84,6 +84,14 @@ A fast, read-only agent for exploring codebases. Cannot modify files. Use this w --- +### Use scout + +_Mode_: `subagent` + +A read-only agent for external docs and dependency research. Use this when you need to clone a dependency repository into OpenCode's managed cache, inspect library source, or cross-reference local code against upstream implementations without modifying your workspace. + +--- + ### Use compaction _Mode_: `primary` diff --git a/packages/web/src/content/docs/ar/agents.mdx b/packages/web/src/content/docs/ar/agents.mdx index 01e13fda89..af12a67691 100644 --- a/packages/web/src/content/docs/ar/agents.mdx +++ b/packages/web/src/content/docs/ar/agents.mdx @@ -35,13 +35,13 @@ description: هيّئ الوكلاء المتخصصين واستخدمهم. الوكلاء الفرعيون هم مساعدين متخصصين يمكن للوكلاء الأساسيين استدعاؤهم لمهام محددة. يمكنك أيضا استدعاؤهم يدويا عبر **الإشارة بـ @** في رسائلك. -يأتي OpenCode مع وكيلين فرعيين مدمجين: **General** و **Explore**. سنلقي نظرة على ذلك أدناه. +يأتي OpenCode مع ثلاثة وكلاء فرعيين مدمجين: **General** و **Explore** و **Scout**. سنلقي نظرة على ذلك أدناه. --- ## المدمجة -يأتي OpenCode مع وكيلين أساسيين مدمجين ووكيلين فرعيين مدمجين. +يأتي OpenCode مع وكيلين أساسيين مدمجين وثلاثة وكلاء فرعيين مدمجين. --- @@ -83,6 +83,14 @@ _الوضع_: `subagent` --- +### استخدام Scout + +_الوضع_: `subagent` + +وكيل للقراءة فقط مخصص للوثائق الخارجية وأبحاث التبعيات. استخدمه عندما تحتاج إلى استنساخ مستودع تبعية داخل ذاكرة التخزين المؤقت المُدارة في OpenCode، أو فحص الشفرة المصدرية لمكتبة، أو إجراء مراجع متقاطعة بين الشفرة المحلية والتنفيذات upstream بدون تعديل مساحة العمل الخاصة بك. + +--- + ### استخدام Compaction _الوضع_: `primary` diff --git a/packages/web/src/content/docs/bs/agents.mdx b/packages/web/src/content/docs/bs/agents.mdx index 8ff674ae67..a2e211b19a 100644 --- a/packages/web/src/content/docs/bs/agents.mdx +++ b/packages/web/src/content/docs/bs/agents.mdx @@ -35,13 +35,13 @@ OpenCode dolazi sa dva ugrađena primarna agenta, **Build** i **Plan**. Pogledat Subagenti su specijalizovani pomoćnici koje primarni agenti mogu pozvati za određene zadatke. Možete ih i ručno pozvati **@ spominjanjem** u svojim porukama. -OpenCode dolazi sa dva ugrađena subagenta, **General** i **Explore**. Ovo ćemo pogledati u nastavku. +OpenCode dolazi sa tri ugrađena subagenta, **General**, **Explore** i **Scout**. Ovo ćemo pogledati u nastavku. --- ## Ugrađeni -OpenCode dolazi sa dva ugrađena primarna agenta i dva ugrađena subagenta. +OpenCode dolazi sa dva ugrađena primarna agenta i tri ugrađena subagenta. --- @@ -83,6 +83,14 @@ Brzi agent samo za čitanje za istraživanje kodnih baza. Nije moguće mijenjati --- +### Scout agent + +_Režim_: `subagent` + +Agent samo za čitanje za istraživanje eksterne dokumentacije i zavisnosti. Koristite ga kada trebate klonirati repozitorij zavisnosti u OpenCode-ov upravljani cache, pregledati izvorni kod biblioteke ili uporediti lokalni kod sa upstream implementacijama bez mijenjanja vašeg radnog prostora. + +--- + ### Compaction agent _Režim_: `primary` diff --git a/packages/web/src/content/docs/da/agents.mdx b/packages/web/src/content/docs/da/agents.mdx index 6ab2e7c39d..058f9eec6e 100644 --- a/packages/web/src/content/docs/da/agents.mdx +++ b/packages/web/src/content/docs/da/agents.mdx @@ -36,13 +36,13 @@ se på disse nedenfor. Subagenter er specialiserede assistenter, som primære agenter kan påbegynde sig til specifikke opgaver. Du kan også kalde dem manuelt ved at **@ nævne** dem i dine beskeder. -OpenCode leveres med to indbyggede underagenter, **Generelt** og **Udforsk**. Vi vil se på dette nedenfor. +OpenCode leveres med tre indbyggede subagenter, **General**, **Explore** og **Scout**. Vi ser nærmere på dem nedenfor. --- ## Indbyggede -OpenCode leveres med to indbyggede primære agenter og to indbyggede subagenter. +OpenCode leveres med to indbyggede primære agenter og tre indbyggede subagenter. --- @@ -84,6 +84,14 @@ En hurtig, skrivebeskyttet agent til at udforske kodebaser. Kan ikke ændre file --- +### Scout-agenten + +_Tilstand_: `subagent` + +En skrivebeskyttet agent til eksterne docs og research af dependencies. Brug denne, når du har brug for at klone et dependency-repository ind i OpenCode's administrerede cache, inspicere kildekoden i et bibliotek eller krydstjekke lokal kode mod upstream-implementeringer uden at ændre dit workspace. + +--- + ### Compact-agenten _Tilstand_: `primary` diff --git a/packages/web/src/content/docs/de/agents.mdx b/packages/web/src/content/docs/de/agents.mdx index 289b113cf6..6bca53488d 100644 --- a/packages/web/src/content/docs/de/agents.mdx +++ b/packages/web/src/content/docs/de/agents.mdx @@ -70,6 +70,14 @@ Ein schneller, schreibgeschützter Agent zum Erkunden von Codebasen. Dateien kö --- +### Scout + +_Modus_: `subagent` + +Ein schreibgeschützter Agent für externe Dokumentation und Dependency-Recherche. Verwenden Sie ihn, wenn Sie ein Dependency-Repository in den von OpenCode verwalteten Cache klonen, den Quellcode einer Bibliothek untersuchen oder lokalen Code mit Upstream-Implementierungen abgleichen müssen, ohne Ihren Workspace zu verändern. + +--- + ### Compaction _Modus_: `primary` diff --git a/packages/web/src/content/docs/es/agents.mdx b/packages/web/src/content/docs/es/agents.mdx index 0b2736ac37..c98a4eb99e 100644 --- a/packages/web/src/content/docs/es/agents.mdx +++ b/packages/web/src/content/docs/es/agents.mdx @@ -36,13 +36,13 @@ mira estos a continuación. Los subagentes son asistentes especializados que los agentes principales pueden invocar para tareas específicas. También puedes invocarlos manualmente **@ mencionándolos** en tus mensajes. -OpenCode viene con dos subagentes integrados, **General** y **Explore**. Veremos esto a continuación. +OpenCode viene con tres subagentes integrados, **General**, **Explore** y **Scout**. Veremos esto a continuación. --- ## Integrados -OpenCode viene con dos agentes primarios integrados y dos subagentes integrados. +OpenCode viene con dos agentes primarios integrados y tres subagentes integrados. --- @@ -84,6 +84,14 @@ Un agente rápido y de solo lectura para explorar bases de código. No se pueden --- +### Scout + +_Modo_: `subagent` + +Un agente de solo lectura para investigar documentación externa y dependencias. Úsalo cuando necesites clonar el repositorio de una dependencia en la caché administrada de OpenCode, inspeccionar el código fuente de una librería o contrastar el código local con implementaciones upstream sin modificar tu espacio de trabajo. + +--- + ### Compactación _Modo_: `primary` diff --git a/packages/web/src/content/docs/fr/agents.mdx b/packages/web/src/content/docs/fr/agents.mdx index b18d335394..a6b323dfc8 100644 --- a/packages/web/src/content/docs/fr/agents.mdx +++ b/packages/web/src/content/docs/fr/agents.mdx @@ -36,13 +36,13 @@ Nous les verrons ci-dessous. Les sous-agents sont des assistants spécialisés que les agents primaires peuvent appeler pour des tâches spécifiques. Vous pouvez également les invoquer manuellement en **@ les mentionnant** dans vos messages. -OpenCode est livré avec deux sous-agents intégrés, **General** et **Explore**. Nous verrons cela ci-dessous. +OpenCode est livré avec trois sous-agents intégrés, **General**, **Explore** et **Scout**. Nous les verrons ci-dessous. --- ## Agents intégrés -OpenCode est livré avec deux agents primaires intégrés et deux sous-agents intégrés. +OpenCode est livré avec deux agents primaires intégrés et trois sous-agents intégrés. --- @@ -84,6 +84,14 @@ Un agent rapide en lecture seule pour explorer les bases de code. Impossible de --- +### Agent Scout + +_Mode_ : `subagent` + +Un agent en lecture seule pour la recherche sur la documentation externe et les dépendances. Utilisez-le lorsque vous devez cloner le dépôt d'une dépendance dans le cache géré d'OpenCode, inspecter le code source d'une bibliothèque ou recouper le code local avec les implémentations upstream sans modifier votre espace de travail. + +--- + ### Agent Compaction _Mode_ : `primary` diff --git a/packages/web/src/content/docs/it/agents.mdx b/packages/web/src/content/docs/it/agents.mdx index 4ecc9fc2a2..70aea57533 100644 --- a/packages/web/src/content/docs/it/agents.mdx +++ b/packages/web/src/content/docs/it/agents.mdx @@ -35,13 +35,13 @@ OpenCode include due agenti primari integrati: **Build** e **Plan**. Li vediamo I subagenti sono assistenti specializzati che gli agenti primari possono invocare per task specifici. Puoi anche invocarli manualmente **menzionandoli con @** nei tuoi messaggi. -OpenCode include due subagenti integrati: **General** e **Explore**. Li vediamo sotto. +OpenCode include tre subagenti integrati: **General**, **Explore** e **Scout**. Li vediamo sotto. --- ## Integrati -OpenCode include due agenti primari integrati e due subagenti integrati. +OpenCode include due agenti primari integrati e tre subagenti integrati. --- @@ -83,6 +83,14 @@ Un agente rapido in sola lettura per esplorare codebase. Non può modificare fil --- +### Scout + +_Mode_: `subagent` + +Un agente in sola lettura per la ricerca su documentazione esterna e dipendenze. Usalo quando devi clonare il repository di una dipendenza nella cache gestita di OpenCode, ispezionare il codice sorgente di una libreria o confrontare il codice locale con implementazioni upstream senza modificare il tuo workspace. + +--- + ### Compaction _Mode_: `primary` diff --git a/packages/web/src/content/docs/ja/agents.mdx b/packages/web/src/content/docs/ja/agents.mdx index 879a43b057..539d30faf8 100644 --- a/packages/web/src/content/docs/ja/agents.mdx +++ b/packages/web/src/content/docs/ja/agents.mdx @@ -35,13 +35,13 @@ OpenCode には、**Build** と **Plan** という 2 つの組み込みプライ サブエージェントは、プライマリエージェントが特定のタスクのために呼び出すことができる特殊なアシスタントです。メッセージ内で **@ メンション**することで、手動で呼び出すこともできます。 -OpenCode には、**General** と **Explore** という 2 つの組み込みサブエージェントが付属しています。これについては以下で見ていきます。 +OpenCode には、**General**、**Explore**、**Scout** という 3 つの組み込みサブエージェントが付属しています。これについては以下で見ていきます。 --- ## 組み込み -OpenCode には、2 つの組み込みプライマリエージェントと 2 つの組み込みサブエージェントが付属しています。 +OpenCode には、2 つの組み込みプライマリエージェントと 3 つの組み込みサブエージェントが付属しています。 --- @@ -83,6 +83,14 @@ _モード_: `subagent` --- +### Scout + +_モード_: `subagent` + +外部ドキュメントや依存関係の調査を行うための読み取り専用エージェントです。依存関係のリポジトリを OpenCode の管理キャッシュにクローンしたいとき、ライブラリのソースコードを調べたいとき、あるいはワークスペースを変更せずにローカルコードを upstream の実装と突き合わせたいときに使用します。 + +--- + ### Compact _モード_: `primary` diff --git a/packages/web/src/content/docs/ko/agents.mdx b/packages/web/src/content/docs/ko/agents.mdx index 34de6250d1..02f31c5b62 100644 --- a/packages/web/src/content/docs/ko/agents.mdx +++ b/packages/web/src/content/docs/ko/agents.mdx @@ -35,13 +35,13 @@ OpenCode에는 기본 제공 primary agent인 **Build**와 **Plan**이 포함되 subagent는 primary agent가 특정 작업을 위해 호출하는 전문 assistant입니다. 메시지에서 **@ mention**으로 직접 호출할 수도 있습니다. -OpenCode에는 기본 제공 subagent인 **General**과 **Explore**가 포함되어 있습니다. 아래에서 살펴보겠습니다. +OpenCode에는 기본 제공 subagent인 **General**, **Explore**, **Scout**가 포함되어 있습니다. 아래에서 살펴보겠습니다. --- ## 기본 제공 -OpenCode는 기본적으로 primary agent 2개와 subagent 2개를 제공합니다. +OpenCode는 기본적으로 primary agent 2개와 subagent 3개를 제공합니다. --- @@ -83,6 +83,14 @@ _Mode_: `subagent` --- +### Use Scout + +_Mode_: `subagent` + +외부 docs와 dependency 리서치를 위한 읽기 전용 agent입니다. dependency repository를 OpenCode의 관리형 cache에 clone하거나, 라이브러리 소스를 살펴보거나, workspace를 수정하지 않고 로컬 코드를 upstream 구현과 교차 확인해야 할 때 사용하세요. + +--- + ### Use compaction _Mode_: `primary` diff --git a/packages/web/src/content/docs/nb/agents.mdx b/packages/web/src/content/docs/nb/agents.mdx index d7831e3387..f9971758d5 100644 --- a/packages/web/src/content/docs/nb/agents.mdx +++ b/packages/web/src/content/docs/nb/agents.mdx @@ -35,13 +35,13 @@ OpenCode kommer med to innebygde primære agenter, **Build** og **Plan**. Vi ser Underagenter er spesialiserte assistenter som primære agenter kan påkalle for spesifikke oppgaver. Du kan også starte dem manuelt ved å **@ nevne** dem i meldingene dine. -OpenCode kommer med to innebygde underagenter, **General** og **Explore**. Vi skal se på dette nedenfor. +OpenCode kommer med tre innebygde underagenter, **General**, **Explore** og **Scout**. Vi skal se på dette nedenfor. --- ## Innebygd -OpenCode kommer med to innebygde primære agenter og to innebygde underagenter. +OpenCode kommer med to innebygde primære agenter og tre innebygde underagenter. --- @@ -83,6 +83,14 @@ En rask, skrivebeskyttet agent for å utforske kodebaser. Kan ikke endre filer. --- +### Bruk av Scout + +_Modus_: `subagent` + +En skrivebeskyttet agent for ekstern dokumentasjon og forskning på avhengigheter. Bruk denne når du trenger å klone et avhengighetsrepo inn i OpenCode sin administrerte cache, inspisere kildekoden til et bibliotek eller kryssjekke lokal kode mot upstream-implementasjoner uten å endre arbeidsområdet ditt. + +--- + ### Bruk av Compaction _Modus_: `primary` diff --git a/packages/web/src/content/docs/pl/agents.mdx b/packages/web/src/content/docs/pl/agents.mdx index 7a4d7a9960..8cf9561e16 100644 --- a/packages/web/src/content/docs/pl/agents.mdx +++ b/packages/web/src/content/docs/pl/agents.mdx @@ -35,13 +35,13 @@ OpenCode zawiera dwa wbudowane agenty główne: **Build** i **Plan**. Przyjrzymy Subagenci to asystenci pomocniczy, których mogą przywoływać agenci główni w celu wykonania konkretnych zadań. Możesz także wywoływać ich ręcznie, **wzmiankując ich (@)** w swoich wiadomościach. -OpenCode ma dwóch wbudowanych subagentów: **General** i **Explore**. Przyjrzymy się im poniżej. +OpenCode ma trzech wbudowanych subagentów: **General**, **Explore** i **Scout**. Przyjrzymy się im poniżej. --- ## Wbudowane -OpenCode ma dwa wbudowane agenty główne i dwa wbudowane subagenty. +OpenCode ma dwa wbudowane agenty główne i trzech wbudowanych subagentów. --- @@ -83,6 +83,14 @@ Szybki agent tylko do odczytu do eksploracji baz kodu. Nie może modyfikować pl --- +### Scout + +_Mode_: `subagent` + +Agent tylko do odczytu do pracy z zewnętrzną dokumentacją i badaniem zależności. Używaj go, gdy chcesz sklonować repozytorium zależności do zarządzanej pamięci podręcznej OpenCode, przejrzeć kod źródłowy biblioteki albo porównać lokalny kod z implementacjami upstream bez modyfikowania swojego workspace. + +--- + ### Compaction _Mode_: `primary` diff --git a/packages/web/src/content/docs/pt-br/agents.mdx b/packages/web/src/content/docs/pt-br/agents.mdx index 9a831e8048..815264d840 100644 --- a/packages/web/src/content/docs/pt-br/agents.mdx +++ b/packages/web/src/content/docs/pt-br/agents.mdx @@ -36,13 +36,13 @@ ver isso abaixo. Subagentes são assistentes especializados que agentes primários podem invocar para tarefas específicas. Você também pode invocá-los manualmente mencionando-os com **@** em suas mensagens. -opencode vem com dois subagentes integrados, **General** e **Explore**. Vamos ver isso abaixo. +OpenCode vem com três subagentes integrados, **General**, **Explore** e **Scout**. Vamos ver isso abaixo. --- ## Integrados -opencode vem com dois agentes primários integrados e dois subagentes integrados. +OpenCode vem com dois agentes primários integrados e três subagentes integrados. --- @@ -84,6 +84,14 @@ Um agente rápido e somente leitura para explorar bases de código. Não pode mo --- +### Scout + +_Modo_: `subagent` + +Um agente somente leitura para pesquisa em documentação externa e dependências. Use-o quando você precisar clonar o repositório de uma dependência para o cache gerenciado do OpenCode, inspecionar o código-fonte de uma biblioteca ou cruzar o código local com implementações upstream sem modificar seu workspace. + +--- + ### compaction _Modo_: `primary` diff --git a/packages/web/src/content/docs/ru/agents.mdx b/packages/web/src/content/docs/ru/agents.mdx index f515c15d7b..767cbf862f 100644 --- a/packages/web/src/content/docs/ru/agents.mdx +++ b/packages/web/src/content/docs/ru/agents.mdx @@ -35,13 +35,13 @@ opencode поставляется с двумя встроенными осно Субагенты — это специализированные помощники, которых основные агенты могут вызывать для выполнения определенных задач. Вы также можете вызвать их вручную, **@ упомянув** их в своих сообщениях. -opencode поставляется с двумя встроенными субагентами: **General** и **Explore**. Мы рассмотрим это ниже. +OpenCode поставляется с тремя встроенными субагентами: **General**, **Explore** и **Scout**. Мы рассмотрим их ниже. --- ## Встроенные агенты -opencode поставляется с двумя встроенными основными агентами и двумя встроенными субагентами. +OpenCode поставляется с двумя встроенными основными агентами и тремя встроенными субагентами. --- @@ -83,6 +83,14 @@ _Режим_: `subagent` --- +### Использование Scout + +_Режим_: `subagent` + +Агент только для чтения для работы с внешней документацией и исследования зависимостей. Используйте его, когда нужно клонировать репозиторий зависимости в управляемый кэш OpenCode, изучить исходный код библиотеки или сверить локальный код с upstream-реализациями без изменений в рабочем пространстве. + +--- + ### Использование Compact _Режим_: `primary` diff --git a/packages/web/src/content/docs/th/agents.mdx b/packages/web/src/content/docs/th/agents.mdx index 567125aced..e37df6ce47 100644 --- a/packages/web/src/content/docs/th/agents.mdx +++ b/packages/web/src/content/docs/th/agents.mdx @@ -36,13 +36,13 @@ OpenCode มีเอเจนต์หลักในตัวได้แก Subagent คือผู้ช่วยเฉพาะทางที่ Primary Agent สามารถเรียกใช้งานได้ หรือคุณสามารถเรียกใช้โดยตรงโดยพิมพ์ **@** ตามด้วยชื่อเอเจนต์ในข้อความของคุณ -OpenCode มี subagent ในตัวได้แก่ **General** และ **Explore** +OpenCode มี subagent ในตัวได้แก่ **General**, **Explore** และ **Scout** ดูรายละเอียดด้านล่าง --- ## บิวท์อิน -OpenCode มาพร้อมกับเอเจนต์หลักและ subagent ในตัวดังนี้ +OpenCode มาพร้อมกับเอเจนต์หลัก 2 ตัวและ subagent ในตัว 3 ตัว --- @@ -84,6 +84,14 @@ _Mode_: `subagent` --- +### Scout + +_Mode_: `subagent` + +เอเจนต์แบบอ่านอย่างเดียวสำหรับค้นคว้าเอกสารภายนอกและ dependency ใช้สิ่งนี้เมื่อคุณต้องการ clone repository ของ dependency เข้าไปใน cache ที่ OpenCode จัดการให้, ตรวจสอบ source code ของไลบรารี, หรือเทียบโค้ดในเครื่องกับ implementation จาก upstream โดยไม่แก้ไข workspace ของคุณ + +--- + ### Compact _Mode_: `primary` diff --git a/packages/web/src/content/docs/tr/agents.mdx b/packages/web/src/content/docs/tr/agents.mdx index 1f582511be..c523b2b3bf 100644 --- a/packages/web/src/content/docs/tr/agents.mdx +++ b/packages/web/src/content/docs/tr/agents.mdx @@ -35,13 +35,13 @@ opencode, **Build** ve **Plan** olmak üzere iki yerleşik birincil agent ile bi Alt agent'lar, birincil agent'ların belirli görevler için çağırabileceği uzman yardımcılardır. Ayrıca mesajlarınızda **@ bahsederek** bunları manuel olarak da çağırabilirsiniz. -opencode, **General** ve **Explore** olmak üzere iki yerleşik alt agent ile birlikte gelir. Buna aşağıda bakacağız. +OpenCode, **General**, **Explore** ve **Scout** olmak üzere üç yerleşik alt agent ile birlikte gelir. Buna aşağıda bakacağız. --- ## Yerleşik -opencode iki yerleşik birincil agent ve iki yerleşik alt agent ile birlikte gelir. +OpenCode iki yerleşik birincil agent ve üç yerleşik alt agent ile birlikte gelir. --- @@ -83,6 +83,14 @@ Kod tabanlarını keşfetmeye yönelik hızlı, salt okunur bir agent. Dosyalar --- +### Scout Kullanımı + +_Mod_: `subagent` + +Harici dokümanlar ve bağımlılık araştırmaları için salt okunur bir agent. Bir bağımlılık repository'sini OpenCode'un yönetilen cache'ine clone etmeniz, kütüphane kaynak kodunu incelemeniz veya workspace'inizi değiştirmeden yerel kodu upstream implementasyonlarla karşılaştırmanız gerektiğinde bunu kullanın. + +--- + ### Compaction Kullanımı _Mod_: `primary` diff --git a/packages/web/src/content/docs/zh-cn/agents.mdx b/packages/web/src/content/docs/zh-cn/agents.mdx index 2087c68366..6f821ff7f8 100644 --- a/packages/web/src/content/docs/zh-cn/agents.mdx +++ b/packages/web/src/content/docs/zh-cn/agents.mdx @@ -35,13 +35,13 @@ OpenCode 内置了两个主代理:**Build** 和 **Plan**。我们将在下面 子代理是主代理可以调用来执行特定任务的专业助手。您也可以通过在消息中 **@ 提及**它们来手动调用。 -OpenCode 内置了两个子代理:**General** 和 **Explore**。我们将在下面介绍它们。 +OpenCode 内置了三个子代理:**General**、**Explore** 和 **Scout**。我们将在下面介绍它们。 --- ## 内置代理 -OpenCode 内置了两个主代理和两个子代理。 +OpenCode 内置了两个主代理和三个子代理。 --- @@ -83,6 +83,14 @@ _模式_:`subagent` --- +### 使用 Scout + +_模式_:`subagent` + +一个用于外部文档和依赖研究的只读代理。当您需要将某个依赖仓库克隆到 OpenCode 的托管缓存中、检查库的源代码,或在不修改工作区的情况下将本地代码与 upstream 实现进行交叉对照时,请使用此代理。 + +--- + ### 使用 Compaction _模式_:`primary` diff --git a/packages/web/src/content/docs/zh-tw/agents.mdx b/packages/web/src/content/docs/zh-tw/agents.mdx index fa8f102543..a9c7bbadbf 100644 --- a/packages/web/src/content/docs/zh-tw/agents.mdx +++ b/packages/web/src/content/docs/zh-tw/agents.mdx @@ -35,13 +35,13 @@ OpenCode 內建了兩個主代理:**Build** 和 **Plan**。我們將在下面 子代理是主代理可以呼叫來執行特定任務的專業助手。您也可以透過在訊息中 **@ 提及**它們來手動呼叫。 -OpenCode 內建了兩個子代理:**General** 和 **Explore**。我們將在下面介紹它們。 +OpenCode 內建了三個子代理:**General**、**Explore** 和 **Scout**。我們將在下面介紹它們。 --- ## 內建代理 -OpenCode 內建了兩個主代理和兩個子代理。 +OpenCode 內建了兩個主代理和三個子代理。 --- @@ -83,6 +83,14 @@ _模式_:`subagent` --- +### 使用 Scout + +_模式_:`subagent` + +一個用於外部文件與依賴研究的唯讀代理。當您需要將某個依賴儲存庫 clone 到 OpenCode 的託管快取中、檢查函式庫的原始碼,或在不修改工作區的情況下將本機程式碼與 upstream 實作交叉比對時,請使用此代理。 + +--- + ### 使用 Compaction _模式_:`primary` From dc7d665e94cfa66cacf2c3b3d934e8822669d712 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Fri, 8 May 2026 20:22:22 +0000 Subject: [PATCH 017/189] chore: generate --- packages/opencode/src/tool/repo_overview.ts | 73 ++++++++++++++++----- packages/opencode/src/util/repository.ts | 6 +- packages/sdk/js/src/v2/gen/types.gen.ts | 6 -- packages/sdk/openapi.json | 53 +++++++++++++++ 4 files changed, 114 insertions(+), 24 deletions(-) diff --git a/packages/opencode/src/tool/repo_overview.ts b/packages/opencode/src/tool/repo_overview.ts index bb788dc084..b08516d2c6 100644 --- a/packages/opencode/src/tool/repo_overview.ts +++ b/packages/opencode/src/tool/repo_overview.ts @@ -17,7 +17,7 @@ export const Parameters = Schema.Struct({ }), depth: Schema.optional(Schema.Number).annotate({ description: "Maximum structure depth to include. Defaults to 3.", - }) + }), }) type Metadata = { @@ -33,7 +33,17 @@ type Metadata = { truncated: boolean } -const IGNORED_DIRS = new Set([".git", "node_modules", "__pycache__", ".venv", "dist", "build", ".next", "target", "vendor"]) +const IGNORED_DIRS = new Set([ + ".git", + "node_modules", + "__pycache__", + ".venv", + "dist", + "build", + ".next", + "target", + "vendor", +]) const STRUCTURE_LIMIT = 200 const DEPENDENCY_FILES = [ "package.json", @@ -73,7 +83,19 @@ function ecosystems(files: Set) { } function commonEntrypoints(files: Set) { - return ["index.ts", "index.tsx", "index.js", "index.mjs", "main.ts", "main.js", "src/index.ts", "src/index.tsx", "src/index.js", "src/main.ts", "src/main.js"].filter((file) => files.has(file)) + return [ + "index.ts", + "index.tsx", + "index.js", + "index.mjs", + "main.ts", + "main.js", + "src/index.ts", + "src/index.tsx", + "src/index.js", + "src/main.ts", + "src/main.js", + ].filter((file) => files.has(file)) } export const RepoOverviewTool = Tool.define( @@ -82,7 +104,9 @@ export const RepoOverviewTool = Tool.define) { + const resolveTarget = Effect.fn("RepoOverviewTool.resolveTarget")(function* ( + params: Schema.Schema.Type, + ) { if (params.path) { const full = path.isAbsolute(params.path) ? params.path : path.resolve(Instance.directory, params.path) return { path: full, repository: params.repository } @@ -104,7 +128,10 @@ export const RepoOverviewTool = Tool.define Effect.Effect = Effect.fnUntraced(function* (dir: string, level: number) { + const visit: (dir: string, level: number) => Effect.Effect = Effect.fnUntraced(function* ( + dir: string, + level: number, + ) { if (level >= depth || lines.length >= STRUCTURE_LIMIT) { truncated = truncated || lines.length >= STRUCTURE_LIMIT return @@ -150,7 +177,8 @@ export const RepoOverviewTool = Tool.define, ctx: Tool.Context) => Effect.gen(function* () { const target = yield* resolveTarget(params) - const depth = !params.depth || !Number.isInteger(params.depth) || params.depth < 1 || params.depth > 6 ? 3 : params.depth + const depth = + !params.depth || !Number.isInteger(params.depth) || params.depth < 1 || params.depth > 6 ? 3 : params.depth yield* assertExternalDirectoryEffect(ctx, target.path, { kind: "directory" }) yield* ctx.ask({ @@ -166,7 +194,8 @@ export const RepoOverviewTool = Tool.define Effect.succeed(undefined))) if (!info) { - if (target.repository) throw new Error(`Repository is not cloned: ${target.repository}. Use repo_clone first.`) + if (target.repository) + throw new Error(`Repository is not cloned: ${target.repository}. Use repo_clone first.`) throw new Error(`Directory not found: ${target.path}`) } if (info.type !== "Directory") throw new Error(`Path is not a directory: ${target.path}`) @@ -175,7 +204,9 @@ export const RepoOverviewTool = Tool.define entry.name)) const dependencyFiles = DEPENDENCY_FILES.filter((file) => topLevel.has(file)) const packageJson = topLevel.has("package.json") - ? (yield* fs.readJson(path.join(target.path, "package.json")).pipe(Effect.orElseSucceed(() => ({})))) as Record + ? ((yield* fs + .readJson(path.join(target.path, "package.json")) + .pipe(Effect.orElseSucceed(() => ({})))) as Record) : {} const entrypoints = [ @@ -187,16 +218,20 @@ export const RepoOverviewTool = Tool.define).map((name) => `bin: ${name}`) : []), ...(packageJson.exports && typeof packageJson.exports === "object" && !Array.isArray(packageJson.exports) - ? Object.keys(packageJson.exports as Record).slice(0, 10).map((name) => `exports: ${name}`) + ? Object.keys(packageJson.exports as Record) + .slice(0, 10) + .map((name) => `exports: ${name}`) : []), ] - const common = commonEntrypoints(new Set([ - ...topLevel, - ...entries - .filter((entry) => entry.name === "src") - .flatMap(() => ["src/index.ts", "src/index.tsx", "src/index.js", "src/main.ts", "src/main.js"]), - ])) + const common = commonEntrypoints( + new Set([ + ...topLevel, + ...entries + .filter((entry) => entry.name === "src") + .flatMap(() => ["src/index.ts", "src/index.tsx", "src/index.js", "src/main.ts", "src/main.js"]), + ]), + ) const structureResult = yield* structure(target.path, depth) const branch = yield* git.branch(target.path) const head = yield* git.run(["rev-parse", "HEAD"], { cwd: target.path }) @@ -225,8 +260,12 @@ export const RepoOverviewTool = Tool.define `- ${entry}`)] : []), + ...(metadata.dependency_files.length + ? [`Dependency files: ${metadata.dependency_files.join(", ")}`] + : []), + ...(metadata.entrypoints.length + ? ["Likely entrypoints:", ...metadata.entrypoints.map((entry) => `- ${entry}`)] + : []), "Top-level structure:", ...structureResult.lines, ...(structureResult.truncated ? ["(Structure truncated)"] : []), diff --git a/packages/opencode/src/util/repository.ts b/packages/opencode/src/util/repository.ts index 279e5af0e4..c7298494f5 100644 --- a/packages/opencode/src/util/repository.ts +++ b/packages/opencode/src/util/repository.ts @@ -14,7 +14,11 @@ export type Reference = { } function normalize(input: string) { - return input.trim().replace(/^git\+/, "").replace(/#.*$/, "").replace(/\/+$/, "") + return input + .trim() + .replace(/^git\+/, "") + .replace(/#.*$/, "") + .replace(/\/+$/, "") } function trimGitSuffix(input: string) { diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index ba1b671c64..7ddaea0a25 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -908,9 +908,6 @@ export type ReferenceConfigEntry = * Git repository URL, host/path reference, or GitHub owner/repo shorthand */ repository: string - /** - * Branch or ref Scout should clone and inspect - */ branch?: string } | { @@ -920,9 +917,6 @@ export type ReferenceConfigEntry = path: string } -/** - * Named git or local directory references that can be @ mentioned as Scout-backed subagents - */ export type ReferenceConfig = { [key: string]: ReferenceConfigEntry } diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 6cb4ce1d5a..23fb1e8414 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -11311,6 +11311,44 @@ "additionalProperties": false, "description": "Server configuration for opencode serve and web commands" }, + "ReferenceConfigEntry": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "repository": { + "type": "string", + "description": "Git repository URL, host/path reference, or GitHub owner/repo shorthand" + }, + "branch": { + "type": "string" + } + }, + "required": ["repository"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "Absolute path, ~/ path, or workspace-relative path to a local reference directory" + } + }, + "required": ["path"], + "additionalProperties": false + } + ] + }, + "ReferenceConfig": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/ReferenceConfigEntry" + } + }, "PermissionActionConfig": { "type": "string", "enum": ["ask", "allow", "deny"] @@ -11375,6 +11413,15 @@ "websearch": { "$ref": "#/components/schemas/PermissionActionConfig" }, + "codesearch": { + "$ref": "#/components/schemas/PermissionActionConfig" + }, + "repo_clone": { + "$ref": "#/components/schemas/PermissionRuleConfig" + }, + "repo_overview": { + "$ref": "#/components/schemas/PermissionRuleConfig" + }, "lsp": { "$ref": "#/components/schemas/PermissionRuleConfig" }, @@ -11851,6 +11898,9 @@ }, "additionalProperties": false }, + "reference": { + "$ref": "#/components/schemas/ReferenceConfig" + }, "watcher": { "type": "object", "properties": { @@ -11961,6 +12011,9 @@ "explore": { "$ref": "#/components/schemas/AgentConfig" }, + "scout": { + "$ref": "#/components/schemas/AgentConfig" + }, "title": { "$ref": "#/components/schemas/AgentConfig" }, From 5bb7b2344071865886b5a0a1bb19426cc6d753c8 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 8 May 2026 16:56:20 -0400 Subject: [PATCH 018/189] Add native LLM core foundation (#24712) --- .gitignore | 1 + .gitleaksignore | 5 + bun.lock | 43 ++ packages/console/app/package.json | 1 + packages/console/app/tsconfig.json | 2 +- packages/enterprise/package.json | 1 + packages/enterprise/tsconfig.json | 2 +- packages/http-recorder/package.json | 26 + packages/http-recorder/src/cassette.ts | 105 ++++ packages/http-recorder/src/diff.ts | 95 +++ packages/http-recorder/src/effect.ts | 211 +++++++ packages/http-recorder/src/index.ts | 10 + packages/http-recorder/src/matching.ts | 36 ++ packages/http-recorder/src/redaction.ts | 112 ++++ packages/http-recorder/src/schema.ts | 67 ++ packages/http-recorder/src/storage.ts | 34 + packages/http-recorder/src/websocket.ts | 204 ++++++ .../recordings/record-replay/multi-step.json | 41 ++ .../recordings/record-replay/retry.json | 41 ++ .../http-recorder/test/record-replay.test.ts | 322 ++++++++++ packages/http-recorder/tsconfig.json | 15 + packages/llm/AGENTS.md | 294 +++++++++ packages/llm/example/tutorial.ts | 242 +++++++ packages/llm/package.json | 51 ++ packages/llm/script/recording-cost-report.ts | 250 ++++++++ packages/llm/script/setup-recording-env.ts | 537 ++++++++++++++++ packages/llm/src/index.ts | 35 ++ packages/llm/src/llm.ts | 224 +++++++ .../llm/src/protocols/anthropic-messages.ts | 592 ++++++++++++++++++ .../llm/src/protocols/bedrock-converse.ts | 531 ++++++++++++++++ .../llm/src/protocols/bedrock-event-stream.ts | 87 +++ packages/llm/src/protocols/gemini.ts | 397 ++++++++++++ packages/llm/src/protocols/index.ts | 6 + packages/llm/src/protocols/openai-chat.ts | 404 ++++++++++++ .../src/protocols/openai-compatible-chat.ts | 28 + .../llm/src/protocols/openai-responses.ts | 575 +++++++++++++++++ packages/llm/src/protocols/shared.ts | 203 ++++++ .../llm/src/protocols/utils/bedrock-auth.ts | 103 +++ .../llm/src/protocols/utils/bedrock-cache.ts | 20 + .../llm/src/protocols/utils/bedrock-media.ts | 80 +++ .../src/protocols/utils/gemini-tool-schema.ts | 101 +++ .../llm/src/protocols/utils/openai-options.ts | 55 ++ .../llm/src/protocols/utils/tool-stream.ts | 196 ++++++ packages/llm/src/provider.ts | 31 + packages/llm/src/providers/amazon-bedrock.ts | 48 ++ packages/llm/src/providers/anthropic.ts | 16 + packages/llm/src/providers/azure.ts | 83 +++ packages/llm/src/providers/cloudflare.ts | 139 ++++ packages/llm/src/providers/github-copilot.ts | 48 ++ packages/llm/src/providers/google.ts | 16 + packages/llm/src/providers/index.ts | 10 + .../providers/openai-compatible-profile.ts | 20 + .../llm/src/providers/openai-compatible.ts | 61 ++ packages/llm/src/providers/openai-options.ts | 70 +++ packages/llm/src/providers/openai.ts | 53 ++ packages/llm/src/providers/openrouter.ts | 88 +++ packages/llm/src/providers/xai.ts | 52 ++ packages/llm/src/route/auth-options.ts | 57 ++ packages/llm/src/route/auth.ts | 196 ++++++ packages/llm/src/route/client.ts | 528 ++++++++++++++++ packages/llm/src/route/endpoint.ts | 41 ++ packages/llm/src/route/executor.ts | 374 +++++++++++ packages/llm/src/route/framing.ts | 27 + packages/llm/src/route/index.ts | 26 + packages/llm/src/route/protocol.ts | 84 +++ packages/llm/src/route/transport/http.ts | 122 ++++ packages/llm/src/route/transport/index.ts | 22 + packages/llm/src/route/transport/websocket.ts | 282 +++++++++ packages/llm/src/schema/errors.ts | 202 ++++++ packages/llm/src/schema/events.ts | 237 +++++++ packages/llm/src/schema/ids.ts | 34 + packages/llm/src/schema/index.ts | 5 + packages/llm/src/schema/messages.ts | 234 +++++++ packages/llm/src/schema/options.ts | 202 ++++++ packages/llm/src/tool-runtime.ts | 240 +++++++ packages/llm/src/tool.ts | 185 ++++++ packages/llm/test/adapter.test.ts | 175 ++++++ packages/llm/test/auth-options.types.ts | 100 +++ packages/llm/test/auth.test.ts | 101 +++ packages/llm/test/endpoint.test.ts | 57 ++ packages/llm/test/executor.test.ts | 416 ++++++++++++ packages/llm/test/exports.test.ts | 56 ++ ...sistant-tool-order-with-default-patch.json | 29 + .../claude-opus-4-7-drives-a-tool-loop.json | 56 ++ ...ed-assistant-tool-order-without-patch.json | 29 + .../anthropic-messages/streams-text.json | 29 + .../anthropic-messages/streams-tool-call.json | 29 + .../bedrock-converse/drives-a-tool-loop.json | 55 ++ .../bedrock-converse/streams-a-tool-call.json | 29 + .../bedrock-converse/streams-text.json | 29 + ...orkers-ai-gpt-oss-20b-tools-tool-call.json | 38 ++ ...-gateway-workers-ai-llama-3-1-8b-text.json | 37 ++ ...orkers-ai-gpt-oss-20b-tools-tool-call.json | 38 ++ ...oudflare-workers-ai-llama-3-1-8b-text.json | 37 ++ .../recordings/gemini/streams-text.json | 28 + .../recordings/gemini/streams-tool-call.json | 28 + .../continues-after-tool-result.json | 28 + .../drives-a-tool-loop-end-to-end.json | 46 ++ .../recordings/openai-chat/streams-text.json | 28 + .../openai-chat/streams-tool-call.json | 28 + .../deepseek-streams-text.json | 28 + ...groq-llama-3-3-70b-drives-a-tool-loop.json | 53 ++ .../groq-streams-text.json | 28 + .../groq-streams-tool-call.json | 28 + ...er-claude-opus-4-7-drives-a-tool-loop.json | 54 ++ ...router-gpt-4o-mini-drives-a-tool-loop.json | 53 ++ ...openrouter-gpt-5-5-drives-a-tool-loop.json | 54 ++ .../openrouter-streams-text.json | 28 + .../openrouter-streams-tool-call.json | 28 + .../togetherai-streams-text.json | 28 + .../togetherai-streams-tool-call.json | 28 + .../gpt-5-5-drives-a-tool-loop.json | 54 ++ .../gpt-5-5-streams-text.json | 28 + .../gpt-5-5-streams-tool-call.json | 28 + packages/llm/test/generate-object.test.ts | 182 ++++++ packages/llm/test/lib/effect.ts | 50 ++ packages/llm/test/lib/http.ts | 96 +++ packages/llm/test/lib/openai-chunks.ts | 27 + packages/llm/test/lib/sse.ts | 17 + packages/llm/test/lib/tool-runtime.ts | 9 + packages/llm/test/llm.test.ts | 135 ++++ packages/llm/test/provider.types.ts | 39 ++ .../anthropic-messages.recorded.test.ts | 46 ++ .../test/provider/anthropic-messages.test.ts | 377 +++++++++++ .../test/provider/bedrock-converse.test.ts | 533 ++++++++++++++++ packages/llm/test/provider/cloudflare.test.ts | 232 +++++++ packages/llm/test/provider/gemini.test.ts | 360 +++++++++++ .../llm/test/provider/golden.recorded.test.ts | 215 +++++++ .../llm/test/provider/openai-chat.test.ts | 355 +++++++++++ .../provider/openai-compatible-chat.test.ts | 237 +++++++ .../test/provider/openai-responses.test.ts | 549 ++++++++++++++++ packages/llm/test/provider/openrouter.test.ts | 56 ++ packages/llm/test/recorded-golden.ts | 103 +++ packages/llm/test/recorded-runner.ts | 100 +++ packages/llm/test/recorded-scenarios.ts | 265 ++++++++ packages/llm/test/recorded-test.ts | 76 +++ packages/llm/test/recorded-utils.ts | 56 ++ packages/llm/test/recorded-websocket.ts | 27 + packages/llm/test/schema.test.ts | 58 ++ packages/llm/test/tool-runtime.test.ts | 454 ++++++++++++++ packages/llm/test/tool-stream.test.ts | 88 +++ packages/llm/test/tool.types.ts | 29 + packages/llm/tsconfig.json | 15 + turbo.json | 4 + 144 files changed, 17052 insertions(+), 2 deletions(-) create mode 100644 .gitleaksignore create mode 100644 packages/http-recorder/package.json create mode 100644 packages/http-recorder/src/cassette.ts create mode 100644 packages/http-recorder/src/diff.ts create mode 100644 packages/http-recorder/src/effect.ts create mode 100644 packages/http-recorder/src/index.ts create mode 100644 packages/http-recorder/src/matching.ts create mode 100644 packages/http-recorder/src/redaction.ts create mode 100644 packages/http-recorder/src/schema.ts create mode 100644 packages/http-recorder/src/storage.ts create mode 100644 packages/http-recorder/src/websocket.ts create mode 100644 packages/http-recorder/test/fixtures/recordings/record-replay/multi-step.json create mode 100644 packages/http-recorder/test/fixtures/recordings/record-replay/retry.json create mode 100644 packages/http-recorder/test/record-replay.test.ts create mode 100644 packages/http-recorder/tsconfig.json create mode 100644 packages/llm/AGENTS.md create mode 100644 packages/llm/example/tutorial.ts create mode 100644 packages/llm/package.json create mode 100644 packages/llm/script/recording-cost-report.ts create mode 100644 packages/llm/script/setup-recording-env.ts create mode 100644 packages/llm/src/index.ts create mode 100644 packages/llm/src/llm.ts create mode 100644 packages/llm/src/protocols/anthropic-messages.ts create mode 100644 packages/llm/src/protocols/bedrock-converse.ts create mode 100644 packages/llm/src/protocols/bedrock-event-stream.ts create mode 100644 packages/llm/src/protocols/gemini.ts create mode 100644 packages/llm/src/protocols/index.ts create mode 100644 packages/llm/src/protocols/openai-chat.ts create mode 100644 packages/llm/src/protocols/openai-compatible-chat.ts create mode 100644 packages/llm/src/protocols/openai-responses.ts create mode 100644 packages/llm/src/protocols/shared.ts create mode 100644 packages/llm/src/protocols/utils/bedrock-auth.ts create mode 100644 packages/llm/src/protocols/utils/bedrock-cache.ts create mode 100644 packages/llm/src/protocols/utils/bedrock-media.ts create mode 100644 packages/llm/src/protocols/utils/gemini-tool-schema.ts create mode 100644 packages/llm/src/protocols/utils/openai-options.ts create mode 100644 packages/llm/src/protocols/utils/tool-stream.ts create mode 100644 packages/llm/src/provider.ts create mode 100644 packages/llm/src/providers/amazon-bedrock.ts create mode 100644 packages/llm/src/providers/anthropic.ts create mode 100644 packages/llm/src/providers/azure.ts create mode 100644 packages/llm/src/providers/cloudflare.ts create mode 100644 packages/llm/src/providers/github-copilot.ts create mode 100644 packages/llm/src/providers/google.ts create mode 100644 packages/llm/src/providers/index.ts create mode 100644 packages/llm/src/providers/openai-compatible-profile.ts create mode 100644 packages/llm/src/providers/openai-compatible.ts create mode 100644 packages/llm/src/providers/openai-options.ts create mode 100644 packages/llm/src/providers/openai.ts create mode 100644 packages/llm/src/providers/openrouter.ts create mode 100644 packages/llm/src/providers/xai.ts create mode 100644 packages/llm/src/route/auth-options.ts create mode 100644 packages/llm/src/route/auth.ts create mode 100644 packages/llm/src/route/client.ts create mode 100644 packages/llm/src/route/endpoint.ts create mode 100644 packages/llm/src/route/executor.ts create mode 100644 packages/llm/src/route/framing.ts create mode 100644 packages/llm/src/route/index.ts create mode 100644 packages/llm/src/route/protocol.ts create mode 100644 packages/llm/src/route/transport/http.ts create mode 100644 packages/llm/src/route/transport/index.ts create mode 100644 packages/llm/src/route/transport/websocket.ts create mode 100644 packages/llm/src/schema/errors.ts create mode 100644 packages/llm/src/schema/events.ts create mode 100644 packages/llm/src/schema/ids.ts create mode 100644 packages/llm/src/schema/index.ts create mode 100644 packages/llm/src/schema/messages.ts create mode 100644 packages/llm/src/schema/options.ts create mode 100644 packages/llm/src/tool-runtime.ts create mode 100644 packages/llm/src/tool.ts create mode 100644 packages/llm/test/adapter.test.ts create mode 100644 packages/llm/test/auth-options.types.ts create mode 100644 packages/llm/test/auth.test.ts create mode 100644 packages/llm/test/endpoint.test.ts create mode 100644 packages/llm/test/executor.test.ts create mode 100644 packages/llm/test/exports.test.ts create mode 100644 packages/llm/test/fixtures/recordings/anthropic-messages/accepts-malformed-assistant-tool-order-with-default-patch.json create mode 100644 packages/llm/test/fixtures/recordings/anthropic-messages/claude-opus-4-7-drives-a-tool-loop.json create mode 100644 packages/llm/test/fixtures/recordings/anthropic-messages/rejects-malformed-assistant-tool-order-without-patch.json create mode 100644 packages/llm/test/fixtures/recordings/anthropic-messages/streams-text.json create mode 100644 packages/llm/test/fixtures/recordings/anthropic-messages/streams-tool-call.json create mode 100644 packages/llm/test/fixtures/recordings/bedrock-converse/drives-a-tool-loop.json create mode 100644 packages/llm/test/fixtures/recordings/bedrock-converse/streams-a-tool-call.json create mode 100644 packages/llm/test/fixtures/recordings/bedrock-converse/streams-text.json create mode 100644 packages/llm/test/fixtures/recordings/cloudflare-ai-gateway/cloudflare-ai-gateway-workers-ai-gpt-oss-20b-tools-tool-call.json create mode 100644 packages/llm/test/fixtures/recordings/cloudflare-ai-gateway/cloudflare-ai-gateway-workers-ai-llama-3-1-8b-text.json create mode 100644 packages/llm/test/fixtures/recordings/cloudflare-workers-ai/cloudflare-workers-ai-gpt-oss-20b-tools-tool-call.json create mode 100644 packages/llm/test/fixtures/recordings/cloudflare-workers-ai/cloudflare-workers-ai-llama-3-1-8b-text.json create mode 100644 packages/llm/test/fixtures/recordings/gemini/streams-text.json create mode 100644 packages/llm/test/fixtures/recordings/gemini/streams-tool-call.json create mode 100644 packages/llm/test/fixtures/recordings/openai-chat/continues-after-tool-result.json create mode 100644 packages/llm/test/fixtures/recordings/openai-chat/drives-a-tool-loop-end-to-end.json create mode 100644 packages/llm/test/fixtures/recordings/openai-chat/streams-text.json create mode 100644 packages/llm/test/fixtures/recordings/openai-chat/streams-tool-call.json create mode 100644 packages/llm/test/fixtures/recordings/openai-compatible-chat/deepseek-streams-text.json create mode 100644 packages/llm/test/fixtures/recordings/openai-compatible-chat/groq-llama-3-3-70b-drives-a-tool-loop.json create mode 100644 packages/llm/test/fixtures/recordings/openai-compatible-chat/groq-streams-text.json create mode 100644 packages/llm/test/fixtures/recordings/openai-compatible-chat/groq-streams-tool-call.json create mode 100644 packages/llm/test/fixtures/recordings/openai-compatible-chat/openrouter-claude-opus-4-7-drives-a-tool-loop.json create mode 100644 packages/llm/test/fixtures/recordings/openai-compatible-chat/openrouter-gpt-4o-mini-drives-a-tool-loop.json create mode 100644 packages/llm/test/fixtures/recordings/openai-compatible-chat/openrouter-gpt-5-5-drives-a-tool-loop.json create mode 100644 packages/llm/test/fixtures/recordings/openai-compatible-chat/openrouter-streams-text.json create mode 100644 packages/llm/test/fixtures/recordings/openai-compatible-chat/openrouter-streams-tool-call.json create mode 100644 packages/llm/test/fixtures/recordings/openai-compatible-chat/togetherai-streams-text.json create mode 100644 packages/llm/test/fixtures/recordings/openai-compatible-chat/togetherai-streams-tool-call.json create mode 100644 packages/llm/test/fixtures/recordings/openai-responses/gpt-5-5-drives-a-tool-loop.json create mode 100644 packages/llm/test/fixtures/recordings/openai-responses/gpt-5-5-streams-text.json create mode 100644 packages/llm/test/fixtures/recordings/openai-responses/gpt-5-5-streams-tool-call.json create mode 100644 packages/llm/test/generate-object.test.ts create mode 100644 packages/llm/test/lib/effect.ts create mode 100644 packages/llm/test/lib/http.ts create mode 100644 packages/llm/test/lib/openai-chunks.ts create mode 100644 packages/llm/test/lib/sse.ts create mode 100644 packages/llm/test/lib/tool-runtime.ts create mode 100644 packages/llm/test/llm.test.ts create mode 100644 packages/llm/test/provider.types.ts create mode 100644 packages/llm/test/provider/anthropic-messages.recorded.test.ts create mode 100644 packages/llm/test/provider/anthropic-messages.test.ts create mode 100644 packages/llm/test/provider/bedrock-converse.test.ts create mode 100644 packages/llm/test/provider/cloudflare.test.ts create mode 100644 packages/llm/test/provider/gemini.test.ts create mode 100644 packages/llm/test/provider/golden.recorded.test.ts create mode 100644 packages/llm/test/provider/openai-chat.test.ts create mode 100644 packages/llm/test/provider/openai-compatible-chat.test.ts create mode 100644 packages/llm/test/provider/openai-responses.test.ts create mode 100644 packages/llm/test/provider/openrouter.test.ts create mode 100644 packages/llm/test/recorded-golden.ts create mode 100644 packages/llm/test/recorded-runner.ts create mode 100644 packages/llm/test/recorded-scenarios.ts create mode 100644 packages/llm/test/recorded-test.ts create mode 100644 packages/llm/test/recorded-utils.ts create mode 100644 packages/llm/test/recorded-websocket.ts create mode 100644 packages/llm/test/schema.test.ts create mode 100644 packages/llm/test/tool-runtime.test.ts create mode 100644 packages/llm/test/tool-stream.test.ts create mode 100644 packages/llm/test/tool.types.ts create mode 100644 packages/llm/tsconfig.json diff --git a/.gitignore b/.gitignore index 52a5a04596..19198a7a59 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ node_modules .worktrees .sst .env +.env.local .idea .vscode .codex diff --git a/.gitleaksignore b/.gitleaksignore new file mode 100644 index 0000000000..cc01a286fb --- /dev/null +++ b/.gitleaksignore @@ -0,0 +1,5 @@ +# Fake secret-looking strings used by HTTP recorder redaction tests. +afa57acfda894e0ebf3c637dd710310b705c0a2f:packages/http-recorder/test/record-replay.test.ts:generic-api-key:69 +afa57acfda894e0ebf3c637dd710310b705c0a2f:packages/http-recorder/test/record-replay.test.ts:generic-api-key:92 +afa57acfda894e0ebf3c637dd710310b705c0a2f:packages/http-recorder/test/record-replay.test.ts:generic-api-key:146 +afa57acfda894e0ebf3c637dd710310b705c0a2f:packages/http-recorder/test/record-replay.test.ts:gcp-api-key:71 diff --git a/bun.lock b/bun.lock index 3e73e0c236..90be71910a 100644 --- a/bun.lock +++ b/bun.lock @@ -111,6 +111,7 @@ "zod": "catalog:", }, "devDependencies": { + "@types/bun": "catalog:", "@typescript/native-preview": "catalog:", "@webgpu/types": "0.1.54", "typescript": "catalog:", @@ -302,6 +303,7 @@ "devDependencies": { "@cloudflare/workers-types": "catalog:", "@tailwindcss/vite": "catalog:", + "@types/bun": "catalog:", "@types/luxon": "catalog:", "@typescript/native-preview": "catalog:", "tailwindcss": "catalog:", @@ -325,6 +327,37 @@ "typescript": "catalog:", }, }, + "packages/http-recorder": { + "name": "@opencode-ai/http-recorder", + "version": "0.0.0", + "dependencies": { + "@effect/platform-node": "catalog:", + "effect": "catalog:", + }, + "devDependencies": { + "@tsconfig/bun": "catalog:", + "@types/bun": "catalog:", + "@typescript/native-preview": "catalog:", + }, + }, + "packages/llm": { + "name": "@opencode-ai/llm", + "version": "1.14.25", + "dependencies": { + "@smithy/eventstream-codec": "4.2.14", + "@smithy/util-utf8": "4.2.2", + "aws4fetch": "1.0.20", + "effect": "catalog:", + }, + "devDependencies": { + "@clack/prompts": "1.0.0-alpha.1", + "@effect/platform-node": "catalog:", + "@opencode-ai/http-recorder": "workspace:*", + "@tsconfig/bun": "catalog:", + "@types/bun": "catalog:", + "@typescript/native-preview": "catalog:", + }, + }, "packages/opencode": { "name": "opencode", "version": "1.14.41", @@ -1552,6 +1585,10 @@ "@opencode-ai/function": ["@opencode-ai/function@workspace:packages/function"], + "@opencode-ai/http-recorder": ["@opencode-ai/http-recorder@workspace:packages/http-recorder"], + + "@opencode-ai/llm": ["@opencode-ai/llm@workspace:packages/llm"], + "@opencode-ai/plugin": ["@opencode-ai/plugin@workspace:packages/plugin"], "@opencode-ai/script": ["@opencode-ai/script@workspace:packages/script"], @@ -5566,6 +5603,10 @@ "@opencode-ai/desktop/typescript": ["typescript@5.6.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw=="], + "@opencode-ai/llm/@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.14", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.14.1", "@smithy/util-hex-encoding": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-erZq0nOIpzfeZdCyzZjdJb4nVSKLUmSkaQUVkRGQTXs30gyUGeKnrYEg+Xe1W5gE3aReS7IgsvANwVPxSzY6Pw=="], + + "@opencode-ai/llm/@smithy/util-utf8": ["@smithy/util-utf8@4.2.2", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw=="], + "@opencode-ai/ui/@solid-primitives/resize-observer": ["@solid-primitives/resize-observer@2.1.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-zBLje5E06TgOg93S7rGPldmhDnouNGhvfZVKOp+oG2XU8snA+GoCSSCz1M+jpNAg5Ek2EakU5UVQqL152WmdXQ=="], "@opencode-ai/web/@shikijs/transformers": ["@shikijs/transformers@3.20.0", "", { "dependencies": { "@shikijs/core": "3.20.0", "@shikijs/types": "3.20.0" } }, "sha512-PrHHMRr3Q5W1qB/42kJW6laqFyWdhrPF2hNR9qjOm1xcSiAO3hAHo7HaVyHE6pMyevmy3i51O8kuGGXC78uK3g=="], @@ -6632,6 +6673,8 @@ "@opencode-ai/desktop/@actions/artifact/@actions/http-client": ["@actions/http-client@2.2.3", "", { "dependencies": { "tunnel": "^0.0.6", "undici": "^5.25.4" } }, "sha512-mx8hyJi/hjFvbPokCg4uRd4ZX78t+YyRPtnKWwIl+RzNaVuFpQHfmlGVfsKEJN8LwTCvL+DfVgAM04XaHkm6bA=="], + "@opencode-ai/llm/@smithy/eventstream-codec/@smithy/types": ["@smithy/types@4.14.1", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-59b5HtSVrVR/eYNei3BUj3DCPKD/G7EtDDe7OEJE7i7FtQFugYo6MxbotS8mVJkLNVf8gYaAlEBwwtJ9HzhWSg=="], + "@opencode-ai/web/@shikijs/transformers/@shikijs/core": ["@shikijs/core@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-f2ED7HYV4JEk827mtMDwe/yQ25pRiXZmtHjWF8uzZKuKiEsJR7Ce1nuQ+HhV9FzDcbIo4ObBCD9GPTzNuy9S1g=="], "@opencode-ai/web/@shikijs/transformers/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="], diff --git a/packages/console/app/package.json b/packages/console/app/package.json index f2471d2926..b31488f155 100644 --- a/packages/console/app/package.json +++ b/packages/console/app/package.json @@ -35,6 +35,7 @@ "zod": "catalog:" }, "devDependencies": { + "@types/bun": "catalog:", "@typescript/native-preview": "catalog:", "@webgpu/types": "0.1.54", "typescript": "catalog:", diff --git a/packages/console/app/tsconfig.json b/packages/console/app/tsconfig.json index e5fb212de5..be7ee43194 100644 --- a/packages/console/app/tsconfig.json +++ b/packages/console/app/tsconfig.json @@ -12,7 +12,7 @@ "allowJs": true, "strict": true, "noEmit": true, - "types": ["vite/client", "@webgpu/types"], + "types": ["vite/client", "@webgpu/types", "bun"], "isolatedModules": true, "paths": { "~/*": ["./src/*"] diff --git a/packages/enterprise/package.json b/packages/enterprise/package.json index beccdb6991..7f964f511a 100644 --- a/packages/enterprise/package.json +++ b/packages/enterprise/package.json @@ -32,6 +32,7 @@ "@cloudflare/workers-types": "catalog:", "@tailwindcss/vite": "catalog:", "@typescript/native-preview": "catalog:", + "@types/bun": "catalog:", "@types/luxon": "catalog:", "tailwindcss": "catalog:", "typescript": "catalog:", diff --git a/packages/enterprise/tsconfig.json b/packages/enterprise/tsconfig.json index af4ce16490..eafea7e4f0 100644 --- a/packages/enterprise/tsconfig.json +++ b/packages/enterprise/tsconfig.json @@ -11,7 +11,7 @@ "allowJs": true, "noEmit": true, "strict": true, - "types": ["@cloudflare/workers-types", "vite/client"], + "types": ["@cloudflare/workers-types", "vite/client", "bun"], "isolatedModules": true, "paths": { "~/*": ["./src/*"] diff --git a/packages/http-recorder/package.json b/packages/http-recorder/package.json new file mode 100644 index 0000000000..ee4865b475 --- /dev/null +++ b/packages/http-recorder/package.json @@ -0,0 +1,26 @@ +{ + "$schema": "https://json.schemastore.org/package.json", + "version": "0.0.0", + "name": "@opencode-ai/http-recorder", + "type": "module", + "license": "MIT", + "private": true, + "scripts": { + "test": "bun test --timeout 30000", + "test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml", + "typecheck": "tsgo --noEmit" + }, + "exports": { + ".": "./src/index.ts", + "./*": "./src/*.ts" + }, + "devDependencies": { + "@tsconfig/bun": "catalog:", + "@types/bun": "catalog:", + "@typescript/native-preview": "catalog:" + }, + "dependencies": { + "@effect/platform-node": "catalog:", + "effect": "catalog:" + } +} diff --git a/packages/http-recorder/src/cassette.ts b/packages/http-recorder/src/cassette.ts new file mode 100644 index 0000000000..23f1ba4e68 --- /dev/null +++ b/packages/http-recorder/src/cassette.ts @@ -0,0 +1,105 @@ +import { Context, Effect, FileSystem, Layer, PlatformError, Ref } from "effect" +import * as path from "node:path" +import { cassetteSecretFindings, type SecretFinding } from "./redaction" +import type { Cassette, CassetteMetadata, Interaction } from "./schema" +import { cassetteFor, cassettePath, DEFAULT_RECORDINGS_DIR, formatCassette, parseCassette } from "./storage" + +export interface Entry { + readonly name: string + readonly path: string +} + +export interface Interface { + readonly path: (name: string) => string + readonly read: (name: string) => Effect.Effect + readonly write: (name: string, cassette: Cassette) => Effect.Effect + readonly append: ( + name: string, + interaction: Interaction, + metadata: CassetteMetadata | undefined, + ) => Effect.Effect< + { + readonly cassette: Cassette + readonly findings: ReadonlyArray + }, + PlatformError.PlatformError + > + readonly exists: (name: string) => Effect.Effect + readonly list: () => Effect.Effect, PlatformError.PlatformError> + readonly scan: (cassette: Cassette) => ReadonlyArray +} + +export class Service extends Context.Service()("@opencode-ai/http-recorder/Cassette") {} + +export const layer = (options: { readonly directory?: string } = {}) => + Layer.effect( + Service, + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem + const directory = options.directory ?? DEFAULT_RECORDINGS_DIR + const recorded = yield* Ref.make(new Map>()) + + const pathFor = (name: string) => cassettePath(name, directory) + + const walk = (directory: string): Effect.Effect, PlatformError.PlatformError> => + Effect.gen(function* () { + const entries = yield* fileSystem + .readDirectory(directory) + .pipe(Effect.catch(() => Effect.succeed([] as string[]))) + const nested = yield* Effect.forEach(entries, (entry) => { + const full = path.join(directory, entry) + return fileSystem.stat(full).pipe( + Effect.flatMap((stat) => (stat.type === "Directory" ? walk(full) : Effect.succeed([full]))), + Effect.catch(() => Effect.succeed([] as string[])), + ) + }) + return nested.flat() + }) + + const read = Effect.fn("Cassette.read")(function* (name: string) { + return parseCassette(yield* fileSystem.readFileString(pathFor(name))) + }) + + const write = Effect.fn("Cassette.write")(function* (name: string, cassette: Cassette) { + yield* fileSystem.makeDirectory(path.dirname(pathFor(name)), { recursive: true }) + yield* fileSystem.writeFileString(pathFor(name), formatCassette(cassette)) + }) + + const append = Effect.fn("Cassette.append")(function* ( + name: string, + interaction: Interaction, + metadata: CassetteMetadata | undefined, + ) { + const interactions = yield* Ref.updateAndGet(recorded, (previous) => + new Map(previous).set(name, [...(previous.get(name) ?? []), interaction]), + ) + const cassette = cassetteFor(name, interactions.get(name) ?? [], metadata) + const findings = cassetteSecretFindings(cassette) + if (findings.length === 0) yield* write(name, cassette) + return { cassette, findings } + }) + + const exists = Effect.fn("Cassette.exists")(function* (name: string) { + return yield* fileSystem.access(pathFor(name)).pipe( + Effect.as(true), + Effect.catch(() => Effect.succeed(false)), + ) + }) + + const list = Effect.fn("Cassette.list")(function* () { + return (yield* walk(directory)) + .filter((file) => file.endsWith(".json")) + .map((file) => ({ + name: path.relative(directory, file).replace(/\\/g, "/").replace(/\.json$/, ""), + path: file, + })) + .toSorted((a, b) => a.name.localeCompare(b.name)) + }) + + return Service.of({ path: pathFor, read, write, append, exists, list, scan: cassetteSecretFindings }) + }), + ) + +export const defaultLayer = layer() + +export * as Cassette from "./cassette" diff --git a/packages/http-recorder/src/diff.ts b/packages/http-recorder/src/diff.ts new file mode 100644 index 0000000000..29517befcb --- /dev/null +++ b/packages/http-recorder/src/diff.ts @@ -0,0 +1,95 @@ +import { Option } from "effect" +import { Headers, HttpBody, HttpClientRequest, UrlParams } from "effect/unstable/http" +import { decodeJson } from "./matching" +import { REDACTED, redactUrl, secretFindings } from "./redaction" +import { httpInteractions, type Cassette, type RequestSnapshot } from "./schema" + +const safeText = (value: unknown) => { + if (value === undefined) return "undefined" + if (secretFindings(value).length > 0) return JSON.stringify(REDACTED) + const text = typeof value === "string" ? JSON.stringify(value) : JSON.stringify(value) + if (!text) return String(value) + return text.length > 300 ? `${text.slice(0, 300)}...` : text +} + +const jsonBody = (body: string) => Option.getOrUndefined(decodeJson(body)) + +const valueDiffs = (expected: unknown, received: unknown, base = "$", limit = 8): ReadonlyArray => { + if (Object.is(expected, received)) return [] + if ( + expected && + received && + typeof expected === "object" && + typeof received === "object" && + !Array.isArray(expected) && + !Array.isArray(received) + ) { + return [...new Set([...Object.keys(expected), ...Object.keys(received)])] + .toSorted() + .flatMap((key) => + valueDiffs( + (expected as Record)[key], + (received as Record)[key], + `${base}.${key}`, + limit, + ), + ) + .slice(0, limit) + } + if (Array.isArray(expected) && Array.isArray(received)) { + return Array.from({ length: Math.max(expected.length, received.length) }, (_, index) => index) + .flatMap((index) => valueDiffs(expected[index], received[index], `${base}[${index}]`, limit)) + .slice(0, limit) + } + return [`${base} expected ${safeText(expected)}, received ${safeText(received)}`] +} + +const headerDiffs = (expected: Record, received: Record) => + [...new Set([...Object.keys(expected), ...Object.keys(received)])].toSorted().flatMap((key) => { + if (expected[key] === received[key]) return [] + if (expected[key] === undefined) return [` ${key} unexpected ${safeText(received[key])}`] + if (received[key] === undefined) return [` ${key} missing expected ${safeText(expected[key])}`] + return [` ${key} expected ${safeText(expected[key])}, received ${safeText(received[key])}`] + }) + +export const requestDiff = (expected: RequestSnapshot, received: RequestSnapshot) => { + const lines = [] + if (expected.method !== received.method) { + lines.push("method:", ` expected ${expected.method}, received ${received.method}`) + } + if (expected.url !== received.url) { + lines.push("url:", ` expected ${expected.url}`, ` received ${received.url}`) + } + const headers = headerDiffs(expected.headers, received.headers) + if (headers.length > 0) lines.push("headers:", ...headers.slice(0, 8)) + const expectedBody = jsonBody(expected.body) + const receivedBody = jsonBody(received.body) + const body = + expectedBody !== undefined && receivedBody !== undefined + ? valueDiffs(expectedBody, receivedBody).map((line) => ` ${line}`) + : expected.body === received.body + ? [] + : [` expected ${safeText(expected.body)}, received ${safeText(received.body)}`] + if (body.length > 0) lines.push("body:", ...body) + return lines +} + +export const mismatchDetail = (cassette: Cassette, incoming: RequestSnapshot) => { + const interactions = httpInteractions(cassette) + if (interactions.length === 0) return "cassette has no recorded HTTP interactions" + const ranked = interactions + .map((interaction, index) => ({ index, lines: requestDiff(interaction.request, incoming) })) + .toSorted((a, b) => a.lines.length - b.lines.length || a.index - b.index) + const best = ranked[0] + return ["no recorded interaction matched", `closest interaction: #${best.index + 1}`, ...best.lines].join("\n") +} + +export const redactedErrorRequest = (request: HttpClientRequest.HttpClientRequest) => + HttpClientRequest.makeWith( + request.method, + redactUrl(request.url), + UrlParams.empty, + Option.none(), + Headers.empty, + HttpBody.empty, + ) diff --git a/packages/http-recorder/src/effect.ts b/packages/http-recorder/src/effect.ts new file mode 100644 index 0000000000..f103e45dc7 --- /dev/null +++ b/packages/http-recorder/src/effect.ts @@ -0,0 +1,211 @@ +import { NodeFileSystem } from "@effect/platform-node" +import { Effect, Layer, Option, Ref } from "effect" +import { + FetchHttpClient, + HttpClient, + HttpClientError, + HttpClientRequest, + HttpClientResponse, +} from "effect/unstable/http" +import { redactedErrorRequest, mismatchDetail, requestDiff } from "./diff" +import { defaultMatcher, decodeJson, type RequestMatcher } from "./matching" +import { redactHeaders, redactUrl, type SecretFinding } from "./redaction" +import { + httpInteractions, + type Cassette, + type CassetteMetadata, + type HttpInteraction, + type ResponseSnapshot, +} from "./schema" +import * as CassetteService from "./cassette" + +export const DEFAULT_REQUEST_HEADERS: ReadonlyArray = ["content-type", "accept", "openai-beta"] +const DEFAULT_RESPONSE_HEADERS: ReadonlyArray = ["content-type"] + +export type RecordReplayMode = "record" | "replay" | "passthrough" + +export interface RecordReplayOptions { + readonly mode?: RecordReplayMode + readonly directory?: string + readonly metadata?: CassetteMetadata + readonly redact?: { + readonly headers?: ReadonlyArray + readonly query?: ReadonlyArray + readonly url?: (url: string) => string + } + readonly requestHeaders?: ReadonlyArray + readonly responseHeaders?: ReadonlyArray + readonly redactBody?: (body: unknown) => unknown + readonly dispatch?: "match" | "sequential" + readonly match?: RequestMatcher +} + +const responseHeaders = ( + response: HttpClientResponse.HttpClientResponse, + allow: ReadonlyArray, + redact: ReadonlyArray | undefined, +) => { + const merged = redactHeaders(response.headers as Record, allow, redact) + if (!merged["content-type"]) merged["content-type"] = "text/event-stream" + return merged +} + +const BINARY_CONTENT_TYPES: ReadonlyArray = ["vnd.amazon.eventstream", "octet-stream"] + +const isBinaryContentType = (contentType: string | undefined) => { + if (!contentType) return false + const lower = contentType.toLowerCase() + return BINARY_CONTENT_TYPES.some((token) => lower.includes(token)) +} + +const captureResponseBody = (response: HttpClientResponse.HttpClientResponse, contentType: string | undefined) => + isBinaryContentType(contentType) + ? response.arrayBuffer.pipe( + Effect.map((bytes) => ({ body: Buffer.from(bytes).toString("base64"), bodyEncoding: "base64" as const })), + ) + : response.text.pipe(Effect.map((body) => ({ body }))) + +const decodeResponseBody = (snapshot: ResponseSnapshot) => + snapshot.bodyEncoding === "base64" ? Buffer.from(snapshot.body, "base64") : snapshot.body + +const fixtureMissing = (request: HttpClientRequest.HttpClientRequest, name: string) => + new HttpClientError.HttpClientError({ + reason: new HttpClientError.TransportError({ + request: redactedErrorRequest(request), + description: `Fixture "${name}" not found. Run with RECORD=true to create it.`, + }), + }) + +const fixtureMismatch = (request: HttpClientRequest.HttpClientRequest, name: string, detail: string) => + new HttpClientError.HttpClientError({ + reason: new HttpClientError.TransportError({ + request: redactedErrorRequest(request), + description: `Fixture "${name}" does not match the current request: ${detail}. Run with RECORD=true to update it.`, + }), + }) + +const unsafeCassette = ( + request: HttpClientRequest.HttpClientRequest, + name: string, + findings: ReadonlyArray, +) => + new HttpClientError.HttpClientError({ + reason: new HttpClientError.TransportError({ + request: redactedErrorRequest(request), + description: `Refusing to write cassette "${name}" because it contains possible secrets: ${findings + .map((item) => `${item.path} (${item.reason})`) + .join(", ")}`, + }), + }) + +export const recordingLayer = ( + name: string, + options: Omit = {}, +): Layer.Layer => + Layer.effect( + HttpClient.HttpClient, + Effect.gen(function* () { + const upstream = yield* HttpClient.HttpClient + const cassetteService = yield* CassetteService.Service + const requestHeadersAllow = options.requestHeaders ?? DEFAULT_REQUEST_HEADERS + const responseHeadersAllow = options.responseHeaders ?? DEFAULT_RESPONSE_HEADERS + const match = options.match ?? defaultMatcher + const mode = options.mode ?? "replay" + const sequential = options.dispatch === "sequential" + const replay = yield* Ref.make(undefined) + const cursor = yield* Ref.make(0) + + const snapshotRequest = (request: HttpClientRequest.HttpClientRequest) => + Effect.gen(function* () { + const web = yield* HttpClientRequest.toWeb(request).pipe(Effect.orDie) + const raw = yield* Effect.promise(() => web.text()) + const body = options.redactBody + ? Option.match(decodeJson(raw), { + onNone: () => raw, + onSome: (parsed) => JSON.stringify(options.redactBody?.(parsed)), + }) + : raw + return { + method: web.method, + url: redactUrl(web.url, options.redact?.query, options.redact?.url), + headers: redactHeaders( + Object.fromEntries(web.headers.entries()), + requestHeadersAllow, + options.redact?.headers, + ), + body, + } + }) + + const selectInteraction = (cassette: Cassette, incoming: HttpInteraction["request"]) => + Effect.gen(function* () { + const interactions = httpInteractions(cassette) + if (sequential) { + const index = yield* Ref.get(cursor) + const interaction = interactions[index] + if (!interaction) + return { interaction, detail: `interaction ${index + 1} of ${interactions.length} not recorded` } + if (!match(incoming, interaction.request)) { + return { interaction: undefined, detail: requestDiff(interaction.request, incoming).join("\n") } + } + yield* Ref.update(cursor, (n) => n + 1) + return { interaction, detail: "" } + } + const interaction = interactions.find((candidate) => match(incoming, candidate.request)) + return { interaction, detail: interaction ? "" : mismatchDetail(cassette, incoming) } + }) + + const loadReplay = (request: HttpClientRequest.HttpClientRequest) => + Effect.gen(function* () { + const cached = yield* Ref.get(replay) + if (cached) return cached + const cassette = yield* cassetteService.read(name).pipe(Effect.mapError(() => fixtureMissing(request, name))) + yield* Ref.set(replay, cassette) + return cassette + }) + + return HttpClient.make((request) => { + if (mode === "passthrough") return upstream.execute(request) + + if (mode === "record") { + return Effect.gen(function* () { + const currentRequest = yield* snapshotRequest(request) + const response = yield* upstream.execute(request) + const headers = responseHeaders(response, responseHeadersAllow, options.redact?.headers) + const captured = yield* captureResponseBody(response, headers["content-type"]) + const interaction: HttpInteraction = { + transport: "http", + request: currentRequest, + response: { status: response.status, headers, ...captured }, + } + const result = yield* cassetteService.append(name, interaction, options.metadata).pipe(Effect.orDie) + const findings = result.findings + if (findings.length > 0) return yield* unsafeCassette(request, name, findings) + return HttpClientResponse.fromWeb( + request, + new Response(decodeResponseBody(interaction.response), interaction.response), + ) + }) + } + + return Effect.gen(function* () { + const cassette = yield* loadReplay(request) + const incoming = yield* snapshotRequest(request) + const { interaction, detail } = yield* selectInteraction(cassette, incoming) + if (!interaction) return yield* fixtureMismatch(request, name, detail) + + return HttpClientResponse.fromWeb( + request, + new Response(decodeResponseBody(interaction.response), interaction.response), + ) + }) + }) + }), + ) + +export const cassetteLayer = (name: string, options: RecordReplayOptions = {}): Layer.Layer => + recordingLayer(name, options).pipe( + Layer.provide(CassetteService.layer({ directory: options.directory })), + Layer.provide(FetchHttpClient.layer), + Layer.provide(NodeFileSystem.layer), + ) diff --git a/packages/http-recorder/src/index.ts b/packages/http-recorder/src/index.ts new file mode 100644 index 0000000000..d85e13bf4c --- /dev/null +++ b/packages/http-recorder/src/index.ts @@ -0,0 +1,10 @@ +export * from "./schema" +export * from "./redaction" +export * from "./matching" +export * from "./diff" +export * from "./storage" +export * from "./websocket" +export * from "./effect" +export * as Cassette from "./cassette" + +export * as HttpRecorder from "." diff --git a/packages/http-recorder/src/matching.ts b/packages/http-recorder/src/matching.ts new file mode 100644 index 0000000000..b66c8fd146 --- /dev/null +++ b/packages/http-recorder/src/matching.ts @@ -0,0 +1,36 @@ +import { Option, Schema } from "effect" +import type { RequestSnapshot } from "./schema" + +const JsonValue = Schema.fromJsonString(Schema.Unknown) +export const decodeJson = Schema.decodeUnknownOption(JsonValue) + +const isRecord = (value: unknown): value is Record => + value !== null && typeof value === "object" && !Array.isArray(value) + +export const canonicalizeJson = (value: unknown): unknown => { + if (Array.isArray(value)) return value.map(canonicalizeJson) + if (isRecord(value)) { + return Object.fromEntries( + Object.keys(value) + .toSorted() + .map((key) => [key, canonicalizeJson(value[key])]), + ) + } + return value +} + +export type RequestMatcher = (incoming: RequestSnapshot, recorded: RequestSnapshot) => boolean + +export const canonicalSnapshot = (snapshot: RequestSnapshot): string => + JSON.stringify({ + method: snapshot.method, + url: snapshot.url, + headers: canonicalizeJson(snapshot.headers), + body: Option.match(decodeJson(snapshot.body), { + onNone: () => snapshot.body, + onSome: canonicalizeJson, + }), + }) + +export const defaultMatcher: RequestMatcher = (incoming, recorded) => + canonicalSnapshot(incoming) === canonicalSnapshot(recorded) diff --git a/packages/http-recorder/src/redaction.ts b/packages/http-recorder/src/redaction.ts new file mode 100644 index 0000000000..062ea61dc7 --- /dev/null +++ b/packages/http-recorder/src/redaction.ts @@ -0,0 +1,112 @@ +import type { Cassette } from "./schema" + +export const REDACTED = "[REDACTED]" + +const DEFAULT_REDACT_HEADERS = [ + "authorization", + "cookie", + "proxy-authorization", + "set-cookie", + "x-api-key", + "x-amz-security-token", + "x-goog-api-key", +] + +const DEFAULT_REDACT_QUERY = [ + "access_token", + "api-key", + "api_key", + "apikey", + "code", + "key", + "signature", + "sig", + "token", + "x-amz-credential", + "x-amz-security-token", + "x-amz-signature", +] + +const SECRET_PATTERNS: ReadonlyArray<{ readonly label: string; readonly pattern: RegExp }> = [ + { label: "bearer token", pattern: /\bBearer\s+[A-Za-z0-9._~+/=-]{16,}\b/i }, + { label: "API key", pattern: /\bsk-[A-Za-z0-9][A-Za-z0-9_-]{20,}\b/ }, + { label: "Anthropic API key", pattern: /\bsk-ant-[A-Za-z0-9_-]{20,}\b/ }, + { label: "Google API key", pattern: /\bAIza[0-9A-Za-z_-]{20,}\b/ }, + { label: "AWS access key", pattern: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/ }, + { label: "GitHub token", pattern: /\bgh[pousr]_[A-Za-z0-9_]{20,}\b/ }, + { label: "private key", pattern: /-----BEGIN [A-Z ]*PRIVATE KEY-----/ }, +] + +const ENV_SECRET_NAMES = /(?:API|AUTH|BEARER|CREDENTIAL|KEY|PASSWORD|SECRET|TOKEN)/i +const SAFE_ENV_VALUES = new Set(["fixture", "test", "test-key"]) + +const envSecrets = () => + Object.entries(process.env).flatMap(([name, value]) => { + if (!value) return [] + if (!ENV_SECRET_NAMES.test(name)) return [] + if (value.length < 12) return [] + if (SAFE_ENV_VALUES.has(value.toLowerCase())) return [] + return [{ name, value }] + }) + +const pathFor = (base: string, key: string) => (base ? `${base}.${key}` : key) + +const stringEntries = (value: unknown, base = ""): ReadonlyArray<{ readonly path: string; readonly value: string }> => { + if (typeof value === "string") return [{ path: base, value }] + if (Array.isArray(value)) return value.flatMap((item, index) => stringEntries(item, `${base}[${index}]`)) + if (value && typeof value === "object") { + return Object.entries(value).flatMap(([key, child]) => stringEntries(child, pathFor(base, key))) + } + return [] +} + +const redactionSet = (values: ReadonlyArray | undefined, defaults: ReadonlyArray) => + new Set([...defaults, ...(values ?? [])].map((value) => value.toLowerCase())) + +export type UrlRedactor = (url: string) => string + +export const redactUrl = (raw: string, query: ReadonlyArray = DEFAULT_REDACT_QUERY, urlRedactor?: UrlRedactor) => { + if (!URL.canParse(raw)) return urlRedactor?.(raw) ?? raw + const url = new URL(raw) + if (url.username) url.username = REDACTED + if (url.password) url.password = REDACTED + const redacted = redactionSet(query, DEFAULT_REDACT_QUERY) + for (const key of [...url.searchParams.keys()]) { + if (redacted.has(key.toLowerCase())) url.searchParams.set(key, REDACTED) + } + return urlRedactor?.(url.toString()) ?? url.toString() +} + +export const redactHeaders = ( + headers: Record, + allow: ReadonlyArray, + redact: ReadonlyArray = DEFAULT_REDACT_HEADERS, +) => { + const allowed = new Set(allow.map((name) => name.toLowerCase())) + const redacted = redactionSet(redact, DEFAULT_REDACT_HEADERS) + return Object.fromEntries( + Object.entries(headers) + .map(([name, value]) => [name.toLowerCase(), value] as const) + .filter(([name]) => allowed.has(name)) + .map(([name, value]) => [name, redacted.has(name) ? REDACTED : value] as const) + .toSorted(([a], [b]) => a.localeCompare(b)), + ) +} + +export type SecretFinding = { + readonly path: string + readonly reason: string +} + +export const secretFindings = (value: unknown): ReadonlyArray => + stringEntries(value).flatMap((entry) => [ + ...SECRET_PATTERNS.filter((item) => item.pattern.test(entry.value)).map((item) => ({ + path: entry.path, + reason: item.label, + })), + ...envSecrets() + .filter((item) => entry.value.includes(item.value)) + .map((item) => ({ path: entry.path, reason: `environment secret ${item.name}` })), + ]) + +export const cassetteSecretFindings = (cassette: Cassette) => secretFindings(cassette) diff --git a/packages/http-recorder/src/schema.ts b/packages/http-recorder/src/schema.ts new file mode 100644 index 0000000000..2692b525b4 --- /dev/null +++ b/packages/http-recorder/src/schema.ts @@ -0,0 +1,67 @@ +import { Schema } from "effect" + +export const RequestSnapshotSchema = Schema.Struct({ + method: Schema.String, + url: Schema.String, + headers: Schema.Record(Schema.String, Schema.String), + body: Schema.String, +}) +export type RequestSnapshot = Schema.Schema.Type + +export const ResponseSnapshotSchema = Schema.Struct({ + status: Schema.Number, + headers: Schema.Record(Schema.String, Schema.String), + body: Schema.String, + bodyEncoding: Schema.optional(Schema.Literals(["text", "base64"])), +}) +export type ResponseSnapshot = Schema.Schema.Type + +export const CassetteMetadataSchema = Schema.Record(Schema.String, Schema.Unknown) +export type CassetteMetadata = Schema.Schema.Type + +export const HttpInteractionSchema = Schema.Struct({ + transport: Schema.tag("http"), + request: RequestSnapshotSchema, + response: ResponseSnapshotSchema, +}) +export type HttpInteraction = Schema.Schema.Type + +export const WebSocketFrameSchema = Schema.Union([ + Schema.Struct({ kind: Schema.tag("text"), body: Schema.String }), + Schema.Struct({ kind: Schema.tag("binary"), body: Schema.String, bodyEncoding: Schema.Literal("base64") }), +]) +export type WebSocketFrame = Schema.Schema.Type + +export const WebSocketInteractionSchema = Schema.Struct({ + transport: Schema.tag("websocket"), + open: Schema.Struct({ + url: Schema.String, + headers: Schema.Record(Schema.String, Schema.String), + }), + client: Schema.Array(WebSocketFrameSchema), + server: Schema.Array(WebSocketFrameSchema), +}) +export type WebSocketInteraction = Schema.Schema.Type + +export const InteractionSchema = Schema.Union([HttpInteractionSchema, WebSocketInteractionSchema]).pipe( + Schema.toTaggedUnion("transport"), +) +export type Interaction = Schema.Schema.Type + +export const isHttpInteraction = InteractionSchema.guards.http + +export const isWebSocketInteraction = InteractionSchema.guards.websocket + +export const httpInteractions = (cassette: Cassette) => cassette.interactions.filter(isHttpInteraction) + +export const webSocketInteractions = (cassette: Cassette) => cassette.interactions.filter(isWebSocketInteraction) + +export const CassetteSchema = Schema.Struct({ + version: Schema.Literal(1), + metadata: Schema.optional(CassetteMetadataSchema), + interactions: Schema.Array(InteractionSchema), +}) +export type Cassette = Schema.Schema.Type + +export const decodeCassette = Schema.decodeUnknownSync(CassetteSchema) +export const encodeCassette = Schema.encodeSync(CassetteSchema) diff --git a/packages/http-recorder/src/storage.ts b/packages/http-recorder/src/storage.ts new file mode 100644 index 0000000000..08dadb1bb9 --- /dev/null +++ b/packages/http-recorder/src/storage.ts @@ -0,0 +1,34 @@ +import { Option } from "effect" +import * as fs from "node:fs" +import * as path from "node:path" +import { encodeCassette, decodeCassette, type Cassette, type CassetteMetadata, type Interaction } from "./schema" + +export const DEFAULT_RECORDINGS_DIR = path.resolve(process.cwd(), "test", "fixtures", "recordings") + +export const cassettePath = (name: string, directory = DEFAULT_RECORDINGS_DIR) => path.join(directory, `${name}.json`) + +export const metadataFor = (name: string, metadata: CassetteMetadata | undefined): CassetteMetadata => ({ + name, + recordedAt: new Date().toISOString(), + ...(metadata ?? {}), +}) + +export const cassetteFor = ( + name: string, + interactions: ReadonlyArray, + metadata: CassetteMetadata | undefined, +): Cassette => ({ + version: 1, + metadata: metadataFor(name, metadata), + interactions, +}) + +export const formatCassette = (cassette: Cassette) => `${JSON.stringify(encodeCassette(cassette), null, 2)}\n` + +export const parseCassette = (raw: string) => decodeCassette(JSON.parse(raw)) + +export const hasCassetteSync = (name: string, options: { readonly directory?: string } = {}) => { + const file = cassettePath(name, options.directory) + if (!fs.existsSync(file)) return false + return Option.isSome(Option.liftThrowable(parseCassette)(fs.readFileSync(file, "utf8"))) +} diff --git a/packages/http-recorder/src/websocket.ts b/packages/http-recorder/src/websocket.ts new file mode 100644 index 0000000000..8a854cb62c --- /dev/null +++ b/packages/http-recorder/src/websocket.ts @@ -0,0 +1,204 @@ +import { Effect, Option, Ref, Scope, Stream } from "effect" +import type { Headers } from "effect/unstable/http" +import * as CassetteService from "./cassette" +import { canonicalizeJson, decodeJson } from "./matching" +import { redactHeaders, redactUrl, type SecretFinding } from "./redaction" +import { webSocketInteractions, type CassetteMetadata, type WebSocketFrame, type WebSocketInteraction } from "./schema" + +export const DEFAULT_WEBSOCKET_REQUEST_HEADERS: ReadonlyArray = ["content-type", "accept", "openai-beta"] + +export interface WebSocketRequest { + readonly url: string + readonly headers: Headers.Headers +} + +export interface WebSocketConnection { + readonly sendText: (message: string) => Effect.Effect + readonly messages: Stream.Stream + readonly close: Effect.Effect +} + +export interface WebSocketExecutor { + readonly open: (request: WebSocketRequest) => Effect.Effect, E> +} + +export interface WebSocketRecordReplayOptions { + readonly name: string + readonly mode?: "record" | "replay" | "passthrough" + readonly metadata?: CassetteMetadata + readonly cassette: CassetteService.Interface + readonly live: WebSocketExecutor + readonly redact?: { + readonly headers?: ReadonlyArray + readonly query?: ReadonlyArray + readonly url?: (url: string) => string + } + readonly requestHeaders?: ReadonlyArray + readonly compareClientMessagesAsJson?: boolean +} + +const headersRecord = (headers: Headers.Headers) => + Object.fromEntries( + Object.entries(headers as Record) + .filter((entry): entry is [string, string] => typeof entry[1] === "string") + .toSorted(([a], [b]) => a.localeCompare(b)), + ) + +const openSnapshot = ( + request: WebSocketRequest, + options: Pick, "redact" | "requestHeaders"> = {}, +) => ({ + url: redactUrl(request.url, options.redact?.query, options.redact?.url), + headers: redactHeaders( + headersRecord(request.headers), + options.requestHeaders ?? DEFAULT_WEBSOCKET_REQUEST_HEADERS, + options.redact?.headers, + ), +}) + +const textFrame = (body: string): WebSocketFrame => ({ kind: "text", body }) + +const frameText = (frame: WebSocketFrame) => { + if (frame.kind === "text") return frame.body + return new TextDecoder().decode(Buffer.from(frame.body, "base64")) +} + +const frameMessage = (frame: WebSocketFrame) => + frame.kind === "text" ? frame.body : new Uint8Array(Buffer.from(frame.body, "base64")) + +const receivedFrame = (message: string | Uint8Array): WebSocketFrame => + typeof message === "string" + ? textFrame(message) + : { kind: "binary", body: Buffer.from(message).toString("base64"), bodyEncoding: "base64" } + +const unsafeCassette = (name: string, findings: ReadonlyArray) => + new Error( + `Refusing to write WebSocket cassette "${name}" because it contains possible secrets: ${findings + .map((item) => `${item.path} (${item.reason})`) + .join(", ")}`, + ) + +const mismatch = (message: string, actual: unknown, expected: unknown) => + new Error(`${message}: expected ${JSON.stringify(expected)}, received ${JSON.stringify(actual)}`) + +const assertEqual = (message: string, actual: unknown, expected: unknown) => + Effect.sync(() => { + if (JSON.stringify(actual) === JSON.stringify(expected)) return + throw mismatch(message, actual, expected) + }) + +const jsonOrText = (value: string) => Option.match(decodeJson(value), { onNone: () => value, onSome: canonicalizeJson }) + +const compareClientMessage = (actual: string, expected: WebSocketFrame | undefined, index: number, asJson: boolean) => { + if (!expected) + return Effect.sync(() => { + throw new Error(`Unexpected WebSocket client frame ${index + 1}: ${actual}`) + }) + const expectedText = frameText(expected) + if (!asJson) return assertEqual(`WebSocket client frame ${index + 1}`, actual, expectedText) + return assertEqual(`WebSocket client JSON frame ${index + 1}`, jsonOrText(actual), jsonOrText(expectedText)) +} + +export const makeWebSocketExecutor = ( + options: WebSocketRecordReplayOptions, +): Effect.Effect, never, Scope.Scope> => + Effect.gen(function* () { + const mode = options.mode ?? "replay" + + if (mode === "passthrough") return options.live + + if (mode === "record") { + return { + open: (request) => + Effect.gen(function* () { + const client: WebSocketFrame[] = [] + const server: WebSocketFrame[] = [] + const connection = yield* options.live.open(request) + const closed = yield* Ref.make(false) + const closeOnce = Effect.gen(function* () { + if (yield* Ref.getAndSet(closed, true)) return + yield* connection.close + const result = yield* options.cassette + .append( + options.name, + { transport: "websocket", open: openSnapshot(request, options), client, server }, + options.metadata, + ) + .pipe(Effect.orDie) + if (result.findings.length > 0) yield* Effect.die(unsafeCassette(options.name, result.findings)) + }) + return { + sendText: (message: string) => + connection.sendText(message).pipe(Effect.tap(() => Effect.sync(() => client.push(textFrame(message))))), + messages: connection.messages.pipe( + Stream.map((message) => { + server.push(receivedFrame(message)) + return message + }), + ), + close: closeOnce, + } + }), + } + } + + const replay = yield* Ref.make<{ readonly interactions: ReadonlyArray } | undefined>( + undefined, + ) + const cursor = yield* Ref.make(0) + + yield* Effect.addFinalizer(() => + Effect.gen(function* () { + const input = yield* Ref.get(replay) + if (!input) return + yield* assertEqual( + `Unused recorded WebSocket interactions in ${options.name}`, + yield* Ref.get(cursor), + input.interactions.length, + ) + }), + ) + + const loadReplay = Effect.fn("WebSocketRecorder.loadReplay")(function* () { + const cached = yield* Ref.get(replay) + if (cached) return cached + const input = { + interactions: webSocketInteractions(yield* options.cassette.read(options.name).pipe(Effect.orDie)), + } + yield* Ref.set(replay, input) + return input + }) + + return { + open: (request) => { + return Effect.gen(function* () { + const input = yield* loadReplay() + const index = yield* Ref.getAndUpdate(cursor, (value) => value + 1) + const interaction = input.interactions[index] + if (!interaction) return yield* Effect.die(new Error(`No recorded WebSocket interaction for ${request.url}`)) + yield* assertEqual(`WebSocket open frame ${index + 1}`, openSnapshot(request, options), interaction.open) + const messageIndex = yield* Ref.make(0) + return { + sendText: (message: string) => + Effect.gen(function* () { + const current = yield* Ref.getAndUpdate(messageIndex, (value) => value + 1) + yield* compareClientMessage( + message, + interaction.client[current], + current, + options.compareClientMessagesAsJson === true, + ) + }), + messages: Stream.fromIterable(interaction.server).pipe(Stream.map(frameMessage)), + close: Effect.gen(function* () { + yield* assertEqual( + `WebSocket client frame count for interaction ${index + 1}`, + yield* Ref.get(messageIndex), + interaction.client.length, + ) + }), + } + }) + }, + } + }) diff --git a/packages/http-recorder/test/fixtures/recordings/record-replay/multi-step.json b/packages/http-recorder/test/fixtures/recordings/record-replay/multi-step.json new file mode 100644 index 0000000000..9953b860cd --- /dev/null +++ b/packages/http-recorder/test/fixtures/recordings/record-replay/multi-step.json @@ -0,0 +1,41 @@ +{ + "version": 1, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://example.test/echo", + "headers": { + "content-type": "application/json" + }, + "body": "{\"step\":1}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "application/json" + }, + "body": "{\"reply\":\"first\"}" + } + }, + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://example.test/echo", + "headers": { + "content-type": "application/json" + }, + "body": "{\"step\":2}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "application/json" + }, + "body": "{\"reply\":\"second\"}" + } + } + ] +} diff --git a/packages/http-recorder/test/fixtures/recordings/record-replay/retry.json b/packages/http-recorder/test/fixtures/recordings/record-replay/retry.json new file mode 100644 index 0000000000..873e5a16c0 --- /dev/null +++ b/packages/http-recorder/test/fixtures/recordings/record-replay/retry.json @@ -0,0 +1,41 @@ +{ + "version": 1, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://example.test/poll", + "headers": { + "content-type": "application/json" + }, + "body": "{\"id\":\"job_1\"}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "application/json" + }, + "body": "{\"status\":\"pending\"}" + } + }, + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://example.test/poll", + "headers": { + "content-type": "application/json" + }, + "body": "{\"id\":\"job_1\"}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "application/json" + }, + "body": "{\"status\":\"complete\"}" + } + } + ] +} diff --git a/packages/http-recorder/test/record-replay.test.ts b/packages/http-recorder/test/record-replay.test.ts new file mode 100644 index 0000000000..2f118a88c1 --- /dev/null +++ b/packages/http-recorder/test/record-replay.test.ts @@ -0,0 +1,322 @@ +import { NodeFileSystem } from "@effect/platform-node" +import { describe, expect, test } from "bun:test" +import { Cause, Effect, Exit, Scope, Stream } from "effect" +import { Headers, HttpBody, HttpClient, HttpClientRequest } from "effect/unstable/http" +import * as fs from "node:fs" +import * as os from "node:os" +import * as path from "node:path" +import { HttpRecorder } from "../src" +import { redactedErrorRequest } from "../src/diff" + +const post = (url: string, body: object) => + Effect.gen(function* () { + const http = yield* HttpClient.HttpClient + const request = HttpClientRequest.post(url, { + headers: { "content-type": "application/json" }, + body: HttpBody.text(JSON.stringify(body), "application/json"), + }) + const response = yield* http.execute(request) + return yield* response.text + }) + +const run = (effect: Effect.Effect) => + Effect.runPromise(effect.pipe(Effect.provide(HttpRecorder.cassetteLayer("record-replay/multi-step")))) + +const runWith = ( + name: string, + options: HttpRecorder.RecordReplayOptions, + effect: Effect.Effect, +) => Effect.runPromise(effect.pipe(Effect.provide(HttpRecorder.cassetteLayer(name, options)))) + +const runRecorder = (effect: Effect.Effect) => + Effect.runPromise( + Effect.scoped( + effect.pipe( + Effect.provide( + HttpRecorder.Cassette.layer({ directory: fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-")) }), + ), + Effect.provide(NodeFileSystem.layer), + ), + ), + ) + +const failureText = (exit: Exit.Exit) => { + if (Exit.isSuccess(exit)) return "" + return Cause.prettyErrors(exit.cause).join("\n") +} + +describe("http-recorder", () => { + test("redacts sensitive URL query parameters", () => { + expect( + HttpRecorder.redactUrl( + "https://example.test/path?key=secret-google-key&api_key=secret-openai-key&safe=value&X-Amz-Signature=secret-signature", + ), + ).toBe( + "https://example.test/path?key=%5BREDACTED%5D&api_key=%5BREDACTED%5D&safe=value&X-Amz-Signature=%5BREDACTED%5D", + ) + }) + + test("redacts URL credentials", () => { + expect(HttpRecorder.redactUrl("https://user:password@example.test/path?safe=value")).toBe( + "https://%5BREDACTED%5D:%5BREDACTED%5D@example.test/path?safe=value", + ) + }) + + test("applies custom URL redaction after built-in redaction", () => { + expect( + HttpRecorder.redactUrl( + "https://example.test/accounts/real-account/path?key=secret-key", + undefined, + (url) => url.replace("/accounts/real-account/", "/accounts/{account}/"), + ), + ).toBe("https://example.test/accounts/{account}/path?key=%5BREDACTED%5D") + }) + + test("redacts sensitive headers when allow-listed", () => { + expect( + HttpRecorder.redactHeaders( + { + authorization: "Bearer secret-token", + "content-type": "application/json", + "x-custom-token": "custom-secret", + "x-api-key": "secret-key", + "x-goog-api-key": "secret-google-key", + }, + ["authorization", "content-type", "x-api-key", "x-goog-api-key", "x-custom-token"], + ["x-custom-token"], + ), + ).toEqual({ + authorization: "[REDACTED]", + "content-type": "application/json", + "x-api-key": "[REDACTED]", + "x-custom-token": "[REDACTED]", + "x-goog-api-key": "[REDACTED]", + }) + }) + + test("redacts error requests without retaining headers, params, or body", () => { + const request = HttpClientRequest.post("https://example.test/path", { + headers: { authorization: "Bearer super-secret" }, + body: HttpBody.text("super-secret-body", "text/plain"), + }).pipe(HttpClientRequest.setUrlParam("api_key", "super-secret-key")) + + expect(redactedErrorRequest(request).toJSON()).toMatchObject({ + url: "https://example.test/path", + urlParams: { params: [] }, + headers: {}, + body: { _tag: "Empty" }, + }) + }) + + test("detects secret-looking values without returning the secret", () => { + expect( + HttpRecorder.cassetteSecretFindings({ + version: 1, + interactions: [ + { + transport: "http", + request: { + method: "POST", + url: "https://example.test/path?key=sk-123456789012345678901234", + headers: {}, + body: JSON.stringify({ nested: "AIzaSyDHibiBRvJZLsFnPYPoiTwxY4ztQ55yqCE" }), + }, + response: { + status: 200, + headers: {}, + body: "Bearer abcdefghijklmnopqrstuvwxyz", + }, + }, + ], + }), + ).toEqual([ + { path: "interactions[0].request.url", reason: "API key" }, + { path: "interactions[0].request.body", reason: "Google API key" }, + { path: "interactions[0].response.body", reason: "bearer token" }, + ]) + }) + + test("detects secret-looking values inside metadata", () => { + expect( + HttpRecorder.cassetteSecretFindings({ + version: 1, + metadata: { token: "sk-123456789012345678901234" }, + interactions: [], + }), + ).toEqual([{ path: "metadata.token", reason: "API key" }]) + }) + + test("formats websocket cassettes with shared metadata", () => { + const cassette = HttpRecorder.cassetteFor( + "websocket/basic", + [ + { + transport: "websocket", + open: { url: "wss://example.test/realtime", headers: { "content-type": "application/json" } }, + client: [{ kind: "text", body: JSON.stringify({ type: "response.create" }) }], + server: [{ kind: "text", body: JSON.stringify({ type: "response.completed" }) }], + }, + ], + { provider: "openai" }, + ) + + expect(cassette.metadata).toMatchObject({ name: "websocket/basic", provider: "openai" }) + expect(HttpRecorder.parseCassette(HttpRecorder.formatCassette(cassette))).toEqual(cassette) + }) + + test("replays websocket interactions from the shared cassette service", async () => { + await runRecorder( + Effect.gen(function* () { + const cassette = yield* HttpRecorder.Cassette.Service + yield* cassette.write( + "websocket/replay", + HttpRecorder.cassetteFor( + "websocket/replay", + [ + { + transport: "websocket", + open: { url: "wss://example.test/realtime", headers: { "content-type": "application/json" } }, + client: [{ kind: "text", body: JSON.stringify({ type: "response.create" }) }], + server: [{ kind: "text", body: JSON.stringify({ type: "response.completed" }) }], + }, + ], + undefined, + ), + ) + const executor = yield* HttpRecorder.makeWebSocketExecutor({ + name: "websocket/replay", + cassette, + compareClientMessagesAsJson: true, + live: { open: () => Effect.die(new Error("unexpected live WebSocket open")) }, + }) + const connection = yield* executor.open({ + url: "wss://example.test/realtime", + headers: Headers.fromInput({ "content-type": "application/json" }), + }) + yield* connection.sendText(JSON.stringify({ type: "response.create" })) + const messages: Array = [] + yield* connection.messages.pipe(Stream.runForEach((message) => Effect.sync(() => messages.push(message)))) + yield* connection.close + + expect(messages).toEqual([JSON.stringify({ type: "response.completed" })]) + }), + ) + }) + + test("records websocket interactions into the shared cassette service", async () => { + await runRecorder( + Effect.gen(function* () { + const cassette = yield* HttpRecorder.Cassette.Service + const executor = yield* HttpRecorder.makeWebSocketExecutor({ + name: "websocket/record", + mode: "record", + metadata: { provider: "test" }, + cassette, + live: { + open: () => + Effect.succeed({ + sendText: () => Effect.void, + messages: Stream.fromIterable([JSON.stringify({ type: "response.completed" })]), + close: Effect.void, + }), + }, + }) + const connection = yield* executor.open({ + url: "wss://example.test/realtime", + headers: Headers.fromInput({ "content-type": "application/json" }), + }) + yield* connection.sendText(JSON.stringify({ type: "response.create" })) + yield* connection.messages.pipe(Stream.runDrain) + yield* connection.close + + expect(yield* cassette.read("websocket/record")).toMatchObject({ + metadata: { name: "websocket/record", provider: "test" }, + interactions: [ + { + transport: "websocket", + open: { url: "wss://example.test/realtime", headers: { "content-type": "application/json" } }, + client: [{ kind: "text", body: JSON.stringify({ type: "response.create" }) }], + server: [{ kind: "text", body: JSON.stringify({ type: "response.completed" }) }], + }, + ], + }) + }), + ) + }) + + test("default matcher dispatches multi-interaction cassettes by request shape", async () => { + await run( + Effect.gen(function* () { + expect(yield* post("https://example.test/echo", { step: 2 })).toBe('{"reply":"second"}') + expect(yield* post("https://example.test/echo", { step: 1 })).toBe('{"reply":"first"}') + }), + ) + }) + + test("sequential dispatch returns recorded responses in order for identical requests", async () => { + await runWith( + "record-replay/retry", + { dispatch: "sequential" }, + Effect.gen(function* () { + expect(yield* post("https://example.test/poll", { id: "job_1" })).toBe('{"status":"pending"}') + expect(yield* post("https://example.test/poll", { id: "job_1" })).toBe('{"status":"complete"}') + }), + ) + }) + + test("default matcher returns the first match for identical requests", async () => { + await runWith( + "record-replay/retry", + {}, + Effect.gen(function* () { + expect(yield* post("https://example.test/poll", { id: "job_1" })).toBe('{"status":"pending"}') + expect(yield* post("https://example.test/poll", { id: "job_1" })).toBe('{"status":"pending"}') + }), + ) + }) + + test("sequential dispatch reports cursor exhaustion when more requests are made than recorded", async () => { + await runWith( + "record-replay/multi-step", + { dispatch: "sequential" }, + Effect.gen(function* () { + yield* post("https://example.test/echo", { step: 1 }) + yield* post("https://example.test/echo", { step: 2 }) + const exit = yield* Effect.exit(post("https://example.test/echo", { step: 3 })) + expect(Exit.isFailure(exit)).toBe(true) + }), + ) + }) + + test("sequential dispatch still validates each recorded request", async () => { + await runWith( + "record-replay/multi-step", + { dispatch: "sequential" }, + Effect.gen(function* () { + yield* post("https://example.test/echo", { step: 1 }) + const exit = yield* Effect.exit(post("https://example.test/echo", { step: 3 })) + expect(Exit.isFailure(exit)).toBe(true) + expect(failureText(exit)).toContain("$.step expected 2, received 3") + expect(yield* post("https://example.test/echo", { step: 2 })).toBe('{"reply":"second"}') + }), + ) + }) + + test("mismatch diagnostics show closest redacted request differences", async () => { + await run( + Effect.gen(function* () { + const exit = yield* Effect.exit( + post("https://example.test/echo?api_key=secret-value", { step: 3, token: "sk-123456789012345678901234" }), + ) + const message = failureText(exit) + expect(message).toContain("closest interaction: #1") + expect(message).toContain("url:") + expect(message).toContain("https://example.test/echo?api_key=%5BREDACTED%5D") + expect(message).toContain("body:") + expect(message).toContain("$.step expected 1, received 3") + expect(message).toContain('$.token expected undefined, received "[REDACTED]"') + expect(message).not.toContain("sk-123456789012345678901234") + }), + ) + }) +}) diff --git a/packages/http-recorder/tsconfig.json b/packages/http-recorder/tsconfig.json new file mode 100644 index 0000000000..2bc480ffbb --- /dev/null +++ b/packages/http-recorder/tsconfig.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "@tsconfig/bun/tsconfig.json", + "compilerOptions": { + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "noUncheckedIndexedAccess": false, + "plugins": [ + { + "name": "@effect/language-service", + "transform": "@effect/language-service/transform", + "namespaceImportPackages": ["effect", "@effect/*"] + } + ] + } +} diff --git a/packages/llm/AGENTS.md b/packages/llm/AGENTS.md new file mode 100644 index 0000000000..61d57cf06b --- /dev/null +++ b/packages/llm/AGENTS.md @@ -0,0 +1,294 @@ +# LLM Package Guide + +## Effect + +- Prefer `HttpClient.HttpClient` / `HttpClientResponse.HttpClientResponse` over web `fetch` / `Response` at package boundaries. +- Use `Stream.Stream` for streaming data flow. Avoid ad hoc async generators or manual web reader loops unless an Effect `Stream` API cannot model the behavior. +- Use Effect Schema codecs for JSON encode/decode (`Schema.fromJsonString(...)`) instead of direct `JSON.parse` / `JSON.stringify` in implementation code. +- In `Effect.gen`, yield yieldable errors directly (`return yield* new MyError(...)`) instead of `Effect.fail(new MyError(...))`. +- Use `Effect.void` instead of `Effect.succeed(undefined)` when the successful value is intentionally void. + +## Tests + +- Use `testEffect(...)` from `test/lib/effect.ts` for tests requiring Effect layers. +- Keep provider tests fixture-first. Live provider calls must stay behind `RECORD=true` and required API-key checks. + +## Architecture + +This package is an Effect Schema-first LLM core. The Schema classes in `src/schema/` are the canonical runtime data model. Convenience functions in `src/llm.ts` are thin constructors that return those same Schema class instances; they should improve callsites without creating a second model. + +### Request Flow + +The intended callsite is: + +```ts +const request = LLM.request({ + model: OpenAI.model("gpt-4o-mini", { apiKey }), + system: "You are concise.", + prompt: "Say hello.", +}) + +const response = yield* LLMClient.generate(request) +``` + +`LLM.request(...)` builds an `LLMRequest`. `LLMClient.generate(...)` selects a registered route by `request.model.route`, builds the provider-native body, asks the route's transport for a real `HttpClientRequest.HttpClientRequest`, sends it through `RequestExecutor.Service`, parses the provider stream into common `LLMEvent`s, and finally returns an `LLMResponse`. + +Use `LLMClient.stream(request)` when callers want incremental `LLMEvent`s. Use `LLMClient.generate(request)` when callers want those same events collected into an `LLMResponse`. Use `LLMClient.prepare(request)` to compile a request through the route pipeline without sending it — the optional `Body` type argument narrows `.body` to the route's native shape (e.g. `prepare(...)` returns a `PreparedRequestOf`). The runtime body is identical; the generic is a type-level assertion. + +Filter or narrow `LLMEvent` streams with `LLMEvent.is.*` (camelCase guards, e.g. `events.filter(LLMEvent.is.toolCall)`). The kebab-case `LLMEvent.guards["tool-call"]` form also works but prefer `is.*` in new code. + +### Routes + +A route is the registered, runnable composition of four orthogonal pieces: + +- **`Protocol`** (`src/route/protocol.ts`) — semantic API contract. Owns request body construction (`body.from`), the body schema (`body.schema`), the streaming-event schema (`stream.event`), and the event-to-`LLMEvent` state machine (`stream.step`). `Route.make(...)` validates and JSON-encodes the body from `body.schema` and decodes frames with `stream.event`. Examples: `OpenAIChat.protocol`, `OpenAIResponses.protocol`, `AnthropicMessages.protocol`, `Gemini.protocol`, `BedrockConverse.protocol`. +- **`Endpoint`** (`src/route/endpoint.ts`) — path construction. The host always lives on `model.baseURL`; the endpoint just supplies the path. `Endpoint.path("/chat/completions")` is the common case; pass a function for paths that embed the model id or a body field (e.g. `Endpoint.path(({ body }) => `/model/${body.modelId}/converse-stream`)`). +- **`Auth`** (`src/route/auth.ts`) — per-request transport authentication. Routes read `model.apiKey` at request time via `Auth.bearer` (the default; sets `Authorization: Bearer `) or `Auth.apiKeyHeader(name)` for providers that use a custom header (Anthropic `x-api-key`, Gemini `x-goog-api-key`). Routes that need per-request signing (Bedrock SigV4, future Vertex IAM, Azure AAD) implement `Auth` as a function that signs the body and merges signed headers into the result. +- **`Framing`** (`src/route/framing.ts`) — bytes → frames. SSE (`Framing.sse`) is shared; Bedrock keeps its AWS event-stream framing as a typed `Framing` value alongside its protocol. + +Compose them via `Route.make(...)`: + +```ts +export const route = Route.make({ + id: "openai-chat", + provider: "openai", + protocol: OpenAIChat.protocol, + transport: HttpTransport.httpJson({ + endpoint: Endpoint.path("/chat/completions"), + auth: Auth.bearer(), + framing: Framing.sse, + encodeBody, + }), + defaults: { + baseURL: "https://api.openai.com/v1", + capabilities: capabilities({ tools: { calls: true, streamingInput: true } }), + }, +}) +``` + +The four-axis decomposition is the reason DeepSeek, TogetherAI, Cerebras, Baseten, Fireworks, and DeepInfra all reuse `OpenAIChat.protocol` verbatim — each provider deployment is a 5-15 line `Route.make(...)` call instead of a 300-400 line route clone. Bug fixes in one protocol propagate to every consumer of that protocol in a single commit. + +When a provider ships a non-HTTP transport (OpenAI's WebSocket Responses backend, hypothetical bidirectional streaming APIs), the seam is `Transport` — `WebSocketTransport.json(...)` constructs a transport whose `prepare` builds a WebSocket URL and message and whose `frames` yields decoded text from the socket. Same protocol, different transport. + +### URL Construction + +`model.baseURL` is required; `Endpoint` only carries the path. Each protocol's `Route.make` includes a canonical URL in `defaults.baseURL` (e.g. `https://api.openai.com/v1`); provider helpers can override by passing `baseURL` in their input. Routes that have no canonical URL (OpenAI-compatible Chat, GitHub Copilot) set `baseURL: string` (required) on their input type so TypeScript catches a missing host at the call site. + +For providers where the URL is derived from typed inputs (Azure resource name, Bedrock region), the provider helper computes `baseURL` at model construction time. Use `AtLeastOne` from `route/auth-options.ts` for inputs that accept either of two derivation paths (Azure: `resourceName` or `baseURL`). + +### Provider Definitions + +Provider-facing APIs are defined with `Provider.make(...)` from `src/provider.ts`: + +```ts +export const provider = Provider.make({ + id: ProviderID.make("openai"), + model: responses, + apis: { responses, chat }, +}) + +export const model = provider.model +export const apis = provider.apis +``` + +Keep provider definitions small and explicit: + +- Use only `id`, `model`, and optional `apis` in `Provider.make(...)`. +- Use branded `ProviderID.make(...)` and `ModelID.make(...)` where ids are constructed directly. +- Use `model` for the default API path and `apis` for named provider-native alternatives such as OpenAI `responses` versus `chat`. +- Do not add author-facing `kind`, `version`, or `routes` fields. +- Export lower-level `routes` arrays separately only when advanced internal wiring needs them. +- Prefer `apiKey` as provider-specific sugar and `auth` as the explicit override; keep them mutually exclusive in provider option types with `ProviderAuthOption`. +- Resolve `apiKey` → `Auth` with `AuthOptions.bearer(options, "_API_KEY")` (it honors an explicit `auth` override and falls back to `Auth.config(envVar)` so missing keys surface a typed `Authentication` error rather than a runtime crash). + +Built-in providers are namespace modules from `src/providers/index.ts`, so aliases like `OpenAI.model(...)`, `OpenAI.responses(...)`, and `OpenAI.apis.chat(...)` are fine. External provider packages should default-export the `Provider.make(...)` result and may add named aliases if useful. + +### Folder layout + +``` +packages/llm/src/ + schema/ canonical Schema model, split by concern + ids.ts branded IDs, literal types, ProviderMetadata + options.ts Generation/Provider/Http options, Capabilities, Limits, ModelRef + messages.ts content parts, Message, ToolDefinition, LLMRequest + events.ts Usage, individual events, LLMEvent, PreparedRequest, LLMResponse + errors.ts error reasons, LLMError, ToolFailure + index.ts barrel + llm.ts request constructors and convenience helpers + route/ + index.ts @opencode-ai/llm/route advanced barrel + client.ts Route.make + LLMClient.prepare/stream/generate + executor.ts RequestExecutor service + transport error mapping + protocol.ts Protocol type + Protocol.make + endpoint.ts Endpoint type + Endpoint.path + auth.ts Auth type + Auth.bearer / Auth.apiKeyHeader / Auth.passthrough + auth-options.ts ProviderAuthOption shape, AuthOptions.bearer, AtLeastOne helper + framing.ts Framing type + Framing.sse + transport/ transport implementations + index.ts Transport type + HttpTransport / WebSocketTransport namespaces + http.ts HttpTransport.httpJson — POST + framing + websocket.ts WebSocketTransport.json + WebSocketExecutor service + protocols/ + shared.ts ProviderShared toolkit used inside protocol impls + openai-chat.ts protocol + route (compose OpenAIChat.protocol) + openai-responses.ts + anthropic-messages.ts + gemini.ts + bedrock-converse.ts + bedrock-event-stream.ts framing for AWS event-stream binary frames + openai-compatible-chat.ts route that reuses OpenAIChat.protocol, no canonical URL + utils/ per-protocol helpers (auth, cache, media, tool-stream, ...) + providers/ + openai-compatible.ts generic compatible helper + family model helpers + openai-compatible-profile.ts family defaults (deepseek, togetherai, ...) + azure.ts / amazon-bedrock.ts / github-copilot.ts / google.ts / xai.ts / openai.ts / anthropic.ts / openrouter.ts + tool.ts typed tool() helper + tool-runtime.ts implementation helpers for LLMClient tool execution +``` + +The dependency arrow points down: `providers/*.ts` files import `protocols`, `endpoint`, `auth`, and `framing`; protocols do not import provider metadata. Lower-level modules know nothing about specific providers. + +### Shared protocol helpers + +`ProviderShared` exports a small toolkit used inside protocol implementations to keep them focused on provider-native shapes: + +- `joinText(parts)` — joins an array of `TextPart` (or anything with a `.text`) with newlines. Use this anywhere a protocol flattens text content into a single string for a provider field. +- `parseToolInput(route, name, raw)` — Schema-decodes a tool-call argument string with the canonical "Invalid JSON input for `` tool call ``" error message. Treats empty input as `{}`. +- `parseJson(route, raw, message)` — generic JSON-via-Schema decode for non-tool bodies. +- `eventError(route, message, ...)` — typed `InvalidProviderOutput` constructor for stream-time decode failures. +- `validateWith(decoder)` — maps Schema decode errors to `InvalidRequest`. `Route.make(...)` uses this for body validation; lower-level routes can reuse it. +- `matchToolChoice(provider, choice, branches)` — branches over `LLMRequest["toolChoice"]` for provider-specific lowering. + +If you find yourself copying a 3-to-5-line snippet between two protocols, lift it into `ProviderShared` next to these helpers rather than duplicating. + +### Tools + +Tool loops are represented in common messages and events: + +```ts +const call = LLM.toolCall({ id: "call_1", name: "lookup", input: { query: "weather" } }) +const result = LLM.toolMessage({ id: "call_1", name: "lookup", result: { forecast: "sunny" } }) + +const followUp = LLM.request({ + model, + messages: [LLM.user("Weather?"), LLM.assistant([call]), result], +}) +``` + +Routes lower these into provider-native assistant tool-call messages and tool-result messages. Streaming providers should emit `tool-input-delta` events while arguments arrive, then a final `tool-call` event with parsed input. + +### Tool runtime + +`LLM.stream({ request, tools })` executes model-requested tools with full type safety. Plain `LLM.stream(request)` only streams the model; if `request.tools` contains schemas, tool calls are returned for the caller to handle. Use `toolExecution: "none"` to pass executable tool definitions as schemas without invoking handlers. Add `stopWhen` to opt into follow-up model rounds after tool results. + +```ts +const get_weather = tool({ + description: "Get current weather for a city", + parameters: Schema.Struct({ city: Schema.String }), + success: Schema.Struct({ temperature: Schema.Number, condition: Schema.String }), + execute: ({ city }) => + Effect.gen(function* () { + // city: string — typed from parameters Schema + const data = yield* WeatherApi.fetch(city) + return { temperature: data.temp, condition: data.cond } + // return type checked against success Schema + }), +}) + +const events = yield* LLM.stream({ + request, + tools: { get_weather, get_time, ... }, + stopWhen: LLM.stepCountIs(10), +}).pipe(Stream.runCollect) +``` + +The runtime: + +- Adds tool definitions (derived from each tool's `parameters` Schema via `Schema.toJsonSchemaDocument`) onto `request.tools`. +- Streams the model. +- On `tool-call`: looks up the named tool, decodes input against `parameters` Schema, dispatches to the typed `execute`, encodes the result against `success` Schema, emits `tool-result`. +- Emits local `tool-result` events in the same step by default. +- Loops only when `stopWhen` is provided and the step finishes with `tool-calls`, appending the assistant + tool messages. + +Handler dependencies (services, permissions, plugin hooks, abort handling) are closed over by the consumer at tool-construction time. The runtime's only environment requirement is `RequestExecutor.Service`. Build the tools record inside an `Effect.gen` once and reuse it across many runs. + +Errors must be expressed as `ToolFailure`. The runtime catches it and emits a `tool-error` event, then a `tool-result` of `type: "error"`, so the model can self-correct on the next step. Anything that is not a `ToolFailure` is treated as a defect and fails the stream. Three recoverable error paths produce `tool-error` events: + +- The model called an unknown tool name. +- Input failed the `parameters` Schema. +- The handler returned a `ToolFailure`. + +Provider-defined / hosted tools (Anthropic `web_search` / `code_execution` / `web_fetch`, OpenAI Responses `web_search_call` / `file_search_call` / `code_interpreter_call` / `mcp_call` / `local_shell_call` / `image_generation_call` / `computer_use_call`) pass through the runtime untouched: + +- Routes surface the model's call as a `tool-call` event with `providerExecuted: true`, and the provider's result as a matching `tool-result` event with `providerExecuted: true`. +- The runtime detects `providerExecuted` on `tool-call` and **skips client dispatch** — no handler is invoked and no `tool-error` is raised for "unknown tool". The provider already executed it. +- Both events are appended to the assistant message in `assistantContent` so the next round's history carries the call + result for context. Anthropic encodes them back as `server_tool_use` + `web_search_tool_result` (or `code_execution_tool_result` / `web_fetch_tool_result`) blocks; OpenAI Responses callers typically use `previous_response_id` instead of resending hosted-tool items. + +Add provider-defined tools to `request.tools` (no runtime entry needed). The matching route must know how to lower the tool definition into the provider-native shape; right now Anthropic accepts `web_search` / `code_execution` / `web_fetch` and OpenAI Responses accepts the hosted tool names listed above. + +## Protocol File Style + +Protocol files should look self-similar. Provider quirks belong behind named helpers so a new route can be reviewed by comparing the same sections across files. + +### Section order + +Use this order for every protocol module: + +1. Public model input +2. Request body schema +3. Streaming event schema +4. Parser state +5. Request body construction (`fromRequest`) +6. Stream parsing (`step` and per-event handlers) +7. Protocol and route +8. Model helper + +### Rules + +- Keep protocol files focused on the protocol. Move provider-specific projection, signing, media normalization, or other bulky transformations into `src/protocols/utils/*`. +- Use `Effect.fn("Provider.fromRequest")` for request body construction entrypoints. Use `Effect.fn(...)` for event handlers that yield effects; keep purely synchronous handlers as plain functions returning a `StepResult` that the dispatcher lifts via `Effect.succeed(...)`. +- Parser state owns terminal information. The state machine records finish reason, usage, and pending tool calls; emit one terminal `request-finish` (or `provider-error`) when a `terminal` event arrives. If a provider splits reason and usage across events, merge them in parser state before flushing. +- Emit exactly one terminal `request-finish` event for a completed response. Use `stream.terminal` to signal the run is over and have `step` emit the final event. +- Use shared helpers for repeated protocol policy such as text joining, usage totals, JSON parsing, and tool-call accumulation. `ToolStream` (`protocols/utils/tool-stream.ts`) accumulates streamed tool-call arguments uniformly. +- Make intentional provider differences explicit in helper names or comments. If two protocol files differ visually, the reason should be obvious from the names. +- Prefer dispatched per-event handlers (`onMessageStart`, `onContentBlockDelta`, ...) called from a small top-level `step` switch over a long if-chain. The dispatcher keeps the event surface visible at a glance. +- Keep tests in the same conceptual order as the protocol: basic prepare, tools prepare, unsupported lowering, text/usage parsing, tool streaming, finish reasons, provider errors. + +### Review checklist + +- Can the file be skimmed side-by-side with `openai-chat.ts` without hunting for equivalent sections? +- Are provider quirks named, isolated, and covered by focused tests? +- Does request body construction validate unsupported common content at the protocol boundary? +- Does stream parsing emit stable common events without leaking provider event order to callers? +- Does `toolChoice: "none"` behavior read as intentional? + +## Recording Tests + +Recorded tests use one cassette file per scenario. A cassette holds an ordered array of `{ request, response }` interactions, so multi-step flows (tool loops, retries, polling) record into a single file. Use `recordedTests({ prefix, requires })` and let the helper derive cassette names from test names: + +```ts +const recorded = recordedTests({ prefix: "openai-chat", requires: ["OPENAI_API_KEY"] }) + +recorded.effect("streams text", () => + Effect.gen(function* () { + // test body + }), +) +``` + +Replay is the default. `RECORD=true` records fresh cassettes and requires the listed env vars. Cassettes are written as pretty-printed JSON so multi-interaction diffs stay reviewable. + +Pass `provider`, `protocol`, and optional `tags` to `recordedTests(...)` / `recorded.effect.with(...)` so cassettes carry searchable metadata. Use recorded-test filters to replay or record a narrow subset without rewriting a whole file: + +- `RECORDED_PROVIDER=openai` matches tests tagged with `provider:openai`; comma-separated values are allowed. +- `RECORDED_PREFIX=openai-chat` matches cassette groups by `recordedTests({ prefix })`; comma-separated values are allowed. +- `RECORDED_TAGS=tool` requires all listed tags to be present, e.g. `RECORDED_TAGS=provider:togetherai,tool`. +- `RECORDED_TEST="streams text"` matches by test name, kebab-case test id, or cassette path. + +Filters apply in replay and record mode. Combine them with `RECORD=true` when refreshing only one provider or scenario. + +**Binary response bodies.** Most providers stream text (SSE, JSON). AWS Bedrock streams binary AWS event-stream frames whose CRC32 fields would be mangled by a UTF-8 round-trip — those bodies are stored as base64 with `bodyEncoding: "base64"` on the response snapshot. Detection is by `Content-Type` in `@opencode-ai/http-recorder` (currently `application/vnd.amazon.eventstream` and `application/octet-stream`); cassettes for SSE/JSON routes omit the field and decode as text. + +**Matching strategies.** Replay defaults to structural matching, which finds an interaction by comparing method, URL, allow-listed headers, and the canonical JSON body. This is the right choice for tool loops because each round's request differs (the message history grows). For scenarios where successive requests are byte-identical and expect different responses (retries, polling), pass `dispatch: "sequential"` in `RecordReplayOptions` — replay then walks the cassette in record order via an internal cursor. `scriptedResponses` (in `test/lib/http.ts`) is the deterministic counterpart for tests that don't need a live provider; it scripts response bodies in order without reading from disk. + +Do not blanket re-record an entire test file when adding one cassette. `RECORD=true` rewrites every recorded case that runs, and provider streams contain volatile IDs, timestamps, fingerprints, and obfuscation fields. Prefer deleting the one cassette you intend to refresh, or run a focused test pattern that only registers the scenario you want to record. Keep stable existing cassettes unchanged unless their request shape or expected behavior changed. diff --git a/packages/llm/example/tutorial.ts b/packages/llm/example/tutorial.ts new file mode 100644 index 0000000000..6b0b894b13 --- /dev/null +++ b/packages/llm/example/tutorial.ts @@ -0,0 +1,242 @@ +import { Config, Effect, Formatter, Layer, Schema, Stream } from "effect" +import { LLM, LLMClient, Provider, ProviderID, Tool, type ProviderModelOptions } from "@opencode-ai/llm" +import { Route, Auth, Endpoint, Framing, Protocol, RequestExecutor } from "@opencode-ai/llm/route" +import { OpenAI } from "@opencode-ai/llm/providers" + +/** + * A runnable walkthrough of the LLM package use-site API. + * + * Run from `packages/llm` with an OpenAI key in the environment: + * + * OPENAI_API_KEY=... bun example/tutorial.ts + * + * The file is intentionally written as a normal TypeScript program. You can + * hover imports and local values to see how the public API is typed. + */ + +const apiKey = Config.redacted("OPENAI_API_KEY") + +// 1. Pick a model. The provider helper records provider identity, protocol +// choice, capabilities, deployment options, authentication, and defaults. +const model = OpenAI.model("gpt-4o-mini", { + apiKey, + generation: { maxTokens: 160 }, + providerOptions: { + openai: { store: false }, + }, +}) + +// 2. Build a provider-neutral request. This is useful when reusing one request +// across generate and stream examples. +// +// Options can live on both the model and the request: +// +// - `generation`: common controls such as max tokens, temperature, topP/topK, +// penalties, seed, and stop sequences. +// - `providerOptions`: namespaced provider-native behavior. For example, +// OpenAI cache keys and store behavior, Anthropic thinking, Gemini thinking +// config, or OpenRouter routing/reasoning. +// - `http`: last-resort serializable overlays for final request body, headers, +// and query params. Prefer typed `providerOptions` when a field is stable. +// +// Model options are defaults. Request options override them for this call. +const request = LLM.request({ + model, + system: "You are concise and practical.", + prompt: "Tell me a joke", + generation: { maxTokens: 80, temperature: 0.7 }, + providerOptions: { + openai: { promptCacheKey: "tutorial-joke" }, + }, +}) + +// `http` is intentionally not needed for normal calls. This shows the shape for +// newly released provider fields before they deserve a typed provider option. +const rawOverlayExample = LLM.request({ + model, + prompt: "Show the final HTTP overlay shape.", + http: { + body: { metadata: { example: "tutorial" } }, + headers: { "x-opencode-tutorial": "1" }, + query: { debug: "1" }, + }, +}) + +// 3. `generate` sends the request and collects the event stream into one +// response object. `response.text` is the collected text output. +const generateOnce = Effect.gen(function* () { + const response = yield* LLM.generate(request) + + console.log("\n== generate ==") + console.log("generated text:", response.text) + console.log("usage", Formatter.formatJson(response.usage, { space: 2 })) +}) + +// 4. `stream` exposes provider output as common `LLMEvent`s for UIs that want +// incremental text, reasoning, tool input, usage, or finish events. +const streamText = LLM.stream(request).pipe( + Stream.tap((event) => + Effect.sync(() => { + if (event.type === "text-delta") process.stdout.write(`\ntext: ${event.text}`) + if (event.type === "request-finish") process.stdout.write(`\nfinish: ${event.reason}\n`) + }), + ), + Stream.runDrain, +) + +// 5. Tools are typed with Effect Schema. Passing tools to `LLMClient.stream` +// adds their definitions to the request and dispatches matching tool calls. +// Add `stopWhen` to opt into follow-up model rounds after tool results. +const tools = { + get_weather: Tool.make({ + description: "Get current weather for a city.", + parameters: Schema.Struct({ city: Schema.String }), + success: Schema.Struct({ forecast: Schema.String }), + execute: (input) => Effect.succeed({ forecast: `${input.city}: sunny, 72F` }), + }), +} + +const streamWithTools = LLM.stream({ + request: LLM.request({ + model, + prompt: "Use get_weather for San Francisco, then answer in one sentence.", + generation: { maxTokens: 80, temperature: 0 }, + }), + tools, + stopWhen: LLM.stepCountIs(3), +}).pipe( + Stream.tap((event) => + Effect.sync(() => { + if (event.type === "tool-call") console.log("tool call", event.name, event.input) + if (event.type === "tool-result") console.log("tool result", event.name, event.result) + if (event.type === "text-delta") process.stdout.write(event.text) + }), + ), + Stream.runDrain, +) + +// 6. `generateObject` is the structured-output helper. It forces a synthetic +// tool call internally, so the same call site works across providers instead of +// depending on provider-specific JSON mode flags. +const WeatherReport = Schema.Struct({ + city: Schema.String, + forecast: Schema.String, + highFahrenheit: Schema.Number, +}) + +const generateStructuredObject = Effect.gen(function* () { + const response = yield* LLM.generateObject({ + model, + system: "Return only structured weather data.", + prompt: "Give me today's weather for San Francisco.", + schema: WeatherReport, + generation: { maxTokens: 120, temperature: 0 }, + }) + + console.log("\n== generateObject ==") + console.log(Formatter.formatJson(response.object, { space: 2 })) +}) + +// If the shape is only known at runtime, pass raw JSON Schema instead. The +// `.object` type is `unknown`; callers that need static types should validate it. +const generateDynamicObject = LLM.generateObject({ + model, + prompt: "Extract the city and forecast from: San Francisco is sunny.", + jsonSchema: { + type: "object", + properties: { + city: { type: "string" }, + forecast: { type: "string" }, + }, + required: ["city", "forecast"], + }, +}) + +// ----------------------------------------------------------------------------- +// Part 2: provider composition with a fake provider +// ----------------------------------------------------------------------------- + +// A protocol is the provider-native API shape: common request -> body, response +// frames -> common events. This fake one turns text prompts into a JSON body +// and treats every SSE frame as output text. +const FakeBody = Schema.Struct({ + model: Schema.String, + input: Schema.String, +}) +type FakeBody = Schema.Schema.Type + +const FakeProtocol = Protocol.make({ + // Protocol ids are open strings, so external packages can define their own + // protocols without changing this package. + id: "fake-echo", + body: { + schema: FakeBody, + from: (request) => + Effect.succeed({ + model: request.model.id, + input: request.messages + .flatMap((message) => message.content) + .filter((part) => part.type === "text") + .map((part) => part.text) + .join("\n"), + }), + }, + stream: { + event: Schema.String, + initial: () => undefined, + step: (_, frame) => Effect.succeed([undefined, [{ type: "text-delta", text: frame }]] as const), + onHalt: () => [{ type: "request-finish", reason: "stop" }], + }, +}) + +// An route is the runnable binding for that protocol. It adds the deployment +// axes that the protocol deliberately does not know: URL, auth, and framing. +const FakeAdapter = Route.make({ + id: "fake-echo", + protocol: FakeProtocol, + endpoint: Endpoint.path("/v1/echo"), + auth: Auth.passthrough, + framing: Framing.sse, +}) + +// A provider module exports a Provider definition. The default `model` helper +// sets provider identity, protocol id, and the route id resolved by the registry. +const fakeEchoModel = Route.model(FakeAdapter, { provider: "fake-echo", baseURL: "https://fake.local" }) +const FakeEcho = Provider.make({ + id: ProviderID.make("fake-echo"), + model: (id: string, options: ProviderModelOptions = {}) => fakeEchoModel({ id, ...options }), +}) + +// `LLMClient.prepare` is the lower-level inspection hook: it compiles through +// body conversion, validation, endpoint, auth, and HTTP construction without +// sending anything over the network. +const inspectFakeProvider = Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + model: FakeEcho.model("tiny-echo"), + prompt: "Show me the provider pipeline.", + }), + ) + + console.log("\n== fake provider prepare ==") + console.log("route:", prepared.route) + console.log("body:", Formatter.formatJson(prepared.body, { space: 2 })) +}) + +// Provide the LLM runtime and the HTTP request executor once. Keep one path +// enabled at a time so the tutorial can demonstrate generate, prepare, stream, +// or tool-loop behavior without spending tokens on every example. +const requestExecutorLayer = RequestExecutor.defaultLayer +const llmClientLayer = LLMClient.layer.pipe(Layer.provide(requestExecutorLayer)) + +const program = Effect.gen(function* () { + // yield* generateOnce + // yield* inspectFakeProvider + // yield* LLMClient.prepare(rawOverlayExample).pipe(Effect.andThen((prepared) => Effect.sync(() => console.log(prepared.body)))) + // yield* streamText + // yield* generateStructuredObject + // yield* generateDynamicObject.pipe(Effect.andThen((response) => Effect.sync(() => console.log(response.object)))) + yield* streamWithTools +}).pipe(Effect.provide(Layer.mergeAll(requestExecutorLayer, llmClientLayer))) + +Effect.runPromise(program) diff --git a/packages/llm/package.json b/packages/llm/package.json new file mode 100644 index 0000000000..3a616f4093 --- /dev/null +++ b/packages/llm/package.json @@ -0,0 +1,51 @@ +{ + "$schema": "https://json.schemastore.org/package.json", + "version": "1.14.25", + "name": "@opencode-ai/llm", + "type": "module", + "license": "MIT", + "private": true, + "scripts": { + "setup:recording-env": "bun run script/setup-recording-env.ts", + "test": "bun test --timeout 30000", + "typecheck": "tsgo --noEmit" + }, + "exports": { + ".": "./src/index.ts", + "./route": "./src/route/index.ts", + "./provider": "./src/provider.ts", + "./providers": "./src/providers/index.ts", + "./providers/amazon-bedrock": "./src/providers/amazon-bedrock.ts", + "./providers/anthropic": "./src/providers/anthropic.ts", + "./providers/azure": "./src/providers/azure.ts", + "./providers/cloudflare": "./src/providers/cloudflare.ts", + "./providers/github-copilot": "./src/providers/github-copilot.ts", + "./providers/google": "./src/providers/google.ts", + "./providers/openai": "./src/providers/openai.ts", + "./providers/openai-compatible": "./src/providers/openai-compatible.ts", + "./providers/openai-compatible-profile": "./src/providers/openai-compatible-profile.ts", + "./providers/openrouter": "./src/providers/openrouter.ts", + "./providers/xai": "./src/providers/xai.ts", + "./protocols": "./src/protocols/index.ts", + "./protocols/anthropic-messages": "./src/protocols/anthropic-messages.ts", + "./protocols/bedrock-converse": "./src/protocols/bedrock-converse.ts", + "./protocols/gemini": "./src/protocols/gemini.ts", + "./protocols/openai-chat": "./src/protocols/openai-chat.ts", + "./protocols/openai-compatible-chat": "./src/protocols/openai-compatible-chat.ts", + "./protocols/openai-responses": "./src/protocols/openai-responses.ts" + }, + "devDependencies": { + "@clack/prompts": "1.0.0-alpha.1", + "@effect/platform-node": "catalog:", + "@opencode-ai/http-recorder": "workspace:*", + "@tsconfig/bun": "catalog:", + "@types/bun": "catalog:", + "@typescript/native-preview": "catalog:" + }, + "dependencies": { + "@smithy/eventstream-codec": "4.2.14", + "@smithy/util-utf8": "4.2.2", + "aws4fetch": "1.0.20", + "effect": "catalog:" + } +} diff --git a/packages/llm/script/recording-cost-report.ts b/packages/llm/script/recording-cost-report.ts new file mode 100644 index 0000000000..5b08e72d5c --- /dev/null +++ b/packages/llm/script/recording-cost-report.ts @@ -0,0 +1,250 @@ +import * as fs from "node:fs/promises" +import * as path from "node:path" + +const RECORDINGS_DIR = path.resolve(import.meta.dir, "..", "test", "fixtures", "recordings") +const MODELS_DEV_URL = "https://models.dev/api.json" + +type JsonRecord = Record + +type Pricing = { + readonly input?: number + readonly output?: number + readonly cache_read?: number + readonly cache_write?: number + readonly reasoning?: number +} + +type Usage = { + readonly inputTokens: number + readonly outputTokens: number + readonly cacheReadTokens: number + readonly cacheWriteTokens: number + readonly reasoningTokens: number + readonly reportedCost: number +} + +type Row = Usage & { + readonly cassette: string + readonly provider: string + readonly model: string + readonly estimatedCost: number + readonly pricingSource: string +} + +const isRecord = (value: unknown): value is JsonRecord => + value !== null && typeof value === "object" && !Array.isArray(value) + +const asNumber = (value: unknown) => (typeof value === "number" && Number.isFinite(value) ? value : 0) + +const asString = (value: unknown) => (typeof value === "string" ? value : undefined) + +const readJson = async (file: string) => JSON.parse(await Bun.file(file).text()) as unknown + +const walk = async (dir: string): Promise> => + (await fs.readdir(dir, { withFileTypes: true })) + .flatMap((entry) => { + const file = path.join(dir, entry.name) + return entry.isDirectory() ? [] : [file] + }) + .concat( + ...(await Promise.all( + (await fs.readdir(dir, { withFileTypes: true })) + .filter((entry) => entry.isDirectory()) + .map((entry) => walk(path.join(dir, entry.name))), + )), + ) + +const providerFromUrl = (url: string) => { + if (url.includes("api.openai.com")) return "openai" + if (url.includes("api.anthropic.com")) return "anthropic" + if (url.includes("generativelanguage.googleapis.com")) return "google" + if (url.includes("bedrock")) return "amazon-bedrock" + if (url.includes("openrouter.ai")) return "openrouter" + if (url.includes("api.x.ai")) return "xai" + if (url.includes("api.groq.com")) return "groq" + if (url.includes("api.deepseek.com")) return "deepseek" + if (url.includes("api.together.xyz")) return "togetherai" + return "unknown" +} + +const providerAliases: Record> = { + openai: ["openai"], + anthropic: ["anthropic"], + google: ["google"], + "amazon-bedrock": ["amazon-bedrock"], + openrouter: ["openrouter", "openai", "anthropic", "google"], + xai: ["xai"], + groq: ["groq"], + deepseek: ["deepseek"], + togetherai: ["togetherai"], +} + +const modelAliases = (model: string) => [ + model, + model.replace(/^models\//, ""), + model.replace(/-\d{8}$/, ""), + model.replace(/-\d{4}-\d{2}-\d{2}$/, ""), + model.replace(/-\d{4}-\d{2}-\d{2}$/, "").replace(/-\d{8}$/, ""), + model.replace(/^openai\//, ""), + model.replace(/^anthropic\//, ""), + model.replace(/^google\//, ""), +] + +const pricingFor = (models: JsonRecord, provider: string, model: string) => { + for (const providerID of providerAliases[provider] ?? [provider]) { + const providerEntry = models[providerID] + if (!isRecord(providerEntry) || !isRecord(providerEntry.models)) continue + for (const modelID of modelAliases(model)) { + const modelEntry = providerEntry.models[modelID] + if (isRecord(modelEntry) && isRecord(modelEntry.cost)) + return { pricing: modelEntry.cost as Pricing, source: `${providerID}/${modelID}` } + } + } + return { pricing: undefined, source: "missing" } +} + +const estimateCost = (usage: Usage, pricing: Pricing | undefined) => { + if (!pricing) return 0 + return ( + (usage.inputTokens * (pricing.input ?? 0) + + usage.outputTokens * (pricing.output ?? 0) + + usage.cacheReadTokens * (pricing.cache_read ?? 0) + + usage.cacheWriteTokens * (pricing.cache_write ?? 0) + + usage.reasoningTokens * (pricing.reasoning ?? 0)) / + 1_000_000 + ) +} + +const emptyUsage = (): Usage => ({ + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + reasoningTokens: 0, + reportedCost: 0, +}) + +const addUsage = (a: Usage, b: Usage): Usage => ({ + inputTokens: a.inputTokens + b.inputTokens, + outputTokens: a.outputTokens + b.outputTokens, + cacheReadTokens: a.cacheReadTokens + b.cacheReadTokens, + cacheWriteTokens: a.cacheWriteTokens + b.cacheWriteTokens, + reasoningTokens: a.reasoningTokens + b.reasoningTokens, + reportedCost: a.reportedCost + b.reportedCost, +}) + +const usageFromObject = (usage: unknown): Usage => { + if (!isRecord(usage)) return emptyUsage() + const promptDetails = isRecord(usage.prompt_tokens_details) ? usage.prompt_tokens_details : {} + const completionDetails = isRecord(usage.completion_tokens_details) ? usage.completion_tokens_details : {} + const inputDetails = isRecord(usage.input_tokens_details) ? usage.input_tokens_details : {} + const outputDetails = isRecord(usage.output_tokens_details) ? usage.output_tokens_details : {} + const cacheWriteTokens = asNumber(promptDetails.cache_write_tokens) + asNumber(inputDetails.cache_write_tokens) + return { + inputTokens: asNumber(usage.prompt_tokens) + asNumber(usage.input_tokens), + outputTokens: asNumber(usage.completion_tokens) + asNumber(usage.output_tokens), + cacheReadTokens: asNumber(promptDetails.cached_tokens) + asNumber(inputDetails.cached_tokens), + cacheWriteTokens, + reasoningTokens: asNumber(completionDetails.reasoning_tokens) + asNumber(outputDetails.reasoning_tokens), + reportedCost: asNumber(usage.cost), + } +} + +const jsonPayloads = (body: string) => + body + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.startsWith("data:")) + .map((line) => line.slice("data:".length).trim()) + .filter((line) => line !== "" && line !== "[DONE]") + .flatMap((line) => { + try { + return [JSON.parse(line) as unknown] + } catch { + return [] + } + }) + +const usageFromResponseBody = (body: string) => + jsonPayloads(body).reduce((usage, payload) => { + if (!isRecord(payload)) return usage + return addUsage( + usage, + addUsage( + usageFromObject(payload.usage), + usageFromObject(isRecord(payload.response) ? payload.response.usage : undefined), + ), + ) + }, emptyUsage()) + +const modelFromRequest = (request: unknown) => { + if (!isRecord(request)) return "unknown" + const requestBody = asString(request.body) + if (!requestBody) return "unknown" + try { + const body = JSON.parse(requestBody) as unknown + if (!isRecord(body)) return "unknown" + return asString(body.model) ?? "unknown" + } catch { + return "unknown" + } +} + +const rowFor = (models: JsonRecord, file: string, cassette: unknown): Row | undefined => { + if (!isRecord(cassette) || !Array.isArray(cassette.interactions)) return undefined + const first = cassette.interactions.find(isRecord) + if (!first || !isRecord(first.request)) return undefined + const provider = providerFromUrl(asString(first.request.url) ?? "") + const model = modelFromRequest(first.request) + const usage = cassette.interactions.filter(isRecord).reduce((total, interaction) => { + if (!isRecord(interaction.response)) return total + const responseBody = asString(interaction.response.body) + if (!responseBody) return total + return addUsage(total, usageFromResponseBody(responseBody)) + }, emptyUsage()) + const priced = pricingFor(models, provider, model) + return { + cassette: path.relative(RECORDINGS_DIR, file), + provider, + model, + ...usage, + estimatedCost: estimateCost(usage, priced.pricing), + pricingSource: priced.source, + } +} + +const money = (value: number) => (value === 0 ? "$0.000000" : `$${value.toFixed(6)}`) +const tokens = (value: number) => value.toLocaleString("en-US") + +const models = (await (await fetch(MODELS_DEV_URL)).json()) as JsonRecord +const rows = ( + await Promise.all( + (await walk(RECORDINGS_DIR)) + .filter((file) => file.endsWith(".json")) + .map(async (file) => rowFor(models, file, await readJson(file))), + ) +).filter((row): row is Row => row !== undefined) + +const totals = rows.reduce( + (total, row) => ({ + ...addUsage(total, row), + estimatedCost: total.estimatedCost + row.estimatedCost, + }), + { ...emptyUsage(), estimatedCost: 0 }, +) + +console.log("# Recording Cost Report") +console.log("") +console.log(`Pricing: ${MODELS_DEV_URL}`) +console.log(`Cassettes: ${rows.length}`) +console.log(`Reported cost: ${money(totals.reportedCost)}`) +console.log(`Estimated cost: ${money(totals.estimatedCost)}`) +console.log("") +console.log("| Provider | Model | Input | Output | Reasoning | Reported | Estimated | Pricing | Cassette |") +console.log("|---|---:|---:|---:|---:|---:|---:|---|---|") +for (const row of rows.toSorted((a, b) => b.reportedCost + b.estimatedCost - (a.reportedCost + a.estimatedCost))) { + if (row.inputTokens + row.outputTokens + row.reasoningTokens + row.reportedCost + row.estimatedCost === 0) continue + console.log( + `| ${row.provider} | ${row.model} | ${tokens(row.inputTokens)} | ${tokens(row.outputTokens)} | ${tokens(row.reasoningTokens)} | ${money(row.reportedCost)} | ${money(row.estimatedCost)} | ${row.pricingSource} | ${row.cassette} |`, + ) +} diff --git a/packages/llm/script/setup-recording-env.ts b/packages/llm/script/setup-recording-env.ts new file mode 100644 index 0000000000..945f2b2ada --- /dev/null +++ b/packages/llm/script/setup-recording-env.ts @@ -0,0 +1,537 @@ +#!/usr/bin/env bun + +import { NodeFileSystem } from "@effect/platform-node" +import * as path from "node:path" +import * as prompts from "@clack/prompts" +import { AwsV4Signer } from "aws4fetch" +import { Config, ConfigProvider, Effect, FileSystem, PlatformError, Redacted } from "effect" +import { FetchHttpClient, HttpClient, HttpClientRequest, type HttpClientResponse } from "effect/unstable/http" +import * as ProviderShared from "../src/protocols/shared" +import * as Cloudflare from "../src/providers/cloudflare" + +type Provider = { + readonly id: string + readonly label: string + readonly tier: "core" | "canary" | "compatible" | "optional" + readonly note: string + readonly vars: ReadonlyArray<{ + readonly name: string + readonly label?: string + readonly optional?: boolean + readonly secret?: boolean + }> + readonly validate?: (env: Env) => Effect.Effect +} + +type Env = Record + +const PROVIDERS: ReadonlyArray = [ + { + id: "openai", + label: "OpenAI", + tier: "core", + note: "Native OpenAI Chat / Responses recorded tests", + vars: [{ name: "OPENAI_API_KEY" }], + validate: (env) => validateBearer("https://api.openai.com/v1/models", Redacted.make(env.OPENAI_API_KEY)), + }, + { + id: "anthropic", + label: "Anthropic", + tier: "core", + note: "Native Anthropic Messages recorded tests", + vars: [{ name: "ANTHROPIC_API_KEY" }], + validate: (env) => + HttpClientRequest.get("https://api.anthropic.com/v1/models").pipe( + HttpClientRequest.setHeaders({ + "anthropic-version": "2023-06-01", + "x-api-key": Redacted.value(Redacted.make(env.ANTHROPIC_API_KEY)), + }), + executeRequest, + ), + }, + { + id: "google", + label: "Google Gemini", + tier: "core", + note: "Native Gemini recorded tests", + vars: [{ name: "GOOGLE_GENERATIVE_AI_API_KEY" }], + validate: (env) => + HttpClientRequest.get( + `https://generativelanguage.googleapis.com/v1beta/models?key=${encodeURIComponent(env.GOOGLE_GENERATIVE_AI_API_KEY)}`, + ).pipe(executeRequest), + }, + { + id: "bedrock", + label: "Amazon Bedrock", + tier: "core", + note: "Native Bedrock Converse recorded tests", + vars: [ + { name: "AWS_ACCESS_KEY_ID" }, + { name: "AWS_SECRET_ACCESS_KEY" }, + { name: "AWS_SESSION_TOKEN", optional: true }, + { name: "BEDROCK_RECORDING_REGION", optional: true }, + { name: "BEDROCK_MODEL_ID", optional: true }, + ], + validate: (env) => validateBedrock(env), + }, + { + id: "groq", + label: "Groq", + tier: "canary", + note: "Fast OpenAI-compatible canary for text/tool streaming", + vars: [{ name: "GROQ_API_KEY" }], + validate: (env) => validateBearer("https://api.groq.com/openai/v1/models", Redacted.make(env.GROQ_API_KEY)), + }, + { + id: "openrouter", + label: "OpenRouter", + tier: "canary", + note: "Router canary for OpenAI-compatible text/tool streaming", + vars: [{ name: "OPENROUTER_API_KEY" }], + validate: (env) => + validateChat({ + url: "https://openrouter.ai/api/v1/chat/completions", + token: Redacted.make(env.OPENROUTER_API_KEY), + model: "openai/gpt-4o-mini", + }), + }, + { + id: "xai", + label: "xAI", + tier: "canary", + note: "OpenAI-compatible xAI chat endpoint", + vars: [{ name: "XAI_API_KEY" }], + validate: (env) => validateBearer("https://api.x.ai/v1/models", Redacted.make(env.XAI_API_KEY)), + }, + { + id: "cloudflare-ai-gateway", + label: "Cloudflare AI Gateway", + tier: "canary", + note: "Cloudflare Unified/OpenAI-compatible gateway; supports provider/model ids like workers-ai/@cf/...", + vars: [ + { name: "CLOUDFLARE_ACCOUNT_ID", label: "Cloudflare account ID", secret: false }, + { name: "CLOUDFLARE_GATEWAY_ID", label: "Cloudflare AI Gateway ID (defaults to default)", optional: true, secret: false }, + { name: "CLOUDFLARE_API_TOKEN", label: "Cloudflare AI Gateway token" }, + ], + validate: (env) => + validateChat({ + url: `${Cloudflare.aiGatewayBaseURL({ + accountId: env.CLOUDFLARE_ACCOUNT_ID, + gatewayId: env.CLOUDFLARE_GATEWAY_ID || undefined, + })}/chat/completions`, + token: Redacted.make(envValue(env, Cloudflare.aiGatewayAuthEnvVars)), + tokenHeader: "cf-aig-authorization", + model: "workers-ai/@cf/meta/llama-3.1-8b-instruct", + }), + }, + { + id: "cloudflare-workers-ai", + label: "Cloudflare Workers AI", + tier: "canary", + note: "Direct Workers AI OpenAI-compatible endpoint; supports model ids like @cf/meta/...", + vars: [ + { name: "CLOUDFLARE_ACCOUNT_ID", label: "Cloudflare account ID", secret: false }, + { name: "CLOUDFLARE_API_KEY", label: "Cloudflare Workers AI API token" }, + ], + validate: (env) => + validateChat({ + url: `${Cloudflare.workersAIBaseURL({ accountId: env.CLOUDFLARE_ACCOUNT_ID })}/chat/completions`, + token: Redacted.make(envValue(env, Cloudflare.workersAIAuthEnvVars)), + model: "@cf/meta/llama-3.1-8b-instruct", + }), + }, + { + id: "deepseek", + label: "DeepSeek", + tier: "compatible", + note: "Existing OpenAI-compatible recorded tests", + vars: [{ name: "DEEPSEEK_API_KEY" }], + validate: (env) => validateBearer("https://api.deepseek.com/models", Redacted.make(env.DEEPSEEK_API_KEY)), + }, + { + id: "togetherai", + label: "TogetherAI", + tier: "compatible", + note: "Existing OpenAI-compatible text/tool recorded tests", + vars: [{ name: "TOGETHER_AI_API_KEY" }], + validate: (env) => validateBearer("https://api.together.xyz/v1/models", Redacted.make(env.TOGETHER_AI_API_KEY)), + }, + { + id: "mistral", + label: "Mistral", + tier: "optional", + note: "OpenAI-compatible bridge; native reasoning parity is follow-up work", + vars: [{ name: "MISTRAL_API_KEY" }], + validate: (env) => validateBearer("https://api.mistral.ai/v1/models", Redacted.make(env.MISTRAL_API_KEY)), + }, + { + id: "perplexity", + label: "Perplexity", + tier: "optional", + note: "OpenAI-compatible bridge; citations/search metadata are follow-up work", + vars: [{ name: "PERPLEXITY_API_KEY" }], + validate: (env) => validateBearer("https://api.perplexity.ai/models", Redacted.make(env.PERPLEXITY_API_KEY)), + }, + { + id: "venice", + label: "Venice", + tier: "optional", + note: "OpenAI-compatible bridge", + vars: [{ name: "VENICE_API_KEY" }], + validate: (env) => validateBearer("https://api.venice.ai/api/v1/models", Redacted.make(env.VENICE_API_KEY)), + }, + { + id: "cerebras", + label: "Cerebras", + tier: "optional", + note: "OpenAI-compatible bridge", + vars: [{ name: "CEREBRAS_API_KEY" }], + validate: (env) => validateBearer("https://api.cerebras.ai/v1/models", Redacted.make(env.CEREBRAS_API_KEY)), + }, + { + id: "deepinfra", + label: "DeepInfra", + tier: "optional", + note: "OpenAI-compatible bridge", + vars: [{ name: "DEEPINFRA_API_KEY" }], + validate: (env) => + validateBearer("https://api.deepinfra.com/v1/openai/models", Redacted.make(env.DEEPINFRA_API_KEY)), + }, + { + id: "fireworks", + label: "Fireworks", + tier: "optional", + note: "OpenAI-compatible bridge", + vars: [{ name: "FIREWORKS_API_KEY" }], + validate: (env) => + validateBearer("https://api.fireworks.ai/inference/v1/models", Redacted.make(env.FIREWORKS_API_KEY)), + }, + { + id: "baseten", + label: "Baseten", + tier: "optional", + note: "OpenAI-compatible bridge", + vars: [{ name: "BASETEN_API_KEY" }], + }, +] + +const args = process.argv.slice(2) +const hasFlag = (name: string) => args.includes(name) +const option = (name: string) => { + const index = args.indexOf(name) + if (index === -1) return undefined + return args[index + 1] +} + +const envPath = path.resolve(process.cwd(), option("--env") ?? ".env.local") +const checkOnly = hasFlag("--check") +const providerOption = option("--providers") +const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY) + +const envNames = Array.from(new Set(PROVIDERS.flatMap((provider) => provider.vars.map((item) => item.name)))) + +const providersForOption = (value: string | undefined) => { + if (!value || value === "recommended") + return PROVIDERS.filter((provider) => provider.tier === "core" || provider.tier === "canary") + if (value === "recorded") return PROVIDERS.filter((provider) => provider.tier !== "optional") + if (value === "all") return PROVIDERS + const ids = new Set( + value + .split(",") + .map((item) => item.trim()) + .filter(Boolean), + ) + return PROVIDERS.filter((provider) => ids.has(provider.id)) +} + +const chooseProviders = async () => { + if (providerOption) return providersForOption(providerOption) + return providersForOption("recommended") +} + +const catchMissingFile = (error: PlatformError.PlatformError) => { + if (error.reason._tag === "NotFound") return Effect.succeed("") + return Effect.fail(error) +} + +const readEnvFile = Effect.fn("RecordingEnv.readFile")(function* () { + const fileSystem = yield* FileSystem.FileSystem + return yield* fileSystem.readFileString(envPath).pipe(Effect.catch(catchMissingFile)) +}) + +const readConfigString = (provider: ConfigProvider.ConfigProvider, name: string) => + Config.string(name) + .parse(provider) + .pipe( + Effect.match({ + onFailure: () => undefined, + onSuccess: (value) => value, + }), + ) + +const parseEnv = Effect.fn("RecordingEnv.parseEnv")(function* (contents: string) { + const provider = ConfigProvider.fromDotEnvContents(contents) + return Object.fromEntries( + (yield* Effect.forEach(envNames, (name) => + readConfigString(provider, name).pipe(Effect.map((value) => [name, value] as const)), + )).filter((entry): entry is readonly [string, string] => entry[1] !== undefined), + ) +}) + +const quote = (value: string) => JSON.stringify(value) + +const status = (name: string, fileEnv: Env) => { + if (fileEnv[name]) return "file" + if (process.env[name]) return "shell" + return "missing" +} + +const statusLine = (provider: Provider, fileEnv: Env) => + [ + `${provider.label} (${provider.tier})`, + provider.note, + ...provider.vars.map((item) => { + const value = status(item.name, fileEnv) + const suffix = item.optional ? " optional" : "" + return ` ${value === "missing" ? "missing" : "set"} ${item.name}${suffix}${value === "shell" ? " (shell only)" : ""}` + }), + ].join("\n") + +const printStatus = (providers: ReadonlyArray, fileEnv: Env) => { + prompts.note(providers.map((provider) => statusLine(provider, fileEnv)).join("\n\n"), `Recording env: ${envPath}`) +} + +const exitIfCancel = (value: A | symbol): A => { + if (!prompts.isCancel(value)) return value as A + prompts.cancel("Cancelled") + process.exit(130) +} + +const upsertEnv = (contents: string, values: Env) => { + const names = Object.keys(values) + const seen = new Set() + const lines = contents.split(/\r?\n/).map((line) => { + const match = line.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/) + if (!match || !names.includes(match[1])) return line + seen.add(match[1]) + return `${match[1]}=${quote(values[match[1]])}` + }) + const missing = names.filter((name) => !seen.has(name)) + if (missing.length === 0) return lines.join("\n").replace(/\n*$/, "\n") + const prefix = lines.join("\n").trimEnd() + const block = [ + "", + "# Added by bun run setup:recording-env", + ...missing.map((name) => `${name}=${quote(values[name])}`), + ].join("\n") + return `${prefix}${block}\n` +} + +const providerRequiredStatus = (provider: Provider, fileEnv: Env) => { + const required = requiredVars(provider) + if (required.some((item) => status(item.name, fileEnv) === "missing")) return "missing" + if (required.some((item) => status(item.name, fileEnv) === "shell")) return "set in shell" + return "already added" +} + +const requiredVars = (provider: Provider) => provider.vars.filter((item) => !item.optional) + +const promptVars = (provider: Provider) => provider.vars.filter((item) => !item.optional || item.secret === false) + +const processEnv = (): Env => + Object.fromEntries(Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined)) + +const envValue = (env: Env, names: ReadonlyArray) => names.map((name) => env[name]).find(Boolean) ?? "" + +const envWithValues = (fileEnv: Env, values: Env): Env => ({ + ...processEnv(), + ...fileEnv, + ...values, +}) + +const responseError = Effect.fn("RecordingEnv.responseError")(function* ( + response: HttpClientResponse.HttpClientResponse, +) { + if (response.status >= 200 && response.status < 300) return undefined + const body = yield* response.text.pipe(Effect.catch(() => Effect.succeed(""))) + return `${response.status}${body ? `: ${body.slice(0, 180)}` : ""}` +}) + +const executeRequest = Effect.fn("RecordingEnv.executeRequest")(function* ( + request: HttpClientRequest.HttpClientRequest, +) { + const http = yield* HttpClient.HttpClient + return yield* http.execute(request).pipe(Effect.flatMap(responseError)) +}) + +const validateBearer = (url: string, token: Redacted.Redacted, headers: Record = {}) => + HttpClientRequest.get(url).pipe( + HttpClientRequest.setHeaders({ ...headers, authorization: `Bearer ${Redacted.value(token)}` }), + executeRequest, + ) + +const validateChat = (input: { + readonly url: string + readonly token: Redacted.Redacted + readonly tokenHeader?: string + readonly model: string + readonly headers?: Record +}) => + ProviderShared.jsonPost({ + url: input.url, + headers: { ...input.headers, [input.tokenHeader ?? "authorization"]: `Bearer ${Redacted.value(input.token)}` }, + body: ProviderShared.encodeJson({ + model: input.model, + messages: [{ role: "user", content: "Reply with exactly: ok" }], + max_tokens: 3, + temperature: 0, + }), + }).pipe(executeRequest) + +const validateBedrock = (env: Env) => + Effect.gen(function* () { + const request = yield* Effect.promise(() => + new AwsV4Signer({ + url: `https://bedrock.${env.BEDROCK_RECORDING_REGION || "us-east-1"}.amazonaws.com/foundation-models`, + method: "GET", + service: "bedrock", + region: env.BEDROCK_RECORDING_REGION || "us-east-1", + accessKeyId: env.AWS_ACCESS_KEY_ID, + secretAccessKey: env.AWS_SECRET_ACCESS_KEY, + sessionToken: env.AWS_SESSION_TOKEN || undefined, + }).sign(), + ) + return yield* HttpClientRequest.get(request.url.toString()).pipe( + HttpClientRequest.setHeaders(Object.fromEntries(request.headers.entries())), + executeRequest, + ) + }) + +const validateProvider = Effect.fn("RecordingEnv.validateProvider")(function* (provider: Provider, env: Env) { + return yield* (provider.validate?.(env) ?? Effect.succeed("no lightweight validator")).pipe( + Effect.catch((error) => { + if (error instanceof Error) return Effect.succeed(error.message) + return Effect.succeed(String(error)) + }), + ) +}) + +const validateProviders = Effect.fn("RecordingEnv.validateProviders")(function* ( + providers: ReadonlyArray, + env: Env, +) { + const spinner = prompts.spinner() + spinner.start("Validating credentials") + const results = yield* Effect.forEach( + providers, + (provider) => validateProvider(provider, env).pipe(Effect.map((error) => ({ provider, error }))), + { concurrency: 4 }, + ) + spinner.stop("Validation complete") + prompts.note( + results + .map( + (result) => + `${result.error ? "failed" : "ok"} ${result.provider.label}${result.error ? ` - ${result.error}` : ""}`, + ) + .join("\n"), + "Credential validation", + ) +}) + +const writeEnvFile = Effect.fn("RecordingEnv.writeFile")(function* (contents: string) { + const fileSystem = yield* FileSystem.FileSystem + yield* fileSystem.makeDirectory(path.dirname(envPath), { recursive: true }) + yield* fileSystem.writeFileString(envPath, contents, { mode: 0o600 }) +}) + +const prompt = (run: () => Promise) => Effect.promise(run).pipe(Effect.map(exitIfCancel)) + +const chooseConfigurableProviders = Effect.fn("RecordingEnv.chooseConfigurableProviders")(function* ( + providers: ReadonlyArray, + fileEnv: Env, +) { + const configurable = providers.filter((provider) => requiredVars(provider).length > 0) + const selected = yield* prompt>(() => + prompts.multiselect({ + message: "Select provider credentials to add or override", + options: configurable.map((provider) => ({ + value: provider.id, + label: provider.label, + hint: `${providerRequiredStatus(provider, fileEnv)} - ${requiredVars(provider) + .map((item) => item.name) + .join(", ")}`, + })), + initialValues: configurable + .filter((provider) => providerRequiredStatus(provider, fileEnv) === "missing") + .map((provider) => provider.id), + }), + ) + return configurable.filter((provider) => selected.includes(provider.id)) +}) + +const promptEnvVar = (item: Provider["vars"][number]) => + prompt(() => { + const input = { + message: item.label ?? item.name, + validate: (input: string | undefined) => { + if (item.optional) return undefined + return !input || input.length === 0 ? "Leave blank by pressing Esc/cancel, or paste a value" : undefined + }, + } + return item.secret === false ? prompts.text(input) : prompts.password(input) + }) + +const promptProviderValues = Effect.fn("RecordingEnv.promptProviderValues")(function* ( + providers: ReadonlyArray, +) { + const values: Env = {} + for (const provider of providers) { + prompts.log.info(`${provider.label}: ${provider.note}`) + for (const item of promptVars(provider)) { + if (values[item.name]) continue + const value = yield* promptEnvVar(item) + if (value !== "") values[item.name] = value + } + } + return values +}) + +const main = Effect.fn("RecordingEnv.main")(function* () { + prompts.intro("LLM recording credentials") + const contents = yield* readEnvFile() + const fileEnv = yield* parseEnv(contents) + const providers = yield* Effect.promise(() => chooseProviders()) + printStatus(providers, fileEnv) + if (checkOnly) { + prompts.outro("Check complete") + return + } + if (!interactive) { + prompts.outro("Run this command in a terminal to enter credentials") + return + } + + const selectedProviders = yield* chooseConfigurableProviders(providers, fileEnv) + const values = yield* promptProviderValues(selectedProviders) + + if (Object.keys(values).length === 0) { + prompts.outro("No changes") + return + } + + if ( + interactive && + (yield* prompt(() => prompts.confirm({ message: "Validate credentials before saving?", initialValue: true }))) + ) { + yield* validateProviders(selectedProviders, envWithValues(fileEnv, values)) + } + + yield* writeEnvFile(upsertEnv(contents, values)) + prompts.log.success( + `Saved ${Object.keys(values).length} value${Object.keys(values).length === 1 ? "" : "s"} to ${envPath}`, + ) + prompts.outro("Keep .env.local local. Store shared team credentials in a password manager or vault.") +}) + +await Effect.runPromise(main().pipe(Effect.provide(NodeFileSystem.layer), Effect.provide(FetchHttpClient.layer))) diff --git a/packages/llm/src/index.ts b/packages/llm/src/index.ts new file mode 100644 index 0000000000..f4adf4859a --- /dev/null +++ b/packages/llm/src/index.ts @@ -0,0 +1,35 @@ +export { LLMClient, modelLimits, modelRef } from "./route/client" +export { Auth } from "./route/auth" +export { Provider } from "./provider" +export type { + RouteModelInput, + RouteRoutedModelInput, + Interface as LLMClientShape, + Service as LLMClientService, + ModelRefInput, +} from "./route/client" +export * from "./schema" +export { Tool, ToolFailure, toDefinitions, tool } from "./tool" +export type { + AnyExecutableTool, + AnyTool, + ExecutableTool, + ExecutableTools, + Tool as ToolShape, + ToolExecute, + Tools, + ToolSchema, +} from "./tool" +export type { + RunOptions as ToolRunOptions, + RuntimeState as ToolRuntimeState, + StopCondition as ToolStopCondition, + ToolExecution, +} from "./tool-runtime" + +export * as LLM from "./llm" +export type { + Definition as ProviderDefinition, + ModelFactory as ProviderModelFactory, + ModelOptions as ProviderModelOptions, +} from "./provider" diff --git a/packages/llm/src/llm.ts b/packages/llm/src/llm.ts new file mode 100644 index 0000000000..21d88302ed --- /dev/null +++ b/packages/llm/src/llm.ts @@ -0,0 +1,224 @@ +import { Effect, JsonSchema, Schema } from "effect" +import { + LLMClient, + modelLimits, + modelRef, + type ModelRefInput, +} from "./route/client" +import { + GenerationOptions, + HttpOptions, + InvalidProviderOutputReason, + LLMError, + LLMEvent, + LLMRequest, + LLMResponse, + Message, + SystemPart, + ToolChoice, + ToolDefinition, + type ContentPart, + ToolCallPart, + ToolResultPart, +} from "./schema" +import { make as makeTool, type ToolSchema } from "./tool" + +export type ModelInput = ModelRefInput + +export type MessageInput = Message.Input + +export type ToolChoiceInput = ToolChoice.Input +export type ToolChoiceMode = ToolChoice.Mode + +export type ToolResultInput = Parameters[0] + +/** Input accepted by `LLM.request`, normalized into the canonical `LLMRequest` class. */ +export type RequestInput = Omit< + ConstructorParameters[0], + "system" | "messages" | "tools" | "toolChoice" | "generation" | "http" | "providerOptions" +> & { + readonly system?: string | SystemPart | ReadonlyArray + readonly prompt?: string | ContentPart | ReadonlyArray + readonly messages?: ReadonlyArray + readonly tools?: ReadonlyArray + readonly toolChoice?: ToolChoiceInput + readonly generation?: GenerationOptions.Input + readonly providerOptions?: ConstructorParameters[0]["providerOptions"] + readonly http?: HttpOptions.Input +} + +export const limits = modelLimits + +export const text = Message.text + +export const system = SystemPart.make + +export const message = Message.make + +export const user = Message.user + +export const assistant = Message.assistant + +export const model = modelRef + +export const toolDefinition = ToolDefinition.make + +export const toolCall = ToolCallPart.make + +export const toolResult = ToolResultPart.make + +export const toolMessage = Message.tool + +export const toolChoiceName = ToolChoice.named + +export const toolChoice = ToolChoice.make + +export const generation = GenerationOptions.make + +export const generate = LLMClient.generate + +export const stream = LLMClient.stream + +export const stepCountIs = LLMClient.stepCountIs + +export const requestInput = (input: LLMRequest): RequestInput => ({ + ...LLMRequest.input(input), +}) + +export const request = (input: RequestInput) => { + const { + system: requestSystem, + prompt, + messages, + tools, + toolChoice: requestToolChoice, + generation: requestGeneration, + providerOptions: requestProviderOptions, + http: requestHttp, + ...rest + } = input + return new LLMRequest({ + ...rest, + system: SystemPart.content(requestSystem), + messages: [...(messages?.map(message) ?? []), ...(prompt === undefined ? [] : [user(prompt)])], + tools: tools?.map(toolDefinition) ?? [], + toolChoice: requestToolChoice ? toolChoice(requestToolChoice) : undefined, + generation: requestGeneration === undefined ? undefined : generation(requestGeneration), + providerOptions: requestProviderOptions, + http: requestHttp === undefined ? undefined : HttpOptions.make(requestHttp), + }) +} + +export const updateRequest = (input: LLMRequest, patch: Partial) => + request({ ...requestInput(input), ...patch }) + +const GENERATE_OBJECT_TOOL_NAME = "generate_object" + +const GENERATE_OBJECT_TOOL_DESCRIPTION = "Return the structured result by calling this tool." + +type GenerateObjectBase = Omit + +export class GenerateObjectResponse { + constructor( + readonly object: T, + readonly response: LLMResponse, + ) {} + + get events() { + return this.response.events + } + + get usage() { + return this.response.usage + } +} + +export interface GenerateObjectOptions> extends GenerateObjectBase { + readonly schema: S +} + +export interface GenerateObjectDynamicOptions extends GenerateObjectBase { + /** Raw JSON Schema object describing the expected output shape. */ + readonly jsonSchema: JsonSchema.JsonSchema +} + +const runGenerateObject = Effect.fn("LLM.generateObject")(function* ( + options: GenerateObjectBase, + tool: ReturnType, +) { + const baseRequest = request(options) + const generateRequest = LLMRequest.update(baseRequest, { + toolChoice: ToolChoice.named(GENERATE_OBJECT_TOOL_NAME), + }) + const response = yield* LLMClient.generate({ + request: generateRequest, + tools: { [GENERATE_OBJECT_TOOL_NAME]: tool }, + toolExecution: "none", + }) + const call = response.toolCalls.find( + (event) => LLMEvent.is.toolCall(event) && event.name === GENERATE_OBJECT_TOOL_NAME, + ) + if (!call || !LLMEvent.is.toolCall(call)) + return yield* new LLMError({ + module: "LLM", + method: "generateObject", + reason: new InvalidProviderOutputReason({ + message: `generateObject: model did not call the forced \`${GENERATE_OBJECT_TOOL_NAME}\` tool`, + }), + }) + const object = yield* tool._decode(call.input).pipe( + Effect.mapError( + (error) => + new LLMError({ + module: "LLM", + method: "generateObject", + reason: new InvalidProviderOutputReason({ + message: `generateObject: tool input failed schema decode: ${error.message}`, + }), + }), + ), + ) + return new GenerateObjectResponse(object, response) +}) + +/** + * Run a model and decode its output against `schema`. Works on every protocol + * because it forces a synthetic tool call internally — provider-native JSON + * modes are intentionally avoided so behaviour is uniform. + * + * Two input modes: + * + * 1. `schema: EffectSchema` — `.object` is decoded and typed as `T`. + * Decode failures surface as `LLMError`. + * 2. `jsonSchema: JsonSchema.JsonSchema` — `.object` is `unknown`. Use when + * the schema is only available at runtime (MCP, plugin manifests). Caller validates. + */ +export function generateObject>( + options: GenerateObjectOptions, +): Effect.Effect>, LLMError> +export function generateObject(options: GenerateObjectDynamicOptions): Effect.Effect, LLMError> +export function generateObject( + options: GenerateObjectOptions> | GenerateObjectDynamicOptions, +) { + if ("schema" in options) { + const { schema, ...rest } = options + return runGenerateObject( + rest, + makeTool({ + description: GENERATE_OBJECT_TOOL_DESCRIPTION, + parameters: schema, + success: Schema.Unknown as ToolSchema, + execute: () => Effect.void, + }), + ) + } + const { jsonSchema, ...rest } = options + return runGenerateObject( + rest, + makeTool({ + description: GENERATE_OBJECT_TOOL_DESCRIPTION, + jsonSchema, + execute: () => Effect.void, + }), + ) +} diff --git a/packages/llm/src/protocols/anthropic-messages.ts b/packages/llm/src/protocols/anthropic-messages.ts new file mode 100644 index 0000000000..ff2239c0d7 --- /dev/null +++ b/packages/llm/src/protocols/anthropic-messages.ts @@ -0,0 +1,592 @@ +import { Effect, Schema } from "effect" +import { Route } from "../route/client" +import { Auth } from "../route/auth" +import { Endpoint } from "../route/endpoint" +import { Framing } from "../route/framing" +import { Protocol } from "../route/protocol" +import { + Usage, + type CacheHint, + type FinishReason, + type LLMEvent, + type LLMRequest, + type ProviderMetadata, + type ToolCallPart, + type ToolDefinition, + type ToolResultPart, +} from "../schema" +import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared" +import { ToolStream } from "./utils/tool-stream" + +const ADAPTER = "anthropic-messages" +export const DEFAULT_BASE_URL = "https://api.anthropic.com/v1" +export const PATH = "/messages" + +// ============================================================================= +// Request Body Schema +// ============================================================================= +const AnthropicCacheControl = Schema.Struct({ type: Schema.tag("ephemeral") }) + +const AnthropicTextBlock = Schema.Struct({ + type: Schema.tag("text"), + text: Schema.String, + cache_control: Schema.optional(AnthropicCacheControl), +}) +type AnthropicTextBlock = Schema.Schema.Type + +const AnthropicThinkingBlock = Schema.Struct({ + type: Schema.tag("thinking"), + thinking: Schema.String, + signature: Schema.optional(Schema.String), + cache_control: Schema.optional(AnthropicCacheControl), +}) + +const AnthropicToolUseBlock = Schema.Struct({ + type: Schema.tag("tool_use"), + id: Schema.String, + name: Schema.String, + input: Schema.Unknown, + cache_control: Schema.optional(AnthropicCacheControl), +}) +type AnthropicToolUseBlock = Schema.Schema.Type + +const AnthropicServerToolUseBlock = Schema.Struct({ + type: Schema.tag("server_tool_use"), + id: Schema.String, + name: Schema.String, + input: Schema.Unknown, + cache_control: Schema.optional(AnthropicCacheControl), +}) +type AnthropicServerToolUseBlock = Schema.Schema.Type + +// Server tool result blocks: web_search_tool_result, code_execution_tool_result, +// and web_fetch_tool_result. The provider executes the tool and inlines the +// structured result into the assistant turn — there is no client tool_result +// round-trip. We round-trip the structured `content` payload as opaque JSON so +// the next request can echo it back when continuing the conversation. +const AnthropicServerToolResultType = Schema.Literals([ + "web_search_tool_result", + "code_execution_tool_result", + "web_fetch_tool_result", +]) +type AnthropicServerToolResultType = Schema.Schema.Type + +const AnthropicServerToolResultBlock = Schema.Struct({ + type: AnthropicServerToolResultType, + tool_use_id: Schema.String, + content: Schema.Unknown, + cache_control: Schema.optional(AnthropicCacheControl), +}) +type AnthropicServerToolResultBlock = Schema.Schema.Type + +const AnthropicToolResultBlock = Schema.Struct({ + type: Schema.tag("tool_result"), + tool_use_id: Schema.String, + content: Schema.String, + is_error: Schema.optional(Schema.Boolean), + cache_control: Schema.optional(AnthropicCacheControl), +}) + +const AnthropicUserBlock = Schema.Union([AnthropicTextBlock, AnthropicToolResultBlock]) +const AnthropicAssistantBlock = Schema.Union([ + AnthropicTextBlock, + AnthropicThinkingBlock, + AnthropicToolUseBlock, + AnthropicServerToolUseBlock, + AnthropicServerToolResultBlock, +]) +type AnthropicAssistantBlock = Schema.Schema.Type +type AnthropicToolResultBlock = Schema.Schema.Type + +const AnthropicMessage = Schema.Union([ + Schema.Struct({ role: Schema.Literal("user"), content: Schema.Array(AnthropicUserBlock) }), + Schema.Struct({ role: Schema.Literal("assistant"), content: Schema.Array(AnthropicAssistantBlock) }), +]).pipe(Schema.toTaggedUnion("role")) +type AnthropicMessage = Schema.Schema.Type + +const AnthropicTool = Schema.Struct({ + name: Schema.String, + description: Schema.String, + input_schema: JsonObject, + cache_control: Schema.optional(AnthropicCacheControl), +}) +type AnthropicTool = Schema.Schema.Type + +const AnthropicToolChoice = Schema.Union([ + Schema.Struct({ type: Schema.Literals(["auto", "any"]) }), + Schema.Struct({ type: Schema.tag("tool"), name: Schema.String }), +]) + +const AnthropicThinking = Schema.Struct({ + type: Schema.tag("enabled"), + budget_tokens: Schema.Number, +}) + +const AnthropicBodyFields = { + model: Schema.String, + system: optionalArray(AnthropicTextBlock), + messages: Schema.Array(AnthropicMessage), + tools: optionalArray(AnthropicTool), + tool_choice: Schema.optional(AnthropicToolChoice), + stream: Schema.Literal(true), + max_tokens: Schema.Number, + temperature: Schema.optional(Schema.Number), + top_p: Schema.optional(Schema.Number), + top_k: Schema.optional(Schema.Number), + stop_sequences: optionalArray(Schema.String), + thinking: Schema.optional(AnthropicThinking), +} +const AnthropicMessagesBody = Schema.Struct(AnthropicBodyFields) +export type AnthropicMessagesBody = Schema.Schema.Type + +const AnthropicUsage = Schema.Struct({ + input_tokens: Schema.optional(Schema.Number), + output_tokens: Schema.optional(Schema.Number), + cache_creation_input_tokens: optionalNull(Schema.Number), + cache_read_input_tokens: optionalNull(Schema.Number), +}) +type AnthropicUsage = Schema.Schema.Type + +const AnthropicStreamBlock = Schema.Struct({ + type: Schema.String, + id: Schema.optional(Schema.String), + name: Schema.optional(Schema.String), + text: Schema.optional(Schema.String), + thinking: Schema.optional(Schema.String), + signature: Schema.optional(Schema.String), + input: Schema.optional(Schema.Unknown), + // *_tool_result blocks arrive whole as content_block_start (no streaming + // delta) with the structured payload in `content` and the originating + // server_tool_use id in `tool_use_id`. + tool_use_id: Schema.optional(Schema.String), + content: Schema.optional(Schema.Unknown), +}) + +const AnthropicStreamDelta = Schema.Struct({ + type: Schema.optional(Schema.String), + text: Schema.optional(Schema.String), + thinking: Schema.optional(Schema.String), + partial_json: Schema.optional(Schema.String), + signature: Schema.optional(Schema.String), + stop_reason: optionalNull(Schema.String), + stop_sequence: optionalNull(Schema.String), +}) + +const AnthropicEvent = Schema.Struct({ + type: Schema.String, + index: Schema.optional(Schema.Number), + message: Schema.optional(Schema.Struct({ usage: Schema.optional(AnthropicUsage) })), + content_block: Schema.optional(AnthropicStreamBlock), + delta: Schema.optional(AnthropicStreamDelta), + usage: Schema.optional(AnthropicUsage), + error: Schema.optional(Schema.Struct({ type: Schema.String, message: Schema.String })), +}) +type AnthropicEvent = Schema.Schema.Type + +interface ParserState { + readonly tools: ToolStream.State + readonly usage?: Usage +} + +const invalid = ProviderShared.invalidRequest + +// ============================================================================= +// Request Lowering +// ============================================================================= +const cacheControl = (cache: CacheHint | undefined) => + cache?.type === "ephemeral" ? { type: "ephemeral" as const } : undefined + +const anthropicMetadata = (metadata: Record): ProviderMetadata => ({ anthropic: metadata }) + +const signatureFromMetadata = (metadata: ProviderMetadata | undefined): string | undefined => { + const anthropic = metadata?.anthropic + if (!ProviderShared.isRecord(anthropic)) return undefined + return typeof anthropic.signature === "string" ? anthropic.signature : undefined +} + +const lowerTool = (tool: ToolDefinition): AnthropicTool => ({ + name: tool.name, + description: tool.description, + input_schema: tool.inputSchema, +}) + +const lowerToolChoice = (toolChoice: NonNullable) => + ProviderShared.matchToolChoice("Anthropic Messages", toolChoice, { + auto: () => ({ type: "auto" as const }), + none: () => undefined, + required: () => ({ type: "any" as const }), + tool: (name) => ({ type: "tool" as const, name }), + }) + +const lowerToolCall = (part: ToolCallPart): AnthropicToolUseBlock => ({ + type: "tool_use", + id: part.id, + name: part.name, + input: part.input, +}) + +const lowerServerToolCall = (part: ToolCallPart): AnthropicServerToolUseBlock => ({ + type: "server_tool_use", + id: part.id, + name: part.name, + input: part.input, +}) + +// Server tool result blocks are typed by name. Anthropic ships three today; +// extend this list when new server tools land. The block content is the +// structured payload returned by the provider, which we round-trip as-is. +const serverToolResultType = (name: string): AnthropicServerToolResultType | undefined => { + if (name === "web_search") return "web_search_tool_result" + if (name === "code_execution") return "code_execution_tool_result" + if (name === "web_fetch") return "web_fetch_tool_result" + return undefined +} + +const lowerServerToolResult = Effect.fn("AnthropicMessages.lowerServerToolResult")(function* (part: ToolResultPart) { + const wireType = serverToolResultType(part.name) + if (!wireType) + return yield* invalid(`Anthropic Messages does not know how to round-trip server tool result for ${part.name}`) + return { type: wireType, tool_use_id: part.id, content: part.result.value } satisfies AnthropicServerToolResultBlock +}) + +const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (request: LLMRequest) { + const messages: AnthropicMessage[] = [] + + for (const message of request.messages) { + if (message.role === "user") { + const content: AnthropicTextBlock[] = [] + for (const part of message.content) { + if (!ProviderShared.supportsContent(part, ["text"])) + return yield* ProviderShared.unsupportedContent("Anthropic Messages", "user", ["text"]) + content.push({ type: "text", text: part.text, cache_control: cacheControl(part.cache) }) + } + messages.push({ role: "user", content }) + continue + } + + if (message.role === "assistant") { + const content: AnthropicAssistantBlock[] = [] + for (const part of message.content) { + if (part.type === "text") { + content.push({ type: "text", text: part.text, cache_control: cacheControl(part.cache) }) + continue + } + if (part.type === "reasoning") { + content.push({ + type: "thinking", + thinking: part.text, + signature: part.encrypted ?? signatureFromMetadata(part.providerMetadata), + }) + continue + } + if (part.type === "tool-call") { + content.push(part.providerExecuted ? lowerServerToolCall(part) : lowerToolCall(part)) + continue + } + if (part.type === "tool-result" && part.providerExecuted) { + content.push(yield* lowerServerToolResult(part)) + continue + } + return yield* invalid( + `Anthropic Messages assistant messages only support text, reasoning, and tool-call content for now`, + ) + } + messages.push({ role: "assistant", content }) + continue + } + + const content: AnthropicToolResultBlock[] = [] + for (const part of message.content) { + if (!ProviderShared.supportsContent(part, ["tool-result"])) + return yield* ProviderShared.unsupportedContent("Anthropic Messages", "tool", ["tool-result"]) + content.push({ + type: "tool_result", + tool_use_id: part.id, + content: ProviderShared.toolResultText(part), + is_error: part.result.type === "error" ? true : undefined, + }) + } + messages.push({ role: "user", content }) + } + + return messages +}) + +const anthropicOptions = (request: LLMRequest) => request.providerOptions?.anthropic + +const lowerThinking = Effect.fn("AnthropicMessages.lowerThinking")(function* (request: LLMRequest) { + const thinking = anthropicOptions(request)?.thinking + if (!ProviderShared.isRecord(thinking) || thinking.type !== "enabled") return undefined + const budget = + typeof thinking.budgetTokens === "number" + ? thinking.budgetTokens + : typeof thinking.budget_tokens === "number" + ? thinking.budget_tokens + : undefined + if (budget === undefined) return yield* invalid("Anthropic thinking provider option requires budgetTokens") + return { type: "enabled" as const, budget_tokens: budget } +}) + +const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (request: LLMRequest) { + const toolChoice = request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined + const generation = request.generation + return { + model: request.model.id, + system: + request.system.length === 0 + ? undefined + : request.system.map((part) => ({ + type: "text" as const, + text: part.text, + cache_control: cacheControl(part.cache), + })), + messages: yield* lowerMessages(request), + tools: request.tools.length === 0 || request.toolChoice?.type === "none" ? undefined : request.tools.map(lowerTool), + tool_choice: toolChoice, + stream: true as const, + max_tokens: generation?.maxTokens ?? request.model.limits.output ?? 4096, + temperature: generation?.temperature, + top_p: generation?.topP, + top_k: generation?.topK, + stop_sequences: generation?.stop, + thinking: yield* lowerThinking(request), + } +}) + +// ============================================================================= +// Stream Parsing +// ============================================================================= +const mapFinishReason = (reason: string | null | undefined): FinishReason => { + if (reason === "end_turn" || reason === "stop_sequence" || reason === "pause_turn") return "stop" + if (reason === "max_tokens") return "length" + if (reason === "tool_use") return "tool-calls" + if (reason === "refusal") return "content-filter" + return "unknown" +} + +const mapUsage = (usage: AnthropicUsage | undefined): Usage | undefined => { + if (!usage) return undefined + return new Usage({ + inputTokens: usage.input_tokens, + outputTokens: usage.output_tokens, + cacheReadInputTokens: usage.cache_read_input_tokens ?? undefined, + cacheWriteInputTokens: usage.cache_creation_input_tokens ?? undefined, + totalTokens: ProviderShared.totalTokens(usage.input_tokens, usage.output_tokens, undefined), + native: usage, + }) +} + +// Anthropic emits usage on `message_start` and again on `message_delta` — the +// final delta carries the authoritative totals. Right-biased merge: each +// field prefers `right` when defined, falls back to `left`. `totalTokens` is +// recomputed from the merged input/output to stay consistent. +const mergeUsage = (left: Usage | undefined, right: Usage | undefined) => { + if (!left) return right + if (!right) return left + const inputTokens = right.inputTokens ?? left.inputTokens + const outputTokens = right.outputTokens ?? left.outputTokens + return new Usage({ + inputTokens, + outputTokens, + cacheReadInputTokens: right.cacheReadInputTokens ?? left.cacheReadInputTokens, + cacheWriteInputTokens: right.cacheWriteInputTokens ?? left.cacheWriteInputTokens, + totalTokens: ProviderShared.totalTokens(inputTokens, outputTokens, undefined), + native: { ...left.native, ...right.native }, + }) +} + +// Server tool result blocks come whole in `content_block_start` (no streaming +// delta sequence). We convert the payload to a `tool-result` event with +// `providerExecuted: true`. The runtime appends it to the assistant message +// for round-trip; downstream consumers can inspect `result.value` for the +// structured payload. +const SERVER_TOOL_RESULT_NAMES: Record = { + web_search_tool_result: "web_search", + code_execution_tool_result: "code_execution", + web_fetch_tool_result: "web_fetch", +} + +const isServerToolResultType = (type: string): type is AnthropicServerToolResultType => type in SERVER_TOOL_RESULT_NAMES + +const serverToolResultEvent = (block: NonNullable): LLMEvent | undefined => { + if (!block.type || !isServerToolResultType(block.type)) return undefined + const errorPayload = + typeof block.content === "object" && block.content !== null && "type" in block.content + ? String((block.content as Record).type) + : "" + const isError = errorPayload.endsWith("_tool_result_error") + return { + type: "tool-result", + id: block.tool_use_id ?? "", + name: SERVER_TOOL_RESULT_NAMES[block.type], + result: isError ? { type: "error", value: block.content } : { type: "json", value: block.content }, + providerExecuted: true, + providerMetadata: anthropicMetadata({ blockType: block.type }), + } +} + +type StepResult = readonly [ParserState, ReadonlyArray] + +const NO_EVENTS: StepResult["1"] = [] + +const onMessageStart = (state: ParserState, event: AnthropicEvent): StepResult => { + const usage = mapUsage(event.message?.usage) + return [usage ? { ...state, usage: mergeUsage(state.usage, usage) } : state, NO_EVENTS] +} + +const onContentBlockStart = (state: ParserState, event: AnthropicEvent): StepResult => { + const block = event.content_block + if (!block) return [state, NO_EVENTS] + + if ((block.type === "tool_use" || block.type === "server_tool_use") && event.index !== undefined) { + return [ + { + ...state, + tools: ToolStream.start(state.tools, event.index, { + id: block.id ?? String(event.index), + name: block.name ?? "", + providerExecuted: block.type === "server_tool_use", + }), + }, + NO_EVENTS, + ] + } + + if (block.type === "text" && block.text) { + return [state, [{ type: "text-delta", text: block.text }]] + } + + if (block.type === "thinking" && block.thinking) { + return [ + state, + [ + { + type: "reasoning-delta", + text: block.thinking, + ...(block.signature ? { providerMetadata: anthropicMetadata({ signature: block.signature }) } : {}), + }, + ], + ] + } + + const result = serverToolResultEvent(block) + return [state, result ? [result] : NO_EVENTS] +} + +const onContentBlockDelta = Effect.fn("AnthropicMessages.onContentBlockDelta")(function* ( + state: ParserState, + event: AnthropicEvent, +) { + const delta = event.delta + + if (delta?.type === "text_delta" && delta.text) { + return [state, [{ type: "text-delta", text: delta.text }]] satisfies StepResult + } + + if (delta?.type === "thinking_delta" && delta.thinking) { + return [state, [{ type: "reasoning-delta", text: delta.thinking }]] satisfies StepResult + } + + if (delta?.type === "signature_delta" && delta.signature) { + return [ + state, + [{ type: "reasoning-delta", text: "", providerMetadata: anthropicMetadata({ signature: delta.signature }) }], + ] satisfies StepResult + } + + if (delta?.type === "input_json_delta" && event.index !== undefined) { + if (!delta.partial_json) return [state, NO_EVENTS] satisfies StepResult + const result = ToolStream.appendExisting( + ADAPTER, + state.tools, + event.index, + delta.partial_json, + "Anthropic Messages tool argument delta is missing its tool call", + ) + if (ToolStream.isError(result)) return yield* result + return [{ ...state, tools: result.tools }, result.event ? [result.event] : NO_EVENTS] satisfies StepResult + } + + return [state, NO_EVENTS] satisfies StepResult +}) + +const onContentBlockStop = Effect.fn("AnthropicMessages.onContentBlockStop")(function* ( + state: ParserState, + event: AnthropicEvent, +) { + if (event.index === undefined) return [state, NO_EVENTS] satisfies StepResult + const result = yield* ToolStream.finish(ADAPTER, state.tools, event.index) + return [{ ...state, tools: result.tools }, result.event ? [result.event] : NO_EVENTS] satisfies StepResult +}) + +const onMessageDelta = (state: ParserState, event: AnthropicEvent): StepResult => { + const usage = mergeUsage(state.usage, mapUsage(event.usage)) + return [ + { ...state, usage }, + [ + { + type: "request-finish", + reason: mapFinishReason(event.delta?.stop_reason), + usage, + ...(event.delta?.stop_sequence + ? { providerMetadata: anthropicMetadata({ stopSequence: event.delta.stop_sequence }) } + : {}), + }, + ], + ] +} + +const onError = (state: ParserState, event: AnthropicEvent): StepResult => [ + state, + [{ type: "provider-error", message: event.error?.message ?? "Anthropic Messages stream error" }], +] + +const step = (state: ParserState, event: AnthropicEvent) => { + if (event.type === "message_start") return Effect.succeed(onMessageStart(state, event)) + if (event.type === "content_block_start") return Effect.succeed(onContentBlockStart(state, event)) + if (event.type === "content_block_delta") return onContentBlockDelta(state, event) + if (event.type === "content_block_stop") return onContentBlockStop(state, event) + if (event.type === "message_delta") return Effect.succeed(onMessageDelta(state, event)) + if (event.type === "error") return Effect.succeed(onError(state, event)) + return Effect.succeed([state, NO_EVENTS]) +} + +// ============================================================================= +// Protocol And Anthropic Route +// ============================================================================= +/** + * The Anthropic Messages protocol — request body construction, body schema, + * and the streaming-event state machine. Used by native Anthropic Cloud and + * (once registered) Vertex Anthropic / Bedrock-hosted Anthropic passthrough. + */ +export const protocol = Protocol.make({ + id: ADAPTER, + body: { + schema: AnthropicMessagesBody, + from: fromRequest, + }, + stream: { + event: Protocol.jsonEvent(AnthropicEvent), + initial: () => ({ tools: ToolStream.empty() }), + step, + }, +}) + +export const route = Route.make({ + id: ADAPTER, + protocol, + endpoint: Endpoint.path(PATH), + auth: Auth.apiKeyHeader("x-api-key"), + framing: Framing.sse, + headers: () => ({ "anthropic-version": "2023-06-01" }), +}) + +// ============================================================================= +// Model Helper +// ============================================================================= +export const model = Route.model(route, { + provider: "anthropic", + baseURL: DEFAULT_BASE_URL, +}) + +export * as AnthropicMessages from "./anthropic-messages" diff --git a/packages/llm/src/protocols/bedrock-converse.ts b/packages/llm/src/protocols/bedrock-converse.ts new file mode 100644 index 0000000000..09176104df --- /dev/null +++ b/packages/llm/src/protocols/bedrock-converse.ts @@ -0,0 +1,531 @@ +import { Effect, Schema } from "effect" +import { Route, type RouteModelInput } from "../route/client" +import { Endpoint } from "../route/endpoint" +import { Protocol } from "../route/protocol" +import { + Usage, + type CacheHint, + type FinishReason, + type LLMEvent, + type LLMRequest, + type ToolCallPart, + type ToolDefinition, + type ToolResultPart, +} from "../schema" +import { BedrockEventStream } from "./bedrock-event-stream" +import { JsonObject, optionalArray, ProviderShared } from "./shared" +import { BedrockAuth, type Credentials as BedrockCredentials } from "./utils/bedrock-auth" +import { BedrockCache } from "./utils/bedrock-cache" +import { BedrockMedia } from "./utils/bedrock-media" +import { ToolStream } from "./utils/tool-stream" + +const ADAPTER = "bedrock-converse" + +export type { Credentials as BedrockCredentials } from "./utils/bedrock-auth" + +// ============================================================================= +// Public Model Input +// ============================================================================= +export type BedrockConverseModelInput = RouteModelInput & { + /** + * Bearer API key (Bedrock's newer API key auth). Sets the `Authorization` + * header and bypasses SigV4 signing. Mutually exclusive with `credentials`. + */ + readonly apiKey?: string + /** + * AWS credentials for SigV4 signing. The route signs each request at + * `toHttp` time using `aws4fetch`. Mutually exclusive with `apiKey`. + */ + readonly credentials?: BedrockCredentials + readonly headers?: Record +} + +// ============================================================================= +// Request Body Schema +// ============================================================================= +const BedrockTextBlock = Schema.Struct({ + text: Schema.String, +}) +type BedrockTextBlock = Schema.Schema.Type + +const BedrockToolUseBlock = Schema.Struct({ + toolUse: Schema.Struct({ + toolUseId: Schema.String, + name: Schema.String, + input: Schema.Unknown, + }), +}) +type BedrockToolUseBlock = Schema.Schema.Type + +const BedrockToolResultContentItem = Schema.Union([ + Schema.Struct({ text: Schema.String }), + Schema.Struct({ json: Schema.Unknown }), +]) + +const BedrockToolResultBlock = Schema.Struct({ + toolResult: Schema.Struct({ + toolUseId: Schema.String, + content: Schema.Array(BedrockToolResultContentItem), + status: Schema.optional(Schema.Literals(["success", "error"])), + }), +}) +type BedrockToolResultBlock = Schema.Schema.Type + +const BedrockReasoningBlock = Schema.Struct({ + reasoningContent: Schema.Struct({ + reasoningText: Schema.optional( + Schema.Struct({ + text: Schema.String, + signature: Schema.optional(Schema.String), + }), + ), + }), +}) + +const BedrockUserBlock = Schema.Union([ + BedrockTextBlock, + BedrockMedia.ImageBlock, + BedrockMedia.DocumentBlock, + BedrockToolResultBlock, + BedrockCache.CachePointBlock, +]) +type BedrockUserBlock = Schema.Schema.Type + +const BedrockAssistantBlock = Schema.Union([ + BedrockTextBlock, + BedrockReasoningBlock, + BedrockToolUseBlock, + BedrockCache.CachePointBlock, +]) +type BedrockAssistantBlock = Schema.Schema.Type + +const BedrockMessage = Schema.Union([ + Schema.Struct({ role: Schema.Literal("user"), content: Schema.Array(BedrockUserBlock) }), + Schema.Struct({ role: Schema.Literal("assistant"), content: Schema.Array(BedrockAssistantBlock) }), +]).pipe(Schema.toTaggedUnion("role")) +type BedrockMessage = Schema.Schema.Type + +const BedrockSystemBlock = Schema.Union([BedrockTextBlock, BedrockCache.CachePointBlock]) +type BedrockSystemBlock = Schema.Schema.Type + +const BedrockTool = Schema.Struct({ + toolSpec: Schema.Struct({ + name: Schema.String, + description: Schema.String, + inputSchema: Schema.Struct({ + json: JsonObject, + }), + }), +}) +type BedrockTool = Schema.Schema.Type + +const BedrockToolChoice = Schema.Union([ + Schema.Struct({ auto: Schema.Struct({}) }), + Schema.Struct({ any: Schema.Struct({}) }), + Schema.Struct({ tool: Schema.Struct({ name: Schema.String }) }), +]) + +const BedrockBodyFields = { + modelId: Schema.String, + messages: Schema.Array(BedrockMessage), + system: optionalArray(BedrockSystemBlock), + inferenceConfig: Schema.optional( + Schema.Struct({ + maxTokens: Schema.optional(Schema.Number), + temperature: Schema.optional(Schema.Number), + topP: Schema.optional(Schema.Number), + stopSequences: optionalArray(Schema.String), + }), + ), + toolConfig: Schema.optional( + Schema.Struct({ + tools: Schema.Array(BedrockTool), + toolChoice: Schema.optional(BedrockToolChoice), + }), + ), + additionalModelRequestFields: Schema.optional(JsonObject), +} +const BedrockConverseBody = Schema.Struct(BedrockBodyFields) +export type BedrockConverseBody = Schema.Schema.Type + +const BedrockUsageSchema = Schema.Struct({ + inputTokens: Schema.optional(Schema.Number), + outputTokens: Schema.optional(Schema.Number), + totalTokens: Schema.optional(Schema.Number), + cacheReadInputTokens: Schema.optional(Schema.Number), + cacheWriteInputTokens: Schema.optional(Schema.Number), +}) +type BedrockUsageSchema = Schema.Schema.Type + +// Streaming event shape — the AWS event stream wraps each JSON payload by its +// `:event-type` header (e.g. `messageStart`, `contentBlockDelta`). We +// reconstruct that wrapping in `decodeFrames` below so the event schema can +// stay a plain discriminated record. +const BedrockEvent = Schema.Struct({ + messageStart: Schema.optional(Schema.Struct({ role: Schema.String })), + contentBlockStart: Schema.optional( + Schema.Struct({ + contentBlockIndex: Schema.Number, + start: Schema.optional( + Schema.Struct({ + toolUse: Schema.optional(Schema.Struct({ toolUseId: Schema.String, name: Schema.String })), + }), + ), + }), + ), + contentBlockDelta: Schema.optional( + Schema.Struct({ + contentBlockIndex: Schema.Number, + delta: Schema.optional( + Schema.Struct({ + text: Schema.optional(Schema.String), + toolUse: Schema.optional(Schema.Struct({ input: Schema.String })), + reasoningContent: Schema.optional( + Schema.Struct({ + text: Schema.optional(Schema.String), + signature: Schema.optional(Schema.String), + }), + ), + }), + ), + }), + ), + contentBlockStop: Schema.optional(Schema.Struct({ contentBlockIndex: Schema.Number })), + messageStop: Schema.optional( + Schema.Struct({ + stopReason: Schema.String, + additionalModelResponseFields: Schema.optional(Schema.Unknown), + }), + ), + metadata: Schema.optional( + Schema.Struct({ + usage: Schema.optional(BedrockUsageSchema), + metrics: Schema.optional(Schema.Unknown), + }), + ), + internalServerException: Schema.optional(Schema.Struct({ message: Schema.String })), + modelStreamErrorException: Schema.optional(Schema.Struct({ message: Schema.String })), + validationException: Schema.optional(Schema.Struct({ message: Schema.String })), + throttlingException: Schema.optional(Schema.Struct({ message: Schema.String })), + serviceUnavailableException: Schema.optional(Schema.Struct({ message: Schema.String })), +}) +type BedrockEvent = Schema.Schema.Type + +// ============================================================================= +// Request Lowering +// ============================================================================= +const lowerTool = (tool: ToolDefinition): BedrockTool => ({ + toolSpec: { + name: tool.name, + description: tool.description, + inputSchema: { json: tool.inputSchema }, + }, +}) + +const textWithCache = ( + text: string, + cache: CacheHint | undefined, +): Array => { + const cachePoint = BedrockCache.block(cache) + return cachePoint ? [{ text }, cachePoint] : [{ text }] +} + +const lowerToolChoice = (toolChoice: NonNullable) => + ProviderShared.matchToolChoice("Bedrock Converse", toolChoice, { + auto: () => ({ auto: {} }) as const, + none: () => undefined, + required: () => ({ any: {} }) as const, + tool: (name) => ({ tool: { name } }) as const, + }) + +const lowerToolCall = (part: ToolCallPart): BedrockToolUseBlock => ({ + toolUse: { + toolUseId: part.id, + name: part.name, + input: part.input, + }, +}) + +const lowerToolResult = (part: ToolResultPart): BedrockToolResultBlock => ({ + toolResult: { + toolUseId: part.id, + content: + part.result.type === "text" || part.result.type === "error" + ? [{ text: ProviderShared.toolResultText(part) }] + : [{ json: part.result.value }], + status: part.result.type === "error" ? "error" : "success", + }, +}) + +const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (request: LLMRequest) { + const messages: BedrockMessage[] = [] + + for (const message of request.messages) { + if (message.role === "user") { + const content: BedrockUserBlock[] = [] + for (const part of message.content) { + if (!ProviderShared.supportsContent(part, ["text", "media"])) + return yield* ProviderShared.unsupportedContent("Bedrock Converse", "user", ["text", "media"]) + if (part.type === "text") { + content.push(...textWithCache(part.text, part.cache)) + continue + } + if (part.type === "media") { + content.push(yield* BedrockMedia.lower(part)) + continue + } + } + messages.push({ role: "user", content }) + continue + } + + if (message.role === "assistant") { + const content: BedrockAssistantBlock[] = [] + for (const part of message.content) { + if (!ProviderShared.supportsContent(part, ["text", "reasoning", "tool-call"])) + return yield* ProviderShared.unsupportedContent("Bedrock Converse", "assistant", [ + "text", + "reasoning", + "tool-call", + ]) + if (part.type === "text") { + content.push(...textWithCache(part.text, part.cache)) + continue + } + if (part.type === "reasoning") { + content.push({ + reasoningContent: { + reasoningText: { text: part.text, signature: part.encrypted }, + }, + }) + continue + } + if (part.type === "tool-call") { + content.push(lowerToolCall(part)) + continue + } + } + messages.push({ role: "assistant", content }) + continue + } + + const content: BedrockToolResultBlock[] = [] + for (const part of message.content) { + if (!ProviderShared.supportsContent(part, ["tool-result"])) + return yield* ProviderShared.unsupportedContent("Bedrock Converse", "tool", ["tool-result"]) + content.push(lowerToolResult(part)) + } + messages.push({ role: "user", content }) + } + + return messages +}) + +// System prompts share the cache-point convention: emit the text block, then +// optionally a positional `cachePoint` marker. +const lowerSystem = (system: ReadonlyArray): BedrockSystemBlock[] => + system.flatMap((part) => textWithCache(part.text, part.cache)) + +const fromRequest = Effect.fn("BedrockConverse.fromRequest")(function* (request: LLMRequest) { + const toolChoice = request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined + const generation = request.generation + return { + modelId: request.model.id, + messages: yield* lowerMessages(request), + system: request.system.length === 0 ? undefined : lowerSystem(request.system), + inferenceConfig: + generation?.maxTokens === undefined && + generation?.temperature === undefined && + generation?.topP === undefined && + (generation?.stop === undefined || generation.stop.length === 0) + ? undefined + : { + maxTokens: generation?.maxTokens, + temperature: generation?.temperature, + topP: generation?.topP, + stopSequences: generation?.stop, + }, + toolConfig: + request.tools.length > 0 && request.toolChoice?.type !== "none" + ? { tools: request.tools.map(lowerTool), toolChoice } + : undefined, + } +}) + +// ============================================================================= +// Stream Parsing +// ============================================================================= +const mapFinishReason = (reason: string): FinishReason => { + if (reason === "end_turn" || reason === "stop_sequence") return "stop" + if (reason === "max_tokens") return "length" + if (reason === "tool_use") return "tool-calls" + if (reason === "content_filtered" || reason === "guardrail_intervened") return "content-filter" + return "unknown" +} + +const mapUsage = (usage: BedrockUsageSchema | undefined): Usage | undefined => { + if (!usage) return undefined + return new Usage({ + inputTokens: usage.inputTokens, + outputTokens: usage.outputTokens, + totalTokens: ProviderShared.totalTokens(usage.inputTokens, usage.outputTokens, usage.totalTokens), + cacheReadInputTokens: usage.cacheReadInputTokens, + cacheWriteInputTokens: usage.cacheWriteInputTokens, + native: usage, + }) +} + +interface ParserState { + readonly tools: ToolStream.State + // Bedrock splits the finish into `messageStop` (carries `stopReason`) and + // `metadata` (carries usage). Hold the terminal event in state so `onHalt` + // can emit exactly one finish after both chunks have had a chance to arrive. + readonly pendingFinish: { readonly reason: FinishReason; readonly usage?: Usage } | undefined +} + +const step = (state: ParserState, event: BedrockEvent) => + Effect.gen(function* () { + if (event.contentBlockStart?.start?.toolUse) { + const index = event.contentBlockStart.contentBlockIndex + return [ + { + ...state, + tools: ToolStream.start(state.tools, index, { + id: event.contentBlockStart.start.toolUse.toolUseId, + name: event.contentBlockStart.start.toolUse.name, + }), + }, + [], + ] as const + } + + if (event.contentBlockDelta?.delta?.text) { + return [state, [{ type: "text-delta" as const, text: event.contentBlockDelta.delta.text }]] as const + } + + if (event.contentBlockDelta?.delta?.reasoningContent?.text) { + return [ + state, + [{ type: "reasoning-delta" as const, text: event.contentBlockDelta.delta.reasoningContent.text }], + ] as const + } + + if (event.contentBlockDelta?.delta?.toolUse) { + const index = event.contentBlockDelta.contentBlockIndex + const result = ToolStream.appendExisting( + ADAPTER, + state.tools, + index, + event.contentBlockDelta.delta.toolUse.input, + "Bedrock Converse tool delta is missing its tool call", + ) + if (ToolStream.isError(result)) return yield* result + return [{ ...state, tools: result.tools }, result.event ? [result.event] : []] as const + } + + if (event.contentBlockStop) { + const result = yield* ToolStream.finish(ADAPTER, state.tools, event.contentBlockStop.contentBlockIndex) + return [{ ...state, tools: result.tools }, result.event ? [result.event] : []] as const + } + + if (event.messageStop) { + return [ + { + ...state, + pendingFinish: { reason: mapFinishReason(event.messageStop.stopReason), usage: state.pendingFinish?.usage }, + }, + [], + ] as const + } + + if (event.metadata) { + const usage = mapUsage(event.metadata.usage) + return [{ ...state, pendingFinish: { reason: state.pendingFinish?.reason ?? "stop", usage } }, []] as const + } + + if (event.internalServerException || event.modelStreamErrorException || event.serviceUnavailableException) { + const message = + event.internalServerException?.message ?? + event.modelStreamErrorException?.message ?? + event.serviceUnavailableException?.message ?? + "Bedrock Converse stream error" + return [state, [{ type: "provider-error" as const, message, retryable: true }]] as const + } + + if (event.validationException || event.throttlingException) { + const message = + event.validationException?.message ?? event.throttlingException?.message ?? "Bedrock Converse error" + return [ + state, + [{ type: "provider-error" as const, message, retryable: event.throttlingException !== undefined }], + ] as const + } + + return [state, []] as const + }) + +const framing = BedrockEventStream.framing(ADAPTER) + +const onHalt = (state: ParserState): ReadonlyArray => + state.pendingFinish + ? [{ type: "request-finish", reason: state.pendingFinish.reason, usage: state.pendingFinish.usage }] + : [] + +// ============================================================================= +// Protocol And Bedrock Route +// ============================================================================= +/** + * The Bedrock Converse protocol — request body construction, body schema, and + * the streaming-event state machine. + */ +export const protocol = Protocol.make({ + id: ADAPTER, + body: { + schema: BedrockConverseBody, + from: fromRequest, + }, + stream: { + event: BedrockEvent, + initial: () => ({ tools: ToolStream.empty(), pendingFinish: undefined }), + step, + onHalt, + }, +}) + +export const route = Route.make({ + id: ADAPTER, + protocol, + // Bedrock's URL embeds the region in the host (set on `model.baseURL` by + // the provider helper from credentials) and the validated modelId in the + // path. We read the validated body so the URL matches the body that gets + // signed. + endpoint: Endpoint.path( + ({ body }) => `/model/${encodeURIComponent(body.modelId)}/converse-stream`, + ), + auth: BedrockAuth.auth, + framing, +}) + +export const nativeCredentials = BedrockAuth.nativeCredentials + +const bedrockModel = Route.model( + route, + { + provider: "bedrock", + }, + { + mapInput: (input: BedrockConverseModelInput) => { + const { credentials, ...rest } = input + const region = credentials?.region ?? "us-east-1" + return { + ...rest, + baseURL: rest.baseURL ?? `https://bedrock-runtime.${region}.amazonaws.com`, + native: nativeCredentials(input.native, credentials), + } + }, + }, +) + +export const model = bedrockModel + +export * as BedrockConverse from "./bedrock-converse" diff --git a/packages/llm/src/protocols/bedrock-event-stream.ts b/packages/llm/src/protocols/bedrock-event-stream.ts new file mode 100644 index 0000000000..d07d7de475 --- /dev/null +++ b/packages/llm/src/protocols/bedrock-event-stream.ts @@ -0,0 +1,87 @@ +import { EventStreamCodec } from "@smithy/eventstream-codec" +import { fromUtf8, toUtf8 } from "@smithy/util-utf8" +import { Effect, Stream } from "effect" +import type { Framing } from "../route/framing" +import { ProviderShared } from "./shared" + +// Bedrock streams responses using the AWS event stream binary protocol — each +// frame is `[length:4][headers-length:4][prelude-crc:4][headers][payload][crc:4]`. +// We use `@smithy/eventstream-codec` to validate framing and CRCs, then +// reconstruct the JSON wrapping by `:event-type` so the chunk schema can match. +const eventCodec = new EventStreamCodec(toUtf8, fromUtf8) +const utf8 = new TextDecoder() + +// Cursor-tracking buffer state. Bytes accumulate in `buffer`; `offset` is the +// read position. Reading by `subarray` is zero-copy. We only allocate a fresh +// buffer when a new network chunk arrives and we need to append. +interface FrameBufferState { + readonly buffer: Uint8Array + readonly offset: number +} + +const initialFrameBuffer: FrameBufferState = { buffer: new Uint8Array(0), offset: 0 } + +const appendChunk = (state: FrameBufferState, chunk: Uint8Array): FrameBufferState => { + const remaining = state.buffer.length - state.offset + // Compact: drop the consumed prefix and append the new chunk in one alloc. + // This bounds buffer growth to at most one network chunk past the live + // window, regardless of stream length. + const next = new Uint8Array(remaining + chunk.length) + next.set(state.buffer.subarray(state.offset), 0) + next.set(chunk, remaining) + return { buffer: next, offset: 0 } +} + +const consumeFrames = (route: string) => (state: FrameBufferState, chunk: Uint8Array) => + Effect.gen(function* () { + let cursor = appendChunk(state, chunk) + const out: object[] = [] + while (cursor.buffer.length - cursor.offset >= 4) { + const view = cursor.buffer.subarray(cursor.offset) + const totalLength = new DataView(view.buffer, view.byteOffset, view.byteLength).getUint32(0, false) + if (view.length < totalLength) break + + const decoded = yield* Effect.try({ + try: () => eventCodec.decode(view.subarray(0, totalLength)), + catch: (error) => + ProviderShared.eventError( + route, + `Failed to decode Bedrock Converse event-stream frame: ${ + error instanceof Error ? error.message : String(error) + }`, + ), + }) + cursor = { buffer: cursor.buffer, offset: cursor.offset + totalLength } + + if (decoded.headers[":message-type"]?.value !== "event") continue + const eventType = decoded.headers[":event-type"]?.value + if (typeof eventType !== "string") continue + const payload = utf8.decode(decoded.body) + if (!payload) continue + // The AWS event stream pads short payloads with a `p` field. Drop it + // before handing the object to the chunk schema. JSON decode goes + // through the shared Schema-driven codec to satisfy the package rule + // against ad-hoc `JSON.parse` calls. + const parsed = (yield* ProviderShared.parseJson( + route, + payload, + "Failed to parse Bedrock Converse event-stream payload", + )) as Record + delete parsed.p + out.push({ [eventType]: parsed }) + } + return [cursor, out] as const + }) + +/** + * AWS event-stream framing for Bedrock Converse. Each frame is decoded by + * `@smithy/eventstream-codec` (length + header + payload + CRC) and rewrapped + * under its `:event-type` header so the chunk schema can match the JSON + * payload directly. + */ +export const framing = (route: string): Framing => ({ + id: "aws-event-stream", + frame: (bytes) => bytes.pipe(Stream.mapAccumEffect(() => initialFrameBuffer, consumeFrames(route))), +}) + +export * as BedrockEventStream from "./bedrock-event-stream" diff --git a/packages/llm/src/protocols/gemini.ts b/packages/llm/src/protocols/gemini.ts new file mode 100644 index 0000000000..0d2bdc8e14 --- /dev/null +++ b/packages/llm/src/protocols/gemini.ts @@ -0,0 +1,397 @@ +import { Effect, Schema } from "effect" +import { Route } from "../route/client" +import { Auth } from "../route/auth" +import { Endpoint } from "../route/endpoint" +import { Framing } from "../route/framing" +import { Protocol } from "../route/protocol" +import { + Usage, + type FinishReason, + type LLMEvent, + type LLMRequest, + type MediaPart, + type TextPart, + type ToolCallPart, + type ToolDefinition, +} from "../schema" +import { JsonObject, optionalArray, ProviderShared } from "./shared" +import { GeminiToolSchema } from "./utils/gemini-tool-schema" + +const ADAPTER = "gemini" +export const DEFAULT_BASE_URL = "https://generativelanguage.googleapis.com/v1beta" + +// ============================================================================= +// Request Body Schema +// ============================================================================= +const GeminiTextPart = Schema.Struct({ + text: Schema.String, + thought: Schema.optional(Schema.Boolean), + thoughtSignature: Schema.optional(Schema.String), +}) + +const GeminiInlineDataPart = Schema.Struct({ + inlineData: Schema.Struct({ + mimeType: Schema.String, + data: Schema.String, + }), +}) + +const GeminiFunctionCallPart = Schema.Struct({ + functionCall: Schema.Struct({ + name: Schema.String, + args: Schema.Unknown, + }), + thoughtSignature: Schema.optional(Schema.String), +}) + +const GeminiFunctionResponsePart = Schema.Struct({ + functionResponse: Schema.Struct({ + name: Schema.String, + response: Schema.Unknown, + }), +}) + +const GeminiContentPart = Schema.Union([ + GeminiTextPart, + GeminiInlineDataPart, + GeminiFunctionCallPart, + GeminiFunctionResponsePart, +]) + +const GeminiContent = Schema.Struct({ + role: Schema.Literals(["user", "model"]), + parts: Schema.Array(GeminiContentPart), +}) +type GeminiContent = Schema.Schema.Type + +const GeminiSystemInstruction = Schema.Struct({ + parts: Schema.Array(Schema.Struct({ text: Schema.String })), +}) + +const GeminiFunctionDeclaration = Schema.Struct({ + name: Schema.String, + description: Schema.String, + parameters: Schema.optional(JsonObject), +}) + +const GeminiTool = Schema.Struct({ + functionDeclarations: Schema.Array(GeminiFunctionDeclaration), +}) + +const GeminiToolConfig = Schema.Struct({ + functionCallingConfig: Schema.Struct({ + mode: Schema.Literals(["AUTO", "NONE", "ANY"]), + allowedFunctionNames: optionalArray(Schema.String), + }), +}) + +const GeminiThinkingConfig = Schema.Struct({ + thinkingBudget: Schema.optional(Schema.Number), + includeThoughts: Schema.optional(Schema.Boolean), +}) + +const GeminiGenerationConfig = Schema.Struct({ + maxOutputTokens: Schema.optional(Schema.Number), + temperature: Schema.optional(Schema.Number), + topP: Schema.optional(Schema.Number), + topK: Schema.optional(Schema.Number), + stopSequences: optionalArray(Schema.String), + thinkingConfig: Schema.optional(GeminiThinkingConfig), +}) + +const GeminiBodyFields = { + contents: Schema.Array(GeminiContent), + systemInstruction: Schema.optional(GeminiSystemInstruction), + tools: optionalArray(GeminiTool), + toolConfig: Schema.optional(GeminiToolConfig), + generationConfig: Schema.optional(GeminiGenerationConfig), +} +const GeminiBody = Schema.Struct(GeminiBodyFields) +export type GeminiBody = Schema.Schema.Type + +const GeminiUsage = Schema.Struct({ + cachedContentTokenCount: Schema.optional(Schema.Number), + thoughtsTokenCount: Schema.optional(Schema.Number), + promptTokenCount: Schema.optional(Schema.Number), + candidatesTokenCount: Schema.optional(Schema.Number), + totalTokenCount: Schema.optional(Schema.Number), +}) +type GeminiUsage = Schema.Schema.Type + +const GeminiCandidate = Schema.Struct({ + content: Schema.optional(GeminiContent), + finishReason: Schema.optional(Schema.String), +}) + +const GeminiEvent = Schema.Struct({ + candidates: optionalArray(GeminiCandidate), + usageMetadata: Schema.optional(GeminiUsage), +}) +type GeminiEvent = Schema.Schema.Type + +interface ParserState { + readonly finishReason?: string + readonly hasToolCalls: boolean + readonly nextToolCallId: number + readonly usage?: Usage +} + +const invalid = ProviderShared.invalidRequest + +const mediaData = ProviderShared.mediaBytes + +// ============================================================================= +// Tool Schema Conversion +// ============================================================================= +// Tool-schema conversion has two distinct concerns: +// +// 1. Sanitize — fix common authoring mistakes Gemini rejects: integer/number +// enums (must be strings), `required` entries that don't match a property, +// untyped arrays (`items` must be present), and `properties`/`required` +// keys on non-object scalars. Mirrors OpenCode's historical Gemini rules. +// +// 2. Project — lossy mapping from JSON Schema to Gemini's schema dialect: +// drop empty objects, derive `nullable: true` from `type: [..., "null"]`, +// coerce `const` to `[const]` enum, recurse properties/items, propagate +// only an allowlisted set of keys (description, required, format, type, +// properties, items, allOf, anyOf, oneOf, minLength). Anything outside the +// allowlist (e.g. `additionalProperties`, `$ref`) is silently dropped. +// +// Sanitize runs first, then project. The implementation lives in +// `utils/gemini-tool-schema` so this protocol keeps the same shape as the other +// provider protocols. + +// ============================================================================= +// Request Lowering +// ============================================================================= +const lowerTool = (tool: ToolDefinition) => ({ + name: tool.name, + description: tool.description, + parameters: GeminiToolSchema.convert(tool.inputSchema), +}) + +const lowerToolConfig = (toolChoice: NonNullable) => + ProviderShared.matchToolChoice("Gemini", toolChoice, { + auto: () => ({ functionCallingConfig: { mode: "AUTO" as const } }), + none: () => ({ functionCallingConfig: { mode: "NONE" as const } }), + required: () => ({ functionCallingConfig: { mode: "ANY" as const } }), + tool: (name) => ({ functionCallingConfig: { mode: "ANY" as const, allowedFunctionNames: [name] } }), + }) + +const lowerUserPart = (part: TextPart | MediaPart) => + part.type === "text" ? { text: part.text } : { inlineData: { mimeType: part.mediaType, data: mediaData(part) } } + +const lowerToolCall = (part: ToolCallPart) => ({ + functionCall: { name: part.name, args: part.input }, +}) + +const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMRequest) { + const contents: GeminiContent[] = [] + + for (const message of request.messages) { + if (message.role === "user") { + const parts: Array> = [] + for (const part of message.content) { + if (!ProviderShared.supportsContent(part, ["text", "media"])) + return yield* ProviderShared.unsupportedContent("Gemini", "user", ["text", "media"]) + parts.push(lowerUserPart(part)) + } + contents.push({ role: "user", parts }) + continue + } + + if (message.role === "assistant") { + const parts: Array> = [] + for (const part of message.content) { + if (!ProviderShared.supportsContent(part, ["text", "reasoning", "tool-call"])) + return yield* ProviderShared.unsupportedContent("Gemini", "assistant", ["text", "reasoning", "tool-call"]) + if (part.type === "text") { + parts.push({ text: part.text }) + continue + } + if (part.type === "reasoning") { + parts.push({ text: part.text, thought: true }) + continue + } + if (part.type === "tool-call") { + parts.push(lowerToolCall(part)) + continue + } + } + contents.push({ role: "model", parts }) + continue + } + + const parts: Array> = [] + for (const part of message.content) { + if (!ProviderShared.supportsContent(part, ["tool-result"])) + return yield* ProviderShared.unsupportedContent("Gemini", "tool", ["tool-result"]) + parts.push({ + functionResponse: { + name: part.name, + response: { + name: part.name, + content: ProviderShared.toolResultText(part), + }, + }, + }) + } + contents.push({ role: "user", parts }) + } + + return contents +}) + +const geminiOptions = (request: LLMRequest) => request.providerOptions?.gemini + +const thinkingConfig = (request: LLMRequest) => { + const value = geminiOptions(request)?.thinkingConfig + if (!ProviderShared.isRecord(value)) return undefined + const result = { + thinkingBudget: typeof value.thinkingBudget === "number" ? value.thinkingBudget : undefined, + includeThoughts: typeof value.includeThoughts === "boolean" ? value.includeThoughts : undefined, + } + return Object.values(result).some((item) => item !== undefined) ? result : undefined +} + +const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMRequest) { + const toolsEnabled = request.tools.length > 0 && request.toolChoice?.type !== "none" + const generation = request.generation + const generationConfig = { + maxOutputTokens: generation?.maxTokens, + temperature: generation?.temperature, + topP: generation?.topP, + topK: generation?.topK, + stopSequences: generation?.stop, + thinkingConfig: thinkingConfig(request), + } + + return { + contents: yield* lowerMessages(request), + systemInstruction: + request.system.length === 0 ? undefined : { parts: [{ text: ProviderShared.joinText(request.system) }] }, + tools: toolsEnabled ? [{ functionDeclarations: request.tools.map(lowerTool) }] : undefined, + toolConfig: toolsEnabled && request.toolChoice ? yield* lowerToolConfig(request.toolChoice) : undefined, + generationConfig: Object.values(generationConfig).some((value) => value !== undefined) + ? generationConfig + : undefined, + } +}) + +// ============================================================================= +// Stream Parsing +// ============================================================================= +const mapUsage = (usage: GeminiUsage | undefined) => { + if (!usage) return undefined + return new Usage({ + inputTokens: usage.promptTokenCount, + outputTokens: usage.candidatesTokenCount, + reasoningTokens: usage.thoughtsTokenCount, + cacheReadInputTokens: usage.cachedContentTokenCount, + totalTokens: ProviderShared.totalTokens(usage.promptTokenCount, usage.candidatesTokenCount, usage.totalTokenCount), + native: usage, + }) +} + +const mapFinishReason = (finishReason: string | undefined, hasToolCalls: boolean): FinishReason => { + if (finishReason === "STOP") return hasToolCalls ? "tool-calls" : "stop" + if (finishReason === "MAX_TOKENS") return "length" + if ( + finishReason === "IMAGE_SAFETY" || + finishReason === "RECITATION" || + finishReason === "SAFETY" || + finishReason === "BLOCKLIST" || + finishReason === "PROHIBITED_CONTENT" || + finishReason === "SPII" + ) + return "content-filter" + if (finishReason === "MALFORMED_FUNCTION_CALL") return "error" + return "unknown" +} + +const finish = (state: ParserState): ReadonlyArray => + state.finishReason || state.usage + ? [{ type: "request-finish", reason: mapFinishReason(state.finishReason, state.hasToolCalls), usage: state.usage }] + : [] + +const step = (state: ParserState, event: GeminiEvent) => { + const nextState = { + ...state, + usage: event.usageMetadata ? (mapUsage(event.usageMetadata) ?? state.usage) : state.usage, + } + const candidate = event.candidates?.[0] + if (!candidate?.content) + return Effect.succeed([ + { ...nextState, finishReason: candidate?.finishReason ?? nextState.finishReason }, + [], + ] as const) + + const events: LLMEvent[] = [] + let hasToolCalls = nextState.hasToolCalls + let nextToolCallId = nextState.nextToolCallId + + for (const part of candidate.content.parts) { + if ("text" in part && part.text.length > 0) { + events.push({ type: part.thought ? "reasoning-delta" : "text-delta", text: part.text }) + continue + } + + if ("functionCall" in part) { + const input = part.functionCall.args + const id = `tool_${nextToolCallId++}` + events.push({ type: "tool-call", id, name: part.functionCall.name, input }) + hasToolCalls = true + } + } + + return Effect.succeed([ + { + ...nextState, + hasToolCalls, + nextToolCallId, + finishReason: candidate.finishReason ?? nextState.finishReason, + }, + events, + ] as const) +} + +// ============================================================================= +// Protocol And Gemini Route +// ============================================================================= +/** + * The Gemini protocol — request body construction, body schema, and the + * streaming-event state machine. Used by Google AI Studio Gemini and (once + * registered) Vertex Gemini. + */ +export const protocol = Protocol.make({ + id: ADAPTER, + body: { + schema: GeminiBody, + from: fromRequest, + }, + stream: { + event: Protocol.jsonEvent(GeminiEvent), + initial: () => ({ hasToolCalls: false, nextToolCallId: 0 }), + step, + onHalt: finish, + }, +}) + +export const route = Route.make({ + id: ADAPTER, + protocol, + // Gemini's path embeds the model id and pins SSE framing at the URL level. + endpoint: Endpoint.path(({ request }) => `/models/${request.model.id}:streamGenerateContent?alt=sse`), + auth: Auth.apiKeyHeader("x-goog-api-key"), + framing: Framing.sse, +}) + +// ============================================================================= +// Model Helper +// ============================================================================= +export const model = Route.model(route, { + provider: "google", + baseURL: DEFAULT_BASE_URL, +}) + +export * as Gemini from "./gemini" diff --git a/packages/llm/src/protocols/index.ts b/packages/llm/src/protocols/index.ts new file mode 100644 index 0000000000..bd8c8d3d9d --- /dev/null +++ b/packages/llm/src/protocols/index.ts @@ -0,0 +1,6 @@ +export * as AnthropicMessages from "./anthropic-messages" +export * as BedrockConverse from "./bedrock-converse" +export * as Gemini from "./gemini" +export * as OpenAIChat from "./openai-chat" +export * as OpenAICompatibleChat from "./openai-compatible-chat" +export * as OpenAIResponses from "./openai-responses" diff --git a/packages/llm/src/protocols/openai-chat.ts b/packages/llm/src/protocols/openai-chat.ts new file mode 100644 index 0000000000..974e22950d --- /dev/null +++ b/packages/llm/src/protocols/openai-chat.ts @@ -0,0 +1,404 @@ +import { Array as Arr, Effect, Schema } from "effect" +import { Route } from "../route/client" +import { Auth } from "../route/auth" +import { Endpoint } from "../route/endpoint" +import { Framing } from "../route/framing" +import { HttpTransport } from "../route/transport" +import { Protocol } from "../route/protocol" +import { + Usage, + type FinishReason, + type LLMEvent, + type LLMRequest, + type TextPart, + type ToolCallPart, + type ToolDefinition, +} from "../schema" +import { isRecord, JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared" +import { OpenAIOptions } from "./utils/openai-options" +import { ToolStream } from "./utils/tool-stream" + +const ADAPTER = "openai-chat" +export const DEFAULT_BASE_URL = "https://api.openai.com/v1" +export const PATH = "/chat/completions" + +// ============================================================================= +// Request Body Schema +// ============================================================================= +// The body schema is the provider-native JSON body. `fromRequest` below builds +// this shape from the common `LLMRequest`, then `Route.make` validates and +// JSON-encodes it before transport. +const OpenAIChatFunction = Schema.Struct({ + name: Schema.String, + description: Schema.String, + parameters: JsonObject, +}) + +const OpenAIChatTool = Schema.Struct({ + type: Schema.tag("function"), + function: OpenAIChatFunction, +}) +type OpenAIChatTool = Schema.Schema.Type + +const OpenAIChatAssistantToolCall = Schema.Struct({ + id: Schema.String, + type: Schema.tag("function"), + function: Schema.Struct({ + name: Schema.String, + arguments: Schema.String, + }), +}) +type OpenAIChatAssistantToolCall = Schema.Schema.Type + +const OpenAIChatMessage = Schema.Union([ + Schema.Struct({ role: Schema.Literal("system"), content: Schema.String }), + Schema.Struct({ role: Schema.Literal("user"), content: Schema.String }), + Schema.Struct({ + role: Schema.Literal("assistant"), + content: Schema.NullOr(Schema.String), + tool_calls: optionalArray(OpenAIChatAssistantToolCall), + reasoning_content: Schema.optional(Schema.String), + }), + Schema.Struct({ role: Schema.Literal("tool"), tool_call_id: Schema.String, content: Schema.String }), +]).pipe(Schema.toTaggedUnion("role")) +type OpenAIChatMessage = Schema.Schema.Type + +const OpenAIChatToolChoice = Schema.Union([ + Schema.Literals(["auto", "none", "required"]), + Schema.Struct({ + type: Schema.tag("function"), + function: Schema.Struct({ name: Schema.String }), + }), +]) + +export const bodyFields = { + model: Schema.String, + messages: Schema.Array(OpenAIChatMessage), + tools: optionalArray(OpenAIChatTool), + tool_choice: Schema.optional(OpenAIChatToolChoice), + stream: Schema.Literal(true), + stream_options: Schema.optional(Schema.Struct({ include_usage: Schema.Boolean })), + store: Schema.optional(Schema.Boolean), + reasoning_effort: Schema.optional(OpenAIOptions.OpenAIReasoningEffort), + max_tokens: Schema.optional(Schema.Number), + temperature: Schema.optional(Schema.Number), + top_p: Schema.optional(Schema.Number), + frequency_penalty: Schema.optional(Schema.Number), + presence_penalty: Schema.optional(Schema.Number), + seed: Schema.optional(Schema.Number), + stop: optionalArray(Schema.String), +} +const OpenAIChatBody = Schema.Struct(bodyFields) +export type OpenAIChatBody = Schema.Schema.Type + +// ============================================================================= +// Streaming Event Schema +// ============================================================================= +// The event schema is one decoded SSE `data:` payload. `Framing.sse` splits the +// byte stream into strings, then `Protocol.jsonEvent` decodes each string into +// this provider-native event shape. +const OpenAIChatUsage = Schema.Struct({ + prompt_tokens: Schema.optional(Schema.Number), + completion_tokens: Schema.optional(Schema.Number), + total_tokens: Schema.optional(Schema.Number), + prompt_tokens_details: optionalNull( + Schema.Struct({ + cached_tokens: Schema.optional(Schema.Number), + }), + ), + completion_tokens_details: optionalNull( + Schema.Struct({ + reasoning_tokens: Schema.optional(Schema.Number), + }), + ), +}) + +const OpenAIChatToolCallDeltaFunction = Schema.Struct({ + name: optionalNull(Schema.String), + arguments: optionalNull(Schema.String), +}) + +const OpenAIChatToolCallDelta = Schema.Struct({ + index: Schema.Number, + id: optionalNull(Schema.String), + function: optionalNull(OpenAIChatToolCallDeltaFunction), +}) +type OpenAIChatToolCallDelta = Schema.Schema.Type + +const OpenAIChatDelta = Schema.Struct({ + content: optionalNull(Schema.String), + tool_calls: optionalNull(Schema.Array(OpenAIChatToolCallDelta)), +}) + +const OpenAIChatChoice = Schema.Struct({ + delta: optionalNull(OpenAIChatDelta), + finish_reason: optionalNull(Schema.String), +}) + +const OpenAIChatEvent = Schema.Struct({ + choices: Schema.Array(OpenAIChatChoice), + usage: optionalNull(OpenAIChatUsage), +}) +type OpenAIChatEvent = Schema.Schema.Type +type OpenAIChatRequestMessage = LLMRequest["messages"][number] + +interface ParserState { + readonly tools: ToolStream.State + readonly toolCallEvents: ReadonlyArray + readonly usage?: Usage + readonly finishReason?: FinishReason +} + +const invalid = ProviderShared.invalidRequest + +// ============================================================================= +// Request Lowering +// ============================================================================= +// Lowering is the only place that knows how common LLM messages map onto the +// OpenAI Chat wire format. Keep provider quirks here instead of leaking native +// fields into `LLMRequest`. +const lowerTool = (tool: ToolDefinition): OpenAIChatTool => ({ + type: "function", + function: { + name: tool.name, + description: tool.description, + parameters: tool.inputSchema, + }, +}) + +const lowerToolChoice = (toolChoice: NonNullable) => + ProviderShared.matchToolChoice("OpenAI Chat", toolChoice, { + auto: () => "auto" as const, + none: () => "none" as const, + required: () => "required" as const, + tool: (name) => ({ type: "function" as const, function: { name } }), + }) + +const lowerToolCall = (part: ToolCallPart): OpenAIChatAssistantToolCall => ({ + id: part.id, + type: "function", + function: { + name: part.name, + arguments: ProviderShared.encodeJson(part.input), + }, +}) + +const openAICompatibleReasoningContent = (native: unknown) => + isRecord(native) && typeof native.reasoning_content === "string" ? native.reasoning_content : undefined + +const lowerUserMessage = Effect.fn("OpenAIChat.lowerUserMessage")(function* (message: OpenAIChatRequestMessage) { + const content: TextPart[] = [] + for (const part of message.content) { + if (!ProviderShared.supportsContent(part, ["text"])) + return yield* ProviderShared.unsupportedContent("OpenAI Chat", "user", ["text"]) + content.push(part) + } + return { role: "user" as const, content: ProviderShared.joinText(content) } +}) + +const lowerAssistantMessage = Effect.fn("OpenAIChat.lowerAssistantMessage")(function* ( + message: OpenAIChatRequestMessage, +) { + const content: TextPart[] = [] + const toolCalls: OpenAIChatAssistantToolCall[] = [] + for (const part of message.content) { + if (!ProviderShared.supportsContent(part, ["text", "tool-call"])) + return yield* ProviderShared.unsupportedContent("OpenAI Chat", "assistant", ["text", "tool-call"]) + if (part.type === "text") { + content.push(part) + continue + } + if (part.type === "tool-call") { + toolCalls.push(lowerToolCall(part)) + continue + } + } + return { + role: "assistant" as const, + content: content.length === 0 ? null : ProviderShared.joinText(content), + tool_calls: toolCalls.length === 0 ? undefined : toolCalls, + reasoning_content: openAICompatibleReasoningContent(message.native?.openaiCompatible), + } +}) + +const lowerToolMessages = Effect.fn("OpenAIChat.lowerToolMessages")(function* (message: OpenAIChatRequestMessage) { + const messages: OpenAIChatMessage[] = [] + for (const part of message.content) { + if (!ProviderShared.supportsContent(part, ["tool-result"])) + return yield* ProviderShared.unsupportedContent("OpenAI Chat", "tool", ["tool-result"]) + messages.push({ role: "tool", tool_call_id: part.id, content: ProviderShared.toolResultText(part) }) + } + return messages +}) + +const lowerMessage = Effect.fn("OpenAIChat.lowerMessage")(function* (message: OpenAIChatRequestMessage) { + if (message.role === "user") return [yield* lowerUserMessage(message)] + if (message.role === "assistant") return [yield* lowerAssistantMessage(message)] + return yield* lowerToolMessages(message) +}) + +const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request: LLMRequest) { + const system: OpenAIChatMessage[] = + request.system.length === 0 ? [] : [{ role: "system", content: ProviderShared.joinText(request.system) }] + return [...system, ...Arr.flatten(yield* Effect.forEach(request.messages, lowerMessage))] +}) + +const lowerOptions = Effect.fn("OpenAIChat.lowerOptions")(function* (request: LLMRequest) { + const store = OpenAIOptions.store(request) + const reasoningEffort = OpenAIOptions.reasoningEffort(request) + if (reasoningEffort && !OpenAIOptions.isReasoningEffort(reasoningEffort)) + return yield* invalid(`OpenAI Chat does not support reasoning effort ${reasoningEffort}`) + return { + ...(store !== undefined ? { store } : {}), + ...(reasoningEffort ? { reasoning_effort: reasoningEffort } : {}), + } +}) + +const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* (request: LLMRequest) { + // `fromRequest` returns the provider body only. Endpoint, auth, framing, + // validation, and HTTP execution are composed by `Route.make`. + const generation = request.generation + return { + model: request.model.id, + messages: yield* lowerMessages(request), + tools: request.tools.length === 0 ? undefined : request.tools.map(lowerTool), + tool_choice: request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined, + stream: true as const, + stream_options: { include_usage: true }, + max_tokens: generation?.maxTokens, + temperature: generation?.temperature, + top_p: generation?.topP, + frequency_penalty: generation?.frequencyPenalty, + presence_penalty: generation?.presencePenalty, + seed: generation?.seed, + stop: generation?.stop, + ...(yield* lowerOptions(request)), + } +}) + +// ============================================================================= +// Stream Parsing +// ============================================================================= +// Streaming parsers are small state machines: every event returns a new state +// plus the common `LLMEvent`s produced by that event. Tool calls are accumulated +// because OpenAI streams JSON arguments across multiple deltas. +const mapFinishReason = (reason: string | null | undefined): FinishReason => { + if (reason === "stop") return "stop" + if (reason === "length") return "length" + if (reason === "content_filter") return "content-filter" + if (reason === "function_call" || reason === "tool_calls") return "tool-calls" + return "unknown" +} + +const mapUsage = (usage: OpenAIChatEvent["usage"]): Usage | undefined => { + if (!usage) return undefined + return new Usage({ + inputTokens: usage.prompt_tokens, + outputTokens: usage.completion_tokens, + reasoningTokens: usage.completion_tokens_details?.reasoning_tokens, + cacheReadInputTokens: usage.prompt_tokens_details?.cached_tokens, + totalTokens: ProviderShared.totalTokens(usage.prompt_tokens, usage.completion_tokens, usage.total_tokens), + native: usage, + }) +} + +const step = (state: ParserState, event: OpenAIChatEvent) => + Effect.gen(function* () { + const events: LLMEvent[] = [] + const usage = mapUsage(event.usage) ?? state.usage + const choice = event.choices[0] + const finishReason = choice?.finish_reason ? mapFinishReason(choice.finish_reason) : state.finishReason + const delta = choice?.delta + const toolDeltas = delta?.tool_calls ?? [] + let tools = state.tools + + if (delta?.content) events.push({ type: "text-delta", text: delta.content }) + + for (const tool of toolDeltas) { + const result = ToolStream.appendOrStart( + ADAPTER, + tools, + tool.index, + { id: tool.id ?? undefined, name: tool.function?.name ?? undefined, text: tool.function?.arguments ?? "" }, + "OpenAI Chat tool call delta is missing id or name", + ) + if (ToolStream.isError(result)) return yield* result + tools = result.tools + if (result.event) events.push(result.event) + } + + // Finalize accumulated tool inputs eagerly when finish_reason arrives so + // JSON parse failures fail the stream at the boundary rather than at halt. + const finished = + finishReason !== undefined && state.finishReason === undefined && Object.keys(tools).length > 0 + ? yield* ToolStream.finishAll(ADAPTER, tools) + : undefined + + return [ + { + tools: finished?.tools ?? tools, + toolCallEvents: finished?.events ?? state.toolCallEvents, + usage, + finishReason, + }, + events, + ] as const + }) + +const finishEvents = (state: ParserState): ReadonlyArray => { + const hasToolCalls = state.toolCallEvents.length > 0 + const reason = state.finishReason === "stop" && hasToolCalls ? "tool-calls" : state.finishReason + return [ + ...state.toolCallEvents, + ...(reason ? ([{ type: "request-finish", reason, usage: state.usage }] satisfies ReadonlyArray) : []), + ] +} + +// ============================================================================= +// Protocol And OpenAI Route +// ============================================================================= +/** + * The OpenAI Chat protocol — request body construction, body schema, and the + * streaming-event state machine. Reused by every route that speaks OpenAI Chat + * over HTTP+SSE: native OpenAI, DeepSeek, TogetherAI, Cerebras, Baseten, + * Fireworks, DeepInfra, and (once added) Azure OpenAI Chat. + */ +export const protocol = Protocol.make({ + id: ADAPTER, + body: { + schema: OpenAIChatBody, + from: fromRequest, + }, + stream: { + event: Protocol.jsonEvent(OpenAIChatEvent), + initial: () => ({ tools: ToolStream.empty(), toolCallEvents: [] }), + step, + onHalt: finishEvents, + }, +}) + +const encodeBody = Schema.encodeSync(Schema.fromJsonString(OpenAIChatBody)) + +export const httpTransport = HttpTransport.httpJson({ + endpoint: Endpoint.path(PATH), + auth: Auth.bearer(), + framing: Framing.sse, + encodeBody, +}) + +export const route = Route.make({ + id: ADAPTER, + provider: "openai", + protocol, + transport: httpTransport, + defaults: { + baseURL: DEFAULT_BASE_URL, + }, +}) + +// ============================================================================= +// Model Helper +// ============================================================================= +export const model = route.model + +export * as OpenAIChat from "./openai-chat" diff --git a/packages/llm/src/protocols/openai-compatible-chat.ts b/packages/llm/src/protocols/openai-compatible-chat.ts new file mode 100644 index 0000000000..76deeac451 --- /dev/null +++ b/packages/llm/src/protocols/openai-compatible-chat.ts @@ -0,0 +1,28 @@ +import { Route, type RouteRoutedModelInput } from "../route/client" +import { Endpoint } from "../route/endpoint" +import { Framing } from "../route/framing" +import * as OpenAIChat from "./openai-chat" + +const ADAPTER = "openai-compatible-chat" + +export type OpenAICompatibleChatModelInput = Omit & { + readonly baseURL: string +} + +/** + * Route for non-OpenAI providers that expose an OpenAI Chat-compatible + * `/chat/completions` endpoint. Reuses `OpenAIChat.protocol` end-to-end and + * overrides only the route id so providers can be resolved per-family without + * colliding with native OpenAI. The model carries the host on `baseURL`, + * supplied by whichever profile/provider helper builds it. + */ +export const route = Route.make({ + id: ADAPTER, + protocol: OpenAIChat.protocol, + endpoint: Endpoint.path("/chat/completions"), + framing: Framing.sse, +}) + +export const model = Route.model(route) + +export * as OpenAICompatibleChat from "./openai-compatible-chat" diff --git a/packages/llm/src/protocols/openai-responses.ts b/packages/llm/src/protocols/openai-responses.ts new file mode 100644 index 0000000000..780ed31bfc --- /dev/null +++ b/packages/llm/src/protocols/openai-responses.ts @@ -0,0 +1,575 @@ +import { Effect, Schema } from "effect" +import { Route } from "../route/client" +import { Auth } from "../route/auth" +import { Endpoint } from "../route/endpoint" +import { Framing } from "../route/framing" +import { HttpTransport, WebSocketTransport } from "../route/transport" +import { Protocol } from "../route/protocol" +import { + Usage, + type FinishReason, + type LLMEvent, + type LLMRequest, + type ProviderMetadata, + type TextPart, + type ToolCallPart, + type ToolDefinition, +} from "../schema" +import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared" +import { OpenAIOptions } from "./utils/openai-options" +import { ToolStream } from "./utils/tool-stream" + +const ADAPTER = "openai-responses" +export const DEFAULT_BASE_URL = "https://api.openai.com/v1" +export const PATH = "/responses" + +// ============================================================================= +// Request Body Schema +// ============================================================================= +const OpenAIResponsesInputText = Schema.Struct({ + type: Schema.tag("input_text"), + text: Schema.String, +}) + +const OpenAIResponsesOutputText = Schema.Struct({ + type: Schema.tag("output_text"), + text: Schema.String, +}) + +const OpenAIResponsesInputItem = Schema.Union([ + Schema.Struct({ role: Schema.tag("system"), content: Schema.String }), + Schema.Struct({ role: Schema.tag("user"), content: Schema.Array(OpenAIResponsesInputText) }), + Schema.Struct({ role: Schema.tag("assistant"), content: Schema.Array(OpenAIResponsesOutputText) }), + Schema.Struct({ + type: Schema.tag("function_call"), + call_id: Schema.String, + name: Schema.String, + arguments: Schema.String, + }), + Schema.Struct({ + type: Schema.tag("function_call_output"), + call_id: Schema.String, + output: Schema.String, + }), +]) +type OpenAIResponsesInputItem = Schema.Schema.Type + +const OpenAIResponsesTool = Schema.Struct({ + type: Schema.tag("function"), + name: Schema.String, + description: Schema.String, + parameters: JsonObject, + strict: Schema.optional(Schema.Boolean), +}) +type OpenAIResponsesTool = Schema.Schema.Type + +const OpenAIResponsesToolChoice = Schema.Union([ + Schema.Literals(["auto", "none", "required"]), + Schema.Struct({ type: Schema.tag("function"), name: Schema.String }), +]) + +// Fields shared between the HTTP body and the WebSocket `response.create` +// message. The HTTP body adds `stream: true`; the WebSocket message adds +// `type: "response.create"`. Defining the shared shape once keeps the two +// transports in sync without a destructure-and-strip dance. +const OpenAIResponsesCoreFields = { + model: Schema.String, + input: Schema.Array(OpenAIResponsesInputItem), + tools: optionalArray(OpenAIResponsesTool), + tool_choice: Schema.optional(OpenAIResponsesToolChoice), + store: Schema.optional(Schema.Boolean), + prompt_cache_key: Schema.optional(Schema.String), + include: optionalArray(Schema.Literal("reasoning.encrypted_content")), + reasoning: Schema.optional( + Schema.Struct({ + effort: Schema.optional(OpenAIOptions.OpenAIReasoningEffort), + summary: Schema.optional(Schema.Literal("auto")), + }), + ), + text: Schema.optional( + Schema.Struct({ + verbosity: Schema.optional(OpenAIOptions.OpenAITextVerbosity), + }), + ), + max_output_tokens: Schema.optional(Schema.Number), + temperature: Schema.optional(Schema.Number), + top_p: Schema.optional(Schema.Number), +} + +const OpenAIResponsesBody = Schema.Struct({ + ...OpenAIResponsesCoreFields, + stream: Schema.Literal(true), +}) +export type OpenAIResponsesBody = Schema.Schema.Type + +const OpenAIResponsesWebSocketMessage = Schema.StructWithRest( + Schema.Struct({ + type: Schema.tag("response.create"), + ...OpenAIResponsesCoreFields, + }), + [Schema.Record(Schema.String, Schema.Unknown)], +) +type OpenAIResponsesWebSocketMessage = Schema.Schema.Type +const encodeWebSocketMessage = Schema.encodeSync(Schema.fromJsonString(OpenAIResponsesWebSocketMessage)) + +const OpenAIResponsesUsage = Schema.Struct({ + input_tokens: Schema.optional(Schema.Number), + input_tokens_details: optionalNull(Schema.Struct({ cached_tokens: Schema.optional(Schema.Number) })), + output_tokens: Schema.optional(Schema.Number), + output_tokens_details: optionalNull(Schema.Struct({ reasoning_tokens: Schema.optional(Schema.Number) })), + total_tokens: Schema.optional(Schema.Number), +}) +type OpenAIResponsesUsage = Schema.Schema.Type + +const OpenAIResponsesStreamItem = Schema.Struct({ + type: Schema.String, + id: Schema.optional(Schema.String), + call_id: Schema.optional(Schema.String), + name: Schema.optional(Schema.String), + arguments: Schema.optional(Schema.String), + // Hosted (provider-executed) tool fields. Each hosted tool item carries its + // own subset of these — we capture them generically so we can surface the + // call's typed input portion and round-trip the full result payload without + // hand-rolling a per-tool schema. + status: Schema.optional(Schema.String), + action: Schema.optional(Schema.Unknown), + queries: Schema.optional(Schema.Unknown), + results: Schema.optional(Schema.Unknown), + code: Schema.optional(Schema.String), + container_id: Schema.optional(Schema.String), + outputs: Schema.optional(Schema.Unknown), + server_label: Schema.optional(Schema.String), + output: Schema.optional(Schema.Unknown), + error: Schema.optional(Schema.Unknown), +}) +type OpenAIResponsesStreamItem = Schema.Schema.Type + +const OpenAIResponsesEvent = Schema.Struct({ + type: Schema.String, + delta: Schema.optional(Schema.String), + item_id: Schema.optional(Schema.String), + item: Schema.optional(OpenAIResponsesStreamItem), + response: Schema.optional( + Schema.Struct({ + id: Schema.optional(Schema.String), + service_tier: Schema.optional(Schema.String), + incomplete_details: optionalNull(Schema.Struct({ reason: Schema.String })), + usage: optionalNull(OpenAIResponsesUsage), + }), + ), + code: Schema.optional(Schema.String), + message: Schema.optional(Schema.String), +}) +type OpenAIResponsesEvent = Schema.Schema.Type + +interface ParserState { + readonly tools: ToolStream.State + readonly hasFunctionCall: boolean +} + +const invalid = ProviderShared.invalidRequest + +// ============================================================================= +// Request Lowering +// ============================================================================= +const lowerTool = (tool: ToolDefinition): OpenAIResponsesTool => ({ + type: "function", + name: tool.name, + description: tool.description, + parameters: tool.inputSchema, +}) + +const lowerToolChoice = (toolChoice: NonNullable) => + ProviderShared.matchToolChoice("OpenAI Responses", toolChoice, { + auto: () => "auto" as const, + none: () => "none" as const, + required: () => "required" as const, + tool: (name) => ({ type: "function" as const, name }), + }) + +const lowerToolCall = (part: ToolCallPart): OpenAIResponsesInputItem => ({ + type: "function_call", + call_id: part.id, + name: part.name, + arguments: ProviderShared.encodeJson(part.input), +}) + +const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (request: LLMRequest) { + const system: OpenAIResponsesInputItem[] = + request.system.length === 0 ? [] : [{ role: "system", content: ProviderShared.joinText(request.system) }] + const input: OpenAIResponsesInputItem[] = [...system] + + for (const message of request.messages) { + if (message.role === "user") { + const content: TextPart[] = [] + for (const part of message.content) { + if (!ProviderShared.supportsContent(part, ["text"])) + return yield* ProviderShared.unsupportedContent("OpenAI Responses", "user", ["text"]) + content.push(part) + } + input.push({ role: "user", content: content.map((part) => ({ type: "input_text", text: part.text })) }) + continue + } + + if (message.role === "assistant") { + const content: TextPart[] = [] + for (const part of message.content) { + if (!ProviderShared.supportsContent(part, ["text", "tool-call"])) + return yield* ProviderShared.unsupportedContent("OpenAI Responses", "assistant", ["text", "tool-call"]) + if (part.type === "text") { + content.push(part) + continue + } + if (part.type === "tool-call") { + input.push(lowerToolCall(part)) + continue + } + } + if (content.length > 0) + input.push({ role: "assistant", content: content.map((part) => ({ type: "output_text", text: part.text })) }) + continue + } + + for (const part of message.content) { + if (!ProviderShared.supportsContent(part, ["tool-result"])) + return yield* ProviderShared.unsupportedContent("OpenAI Responses", "tool", ["tool-result"]) + input.push({ type: "function_call_output", call_id: part.id, output: ProviderShared.toolResultText(part) }) + } + } + + return input +}) + +const lowerOptions = Effect.fn("OpenAIResponses.lowerOptions")(function* (request: LLMRequest) { + const store = OpenAIOptions.store(request) + const promptCacheKey = OpenAIOptions.promptCacheKey(request) + const effort = OpenAIOptions.reasoningEffort(request) + if (effort && !OpenAIOptions.isReasoningEffort(effort)) + return yield* invalid(`OpenAI Responses does not support reasoning effort ${effort}`) + const summary = OpenAIOptions.reasoningSummary(request) + const encryptedState = OpenAIOptions.encryptedReasoning(request) + const verbosity = OpenAIOptions.textVerbosity(request) + return { + ...(store !== undefined ? { store } : {}), + ...(promptCacheKey ? { prompt_cache_key: promptCacheKey } : {}), + ...(encryptedState ? { include: ["reasoning.encrypted_content"] as const } : {}), + ...(effort || summary ? { reasoning: { effort, summary } } : {}), + ...(verbosity ? { text: { verbosity } } : {}), + } +}) + +const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request: LLMRequest) { + const generation = request.generation + return { + model: request.model.id, + input: yield* lowerMessages(request), + tools: request.tools.length === 0 ? undefined : request.tools.map(lowerTool), + tool_choice: request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined, + stream: true as const, + max_output_tokens: generation?.maxTokens, + temperature: generation?.temperature, + top_p: generation?.topP, + ...(yield* lowerOptions(request)), + } +}) + +// ============================================================================= +// Stream Parsing +// ============================================================================= +const mapUsage = (usage: OpenAIResponsesUsage | null | undefined) => { + if (!usage) return undefined + return new Usage({ + inputTokens: usage.input_tokens, + outputTokens: usage.output_tokens, + reasoningTokens: usage.output_tokens_details?.reasoning_tokens, + cacheReadInputTokens: usage.input_tokens_details?.cached_tokens, + totalTokens: ProviderShared.totalTokens(usage.input_tokens, usage.output_tokens, usage.total_tokens), + native: usage, + }) +} + +const mapFinishReason = (event: OpenAIResponsesEvent, hasFunctionCall: boolean): FinishReason => { + const reason = event.response?.incomplete_details?.reason + if (reason === undefined || reason === null) return hasFunctionCall ? "tool-calls" : "stop" + if (reason === "max_output_tokens") return "length" + if (reason === "content_filter") return "content-filter" + return hasFunctionCall ? "tool-calls" : "unknown" +} + +const openaiMetadata = (metadata: Record): ProviderMetadata => ({ openai: metadata }) + +// Hosted tool items (provider-executed) ship their typed input + status + +// result fields all in one item. We expose them as a `tool-call` + +// `tool-result` pair so consumers can treat them uniformly with client tools, +// only differentiated by `providerExecuted: true`. +// +// One record per OpenAI Responses item type that represents a hosted +// (provider-executed) tool call: the common name we surface, plus an `input` +// extractor that picks the fields the model actually populated for that tool. +// Falling back to `{}` when an entry isn't fully typed keeps unknown tools +// observable without rolling a per-tool schema. +const HOSTED_TOOLS = { + web_search_call: { name: "web_search", input: (item) => item.action ?? {} }, + web_search_preview_call: { name: "web_search_preview", input: (item) => item.action ?? {} }, + file_search_call: { name: "file_search", input: (item) => ({ queries: item.queries ?? [] }) }, + code_interpreter_call: { + name: "code_interpreter", + input: (item) => ({ code: item.code, container_id: item.container_id }), + }, + computer_use_call: { name: "computer_use", input: (item) => item.action ?? {} }, + image_generation_call: { name: "image_generation", input: () => ({}) }, + mcp_call: { + name: "mcp", + input: (item) => ({ server_label: item.server_label, name: item.name, arguments: item.arguments }), + }, + local_shell_call: { name: "local_shell", input: (item) => item.action ?? {} }, +} as const satisfies Record< + string, + { readonly name: string; readonly input: (item: OpenAIResponsesStreamItem) => unknown } +> + +type HostedToolType = keyof typeof HOSTED_TOOLS + +const isHostedToolItem = ( + item: OpenAIResponsesStreamItem, +): item is OpenAIResponsesStreamItem & { type: HostedToolType; id: string } => + item.type in HOSTED_TOOLS && typeof item.id === "string" && item.id.length > 0 + +// Round-trip the full item as the structured result so consumers can extract +// outputs / sources / status without re-decoding. +const hostedToolResult = (item: OpenAIResponsesStreamItem) => { + const isError = typeof item.error !== "undefined" && item.error !== null + return isError ? { type: "error" as const, value: item.error } : { type: "json" as const, value: item } +} + +const hostedToolEvents = ( + item: OpenAIResponsesStreamItem & { type: HostedToolType; id: string }, +): ReadonlyArray => { + const tool = HOSTED_TOOLS[item.type] + const providerMetadata = openaiMetadata({ itemId: item.id }) + return [ + { + type: "tool-call", + id: item.id, + name: tool.name, + input: tool.input(item), + providerExecuted: true, + providerMetadata, + }, + { + type: "tool-result", + id: item.id, + name: tool.name, + result: hostedToolResult(item), + providerExecuted: true, + providerMetadata, + }, + ] +} + +type StepResult = readonly [ParserState, ReadonlyArray] + +const NO_EVENTS: StepResult["1"] = [] + +// `response.completed` / `response.incomplete` are clean finishes that emit a +// `request-finish` event; `response.failed` is a hard failure that emits a +// `provider-error`. All three end the stream — kept in one set so `step` and +// the protocol's `terminal` predicate stay in sync. +const TERMINAL_TYPES = new Set(["response.completed", "response.incomplete", "response.failed"]) + +const onOutputTextDelta = (state: ParserState, event: OpenAIResponsesEvent): StepResult => { + if (!event.delta) return [state, NO_EVENTS] + return [ + state, + [ + { + type: "text-delta", + id: event.item_id, + text: event.delta, + ...(event.item_id ? { providerMetadata: openaiMetadata({ itemId: event.item_id }) } : {}), + }, + ], + ] +} + +const onOutputItemAdded = (state: ParserState, event: OpenAIResponsesEvent): StepResult => { + const item = event.item + if (item?.type !== "function_call" || !item.id) return [state, NO_EVENTS] + return [ + { + hasFunctionCall: state.hasFunctionCall, + tools: ToolStream.start(state.tools, item.id, { + id: item.call_id ?? item.id, + name: item.name ?? "", + input: item.arguments ?? "", + providerMetadata: openaiMetadata({ itemId: item.id }), + }), + }, + NO_EVENTS, + ] +} + +const onFunctionCallArgumentsDelta = Effect.fn("OpenAIResponses.onFunctionCallArgumentsDelta")(function* ( + state: ParserState, + event: OpenAIResponsesEvent, +) { + if (!event.item_id || !event.delta) return [state, NO_EVENTS] satisfies StepResult + const result = ToolStream.appendExisting( + ADAPTER, + state.tools, + event.item_id, + event.delta, + "OpenAI Responses tool argument delta is missing its tool call", + ) + if (ToolStream.isError(result)) return yield* result + return [ + { hasFunctionCall: state.hasFunctionCall, tools: result.tools }, + result.event ? [result.event] : NO_EVENTS, + ] satisfies StepResult +}) + +const onOutputItemDone = Effect.fn("OpenAIResponses.onOutputItemDone")(function* ( + state: ParserState, + event: OpenAIResponsesEvent, +) { + const item = event.item + if (!item) return [state, NO_EVENTS] satisfies StepResult + + if (item.type === "function_call") { + if (!item.id || !item.call_id || !item.name) return [state, NO_EVENTS] satisfies StepResult + const tools = state.tools[item.id] + ? state.tools + : ToolStream.start(state.tools, item.id, { id: item.call_id, name: item.name }) + const result = + item.arguments === undefined + ? yield* ToolStream.finish(ADAPTER, tools, item.id) + : yield* ToolStream.finishWithInput(ADAPTER, tools, item.id, item.arguments) + return [ + { hasFunctionCall: result.event ? true : state.hasFunctionCall, tools: result.tools }, + result.event ? [result.event] : NO_EVENTS, + ] satisfies StepResult + } + + if (isHostedToolItem(item)) return [state, hostedToolEvents(item)] satisfies StepResult + + return [state, NO_EVENTS] satisfies StepResult +}) + +const onResponseFinish = (state: ParserState, event: OpenAIResponsesEvent): StepResult => [ + state, + [ + { + type: "request-finish", + reason: mapFinishReason(event, state.hasFunctionCall), + usage: mapUsage(event.response?.usage), + ...(event.response?.id || event.response?.service_tier + ? { + providerMetadata: openaiMetadata({ + responseId: event.response.id, + serviceTier: event.response.service_tier, + }), + } + : {}), + }, + ], +] + +const onResponseFailed = (state: ParserState, event: OpenAIResponsesEvent): StepResult => [ + state, + [{ type: "provider-error", message: event.message ?? event.code ?? "OpenAI Responses response failed" }], +] + +const onError = (state: ParserState, event: OpenAIResponsesEvent): StepResult => [ + state, + [{ type: "provider-error", message: event.message ?? event.code ?? "OpenAI Responses stream error" }], +] + +const step = (state: ParserState, event: OpenAIResponsesEvent) => { + if (event.type === "response.output_text.delta") return Effect.succeed(onOutputTextDelta(state, event)) + if (event.type === "response.output_item.added") return Effect.succeed(onOutputItemAdded(state, event)) + if (event.type === "response.function_call_arguments.delta") return onFunctionCallArgumentsDelta(state, event) + if (event.type === "response.output_item.done") return onOutputItemDone(state, event) + if (event.type === "response.completed" || event.type === "response.incomplete") + return Effect.succeed(onResponseFinish(state, event)) + if (event.type === "response.failed") return Effect.succeed(onResponseFailed(state, event)) + if (event.type === "error") return Effect.succeed(onError(state, event)) + return Effect.succeed([state, NO_EVENTS]) +} + +// ============================================================================= +// Protocol And OpenAI Route +// ============================================================================= +/** + * The OpenAI Responses protocol — request body construction, body schema, and + * the streaming-event state machine. Used by native OpenAI and (once + * registered) Azure OpenAI Responses. + */ +export const protocol = Protocol.make({ + id: ADAPTER, + body: { + schema: OpenAIResponsesBody, + from: fromRequest, + }, + stream: { + event: Protocol.jsonEvent(OpenAIResponsesEvent), + initial: () => ({ hasFunctionCall: false, tools: ToolStream.empty() }), + step, + terminal: (event) => TERMINAL_TYPES.has(event.type), + }, +}) + +const encodeBody = Schema.encodeSync(Schema.fromJsonString(OpenAIResponsesBody)) +const transportBase = { + endpoint: Endpoint.path(PATH), + auth: Auth.bearer(), + encodeBody, +} +const routeDefaults = { + baseURL: DEFAULT_BASE_URL, +} + +export const httpTransport = HttpTransport.httpJson({ + ...transportBase, + framing: Framing.sse, +}) + +export const route = Route.make({ + id: ADAPTER, + provider: "openai", + protocol, + transport: httpTransport, + defaults: routeDefaults, +}) + +const decodeWebSocketMessage = ProviderShared.validateWith(Schema.decodeUnknownEffect(OpenAIResponsesWebSocketMessage)) + +const webSocketMessage = (body: OpenAIResponsesBody | Record) => + Effect.gen(function* () { + if (!ProviderShared.isRecord(body)) + return yield* ProviderShared.invalidRequest("OpenAI Responses WebSocket body must be a JSON object") + const { stream: _stream, ...message } = body + return yield* decodeWebSocketMessage({ ...message, type: "response.create" }) + }) + +export const webSocketTransport = WebSocketTransport.json({ + ...transportBase, + toMessage: webSocketMessage, + encodeMessage: encodeWebSocketMessage, +}) + +export const webSocketRoute = Route.make({ + id: `${ADAPTER}-websocket`, + provider: "openai", + protocol, + transport: webSocketTransport, + defaults: routeDefaults, +}) + +// ============================================================================= +// Model Helper +// ============================================================================= +export const model = route.model + +export const webSocketModel = webSocketRoute.model + +export * as OpenAIResponses from "./openai-responses" diff --git a/packages/llm/src/protocols/shared.ts b/packages/llm/src/protocols/shared.ts new file mode 100644 index 0000000000..c931353998 --- /dev/null +++ b/packages/llm/src/protocols/shared.ts @@ -0,0 +1,203 @@ +import { Buffer } from "node:buffer" +import { Effect, Schema, Stream } from "effect" +import * as Sse from "effect/unstable/encoding/Sse" +import { Headers, HttpClientRequest } from "effect/unstable/http" +import { + InvalidProviderOutputReason, + InvalidRequestReason, + LLMError, + type ContentPart, + type LLMRequest, + type MediaPart, + type ToolResultPart, +} from "../schema" + +export const Json = Schema.fromJsonString(Schema.Unknown) +export const decodeJson = Schema.decodeUnknownSync(Json) +export const encodeJson = Schema.encodeSync(Json) +export const JsonObject = Schema.Record(Schema.String, Schema.Unknown) +export const optionalArray = (schema: S) => Schema.optional(Schema.Array(schema)) +export const optionalNull = (schema: S) => Schema.optional(Schema.NullOr(schema)) + +/** + * Plain-record narrowing. Excludes arrays so routes checking nested JSON + * Schema fragments don't accidentally treat a tuple as a key/value bag. + */ +export const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value) + +/** + * Streaming tool-call accumulator. Adapters that build a tool call across + * multiple `tool-input-delta` chunks store the partial JSON input string here + * and finalize it with `parseToolInput` once the call completes. + */ +export interface ToolAccumulator { + readonly id: string + readonly name: string + readonly input: string +} + +/** + * `Usage.totalTokens` policy shared by every route. Honors a provider- + * supplied total; otherwise falls back to `inputTokens + outputTokens` only + * when at least one is defined. Returns `undefined` when neither input nor + * output is known so routes don't publish a misleading `0`. + */ +export const totalTokens = ( + inputTokens: number | undefined, + outputTokens: number | undefined, + total: number | undefined, +) => { + if (total !== undefined) return total + if (inputTokens === undefined && outputTokens === undefined) return undefined + return (inputTokens ?? 0) + (outputTokens ?? 0) +} + +export const eventError = (route: string, message: string, raw?: string) => + new LLMError({ + module: "ProviderShared", + method: "stream", + reason: new InvalidProviderOutputReason({ route, message, raw }), + }) + +export const parseJson = (route: string, input: string, message: string) => + Effect.try({ + try: () => decodeJson(input), + catch: () => eventError(route, message, input), + }) + +/** + * Join the `text` field of a list of parts with newlines. Used by routes + * that flatten system / message content arrays into a single provider string + * (OpenAI Chat `system` content, OpenAI Responses `system` content, Gemini + * `systemInstruction.parts[].text`). + */ +export const joinText = (parts: ReadonlyArray<{ readonly text: string }>) => parts.map((part) => part.text).join("\n") + +/** + * Parse the streamed JSON input of a tool call. Treats an empty string as + * `"{}"` — providers occasionally finish a tool call without ever emitting + * input deltas (e.g. zero-arg tools). The error message is uniform across + * routes: `Invalid JSON input for tool call `. + */ +export const parseToolInput = (route: string, name: string, raw: string) => + parseJson(route, raw || "{}", `Invalid JSON input for ${route} tool call ${name}`) + +/** + * Encode a `MediaPart`'s raw bytes for inclusion in a JSON request body. + * `data: string` is assumed to already be base64 (matches caller convention + * across Gemini / Bedrock); `data: Uint8Array` is base64-encoded here. Used + * by every route that supports image / document inputs. + */ +export const mediaBytes = (part: MediaPart) => + typeof part.data === "string" ? part.data : Buffer.from(part.data).toString("base64") + +export const trimBaseUrl = (value: string) => value.replace(/\/+$/, "") + +export const toolResultText = (part: ToolResultPart) => { + if (part.result.type === "text" || part.result.type === "error") return String(part.result.value) + return encodeJson(part.result.value) +} + +export const errorText = (error: unknown) => { + if (error instanceof Error) return error.message + if (typeof error === "string") return error + if (typeof error === "number" || typeof error === "boolean" || typeof error === "bigint") return String(error) + if (error === null) return "null" + if (error === undefined) return "undefined" + return "Unknown stream error" +} + +/** + * `framing` step for Server-Sent Events. Decodes UTF-8, runs the SSE channel + * decoder, and drops empty / `[DONE]` keep-alive events so the downstream + * `decodeChunk` sees one JSON string per element. The SSE channel emits a + * `Retry` control event on its error channel; we drop it here (we don't + * implement client-driven retries) so the public error channel stays + * `LLMError`. + */ +export const sseFraming = (bytes: Stream.Stream): Stream.Stream => + bytes.pipe( + Stream.decodeText(), + Stream.pipeThroughChannel(Sse.decode()), + Stream.catchTag("Retry", () => Stream.empty), + Stream.filter((event) => event.data.length > 0 && event.data !== "[DONE]"), + Stream.map((event) => event.data), + ) + +/** + * Canonical invalid-request constructor. Lift one-line `const invalid = + * (message) => invalidRequest(message)` aliases out of every + * route so the error constructor lives in one place. If we ever extend + * `InvalidRequestReason` with route context or trace metadata, the change + * lands here. + */ +export const invalidRequest = (message: string) => + new LLMError({ + module: "ProviderShared", + method: "request", + reason: new InvalidRequestReason({ message }), + }) + +export const matchToolChoice = ( + route: string, + toolChoice: NonNullable, + cases: { + readonly auto: () => Auto + readonly none: () => None + readonly required: () => Required + readonly tool: (name: string) => Tool + }, +) => + Effect.gen(function* () { + if (toolChoice.type === "auto") return cases.auto() + if (toolChoice.type === "none") return cases.none() + if (toolChoice.type === "required") return cases.required() + if (!toolChoice.name) return yield* invalidRequest(`${route} tool choice requires a tool name`) + return cases.tool(toolChoice.name) + }) + +type ContentType = ContentPart["type"] + +const formatContentTypes = (types: ReadonlyArray) => { + if (types.length <= 1) return types[0] ?? "" + if (types.length === 2) return `${types[0]} and ${types[1]}` + return `${types.slice(0, -1).join(", ")}, and ${types.at(-1)}` +} + +export const supportsContent = ( + part: ContentPart, + types: ReadonlyArray, +): part is Extract => (types as ReadonlyArray).includes(part.type) + +export const unsupportedContent = ( + route: string, + role: LLMRequest["messages"][number]["role"], + types: ReadonlyArray, +) => invalidRequest(`${route} ${role} messages only support ${formatContentTypes(types)} content for now`) + +/** + * Build a `validate` step from a Schema decoder. Replaces the per-route + * lambda body `(payload) => decode(payload).pipe(Effect.mapError((e) => + * invalid(e.message)))`. Any decode error is translated into + * `LLMError` carrying the original parse-error message. + */ +export const validateWith = + (decode: (input: I) => Effect.Effect) => + (payload: I) => + decode(payload).pipe(Effect.mapError((error) => invalidRequest(error.message))) + +/** + * Build an HTTP POST with a JSON body. Sets `content-type: application/json` + * automatically after caller-supplied headers so routes cannot accidentally + * send JSON with a stale content type. The body is passed pre-encoded so + * routes can choose between + * `Schema.encodeSync(payload)` and `ProviderShared.encodeJson(payload)`. + */ +export const jsonPost = (input: { readonly url: string; readonly body: string; readonly headers?: Headers.Input }) => + HttpClientRequest.post(input.url).pipe( + HttpClientRequest.setHeaders(Headers.set(Headers.fromInput(input.headers), "content-type", "application/json")), + HttpClientRequest.bodyText(input.body, "application/json"), + ) + +export * as ProviderShared from "./shared" diff --git a/packages/llm/src/protocols/utils/bedrock-auth.ts b/packages/llm/src/protocols/utils/bedrock-auth.ts new file mode 100644 index 0000000000..58d16d95f8 --- /dev/null +++ b/packages/llm/src/protocols/utils/bedrock-auth.ts @@ -0,0 +1,103 @@ +import { AwsV4Signer } from "aws4fetch" +import { Effect, Option, Schema } from "effect" +import { Headers } from "effect/unstable/http" +import { Auth, type AuthInput } from "../../route/auth" +import type { LLMRequest } from "../../schema" +import { ProviderShared } from "../shared" + +/** + * AWS credentials for SigV4 signing. Bedrock also supports Bearer API key auth + * via `model.apiKey`, which bypasses SigV4 signing. STS-vended credentials + * should be refreshed by the consumer (rebuild the model) before they expire; + * the route does not refresh. + */ +export interface Credentials { + readonly region: string + readonly accessKeyId: string + readonly secretAccessKey: string + readonly sessionToken?: string +} + +const NativeCredentials = Schema.Struct({ + accessKeyId: Schema.String, + secretAccessKey: Schema.String, + region: Schema.optional(Schema.String), + sessionToken: Schema.optional(Schema.String), +}) + +const decodeNativeCredentials = Schema.decodeUnknownOption(NativeCredentials) + +export const region = (request: LLMRequest) => { + const fromNative = request.model.native?.aws_region + if (typeof fromNative === "string" && fromNative !== "") return fromNative + return ( + decodeNativeCredentials(request.model.native?.aws_credentials).pipe( + Option.map((credentials) => credentials.region), + Option.getOrUndefined, + ) ?? "us-east-1" + ) +} + +const credentialsFromInput = (request: LLMRequest): Credentials | undefined => + decodeNativeCredentials(request.model.native?.aws_credentials).pipe( + Option.map((creds) => ({ ...creds, region: creds.region ?? region(request) })), + Option.getOrUndefined, + ) + +const signRequest = (input: { + readonly url: string + readonly body: string + readonly headers: Headers.Headers + readonly credentials: Credentials +}) => + Effect.tryPromise({ + try: async () => { + const signed = await new AwsV4Signer({ + url: input.url, + method: "POST", + headers: Object.entries(input.headers), + body: input.body, + region: input.credentials.region, + accessKeyId: input.credentials.accessKeyId, + secretAccessKey: input.credentials.secretAccessKey, + sessionToken: input.credentials.sessionToken, + service: "bedrock", + }).sign() + return Object.fromEntries(signed.headers.entries()) + }, + catch: (error) => + ProviderShared.invalidRequest( + `Bedrock Converse SigV4 signing failed: ${error instanceof Error ? error.message : String(error)}`, + ), + }) + +/** + * Bedrock auth. `model.apiKey` (Bedrock's newer Bearer API key auth) wins if + * set; otherwise sign the exact JSON bytes with SigV4 using credentials from + * `model.native.aws_credentials`. + */ +export const auth = Auth.custom((input: AuthInput) => { + if (input.request.model.apiKey) return Auth.toEffect(Auth.bearer())(input) + return Effect.gen(function* () { + const credentials = credentialsFromInput(input.request) + if (!credentials) { + return yield* ProviderShared.invalidRequest( + "Bedrock Converse requires either model.apiKey or AWS credentials in model.native.aws_credentials", + ) + } + const headersForSigning = Headers.set(input.headers, "content-type", "application/json") + const signed = yield* signRequest({ url: input.url, body: input.body, headers: headersForSigning, credentials }) + return Headers.setAll(headersForSigning, signed) + }) +}) + +export const nativeCredentials = (native: Record | undefined, credentials: Credentials | undefined) => + credentials + ? { + ...native, + aws_credentials: credentials, + aws_region: credentials.region, + } + : native + +export * as BedrockAuth from "./bedrock-auth" diff --git a/packages/llm/src/protocols/utils/bedrock-cache.ts b/packages/llm/src/protocols/utils/bedrock-cache.ts new file mode 100644 index 0000000000..ca6e52cd11 --- /dev/null +++ b/packages/llm/src/protocols/utils/bedrock-cache.ts @@ -0,0 +1,20 @@ +import { Schema } from "effect" +import type { CacheHint } from "../../schema" + +// Bedrock cache markers are positional: emit a `cachePoint` block immediately +// after the content the caller wants treated as a cacheable prefix. +export const CachePointBlock = Schema.Struct({ + cachePoint: Schema.Struct({ type: Schema.tag("default") }), +}) +export type CachePointBlock = Schema.Schema.Type + +// Bedrock recently added optional `ttl: "5m" | "1h"` on cachePoint. Map +// `CacheHint.ttlSeconds` here once a recorded cassette validates the wire shape. +const DEFAULT: CachePointBlock = { cachePoint: { type: "default" } } + +export const block = (cache: CacheHint | undefined): CachePointBlock | undefined => { + if (cache?.type !== "ephemeral" && cache?.type !== "persistent") return undefined + return DEFAULT +} + +export * as BedrockCache from "./bedrock-cache" diff --git a/packages/llm/src/protocols/utils/bedrock-media.ts b/packages/llm/src/protocols/utils/bedrock-media.ts new file mode 100644 index 0000000000..0fbb396f96 --- /dev/null +++ b/packages/llm/src/protocols/utils/bedrock-media.ts @@ -0,0 +1,80 @@ +import { Effect, Schema } from "effect" +import type { MediaPart } from "../../schema" +import { ProviderShared } from "../shared" + +// Bedrock Converse accepts image `format` as the file extension and +// `source.bytes` as base64 in the JSON wire format. +export const ImageFormat = Schema.Literals(["png", "jpeg", "gif", "webp"]) +export type ImageFormat = Schema.Schema.Type + +export const ImageBlock = Schema.Struct({ + image: Schema.Struct({ + format: ImageFormat, + source: Schema.Struct({ bytes: Schema.String }), + }), +}) +export type ImageBlock = Schema.Schema.Type + +// Bedrock document blocks require a user-facing name so the model can refer to +// the uploaded document. +export const DocumentFormat = Schema.Literals(["pdf", "csv", "doc", "docx", "xls", "xlsx", "html", "txt", "md"]) +export type DocumentFormat = Schema.Schema.Type + +export const DocumentBlock = Schema.Struct({ + document: Schema.Struct({ + format: DocumentFormat, + name: Schema.String, + source: Schema.Struct({ bytes: Schema.String }), + }), +}) +export type DocumentBlock = Schema.Schema.Type + +const IMAGE_FORMATS = { + "image/png": "png", + "image/jpeg": "jpeg", + "image/jpg": "jpeg", + "image/gif": "gif", + "image/webp": "webp", +} as const satisfies Record + +const DOCUMENT_FORMATS = { + "application/pdf": "pdf", + "text/csv": "csv", + "application/msword": "doc", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document": "docx", + "application/vnd.ms-excel": "xls", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": "xlsx", + "text/html": "html", + "text/plain": "txt", + "text/markdown": "md", +} as const satisfies Record + +const imageBlock = (part: MediaPart, format: ImageFormat): ImageBlock => ({ + image: { format, source: { bytes: ProviderShared.mediaBytes(part) } }, +}) + +const documentBlock = (part: MediaPart, format: DocumentFormat): DocumentBlock => ({ + document: { + format, + name: part.filename ?? `document.${format}`, + source: { bytes: ProviderShared.mediaBytes(part) }, + }, +}) + +// Route by MIME. Known image/document formats lower into a typed block; anything +// else fails with a clear error instead of silently degrading to a malformed +// document block. Image MIME types not in `IMAGE_FORMATS` (e.g. `image/svg+xml`) +// get an image-specific error so the caller knows it's a format-support issue, +// not a kind-detection issue. +export const lower = (part: MediaPart) => { + const mime = part.mediaType.toLowerCase() + const imageFormat = IMAGE_FORMATS[mime as keyof typeof IMAGE_FORMATS] + if (imageFormat) return Effect.succeed(imageBlock(part, imageFormat)) + if (mime.startsWith("image/")) + return ProviderShared.invalidRequest(`Bedrock Converse does not support image media type ${part.mediaType}`) + const documentFormat = DOCUMENT_FORMATS[mime as keyof typeof DOCUMENT_FORMATS] + if (documentFormat) return Effect.succeed(documentBlock(part, documentFormat)) + return ProviderShared.invalidRequest(`Bedrock Converse does not support media type ${part.mediaType}`) +} + +export * as BedrockMedia from "./bedrock-media" diff --git a/packages/llm/src/protocols/utils/gemini-tool-schema.ts b/packages/llm/src/protocols/utils/gemini-tool-schema.ts new file mode 100644 index 0000000000..7690b2e600 --- /dev/null +++ b/packages/llm/src/protocols/utils/gemini-tool-schema.ts @@ -0,0 +1,101 @@ +import { ProviderShared } from "../shared" + +// Gemini accepts a JSON Schema-like dialect for tool parameters, but rejects a +// handful of common JSON Schema shapes. Keep this projection isolated so the +// Gemini protocol file still reads like the other protocol modules. +const SCHEMA_INTENT_KEYS = [ + "type", + "properties", + "items", + "prefixItems", + "enum", + "const", + "$ref", + "additionalProperties", + "patternProperties", + "required", + "not", + "if", + "then", + "else", +] + +const isRecord = ProviderShared.isRecord + +const hasCombiner = (schema: unknown) => + isRecord(schema) && (Array.isArray(schema.anyOf) || Array.isArray(schema.oneOf) || Array.isArray(schema.allOf)) + +const hasSchemaIntent = (schema: unknown) => + isRecord(schema) && (hasCombiner(schema) || SCHEMA_INTENT_KEYS.some((key) => key in schema)) + +const sanitizeNode = (schema: unknown): unknown => { + if (!isRecord(schema)) return Array.isArray(schema) ? schema.map(sanitizeNode) : schema + + const result: Record = Object.fromEntries( + Object.entries(schema).map(([key, value]) => [ + key, + key === "enum" && Array.isArray(value) ? value.map(String) : sanitizeNode(value), + ]), + ) + + if (Array.isArray(result.enum) && (result.type === "integer" || result.type === "number")) result.type = "string" + + const properties = result.properties + if (result.type === "object" && isRecord(properties) && Array.isArray(result.required)) { + result.required = result.required.filter((field) => typeof field === "string" && field in properties) + } + + if (result.type === "array" && !hasCombiner(result)) { + result.items = result.items ?? {} + if (isRecord(result.items) && !hasSchemaIntent(result.items)) result.items = { ...result.items, type: "string" } + } + + if (typeof result.type === "string" && result.type !== "object" && !hasCombiner(result)) { + delete result.properties + delete result.required + } + + return result +} + +const emptyObjectSchema = (schema: Record) => + schema.type === "object" && + (!isRecord(schema.properties) || Object.keys(schema.properties).length === 0) && + !schema.additionalProperties + +const projectNode = (schema: unknown): Record | undefined => { + if (!isRecord(schema)) return undefined + if (emptyObjectSchema(schema)) return undefined + return Object.fromEntries( + [ + ["description", schema.description], + ["required", schema.required], + ["format", schema.format], + ["type", Array.isArray(schema.type) ? schema.type.filter((type) => type !== "null")[0] : schema.type], + ["nullable", Array.isArray(schema.type) && schema.type.includes("null") ? true : undefined], + ["enum", schema.const !== undefined ? [schema.const] : schema.enum], + [ + "properties", + isRecord(schema.properties) + ? Object.fromEntries(Object.entries(schema.properties).map(([key, value]) => [key, projectNode(value)])) + : undefined, + ], + [ + "items", + Array.isArray(schema.items) + ? schema.items.map(projectNode) + : schema.items === undefined + ? undefined + : projectNode(schema.items), + ], + ["allOf", Array.isArray(schema.allOf) ? schema.allOf.map(projectNode) : undefined], + ["anyOf", Array.isArray(schema.anyOf) ? schema.anyOf.map(projectNode) : undefined], + ["oneOf", Array.isArray(schema.oneOf) ? schema.oneOf.map(projectNode) : undefined], + ["minLength", schema.minLength], + ].filter((entry) => entry[1] !== undefined), + ) +} + +export const convert = (schema: unknown) => projectNode(sanitizeNode(schema)) + +export * as GeminiToolSchema from "./gemini-tool-schema" diff --git a/packages/llm/src/protocols/utils/openai-options.ts b/packages/llm/src/protocols/utils/openai-options.ts new file mode 100644 index 0000000000..080ef83f50 --- /dev/null +++ b/packages/llm/src/protocols/utils/openai-options.ts @@ -0,0 +1,55 @@ +import { Schema } from "effect" +import type { LLMRequest, ReasoningEffort, TextVerbosity as TextVerbosityValue } from "../../schema" +import { ReasoningEfforts, TextVerbosity } from "../../schema" + +export const OpenAIReasoningEfforts = ReasoningEfforts.filter( + (effort): effort is Exclude => effort !== "max", +) +export type OpenAIReasoningEffort = (typeof OpenAIReasoningEfforts)[number] + +const REASONING_EFFORTS = new Set(ReasoningEfforts) +const OPENAI_REASONING_EFFORTS = new Set(OpenAIReasoningEfforts) +const TEXT_VERBOSITY = new Set(["low", "medium", "high"]) + +export const OpenAIReasoningEffort = Schema.Literals(OpenAIReasoningEfforts) +export const OpenAITextVerbosity = TextVerbosity + +const isAnyReasoningEffort = (effort: unknown): effort is ReasoningEffort => + typeof effort === "string" && REASONING_EFFORTS.has(effort) + +export const isReasoningEffort = (effort: unknown): effort is OpenAIReasoningEffort => + typeof effort === "string" && OPENAI_REASONING_EFFORTS.has(effort) + +const isTextVerbosity = (value: unknown): value is TextVerbosityValue => + typeof value === "string" && TEXT_VERBOSITY.has(value) + +const options = (request: LLMRequest) => request.providerOptions?.openai + +export const store = (request: LLMRequest): boolean | undefined => { + const value = options(request)?.store + return typeof value === "boolean" ? value : undefined +} + +export const reasoningEffort = (request: LLMRequest): ReasoningEffort | undefined => { + const value = options(request)?.reasoningEffort + return isAnyReasoningEffort(value) ? value : undefined +} + +export const reasoningSummary = (request: LLMRequest): "auto" | undefined => { + return options(request)?.reasoningSummary === "auto" ? "auto" : undefined +} + +export const encryptedReasoning = (request: LLMRequest) => + options(request)?.includeEncryptedReasoning === true ? true : undefined + +export const promptCacheKey = (request: LLMRequest) => { + const value = options(request)?.promptCacheKey + return typeof value === "string" ? value : undefined +} + +export const textVerbosity = (request: LLMRequest) => { + const value = options(request)?.textVerbosity + return isTextVerbosity(value) ? value : undefined +} + +export * as OpenAIOptions from "./openai-options" diff --git a/packages/llm/src/protocols/utils/tool-stream.ts b/packages/llm/src/protocols/utils/tool-stream.ts new file mode 100644 index 0000000000..e6ac5fefd0 --- /dev/null +++ b/packages/llm/src/protocols/utils/tool-stream.ts @@ -0,0 +1,196 @@ +import { Effect } from "effect" +import { LLMError, type ProviderMetadata, type ToolCall, type ToolInputDelta } from "../../schema" +import { eventError, parseToolInput, type ToolAccumulator } from "../shared" + +type StreamKey = string | number + +/** + * One pending streamed tool call. Providers emit the tool identity and JSON + * argument text across separate chunks; `input` is the raw JSON string collected + * so far, not the parsed object. + */ +export interface PendingTool extends ToolAccumulator { + readonly providerExecuted?: boolean + readonly providerMetadata?: ProviderMetadata +} + +/** + * Sparse parser state keyed by the provider's stream-local tool identifier. + * + * This key is not the final tool-call id (`call_...`). It is the id/index the + * provider uses while streaming a partial call: OpenAI Chat / Anthropic / + * Bedrock use numeric content indexes, while OpenAI Responses uses string + * `item_id`s. The generic keeps each protocol internally consistent. + */ +export type State = Partial> + +/** + * Result of adding argument text to one pending tool call. It returns both the + * next `tools` state and the updated `tool` because parsers often need the + * current id/name immediately. `event` is present only when new text arrived; + * metadata-only deltas update identity without emitting `tool-input-delta`. + */ +export interface AppendOutcome { + readonly tools: State + readonly tool: PendingTool + readonly event?: ToolInputDelta +} + +/** Create empty accumulator state for one provider stream. */ +export const empty = (): State => ({}) + +const withTool = (tools: State, key: K, tool: PendingTool): State => { + return { ...tools, [key]: tool } +} + +const withoutTool = (tools: State, key: K): State => { + const next = { ...tools } + delete next[key] + return next +} + +const inputDelta = (tool: PendingTool, text: string): ToolInputDelta => ({ + type: "tool-input-delta", + id: tool.id, + name: tool.name, + text, + ...(tool.providerMetadata ? { providerMetadata: tool.providerMetadata } : {}), +}) + +const toolCall = (route: string, tool: PendingTool, inputOverride?: string) => + parseToolInput(route, tool.name, inputOverride ?? tool.input).pipe( + Effect.map( + (input): ToolCall => + tool.providerExecuted + ? { + type: "tool-call", + id: tool.id, + name: tool.name, + input, + providerExecuted: true, + ...(tool.providerMetadata ? { providerMetadata: tool.providerMetadata } : {}), + } + : { + type: "tool-call", + id: tool.id, + name: tool.name, + input, + ...(tool.providerMetadata ? { providerMetadata: tool.providerMetadata } : {}), + }, + ), + ) + +/** Store the updated tool and produce the optional public delta event. */ +const appendTool = ( + tools: State, + key: K, + tool: PendingTool, + text: string, +): AppendOutcome => ({ + tools: withTool(tools, key, tool), + tool, + event: text.length === 0 ? undefined : inputDelta(tool, text), +}) + +export const isError = (result: AppendOutcome | LLMError): result is LLMError => + result instanceof LLMError + +/** + * Register a tool call whose start event arrived before any argument deltas. + * Used by Anthropic `content_block_start`, Bedrock `contentBlockStart`, and + * OpenAI Responses `response.output_item.added`. + */ +export const start = ( + tools: State, + key: K, + tool: Omit & { readonly input?: string }, +) => withTool(tools, key, { ...tool, input: tool.input ?? "" }) + +/** + * Append a streamed argument delta, starting the tool if this provider encodes + * identity on the first delta instead of a separate start event. OpenAI Chat has + * this shape: `tool_calls[].index` is the stream key, and `id` / `name` may only + * appear on the first delta for that index. + */ +export const appendOrStart = ( + route: string, + tools: State, + key: K, + delta: { readonly id?: string; readonly name?: string; readonly text: string }, + missingToolMessage: string, +): AppendOutcome | LLMError => { + const current = tools[key] + const id = delta.id ?? current?.id + const name = delta.name ?? current?.name + if (!id || !name) return eventError(route, missingToolMessage) + + const tool = { + id, + name, + input: `${current?.input ?? ""}${delta.text}`, + providerExecuted: current?.providerExecuted, + providerMetadata: current?.providerMetadata, + } + if (current && delta.text.length === 0 && current.id === id && current.name === name) return { tools, tool: current } + return appendTool(tools, key, tool, delta.text) +} + +/** + * Append argument text to a tool that must already have been started. This keeps + * protocols honest when their stream grammar promises a start event before any + * argument delta. + */ +export const appendExisting = ( + route: string, + tools: State, + key: K, + text: string, + missingToolMessage: string, +): AppendOutcome | LLMError => { + const current = tools[key] + if (!current) return eventError(route, missingToolMessage) + if (text.length === 0) return { tools, tool: current } + return appendTool(tools, key, { ...current, input: `${current.input}${text}` }, text) +} + +/** + * Finalize one pending tool call: parse the accumulated raw JSON, remove it + * from state, and return the optional public `tool-call` event. Missing keys are + * a no-op because some providers emit stop events for non-tool content blocks. + */ +export const finish = (route: string, tools: State, key: K) => + Effect.gen(function* () { + const tool = tools[key] + if (!tool) return { tools } + return { tools: withoutTool(tools, key), event: yield* toolCall(route, tool) } + }) + +/** + * Finalize one pending tool call with an authoritative final input string. + * OpenAI Responses can send accumulated deltas and then repeat the completed + * arguments on `response.output_item.done`; the final value wins. + */ +export const finishWithInput = (route: string, tools: State, key: K, input: string) => + Effect.gen(function* () { + const tool = tools[key] + if (!tool) return { tools } + return { tools: withoutTool(tools, key), event: yield* toolCall(route, tool, input) } + }) + +/** + * Finalize every pending tool call at once. OpenAI Chat has this shape: it does + * not emit per-tool stop events, so all accumulated calls finish when the choice + * receives a terminal `finish_reason`. + */ +export const finishAll = (route: string, tools: State) => + Effect.gen(function* () { + const pending = Object.values(tools).filter( + (tool): tool is PendingTool => tool !== undefined, + ) + return { + tools: empty(), + events: yield* Effect.forEach(pending, (tool) => toolCall(route, tool)), + } + }) + +export * as ToolStream from "./tool-stream" diff --git a/packages/llm/src/provider.ts b/packages/llm/src/provider.ts new file mode 100644 index 0000000000..8299b5865c --- /dev/null +++ b/packages/llm/src/provider.ts @@ -0,0 +1,31 @@ +import type { RouteModelInput } from "./route/client" +import type { ModelID, ModelRef, ProviderID } from "./schema" + +export type ModelOptions = Omit + +export type ModelFactory = ( + id: string | ModelID, + options?: Options, +) => ModelRef + +type AnyModelFactory = (...args: never[]) => ModelRef + +export interface Definition { + readonly id: ProviderID + readonly model: Factory + readonly apis?: Record +} + +type DefinitionShape = { + readonly id: ProviderID + readonly model: (...args: never[]) => ModelRef + readonly apis?: Record ModelRef> +} + +type NoExtraFields = Input & Record, never> + +export const make = ( + definition: NoExtraFields, +) => definition + +export * as Provider from "./provider" diff --git a/packages/llm/src/providers/amazon-bedrock.ts b/packages/llm/src/providers/amazon-bedrock.ts new file mode 100644 index 0000000000..82408d514e --- /dev/null +++ b/packages/llm/src/providers/amazon-bedrock.ts @@ -0,0 +1,48 @@ +import { Route, type RouteModelInput } from "../route/client" +import { Provider } from "../provider" +import { ProviderID, type ModelID } from "../schema" +import * as BedrockConverse from "../protocols/bedrock-converse" +import type { BedrockCredentials } from "../protocols/bedrock-converse" + +export const id = ProviderID.make("amazon-bedrock") + +export type ModelOptions = Omit & { + readonly apiKey?: string + readonly headers?: Record + readonly credentials?: BedrockCredentials + /** AWS region. Defaults to `us-east-1` when neither this nor `credentials.region` is set. */ + readonly region?: string + /** Override the computed `https://bedrock-runtime..amazonaws.com` URL. */ + readonly baseURL?: string +} +type ModelInput = ModelOptions & Pick + +export const routes = [BedrockConverse.route] + +const bedrockBaseURL = (region: string) => `https://bedrock-runtime.${region}.amazonaws.com` + +const converseModel = Route.model( + BedrockConverse.route, + { + provider: "amazon-bedrock", + }, + { + mapInput: (input) => { + const { credentials, region, baseURL, ...rest } = input + const resolvedRegion = region ?? credentials?.region ?? "us-east-1" + return { + ...rest, + baseURL: baseURL ?? bedrockBaseURL(resolvedRegion), + native: BedrockConverse.nativeCredentials(input.native, credentials), + } + }, + }, +) + +export const model = (modelID: string | ModelID, options: ModelOptions = {}) => + converseModel({ ...options, id: modelID }) + +export const provider = Provider.make({ + id, + model, +}) diff --git a/packages/llm/src/providers/anthropic.ts b/packages/llm/src/providers/anthropic.ts new file mode 100644 index 0000000000..a7ec7ede9f --- /dev/null +++ b/packages/llm/src/providers/anthropic.ts @@ -0,0 +1,16 @@ +import type { RouteModelInput } from "../route/client" +import { Provider } from "../provider" +import { ProviderID, type ModelID } from "../schema" +import * as AnthropicMessages from "../protocols/anthropic-messages" + +export const id = ProviderID.make("anthropic") + +export const routes = [AnthropicMessages.route] + +export const model = (id: string | ModelID, options: Omit & { readonly baseURL?: string } = {}) => + AnthropicMessages.model({ ...options, id }) + +export const provider = Provider.make({ + id, + model, +}) diff --git a/packages/llm/src/providers/azure.ts b/packages/llm/src/providers/azure.ts new file mode 100644 index 0000000000..8d60fb6669 --- /dev/null +++ b/packages/llm/src/providers/azure.ts @@ -0,0 +1,83 @@ +import { Auth } from "../route/auth" +import { type AtLeastOne, type ProviderAuthOption } from "../route/auth-options" +import { Route } from "../route/client" +import type { ModelInput } from "../llm" +import { Provider } from "../provider" +import { ProviderID, type ModelID } from "../schema" +import * as OpenAIChat from "../protocols/openai-chat" +import * as OpenAIResponses from "../protocols/openai-responses" +import { withOpenAIOptions, type OpenAIProviderOptionsInput } from "./openai-options" + +export const id = ProviderID.make("azure") +const routeAuth = Auth.remove("authorization").andThen(Auth.apiKeyHeader("api-key")) + +// Azure needs the customer's resource URL; supply either `resourceName` +// (helper builds the URL) or `baseURL` directly. +type AzureURL = AtLeastOne<{ readonly resourceName: string; readonly baseURL: string }> + +export type ModelOptions = AzureURL & + Omit & + ProviderAuthOption<"optional"> & { + readonly apiVersion?: string + readonly useCompletionUrls?: boolean + readonly providerOptions?: OpenAIProviderOptionsInput + } +type AzureModelInput = ModelOptions & Pick + +const resourceBaseURL = (resourceName: string) => `https://${resourceName.trim()}.openai.azure.com/openai/v1` + +const responsesRoute = OpenAIResponses.route.with({ + id: "azure-openai-responses", + provider: id, + transport: OpenAIResponses.httpTransport.with({ auth: routeAuth }), +}) + +const chatRoute = OpenAIChat.route.with({ + id: "azure-openai-chat", + provider: id, + transport: OpenAIChat.httpTransport.with({ auth: routeAuth }), +}) + +export const routes = [responsesRoute, chatRoute] + +const mapInput = (input: AzureModelInput) => { + const { apiKey: _, apiVersion, resourceName, useCompletionUrls, ...rest } = input + return { + ...withOpenAIOptions(input.id, rest), + auth: + "auth" in input && input.auth + ? input.auth + : Auth.remove("authorization").andThen( + Auth.optional("apiKey" in input ? input.apiKey : undefined, "apiKey") + .orElse(Auth.config("AZURE_OPENAI_API_KEY")) + .pipe(Auth.header("api-key")), + ), + // AtLeastOne guarantees at least one is set; baseURL wins if both are. + baseURL: rest.baseURL ?? resourceBaseURL(resourceName!), + queryParams: { + ...rest.queryParams, + "api-version": apiVersion ?? rest.queryParams?.["api-version"] ?? "v1", + }, + } +} + +const chatModel = Route.model(chatRoute, {}, { mapInput }) +const responsesModel = Route.model(responsesRoute, {}, { mapInput }) + +export const responses = (modelID: string | ModelID, options: ModelOptions) => + responsesModel({ ...options, id: modelID }) + +export const chat = (modelID: string | ModelID, options: ModelOptions) => chatModel({ ...options, id: modelID }) + +export const model = (modelID: string | ModelID, options: ModelOptions) => { + if (options.useCompletionUrls === true) return chat(modelID, options) + return responses(modelID, options) +} + +export const provider = Provider.make({ + id, + model, + apis: { responses, chat }, +}) + +export const apis = provider.apis diff --git a/packages/llm/src/providers/cloudflare.ts b/packages/llm/src/providers/cloudflare.ts new file mode 100644 index 0000000000..263595a755 --- /dev/null +++ b/packages/llm/src/providers/cloudflare.ts @@ -0,0 +1,139 @@ +import type { Config, Redacted } from "effect" +import { type ModelInput } from "../llm" +import { Provider } from "../provider" +import * as OpenAICompatibleChat from "../protocols/openai-compatible-chat" +import { Auth } from "../route/auth" +import { AuthOptions, type AtLeastOne, type ProviderAuthOption } from "../route/auth-options" +import { Route } from "../route/client" +import { ProviderID, type ModelID } from "../schema" + +export const aiGatewayID = ProviderID.make("cloudflare-ai-gateway") +export const workersAIID = ProviderID.make("cloudflare-workers-ai") +export const id = aiGatewayID +export const aiGatewayAuthEnvVars = ["CLOUDFLARE_API_TOKEN", "CF_AIG_TOKEN"] as const +export const workersAIAuthEnvVars = ["CLOUDFLARE_API_KEY", "CLOUDFLARE_WORKERS_AI_TOKEN"] as const + +type CloudflareSecret = string | Redacted.Redacted | Config.Config> + +type GatewayURL = AtLeastOne<{ + readonly accountId: string + readonly baseURL: string +}> & { + readonly gatewayId?: string +} + +export type AIGatewayOptions = GatewayURL & + Omit & + ProviderAuthOption<"optional"> & { + /** Cloudflare AI Gateway authentication token. Sent as `cf-aig-authorization`. */ + readonly gatewayApiKey?: CloudflareSecret + } + +type AIGatewayInput = AIGatewayOptions & Pick + +type WorkersAIURL = AtLeastOne<{ + readonly accountId: string + readonly baseURL: string +}> + +export type WorkersAIOptions = WorkersAIURL & + Omit & + ProviderAuthOption<"optional"> + +type WorkersAIInput = WorkersAIOptions & Pick + +export const aiGatewayBaseURL = (input: GatewayURL) => { + if (input.baseURL) return input.baseURL + if (!input.accountId) throw new Error("Cloudflare.aiGateway requires accountId unless baseURL is supplied") + return `https://gateway.ai.cloudflare.com/v1/${encodeURIComponent(input.accountId)}/${encodeURIComponent(input.gatewayId?.trim() || "default")}/compat` +} + +const aiGatewayAuth = (input: AIGatewayInput) => { + if ("auth" in input && input.auth) return input.auth + const gateway = Auth.optional(input.gatewayApiKey, "gatewayApiKey") + .orElse(Auth.config("CLOUDFLARE_API_TOKEN")) + .orElse(Auth.config("CF_AIG_TOKEN")) + .pipe(Auth.bearerHeader("cf-aig-authorization")) + if (!("apiKey" in input) || input.apiKey === undefined) return gateway + if (input.gatewayApiKey === undefined) return Auth.bearer(input.apiKey) + return Auth.bearerHeader("cf-aig-authorization", input.gatewayApiKey).andThen(Auth.bearer(input.apiKey)) +} + +export const workersAIBaseURL = (input: WorkersAIURL) => { + if (input.baseURL) return input.baseURL + if (!input.accountId) throw new Error("Cloudflare.workersAI requires accountId unless baseURL is supplied") + return `https://api.cloudflare.com/client/v4/accounts/${encodeURIComponent(input.accountId)}/ai/v1` +} + +const workersAIAuth = (input: WorkersAIInput) => { + return AuthOptions.bearer(input, workersAIAuthEnvVars) +} + +export const aiGatewayRoute = OpenAICompatibleChat.route.with({ + id: "cloudflare-ai-gateway", + provider: aiGatewayID, +}) + +export const workersAIRoute = OpenAICompatibleChat.route.with({ + id: "cloudflare-workers-ai", + provider: workersAIID, +}) + +export const routes = [aiGatewayRoute, workersAIRoute] + +const aiGatewayModel = Route.model( + aiGatewayRoute, + { + provider: id, + }, + { + mapInput: (input) => { + const { + accountId: _accountId, + gatewayId: _gatewayId, + apiKey: _apiKey, + gatewayApiKey: _gatewayApiKey, + auth: _auth, + ...rest + } = input + return { + ...rest, + auth: aiGatewayAuth(input), + baseURL: aiGatewayBaseURL(input), + } + }, + }, +) + +const workersAIModel = Route.model( + workersAIRoute, + { + provider: workersAIID, + }, + { + mapInput: (input) => { + const { accountId: _accountId, apiKey: _apiKey, auth: _auth, ...rest } = input + return { + ...rest, + auth: workersAIAuth(input), + baseURL: workersAIBaseURL(input), + } + }, + }, +) + +export const aiGateway = (modelID: string | ModelID, options: AIGatewayOptions) => + aiGatewayModel({ ...options, id: modelID }) + +export const workersAI = (modelID: string | ModelID, options: WorkersAIOptions) => + workersAIModel({ ...options, id: modelID }) + +export const model = aiGateway + +export const provider = Provider.make({ + id, + model, + apis: { aiGateway, workersAI }, +}) + +export const apis = provider.apis diff --git a/packages/llm/src/providers/github-copilot.ts b/packages/llm/src/providers/github-copilot.ts new file mode 100644 index 0000000000..5de738a3bf --- /dev/null +++ b/packages/llm/src/providers/github-copilot.ts @@ -0,0 +1,48 @@ +import { Route } from "../route/client" +import type { ModelInput } from "../llm" +import { Provider } from "../provider" +import { ProviderID, type ModelID } from "../schema" +import * as OpenAIChat from "../protocols/openai-chat" +import * as OpenAIResponses from "../protocols/openai-responses" +import { withOpenAIOptions, type OpenAIProviderOptionsInput } from "./openai-options" + +export const id = ProviderID.make("github-copilot") + +// GitHub Copilot has no canonical public URL — callers (opencode, etc.) must +// supply `baseURL` explicitly. +export type ModelOptions = Omit & { + readonly providerOptions?: OpenAIProviderOptionsInput +} +type CopilotModelInput = ModelOptions & Pick + +export const shouldUseResponsesApi = (modelID: string | ModelID) => { + const model = String(modelID) + const match = /^gpt-(\d+)/.exec(model) + if (!match) return false + return Number(match[1]) >= 5 && !model.startsWith("gpt-5-mini") +} + +export const routes = [OpenAIResponses.route, OpenAIChat.route] + +const mapInput = (input: CopilotModelInput) => withOpenAIOptions(input.id, input) + +const chatModel = Route.model(OpenAIChat.route, { provider: id }, { mapInput }) +const responsesModel = Route.model(OpenAIResponses.route, { provider: id }, { mapInput }) + +export const responses = (modelID: string | ModelID, options: ModelOptions) => + responsesModel({ ...options, id: modelID }) + +export const chat = (modelID: string | ModelID, options: ModelOptions) => chatModel({ ...options, id: modelID }) + +export const model = (modelID: string | ModelID, options: ModelOptions) => { + const create = shouldUseResponsesApi(modelID) ? responsesModel : chatModel + return create({ ...options, id: modelID }) +} + +export const provider = Provider.make({ + id, + model, + apis: { responses, chat }, +}) + +export const apis = provider.apis diff --git a/packages/llm/src/providers/google.ts b/packages/llm/src/providers/google.ts new file mode 100644 index 0000000000..d63439bfec --- /dev/null +++ b/packages/llm/src/providers/google.ts @@ -0,0 +1,16 @@ +import type { RouteModelInput } from "../route/client" +import { Provider } from "../provider" +import { ProviderID, type ModelID } from "../schema" +import * as Gemini from "../protocols/gemini" + +export const id = ProviderID.make("google") + +export const routes = [Gemini.route] + +export const model = (id: string | ModelID, options: Omit & { readonly baseURL?: string } = {}) => + Gemini.model({ ...options, id }) + +export const provider = Provider.make({ + id, + model, +}) diff --git a/packages/llm/src/providers/index.ts b/packages/llm/src/providers/index.ts new file mode 100644 index 0000000000..39adbe25c0 --- /dev/null +++ b/packages/llm/src/providers/index.ts @@ -0,0 +1,10 @@ +export * as Anthropic from "./anthropic" +export * as AmazonBedrock from "./amazon-bedrock" +export * as Azure from "./azure" +export * as Cloudflare from "./cloudflare" +export * as GitHubCopilot from "./github-copilot" +export * as Google from "./google" +export * as OpenAI from "./openai" +export * as OpenAICompatible from "./openai-compatible" +export * as OpenRouter from "./openrouter" +export * as XAI from "./xai" diff --git a/packages/llm/src/providers/openai-compatible-profile.ts b/packages/llm/src/providers/openai-compatible-profile.ts new file mode 100644 index 0000000000..30770c9671 --- /dev/null +++ b/packages/llm/src/providers/openai-compatible-profile.ts @@ -0,0 +1,20 @@ +export interface OpenAICompatibleProfile { + readonly provider: string + readonly baseURL: string +} + +export const profiles = { + baseten: { provider: "baseten", baseURL: "https://inference.baseten.co/v1" }, + cerebras: { provider: "cerebras", baseURL: "https://api.cerebras.ai/v1" }, + deepinfra: { provider: "deepinfra", baseURL: "https://api.deepinfra.com/v1/openai" }, + deepseek: { provider: "deepseek", baseURL: "https://api.deepseek.com/v1" }, + fireworks: { provider: "fireworks", baseURL: "https://api.fireworks.ai/inference/v1" }, + groq: { provider: "groq", baseURL: "https://api.groq.com/openai/v1" }, + openrouter: { provider: "openrouter", baseURL: "https://openrouter.ai/api/v1" }, + togetherai: { provider: "togetherai", baseURL: "https://api.together.xyz/v1" }, + xai: { provider: "xai", baseURL: "https://api.x.ai/v1" }, +} as const satisfies Record + +export const byProvider: Record = Object.fromEntries( + Object.values(profiles).map((profile) => [profile.provider, profile]), +) diff --git a/packages/llm/src/providers/openai-compatible.ts b/packages/llm/src/providers/openai-compatible.ts new file mode 100644 index 0000000000..e37dcb4adf --- /dev/null +++ b/packages/llm/src/providers/openai-compatible.ts @@ -0,0 +1,61 @@ +import { Provider } from "../provider" +import { ProviderID, type ModelID } from "../schema" +import * as OpenAICompatibleChat from "../protocols/openai-compatible-chat" +import type { OpenAICompatibleChatModelInput } from "../protocols/openai-compatible-chat" +import { profiles, type OpenAICompatibleProfile } from "./openai-compatible-profile" + +export const id = ProviderID.make("openai-compatible") + +export type ModelOptions = Omit & { + readonly provider: string +} + +type GenericModelOptions = Omit & { + readonly provider?: string +} + +export type FamilyModelOptions = Omit & { + readonly baseURL?: string +} + +export const routes = [OpenAICompatibleChat.route] + +export const model = (id: string | ModelID, options: ModelOptions) => { + return OpenAICompatibleChat.model({ + ...options, + id, + provider: ProviderID.make(options.provider), + }) +} + +export const profileModel = ( + profile: OpenAICompatibleProfile, + id: string | ModelID, + options: FamilyModelOptions = {}, +) => + OpenAICompatibleChat.model({ + ...options, + id, + provider: profile.provider, + baseURL: options.baseURL ?? profile.baseURL, + }) + +const define = (profile: OpenAICompatibleProfile) => + Provider.make({ + id: ProviderID.make(profile.provider), + model: (id: string | ModelID, options: FamilyModelOptions = {}) => profileModel(profile, id, options), + }) + +export const provider = Provider.make({ + id, + model: (id: string | ModelID, options: GenericModelOptions) => + model(id, { ...options, provider: options.provider ?? "openai-compatible" }), +}) + +export const baseten = define(profiles.baseten) +export const cerebras = define(profiles.cerebras) +export const deepinfra = define(profiles.deepinfra) +export const deepseek = define(profiles.deepseek) +export const fireworks = define(profiles.fireworks) +export const groq = define(profiles.groq) +export const togetherai = define(profiles.togetherai) diff --git a/packages/llm/src/providers/openai-options.ts b/packages/llm/src/providers/openai-options.ts new file mode 100644 index 0000000000..8d3980f609 --- /dev/null +++ b/packages/llm/src/providers/openai-options.ts @@ -0,0 +1,70 @@ +import type { ProviderOptions, ReasoningEffort, TextVerbosity } from "../schema" +import { mergeProviderOptions } from "../schema" + +export interface OpenAIOptionsInput { + readonly [key: string]: unknown + readonly store?: boolean + readonly promptCacheKey?: string + readonly reasoningEffort?: ReasoningEffort + readonly reasoningSummary?: "auto" + readonly includeEncryptedReasoning?: boolean + readonly textVerbosity?: TextVerbosity +} + +export type OpenAIProviderOptionsInput = ProviderOptions & { + readonly openai?: OpenAIOptionsInput +} + +const definedEntries = (input: Record) => + Object.entries(input).filter((entry) => entry[1] !== undefined) + +const openAIProviderOptions = (options: OpenAIOptionsInput | undefined): ProviderOptions | undefined => { + const openai = Object.fromEntries( + definedEntries({ + store: options?.store, + promptCacheKey: options?.promptCacheKey, + reasoningEffort: options?.reasoningEffort, + reasoningSummary: options?.reasoningSummary, + includeEncryptedReasoning: options?.includeEncryptedReasoning, + textVerbosity: options?.textVerbosity, + }), + ) + if (Object.keys(openai).length === 0) return undefined + return { openai } +} + +export const gpt5DefaultOptions = ( + modelID: string, + options: { readonly textVerbosity?: boolean } = {}, +): ProviderOptions | undefined => { + const id = modelID.toLowerCase() + if (!id.includes("gpt-5") || id.includes("gpt-5-chat") || id.includes("gpt-5-pro")) return undefined + return openAIProviderOptions({ + reasoningEffort: "medium", + reasoningSummary: "auto", + textVerbosity: + options.textVerbosity === true && id.includes("gpt-5.") && !id.includes("codex") && !id.includes("-chat") + ? "low" + : undefined, + }) +} + +export const openAIDefaultOptions = ( + modelID: string, + options: { readonly textVerbosity?: boolean } = {}, +): ProviderOptions | undefined => + mergeProviderOptions(openAIProviderOptions({ store: false }), gpt5DefaultOptions(modelID, options)) + +export const withOpenAIOptions = ( + modelID: string, + options: Options, + defaults: { readonly textVerbosity?: boolean } = {}, +): Options & { readonly id: string; readonly providerOptions?: ProviderOptions } => { + return { + ...options, + id: modelID, + providerOptions: mergeProviderOptions(openAIDefaultOptions(modelID, defaults), options.providerOptions), + } +} + +export * as OpenAIProviderOptions from "./openai-options" diff --git a/packages/llm/src/providers/openai.ts b/packages/llm/src/providers/openai.ts new file mode 100644 index 0000000000..cbd9b99522 --- /dev/null +++ b/packages/llm/src/providers/openai.ts @@ -0,0 +1,53 @@ +import { AuthOptions, type ProviderAuthOption } from "../route/auth-options" +import type { RouteModelInput } from "../route/client" +import { Provider } from "../provider" +import { ProviderID, type ModelID } from "../schema" +import * as OpenAIChat from "../protocols/openai-chat" +import * as OpenAIResponses from "../protocols/openai-responses" +import { withOpenAIOptions, type OpenAIProviderOptionsInput } from "./openai-options" + +export type { OpenAIOptionsInput } from "./openai-options" + +export const id = ProviderID.make("openai") + +export const routes = [OpenAIResponses.route, OpenAIResponses.webSocketRoute, OpenAIChat.route] + +// This provider facade wraps the lower-level Responses and Chat model factories +// with OpenAI-specific conveniences: typed options, API-key sugar, env fallback, +// and default option normalization. +type OpenAIModelInput = Omit & + ProviderAuthOption<"optional"> & { + readonly baseURL?: string + readonly providerOptions?: OpenAIProviderOptionsInput + } + +const auth = (options: ProviderAuthOption<"optional">) => AuthOptions.bearer(options, "OPENAI_API_KEY") + +export const responses = (id: string | ModelID, options: OpenAIModelInput> = {}) => { + const { apiKey: _, ...rest } = options + return OpenAIResponses.model(withOpenAIOptions(id, { ...rest, auth: auth(options) }, { textVerbosity: true })) +} + +export const responsesWebSocket = ( + id: string | ModelID, + options: OpenAIModelInput> = {}, +) => { + const { apiKey: _, ...rest } = options + return OpenAIResponses.webSocketModel( + withOpenAIOptions(id, { ...rest, auth: auth(options) }, { textVerbosity: true }), + ) +} + +export const chat = (id: string | ModelID, options: OpenAIModelInput> = {}) => { + const { apiKey: _, ...rest } = options + return OpenAIChat.model(withOpenAIOptions(id, { ...rest, auth: auth(options) })) +} + +export const provider = Provider.make({ + id, + model: responses, + apis: { responses, responsesWebSocket, chat }, +}) + +export const model = provider.model +export const apis = provider.apis diff --git a/packages/llm/src/providers/openrouter.ts b/packages/llm/src/providers/openrouter.ts new file mode 100644 index 0000000000..4c1a432106 --- /dev/null +++ b/packages/llm/src/providers/openrouter.ts @@ -0,0 +1,88 @@ +import { Effect, Schema } from "effect" +import { Route, type RouteModelInput } from "../route/client" +import { Endpoint } from "../route/endpoint" +import { Framing } from "../route/framing" +import { Provider } from "../provider" +import { Protocol } from "../route/protocol" +import { ProviderID, type ModelID, type ProviderOptions } from "../schema" +import * as OpenAICompatibleProfiles from "./openai-compatible-profile" +import * as OpenAIChat from "../protocols/openai-chat" +import { isRecord } from "../protocols/shared" + +export const profile = OpenAICompatibleProfiles.profiles.openrouter +export const id = ProviderID.make(profile.provider) +const ADAPTER = "openrouter" + +export interface OpenRouterOptions { + readonly [key: string]: unknown + readonly usage?: boolean | Record + readonly reasoning?: Record + readonly promptCacheKey?: string +} + +export type OpenRouterProviderOptionsInput = ProviderOptions & { + readonly openrouter?: OpenRouterOptions +} + +export type ModelOptions = Omit & { + readonly baseURL?: string + readonly providerOptions?: OpenRouterProviderOptionsInput +} +type ModelInput = ModelOptions & Pick + +const OpenRouterBody = Schema.StructWithRest(Schema.Struct(OpenAIChat.bodyFields), [ + Schema.Record(Schema.String, Schema.Any), +]) +export type OpenRouterBody = Schema.Schema.Type + +export const protocol = Protocol.make({ + id: "openrouter-chat", + body: { + schema: OpenRouterBody, + from: (request) => + OpenAIChat.protocol.body.from(request).pipe( + Effect.map( + (body) => + ({ + ...body, + ...bodyOptions(request.providerOptions?.openrouter), + }) as OpenRouterBody, + ), + ), + }, + stream: OpenAIChat.protocol.stream, +}) + +const bodyOptions = (input: unknown) => { + const openrouter = isRecord(input) ? input : {} + return { + ...(openrouter.usage === true + ? { usage: { include: true } } + : isRecord(openrouter.usage) + ? { usage: openrouter.usage } + : {}), + ...(isRecord(openrouter.reasoning) ? { reasoning: openrouter.reasoning } : {}), + ...(typeof openrouter.promptCacheKey === "string" ? { prompt_cache_key: openrouter.promptCacheKey } : {}), + } +} + +export const route = Route.make({ + id: ADAPTER, + protocol, + endpoint: Endpoint.path("/chat/completions"), + framing: Framing.sse, +}) + +export const routes = [route] + +const modelRef = Route.model(route, { + provider: profile.provider, + baseURL: profile.baseURL, +}) + +export const model = (id: string | ModelID, options: ModelOptions = {}) => modelRef({ ...options, id }) + +export const provider = Provider.make({ + id, + model, +}) diff --git a/packages/llm/src/providers/xai.ts b/packages/llm/src/providers/xai.ts new file mode 100644 index 0000000000..089c8c7339 --- /dev/null +++ b/packages/llm/src/providers/xai.ts @@ -0,0 +1,52 @@ +import { AuthOptions, type ProviderAuthOption } from "../route/auth-options" +import { Route } from "../route/client" +import type { RouteModelInput } from "../route/client" +import { Provider } from "../provider" +import { ProviderID, type ModelID } from "../schema" +import * as OpenAICompatibleProfiles from "./openai-compatible-profile" +import * as OpenAICompatibleChat from "../protocols/openai-compatible-chat" +import * as OpenAIResponses from "../protocols/openai-responses" + +export const id = ProviderID.make("xai") + +export type ModelOptions = Omit & + ProviderAuthOption<"optional"> & { + readonly baseURL?: string + } + +export const routes = [OpenAIResponses.route, OpenAICompatibleChat.route] + +const responsesModel = Route.model(OpenAIResponses.route, { provider: id }) +const chatModel = OpenAICompatibleChat.model + +const auth = (options: ProviderAuthOption<"optional">) => AuthOptions.bearer(options, "XAI_API_KEY") + +export const responses = (modelID: string | ModelID, options: ModelOptions = {}) => { + const { apiKey: _, ...rest } = options + return responsesModel({ + ...rest, + auth: auth(options), + id: modelID, + baseURL: options.baseURL ?? OpenAICompatibleProfiles.profiles.xai.baseURL, + }) +} + +export const chat = (modelID: string | ModelID, options: ModelOptions = {}) => { + const { apiKey: _, ...rest } = options + return chatModel({ + ...rest, + auth: auth(options), + id: modelID, + provider: id, + baseURL: options.baseURL ?? OpenAICompatibleProfiles.profiles.xai.baseURL, + }) +} + +export const provider = Provider.make({ + id, + model: responses, + apis: { responses, chat }, +}) + +export const model = provider.model +export const apis = provider.apis diff --git a/packages/llm/src/route/auth-options.ts b/packages/llm/src/route/auth-options.ts new file mode 100644 index 0000000000..7e40aa12a2 --- /dev/null +++ b/packages/llm/src/route/auth-options.ts @@ -0,0 +1,57 @@ +import type { Config, Redacted } from "effect" +import { Auth } from "./auth" + +export type ApiKeyMode = "optional" | "required" + +export type AuthOverride = { + readonly auth: Auth + readonly apiKey?: never +} + +export type OptionalApiKeyAuth = { + readonly apiKey?: string | Redacted.Redacted | Config.Config> + readonly auth?: never +} + +export type RequiredApiKeyAuth = { + readonly apiKey: string | Redacted.Redacted | Config.Config> + readonly auth?: never +} + +export type ProviderAuthOption = + | AuthOverride + | (Mode extends "optional" ? OptionalApiKeyAuth : RequiredApiKeyAuth) + +export type ModelOptions = Omit & ProviderAuthOption + +export type ModelArgs = Mode extends "optional" + ? readonly [options?: ModelOptions] + : readonly [options: ModelOptions] + +export type ModelFactory = (id: string, ...args: ModelArgs) => Model + +/** + * Require at least one of the keys in `T`. Use for option shapes where any + * subset of fields is acceptable but at least one must be present (e.g. Azure + * accepts `resourceName` or `baseURL`). + */ +export type AtLeastOne = { + [K in keyof T]: Required> & Partial> +}[keyof T] + +/** + * Standard bearer-auth resolution for providers: honor an explicit `auth` + * override, otherwise resolve `apiKey` (option > config var) and apply it as + * a bearer token. + */ +export const bearer = (options: ProviderAuthOption<"optional">, envVar: string | ReadonlyArray): Auth => { + if ("auth" in options && options.auth) return options.auth + return (Array.isArray(envVar) ? envVar : [envVar]) + .reduce( + (auth, name) => auth.orElse(Auth.config(name)), + Auth.optional("apiKey" in options ? options.apiKey : undefined, "apiKey"), + ) + .bearer() +} + +export * as AuthOptions from "./auth-options" diff --git a/packages/llm/src/route/auth.ts b/packages/llm/src/route/auth.ts new file mode 100644 index 0000000000..540c2845f2 --- /dev/null +++ b/packages/llm/src/route/auth.ts @@ -0,0 +1,196 @@ +import { Config, Effect, Redacted } from "effect" +import { Headers } from "effect/unstable/http" +import { AuthenticationReason, InvalidRequestReason, LLMError, type LLMRequest } from "../schema" + +export class MissingCredentialError extends Error { + readonly _tag = "MissingCredentialError" + + constructor(readonly source: string) { + super(`Missing auth credential: ${source}`) + } +} + +export type CredentialError = MissingCredentialError | Config.ConfigError +export type AuthError = CredentialError | LLMError + +export interface AuthInput { + readonly request: LLMRequest + readonly method: "POST" | "GET" + readonly url: string + readonly body: string + readonly headers: Headers.Headers +} + +export interface Credential { + readonly load: Effect.Effect, CredentialError> + readonly orElse: (that: Credential) => Credential + readonly bearer: () => Auth + readonly header: (name: string) => Auth + readonly pipe: (f: (self: Credential) => A) => A +} + +export interface Auth { + readonly apply: (input: AuthInput) => Effect.Effect + readonly andThen: (that: Auth) => Auth + readonly orElse: (that: Auth) => Auth + readonly pipe: (f: (self: Auth) => A) => A +} + +export const isAuth = (input: unknown): input is Auth => + typeof input === "object" && input !== null && "apply" in input && typeof input.apply === "function" + +const credential = (load: Effect.Effect, CredentialError>): Credential => { + const self: Credential = { + load, + orElse: (that) => credential(load.pipe(Effect.catch(() => that.load))), + bearer: () => fromCredential(self, (secret) => ({ authorization: `Bearer ${secret}` })), + header: (name) => fromCredential(self, (secret) => ({ [name]: secret })), + pipe: (f) => f(self), + } + return self +} + +const auth = (apply: Auth["apply"]): Auth => { + const self: Auth = { + apply, + andThen: (that) => + auth((input) => apply(input).pipe(Effect.flatMap((headers) => that.apply({ ...input, headers })))), + orElse: (that) => auth((input) => apply(input).pipe(Effect.catch(() => that.apply(input)))), + pipe: (f) => f(self), + } + return self +} + +const fromCredential = (source: Credential, render: (secret: string) => Headers.Input) => + auth((input) => + source.load.pipe(Effect.map((secret) => Headers.setAll(input.headers, render(Redacted.value(secret))))), + ) + +const secretEffect = (secret: string | Redacted.Redacted, source: string) => { + const redacted = typeof secret === "string" ? Redacted.make(secret) : secret + if (Redacted.value(redacted) === "") return Effect.fail(new MissingCredentialError(source)) + return Effect.succeed(redacted) +} + +const credentialFromSecret = ( + secret: string | Redacted.Redacted | Config.Config>, + source: string, +) => { + if (typeof secret === "string" || Redacted.isRedacted(secret)) return credential(secretEffect(secret, source)) + return credential( + Effect.gen(function* () { + return yield* secretEffect(yield* secret, source) + }), + ) +} + +export const value = (secret: string, source = "value") => credentialFromSecret(secret, source) + +export const optional = ( + secret: string | Redacted.Redacted | Config.Config> | undefined, + source = "optional value", +) => + secret === undefined + ? credential(Effect.fail(new MissingCredentialError(source))) + : credentialFromSecret(secret, source) + +export const config = (name: string) => credentialFromSecret(Config.redacted(name), name) + +export const effect = (load: Effect.Effect, CredentialError>) => credential(load) + +export const none = auth((input) => Effect.succeed(input.headers)) + +export const headers = (input: Headers.Input) => + auth((inputAuth) => Effect.succeed(Headers.setAll(inputAuth.headers, input))) + +export const remove = (name: string) => auth((input) => Effect.succeed(Headers.remove(input.headers, name))) + +export const custom = (apply: (input: AuthInput) => Effect.Effect) => auth(apply) + +export const passthrough = none + +const fromModelApiKey = (from: (apiKey: string) => Headers.Input) => + auth(({ request, headers }) => { + const key = request.model.apiKey + if (!key) return Effect.succeed(headers) + return Effect.succeed(Headers.setAll(headers, from(key))) + }) + +const credentialInput = ( + source: string | Redacted.Redacted | Config.Config> | Credential, +) => + typeof source === "string" || Redacted.isRedacted(source) || Config.isConfig(source) + ? credentialFromSecret(source, "value") + : source + +export function bearer(): Auth +export function bearer( + source: string | Redacted.Redacted | Config.Config> | Credential, +): Auth +export function bearer( + source?: string | Redacted.Redacted | Config.Config> | Credential, +) { + if (source === undefined) return fromModelApiKey((key) => ({ authorization: `Bearer ${key}` })) + return credentialInput(source).bearer() +} + +export const apiKey = bearer + +export const apiKeyHeader = (name: string) => fromModelApiKey((key) => ({ [name]: key })) + +export function header( + name: string, +): (source: string | Redacted.Redacted | Config.Config> | Credential) => Auth +export function header( + name: string, + source: string | Redacted.Redacted | Config.Config> | Credential, +): Auth +export function header( + name: string, + source?: string | Redacted.Redacted | Config.Config> | Credential, +) { + if (source === undefined) { + return ( + next: string | Redacted.Redacted | Config.Config> | Credential, + ) => credentialInput(next).header(name) + } + return credentialInput(source).header(name) +} + +export function bearerHeader( + name: string, +): (source: string | Redacted.Redacted | Config.Config> | Credential) => Auth +export function bearerHeader( + name: string, + source: string | Redacted.Redacted | Config.Config> | Credential, +): Auth +export function bearerHeader( + name: string, + source?: string | Redacted.Redacted | Config.Config> | Credential, +) { + const render = (input: string | Redacted.Redacted | Config.Config> | Credential) => + fromCredential(credentialInput(input), (secret) => ({ [name]: `Bearer ${secret}` })) + if (source === undefined) return render + return render(source) +} + +const toLLMError = (error: AuthError): LLMError => { + if (error instanceof MissingCredentialError || error instanceof Config.ConfigError) { + return new LLMError({ + module: "Auth", + method: "apply", + reason: + error instanceof MissingCredentialError + ? new AuthenticationReason({ message: error.message, kind: "missing" }) + : new InvalidRequestReason({ message: `Failed to resolve auth config: ${error.message}` }), + }) + } + return error +} + +export const toEffect = + (input: Auth) => + (authInput: AuthInput): Effect.Effect => + input.apply(authInput).pipe(Effect.mapError(toLLMError)) + +export * as Auth from "./auth" diff --git a/packages/llm/src/route/client.ts b/packages/llm/src/route/client.ts new file mode 100644 index 0000000000..0b9d92cecb --- /dev/null +++ b/packages/llm/src/route/client.ts @@ -0,0 +1,528 @@ +import { Cause, Context, Effect, Layer, Schema, Stream } from "effect" +import type { Auth as AuthDef } from "./auth" +import type { Endpoint } from "./endpoint" +import { RequestExecutor } from "./executor" +import type { Framing } from "./framing" +import { HttpTransport } from "./transport" +import type { Transport, TransportRuntime } from "./transport" +import { WebSocketExecutor } from "./transport" +import type { Service as WebSocketExecutorService } from "./transport/websocket" +import type { Protocol } from "./protocol" +import * as ProviderShared from "../protocols/shared" +import * as ToolRuntime from "../tool-runtime" +import type { Tools } from "../tool" +import type { LLMError, LLMEvent, PreparedRequestOf, ProtocolID } from "../schema" +import { + GenerationOptions, + HttpOptions, + LLMRequest, + LLMResponse, + ModelID, + ModelLimits, + ModelRef, + LLMError as LLMErrorClass, + NoRouteReason, + PreparedRequest, + ProviderID, + RouteID, + mergeGenerationOptions, + mergeHttpOptions, + mergeProviderOptions, +} from "../schema" + +export interface RouteBody { + /** Schema for the validated provider-native body sent as the JSON request. */ + readonly schema: Schema.Codec + /** Build the provider-native body from a common `LLMRequest`. */ + readonly from: (request: LLMRequest) => Effect.Effect +} + +export interface Route { + readonly id: string + readonly provider?: ProviderID + readonly protocol: ProtocolID + readonly transport: Transport + readonly defaults: RouteDefaults + readonly body: RouteBody + readonly with: (patch: RoutePatch) => Route + readonly model: (input: Input) => ModelRef + readonly prepareTransport: (body: Body, request: LLMRequest) => Effect.Effect + readonly streamPrepared: ( + prepared: Prepared, + request: LLMRequest, + runtime: TransportRuntime, + ) => Stream.Stream +} + +// Route registries intentionally erase body generics after construction. +// Normal call sites use `OpenAIChat.route`; callers only need body types +// when preparing a request with a protocol-specific type assertion. +// oxlint-disable-next-line typescript-eslint/no-explicit-any +export type AnyRoute = Route + +const routeRegistry = new Map() + +// Route lookup is intentionally global: model refs name a route id, and +// importing the provider/protocol/custom-route module registers the runnable +// implementation. Duplicate ids are bugs because model refs cannot disambiguate +// them. +const register = (route: R): R => { + const existing = routeRegistry.get(route.id) + if (existing && existing !== route) throw new Error(`Duplicate LLM route id "${route.id}"`) + routeRegistry.set(route.id, route) + return route +} + +const registeredRoute = (id: string) => routeRegistry.get(id) + +export type HttpOptionsInput = HttpOptions.Input + +export type ModelRefInput = Omit< + ConstructorParameters[0], + "id" | "provider" | "route" | "limits" | "generation" | "http" | "auth" +> & { + readonly id: string | ModelID + readonly provider: string | ProviderID + readonly route: string | RouteID + readonly auth?: AuthDef + readonly limits?: ModelLimits.Input + readonly generation?: GenerationOptions.Input + readonly http?: HttpOptionsInput +} + +// `baseURL` is required on `ModelRefInput` (every materialized `ModelRef` has +// a host) but optional at the route-input layers below. The route's `defaults` +// can supply a canonical URL (e.g. OpenAI/Anthropic) so the user's input may +// omit it. Routes without a canonical URL (OpenAI-compatible, GitHub Copilot) +// re-tighten this in their own input type. +export type RouteModelInput = Omit & { + readonly baseURL?: string +} + +export type RouteModelDefaults = Omit & { + readonly baseURL?: string +} + +export type RouteRoutedModelInput = Omit & { + readonly baseURL?: string +} + +export type RouteRoutedModelDefaults = Partial> + +export type RouteDefaults = Partial> + +export interface RoutePatch extends RouteDefaults { + readonly id: string + readonly provider?: string | ProviderID + readonly transport?: Transport +} + +type RouteMappedModelInput = RouteModelInput | RouteRoutedModelInput + +export interface RouteModelOptions< + Input extends RouteMappedModelInput, + Output extends RouteMappedModelInput = RouteMappedModelInput, +> { + readonly mapInput?: (input: Input) => Output +} + +export interface RouteMappedModelOptions { + readonly mapInput: (input: Input) => Output +} + +const modelWithDefaults = + ( + route: AnyRoute, + defaults: Partial>, + options: { readonly mapInput?: (input: Input) => RouteMappedModelInput }, + ) => + (input: Input) => { + const mapped = options.mapInput === undefined ? (input as RouteMappedModelInput) : options.mapInput(input) + const provider = defaults.provider ?? route.provider ?? ("provider" in mapped ? mapped.provider : undefined) + if (!provider) throw new Error(`Route.model(${route.id}) requires a provider`) + const baseURL = mapped.baseURL ?? defaults.baseURL ?? route.defaults.baseURL + if (!baseURL) + throw new Error( + `Route.model(${route.id}) requires a baseURL — supply it via input, defaults, or route defaults`, + ) + const generation = mergeGenerationOptions(route.defaults.generation, defaults.generation) + const providerOptions = mergeProviderOptions(route.defaults.providerOptions, defaults.providerOptions) + const http = mergeHttpOptions(httpOptions(route.defaults.http), httpOptions(defaults.http)) + return modelRef({ + ...route.defaults, + ...defaults, + ...mapped, + baseURL, + provider, + route: route.id, + limits: mapped.limits ?? defaults.limits ?? route.defaults.limits, + generation: mergeGenerationOptions(generation, mapped.generation), + providerOptions: mergeProviderOptions(providerOptions, mapped.providerOptions), + http: mergeHttpOptions(http, httpOptions(mapped.http)), + }) + } + +const mergeRouteDefaults = (base: RouteDefaults | undefined, patch: RouteDefaults): RouteDefaults => ({ + ...base, + ...patch, + limits: patch.limits ?? base?.limits, + generation: mergeGenerationOptions(generationOptions(base?.generation), generationOptions(patch.generation)), + providerOptions: mergeProviderOptions(base?.providerOptions, patch.providerOptions), + http: mergeHttpOptions(httpOptions(base?.http), httpOptions(patch.http)), +}) + +export const modelLimits = ModelLimits.make + +export const generationOptions = (input: GenerationOptions.Input | undefined) => + input === undefined ? undefined : GenerationOptions.make(input) + +export const httpOptions = (input: HttpOptionsInput | undefined) => { + if (input === undefined) return input + return HttpOptions.make(input) +} + +export const modelRef = (input: ModelRefInput) => + new ModelRef({ + ...input, + id: ModelID.make(input.id), + provider: ProviderID.make(input.provider), + route: RouteID.make(input.route), + limits: modelLimits(input.limits), + generation: generationOptions(input.generation), + http: httpOptions(input.http), + }) + +function model( + route: AnyRoute, + defaults: RouteModelDefaults, + options?: RouteModelOptions, +): (input: Input) => ModelRef +function model( + route: AnyRoute, + defaults?: RouteRoutedModelDefaults, + options?: RouteModelOptions, +): (input: Input) => ModelRef +function model( + route: AnyRoute, + defaults: Partial>, + options: RouteMappedModelOptions, +): (input: Input) => ModelRef +function model( + route: AnyRoute, + defaults: Partial> = {}, + options: { readonly mapInput?: (input: Input) => RouteMappedModelInput } = {}, +) { + return modelWithDefaults(route, defaults, options) +} + +export interface Interface { + /** + * Compile a request through protocol body construction, validation, and HTTP + * preparation without sending it. Returns the prepared request including the + * provider-native body. + * + * Pass a `Body` type argument to statically expose the route's body + * shape (e.g. `prepare(...)`) — the runtime body is + * identical, so this is a type-level assertion the caller makes about which + * route the request will resolve to. + */ + readonly prepare: (request: LLMRequest) => Effect.Effect, LLMError> + readonly stream: StreamMethod + readonly generate: GenerateMethod +} + +export interface StreamMethod { + (request: LLMRequest): Stream.Stream + (options: ToolRuntime.RunOptions): Stream.Stream +} + +export interface GenerateMethod { + (request: LLMRequest): Effect.Effect + (options: ToolRuntime.RunOptions): Effect.Effect +} + +export class Service extends Context.Service()("@opencode/LLMClient") {} + +const noRoute = (model: ModelRef) => + new LLMErrorClass({ + module: "LLMClient", + method: "resolveRoute", + reason: new NoRouteReason({ route: model.route, provider: model.provider, model: model.id }), + }) + +const resolveRequestOptions = (request: LLMRequest) => + LLMRequest.update(request, { + generation: mergeGenerationOptions(request.model.generation, request.generation) ?? new GenerationOptions({}), + providerOptions: mergeProviderOptions(request.model.providerOptions, request.providerOptions), + http: mergeHttpOptions(request.model.http, request.http), + }) + +export interface MakeInput { + /** Route id used in registry lookup and error messages. */ + readonly id: string + /** Provider identity for route-owned model construction. */ + readonly provider?: string | ProviderID + /** Semantic API contract — owns body construction, body schema, and parsing. */ + readonly protocol: Protocol + /** Where the request is sent. */ + readonly endpoint: Endpoint + /** Per-request transport auth. Model-level `Auth` overrides this. */ + readonly auth?: AuthDef + /** Stream framing — bytes -> frames before `protocol.stream.event` decoding. */ + readonly framing: Framing + /** Static / per-request headers added before `auth` runs. */ + readonly headers?: (input: { readonly request: LLMRequest }) => Record + /** Model defaults used by the route's `.model(...)` helper. */ + readonly defaults?: RouteDefaults +} + +export interface MakeTransportInput { + /** Route id used in registry lookup and error messages. */ + readonly id: string + /** Provider identity for route-owned model construction. */ + readonly provider?: string | ProviderID + /** Semantic API contract — owns body construction, body schema, and parsing. */ + readonly protocol: Protocol + /** Runnable transport route. */ + readonly transport: Transport + /** Provider/model defaults used by the route's `.model(...)` helper. */ + readonly defaults?: RouteDefaults +} + +const streamError = (route: string, message: string, cause: Cause.Cause) => { + const failed = cause.reasons.find(Cause.isFailReason)?.error + if (failed instanceof LLMErrorClass) return failed + return ProviderShared.eventError(route, message, Cause.pretty(cause)) +} + +function makeFromTransport( + input: MakeTransportInput, +): Route { + const protocol = input.protocol + const decodeEventEffect = Schema.decodeUnknownEffect(protocol.stream.event) + const decodeEvent = (route: string) => (frame: Frame) => + decodeEventEffect(frame).pipe( + Effect.mapError(() => + ProviderShared.eventError( + input.id, + `Invalid ${route} stream event`, + typeof frame === "string" ? frame : ProviderShared.encodeJson(frame), + ), + ), + ) + + const build = (routeInput: MakeTransportInput): Route => { + const route: Route = { + id: routeInput.id, + provider: routeInput.provider === undefined ? undefined : ProviderID.make(routeInput.provider), + protocol: protocol.id, + transport: routeInput.transport, + defaults: routeInput.defaults ?? {}, + body: protocol.body, + with: (patch: RoutePatch) => { + const { id, provider, transport, ...defaults } = patch + if (!id || id === routeInput.id) throw new Error(`Route.with(${routeInput.id}) requires a new route id`) + return build({ + ...routeInput, + id, + provider: provider ?? routeInput.provider, + transport: (transport as Transport | undefined) ?? routeInput.transport, + defaults: mergeRouteDefaults(routeInput.defaults, defaults), + }) + }, + model: (input: RouteModelInput): ModelRef => modelWithDefaults(route, {}, {})(input), + prepareTransport: routeInput.transport.prepare, + streamPrepared: (prepared: Prepared, request: LLMRequest, runtime: TransportRuntime) => { + const route = `${request.model.provider}/${request.model.route}` + const events = routeInput.transport + .frames(prepared, request, runtime) + .pipe( + Stream.mapEffect(decodeEvent(route)), + protocol.stream.terminal ? Stream.takeUntil(protocol.stream.terminal) : (stream) => stream, + ) + return events.pipe( + Stream.mapAccumEffect( + protocol.stream.initial, + protocol.stream.step, + protocol.stream.onHalt ? { onHalt: protocol.stream.onHalt } : undefined, + ), + Stream.catchCause((cause) => Stream.fail(streamError(route, `Failed to read ${route} stream`, cause))), + ) + }, + } satisfies Route + return register(route) + } + + return build(input) +} + +export function make( + input: MakeTransportInput, +): Route +/** + * Build a `Route` by composing the four orthogonal pieces of a deployment: + * + * - `Protocol` — what is the API I'm speaking? + * - `Endpoint` — where do I send the request? + * - `Auth` — how do I authenticate it? + * - `Framing` — how do I cut the response stream into protocol frames? + * + * Plus optional `headers` for cross-cutting deployment concerns (provider + * version pins, per-deployment quirks). + * + * This is the canonical route constructor. If a new route does not fit + * this four-axis model, add a purpose-built constructor rather than widening + * the public surface preemptively. + */ +export function make( + input: MakeInput, +): Route> +export function make( + input: MakeInput | MakeTransportInput, +): Route | Route> { + if ("transport" in input) return makeFromTransport(input) + const protocol = input.protocol + const encodeBody = Schema.encodeSync(Schema.fromJsonString(protocol.body.schema)) + return makeFromTransport({ + id: input.id, + provider: input.provider, + protocol, + transport: HttpTransport.httpJson({ + endpoint: input.endpoint, + auth: input.auth, + framing: input.framing, + encodeBody, + headers: input.headers, + }), + defaults: input.defaults, + }) +} + +// `compile` is the important boundary: it turns a common `LLMRequest` into a +// validated provider body plus transport-private prepared data, but does not +// execute transport. +const compile = Effect.fn("LLM.compile")(function* (request: LLMRequest) { + const resolved = resolveRequestOptions(request) + const route = registeredRoute(resolved.model.route) + if (!route) return yield* noRoute(resolved.model) + + const body = yield* route.body + .from(resolved) + .pipe(Effect.flatMap(ProviderShared.validateWith(Schema.decodeUnknownEffect(route.body.schema)))) + const prepared = yield* route.prepareTransport(body, resolved) + + return { + request: resolved, + route, + body, + prepared, + } +}) + +const prepareWith = Effect.fn("LLMClient.prepare")(function* (request: LLMRequest) { + const compiled = yield* compile(request) + + return new PreparedRequest({ + id: compiled.request.id ?? "request", + route: compiled.route.id, + protocol: compiled.route.protocol, + model: compiled.request.model, + body: compiled.body, + metadata: { transport: compiled.route.transport.id }, + }) +}) + +const streamRequestWith = (runtime: TransportRuntime) => (request: LLMRequest) => + Stream.unwrap( + Effect.gen(function* () { + const compiled = yield* compile(request) + return compiled.route.streamPrepared(compiled.prepared, compiled.request, runtime) + }), + ) + +const isToolRunOptions = (input: LLMRequest | ToolRuntime.RunOptions): input is ToolRuntime.RunOptions => + "request" in input && "tools" in input + +const streamWith = (streamRequest: (request: LLMRequest) => Stream.Stream): StreamMethod => + ((input: LLMRequest | ToolRuntime.RunOptions) => { + if (isToolRunOptions(input)) return ToolRuntime.stream({ ...input, stream: streamRequest }) + return streamRequest(input) + }) as StreamMethod + +const generateWith = (stream: Interface["stream"]) => + Effect.fn("LLM.generate")(function* (input: LLMRequest | ToolRuntime.RunOptions) { + return new LLMResponse( + yield* stream(input as never).pipe( + Stream.runFold( + () => ({ events: [] as LLMEvent[], usage: undefined as LLMResponse["usage"] }), + (acc, event) => { + acc.events.push(event) + if ("usage" in event && event.usage !== undefined) acc.usage = event.usage + return acc + }, + ), + ), + ) + }) + +export const prepare = (request: LLMRequest) => + prepareWith(request) as Effect.Effect, LLMError> + +export function stream(request: LLMRequest): Stream.Stream +export function stream(options: ToolRuntime.RunOptions): Stream.Stream +export function stream(input: LLMRequest | ToolRuntime.RunOptions) { + return Stream.unwrap( + Effect.gen(function* () { + return (yield* Service).stream(input as never) + }), + ) +} + +export function generate(request: LLMRequest): Effect.Effect +export function generate(options: ToolRuntime.RunOptions): Effect.Effect +export function generate(input: LLMRequest | ToolRuntime.RunOptions) { + return Effect.gen(function* () { + return yield* (yield* Service).generate(input as never) + }) +} + +export const streamRequest = (request: LLMRequest) => + Stream.unwrap( + Effect.gen(function* () { + return (yield* Service).stream(request) + }), + ) + +export const layer: Layer.Layer = Layer.effect( + Service, + Effect.gen(function* () { + const stream = streamWith(streamRequestWith({ http: yield* RequestExecutor.Service })) + return Service.of({ prepare: prepareWith as Interface["prepare"], stream, generate: generateWith(stream) }) + }), +) + +export const layerWithWebSocket: Layer.Layer = + Layer.effect( + Service, + Effect.gen(function* () { + const stream = streamWith( + streamRequestWith({ + http: yield* RequestExecutor.Service, + webSocket: yield* WebSocketExecutor.Service, + }), + ) + return Service.of({ prepare: prepareWith as Interface["prepare"], stream, generate: generateWith(stream) }) + }), + ) + +export const Route = { make, model } as const + +export const LLMClient = { + Service, + layer, + layerWithWebSocket, + prepare, + stream, + generate, + stepCountIs: ToolRuntime.stepCountIs, +} as const diff --git a/packages/llm/src/route/endpoint.ts b/packages/llm/src/route/endpoint.ts new file mode 100644 index 0000000000..71d551893d --- /dev/null +++ b/packages/llm/src/route/endpoint.ts @@ -0,0 +1,41 @@ +import type { LLMRequest } from "../schema" +import * as ProviderShared from "../protocols/shared" + +export interface EndpointInput { + readonly request: LLMRequest + readonly body: Body +} + +export type EndpointPart = string | ((input: EndpointInput) => string) + +/** + * Declarative URL construction for one route. + * + * `Endpoint` carries only the path. The host always lives on `model.baseURL`, + * supplied by the provider helper that constructs the model. `render(...)` + * just appends the path (and any `model.queryParams`) to that host. + * + * `path` may be a string or a function of `EndpointInput`, for routes whose + * URL embeds the model id, region, or another body field (e.g. Bedrock, + * Gemini). + */ +export interface Endpoint { + readonly path: EndpointPart +} + +/** Construct an `Endpoint` from a path string or path function. */ +export const path = (value: EndpointPart): Endpoint => ({ path: value }) + +const renderPart = (part: EndpointPart, input: EndpointInput) => + typeof part === "function" ? part(input) : part + +export const render = (endpoint: Endpoint, input: EndpointInput) => { + const url = new URL( + `${ProviderShared.trimBaseUrl(input.request.model.baseURL)}${renderPart(endpoint.path, input)}`, + ) + const params = input.request.model.queryParams + if (params) for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value) + return url +} + +export * as Endpoint from "./endpoint" diff --git a/packages/llm/src/route/executor.ts b/packages/llm/src/route/executor.ts new file mode 100644 index 0000000000..815b2c289c --- /dev/null +++ b/packages/llm/src/route/executor.ts @@ -0,0 +1,374 @@ +import { Cause, Context, Effect, Layer, Random } from "effect" +import { + FetchHttpClient, + Headers, + HttpClient, + HttpClientError, + HttpClientRequest, + HttpClientResponse, +} from "effect/unstable/http" +import { + AuthenticationReason, + ContentPolicyReason, + HttpContext, + HttpRateLimitDetails, + HttpRequestDetails, + HttpResponseDetails, + InvalidRequestReason, + LLMError, + ProviderInternalReason, + QuotaExceededReason, + RateLimitReason, + TransportReason, + UnknownProviderReason, +} from "../schema" + +export interface Interface { + readonly execute: ( + request: HttpClientRequest.HttpClientRequest, + ) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/LLM/RequestExecutor") {} + +const BODY_LIMIT = 16_384 +const MAX_RETRIES = 2 +const BASE_DELAY_MS = 500 +const MAX_DELAY_MS = 10_000 +const REDACTED = "" + +// One source of truth for what counts as a sensitive name across headers, +// URL query keys, and field names embedded inside request/response bodies. +// +// `SENSITIVE_NAME` is used as both a substring matcher (for free-form header +// names like `Authorization` / `X-API-Key`) and as the body-field alternation +// list. `SHORT_QUERY_NAME` covers anchored short keys like `?key=…` / `?sig=…` +// that are too generic to redact substring-style without false positives. +const SENSITIVE_NAME_SOURCE = + "authorization|api[-_]?key|access[-_]?token|refresh[-_]?token|id[-_]?token|token|secret|credential|signature|x-amz-signature" +const SENSITIVE_NAME = new RegExp(SENSITIVE_NAME_SOURCE, "i") +const SHORT_QUERY_NAME = /^(key|sig)$/i +const SENSITIVE_BODY_FIELD = new RegExp(`(?:${SENSITIVE_NAME_SOURCE}|key)`, "i") +const REDACT_JSON_FIELD = new RegExp(`("(?:${SENSITIVE_BODY_FIELD.source})"\\s*:\\s*)"[^"]*"`, "gi") +const REDACT_QUERY_FIELD = new RegExp(`((?:${SENSITIVE_BODY_FIELD.source})=)[^&\\s"]+`, "gi") + +const isSensitiveHeaderName = (name: string) => SENSITIVE_NAME.test(name) + +const isSensitiveQueryName = (name: string) => isSensitiveHeaderName(name) || SHORT_QUERY_NAME.test(name) + +const redactHeaders = (headers: Headers.Headers, redactedNames: ReadonlyArray) => + Object.fromEntries( + Object.entries(Headers.redact(headers, [...redactedNames, SENSITIVE_NAME])).map(([name, value]) => [ + name, + String(value), + ]), + ) + +const redactUrl = (value: string) => { + if (!URL.canParse(value)) return REDACTED + const url = new URL(value) + url.searchParams.forEach((_, key) => { + if (isSensitiveQueryName(key)) url.searchParams.set(key, REDACTED) + }) + return url.toString() +} + +const normalizedHeaders = (headers: Headers.Headers) => + Object.fromEntries(Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value])) + +const requestId = (headers: Record) => { + return ( + headers["x-request-id"] ?? + headers["request-id"] ?? + headers["x-amzn-requestid"] ?? + headers["x-amz-request-id"] ?? + headers["x-goog-request-id"] ?? + headers["cf-ray"] + ) +} + +const retryableStatus = (status: number) => status === 429 || status === 503 || status === 504 || status === 529 + +const retryAfterMs = (headers: Record) => { + const millis = Number(headers["retry-after-ms"]) + if (Number.isFinite(millis)) return Math.max(0, millis) + + const value = headers["retry-after"] + if (!value) return undefined + + const seconds = Number(value) + if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000) + + const date = Date.parse(value) + if (!Number.isNaN(date)) return Math.max(0, date - Date.now()) + return undefined +} + +const addRateLimitValue = (target: Record, key: string, value: string) => { + if (key.length > 0) target[key] = value +} + +const rateLimitDetails = (headers: Record, retryAfter: number | undefined) => { + const limit: Record = {} + const remaining: Record = {} + const reset: Record = {} + + Object.entries(headers).forEach(([name, value]) => { + const openaiLimit = /^x-ratelimit-limit-(.+)$/.exec(name)?.[1] + if (openaiLimit) return addRateLimitValue(limit, openaiLimit, value) + + const openaiRemaining = /^x-ratelimit-remaining-(.+)$/.exec(name)?.[1] + if (openaiRemaining) return addRateLimitValue(remaining, openaiRemaining, value) + + const openaiReset = /^x-ratelimit-reset-(.+)$/.exec(name)?.[1] + if (openaiReset) return addRateLimitValue(reset, openaiReset, value) + + const anthropic = /^anthropic-ratelimit-(.+)-(limit|remaining|reset)$/.exec(name) + if (!anthropic) return + if (anthropic[2] === "limit") return addRateLimitValue(limit, anthropic[1], value) + if (anthropic[2] === "remaining") return addRateLimitValue(remaining, anthropic[1], value) + return addRateLimitValue(reset, anthropic[1], value) + }) + + if ( + retryAfter === undefined && + Object.keys(limit).length === 0 && + Object.keys(remaining).length === 0 && + Object.keys(reset).length === 0 + ) + return undefined + + return new HttpRateLimitDetails({ + retryAfterMs: retryAfter, + limit: Object.keys(limit).length === 0 ? undefined : limit, + remaining: Object.keys(remaining).length === 0 ? undefined : remaining, + reset: Object.keys(reset).length === 0 ? undefined : reset, + }) +} + +const requestDetails = (request: HttpClientRequest.HttpClientRequest, redactedNames: ReadonlyArray) => + new HttpRequestDetails({ + method: request.method, + url: redactUrl(request.url), + headers: redactHeaders(request.headers, redactedNames), + }) + +const responseDetails = ( + response: HttpClientResponse.HttpClientResponse, + redactedNames: ReadonlyArray, +) => + new HttpResponseDetails({ + status: response.status, + headers: redactHeaders(response.headers, redactedNames), + }) + +const secretValues = (request: HttpClientRequest.HttpClientRequest) => { + const values = new Set() + const add = (value: string) => { + if (value.length < 4) return + values.add(value) + values.add(encodeURIComponent(value)) + } + + Object.entries(request.headers).forEach(([name, value]) => { + if (!isSensitiveHeaderName(name)) return + add(value) + const bearer = /^Bearer\s+(.+)$/i.exec(value)?.[1] + if (bearer) add(bearer) + }) + + if (!URL.canParse(request.url)) return values + new URL(request.url).searchParams.forEach((value, key) => { + if (isSensitiveQueryName(key)) add(value) + }) + return values +} + +// Two passes: structural (redact `"name": "value"` and `name=value` patterns +// for any field name that looks sensitive) plus literal (replace any actual +// secret values we sent in the request, in case the response echoes one back). +const redactBody = (body: string, request: HttpClientRequest.HttpClientRequest) => + Array.from(secretValues(request)).reduce( + (text, secret) => text.split(secret).join(REDACTED), + body.replace(REDACT_JSON_FIELD, `$1"${REDACTED}"`).replace(REDACT_QUERY_FIELD, `$1${REDACTED}`), + ) + +const responseBody = (body: string | void, request: HttpClientRequest.HttpClientRequest) => { + if (body === undefined) return {} + const redacted = redactBody(body, request) + if (redacted.length <= BODY_LIMIT) return { body: redacted } + return { body: redacted.slice(0, BODY_LIMIT), bodyTruncated: true } +} + +const providerMessage = (status: number, body: { readonly body?: string }) => { + if (body.body && body.body.length <= 500) return `Provider request failed with HTTP ${status}: ${body.body}` + return `Provider request failed with HTTP ${status}` +} + +const responseHttp = (input: { + readonly request: HttpClientRequest.HttpClientRequest + readonly response: HttpClientResponse.HttpClientResponse + readonly redactedNames: ReadonlyArray + readonly body: ReturnType + readonly requestId?: string | undefined + readonly rateLimit?: HttpRateLimitDetails | undefined +}) => + new HttpContext({ + request: requestDetails(input.request, input.redactedNames), + response: responseDetails(input.response, input.redactedNames), + ...input.body, + requestId: input.requestId, + rateLimit: input.rateLimit, + }) + +const statusReason = (input: { + readonly status: number + readonly message: string + readonly retryAfterMs?: number | undefined + readonly rateLimit?: HttpRateLimitDetails | undefined + readonly http: HttpContext +}) => { + const body = input.http.body ?? "" + if (/content[-_\s]?policy|content_filter|safety/i.test(body)) { + return new ContentPolicyReason({ message: input.message, http: input.http }) + } + if (input.status === 401) { + return new AuthenticationReason({ message: input.message, kind: "invalid", http: input.http }) + } + if (input.status === 403) { + return new AuthenticationReason({ message: input.message, kind: "insufficient-permissions", http: input.http }) + } + if (input.status === 429) { + if (/insufficient[-_\s]?quota|quota[-_\s]?exceeded/i.test(body)) { + return new QuotaExceededReason({ message: input.message, http: input.http }) + } + return new RateLimitReason({ + message: input.message, + retryAfterMs: input.retryAfterMs, + rateLimit: input.rateLimit, + http: input.http, + }) + } + if (input.status === 400 || input.status === 404 || input.status === 409 || input.status === 422) { + return new InvalidRequestReason({ message: input.message, http: input.http }) + } + if (input.status >= 500 || retryableStatus(input.status)) { + return new ProviderInternalReason({ + message: input.message, + status: input.status, + retryAfterMs: input.retryAfterMs, + http: input.http, + }) + } + return new UnknownProviderReason({ message: input.message, status: input.status, http: input.http }) +} + +const statusError = + (request: HttpClientRequest.HttpClientRequest, redactedNames: ReadonlyArray) => + (response: HttpClientResponse.HttpClientResponse) => + Effect.gen(function* () { + if (response.status < 400) return response + const body = yield* response.text.pipe(Effect.catch(() => Effect.void)) + const headers = normalizedHeaders(response.headers) + const retryAfter = retryAfterMs(headers) + const rateLimit = rateLimitDetails(headers, retryAfter) + const details = responseBody(body, request) + return yield* new LLMError({ + module: "RequestExecutor", + method: "execute", + reason: statusReason({ + status: response.status, + message: providerMessage(response.status, details), + retryAfterMs: retryAfter, + rateLimit, + http: responseHttp({ + request, + response, + redactedNames, + body: details, + requestId: requestId(headers), + rateLimit, + }), + }), + }) + }) + +const toHttpError = (redactedNames: ReadonlyArray) => (error: unknown) => { + const transportError = (input: { + readonly message: string + readonly kind?: string | undefined + readonly request?: HttpClientRequest.HttpClientRequest | undefined + }) => + new LLMError({ + module: "RequestExecutor", + method: "execute", + reason: new TransportReason({ + message: input.message, + kind: input.kind, + url: input.request ? redactUrl(input.request.url) : undefined, + http: input.request ? new HttpContext({ request: requestDetails(input.request, redactedNames) }) : undefined, + }), + }) + + if (Cause.isTimeoutError(error)) { + return transportError({ message: error.message, kind: "Timeout" }) + } + if (!HttpClientError.isHttpClientError(error)) { + return transportError({ message: "HTTP transport failed" }) + } + const request = "request" in error ? error.request : undefined + if (error.reason._tag === "TransportError") { + return transportError({ + message: error.reason.description ?? "HTTP transport failed", + kind: error.reason._tag, + request, + }) + } + return transportError({ + message: `HTTP transport failed: ${error.reason._tag}`, + kind: error.reason._tag, + request, + }) +} + +const retryDelay = (error: LLMError, attempt: number) => { + if (error.retryAfterMs !== undefined) return Effect.succeed(Math.min(error.retryAfterMs, MAX_DELAY_MS)) + return Random.nextBetween( + Math.min(BASE_DELAY_MS * 2 ** attempt * 0.8, MAX_DELAY_MS), + Math.min(BASE_DELAY_MS * 2 ** attempt * 1.2, MAX_DELAY_MS), + ).pipe(Effect.map((delay) => Math.round(delay))) +} + +const retryStatusFailures = ( + effect: Effect.Effect, + retries = MAX_RETRIES, + attempt = 0, +): Effect.Effect => + Effect.catchTag(effect, "LLM.Error", (error): Effect.Effect => { + if (!error.retryable || retries <= 0) return Effect.fail(error) + return retryDelay(error, attempt).pipe( + Effect.flatMap((delay) => Effect.sleep(delay)), + Effect.flatMap(() => retryStatusFailures(effect, retries - 1, attempt + 1)), + ) + }) + +export const layer: Layer.Layer = Layer.effect( + Service, + Effect.gen(function* () { + const http = yield* HttpClient.HttpClient + const executeOnce = (request: HttpClientRequest.HttpClientRequest) => + Effect.gen(function* () { + const redactedNames = yield* Headers.CurrentRedactedNames + return yield* http + .execute(request) + .pipe(Effect.mapError(toHttpError(redactedNames)), Effect.flatMap(statusError(request, redactedNames))) + }) + return Service.of({ + execute: (request) => retryStatusFailures(executeOnce(request)), + }) + }), +) + +export const defaultLayer = layer.pipe(Layer.provide(FetchHttpClient.layer)) + +export * as RequestExecutor from "./executor" diff --git a/packages/llm/src/route/framing.ts b/packages/llm/src/route/framing.ts new file mode 100644 index 0000000000..ef4855817d --- /dev/null +++ b/packages/llm/src/route/framing.ts @@ -0,0 +1,27 @@ +import type { Stream } from "effect" +import * as ProviderShared from "../protocols/shared" +import type { LLMError } from "../schema" + +/** + * Decode a streaming HTTP response body into provider-protocol frames. + * + * `Framing` is the byte-stream-shaped seam between transport and protocol: + * + * - SSE (`Framing.sse`) — UTF-8 decode the body, run the SSE channel decoder, + * drop empty / `[DONE]` keep-alives. Each emitted frame is the JSON `data:` + * payload of one event. + * - AWS event stream — length-prefixed binary frames with CRC checksums. + * Each emitted frame is one parsed binary event record. + * + * The frame type is opaque to this layer; the protocol's `decode` step turns + * a frame into a typed chunk. + */ +export interface Framing { + readonly id: string + readonly frame: (bytes: Stream.Stream) => Stream.Stream +} + +/** Server-Sent Events framing. Used by every JSON-streaming HTTP provider. */ +export const sse: Framing = { id: "sse", frame: ProviderShared.sseFraming } + +export * as Framing from "./framing" diff --git a/packages/llm/src/route/index.ts b/packages/llm/src/route/index.ts new file mode 100644 index 0000000000..a75dd3e038 --- /dev/null +++ b/packages/llm/src/route/index.ts @@ -0,0 +1,26 @@ +export { Route, LLMClient, modelLimits, modelRef } from "./client" +export type { + Route as RouteShape, + RouteModelDefaults, + RouteModelInput, + RouteRoutedModelDefaults, + RouteRoutedModelInput, + AnyRoute, + Interface as LLMClientShape, + Service as LLMClientService, + ModelRefInput, +} from "./client" +export * from "./executor" +export { Auth } from "./auth" +export { AuthOptions } from "./auth-options" +export { Endpoint } from "./endpoint" +export { Framing } from "./framing" +export { Protocol } from "./protocol" +export { HttpTransport, WebSocketExecutor, WebSocketTransport } from "./transport" +export * as Transport from "./transport" +export type { Auth as AuthShape, AuthInput, Credential, CredentialError } from "./auth" +export type { ApiKeyMode, AuthOverride, ProviderAuthOption } from "./auth-options" +export type { Endpoint as EndpointFn, EndpointInput } from "./endpoint" +export type { Framing as FramingDef } from "./framing" +export type { Protocol as ProtocolDef } from "./protocol" +export type { Transport as TransportDef, TransportRuntime } from "./transport" diff --git a/packages/llm/src/route/protocol.ts b/packages/llm/src/route/protocol.ts new file mode 100644 index 0000000000..3ce0f7827d --- /dev/null +++ b/packages/llm/src/route/protocol.ts @@ -0,0 +1,84 @@ +import { Schema, type Effect } from "effect" +import type { LLMError, LLMEvent, LLMRequest, ProtocolID } from "../schema" + +/** + * The semantic API contract of one model server family. + * + * A `Protocol` owns the parts of a route that are intrinsic to "what does + * this API look like": how a common `LLMRequest` becomes a provider-native + * body, what schema that body must satisfy before it is JSON-encoded, and + * how the streaming response decodes back into common `LLMEvent`s. + * + * Examples: + * + * - `OpenAIChat.protocol` — chat completions style + * - `OpenAIResponses.protocol` — responses API + * - `AnthropicMessages.protocol` — messages API with content blocks + * - `Gemini.protocol` — generateContent + * - `BedrockConverse.protocol` — Converse with binary event-stream framing + * + * A `Protocol` is **not** a deployment. It does not know which URL, which + * headers, or which auth scheme to use. Those are deployment concerns owned + * by `Route.make(...)` along with the chosen `Endpoint`, `Auth`, + * and `Framing`. This separation is what lets DeepSeek, TogetherAI, Cerebras, + * etc. all reuse `OpenAIChat.protocol` without forking 300 lines per provider. + * + * The four type parameters reflect the pipeline: + * + * - `Body` — provider-native request body candidate. `Route.make(...)` + * validates and JSON-encodes it with `body.schema`. + * - `Frame` — one unit of the framed response stream. SSE: a JSON data + * string. AWS event stream: a parsed binary frame. + * - `Event` — schema-decoded provider event produced from one frame. + * - `State` — accumulator threaded through `stream.step` to translate event + * sequences into `LLMEvent` sequences. + */ +export interface Protocol { + /** Stable id for the wire protocol implementation. */ + readonly id: ProtocolID + /** Request side: schema for the provider-native body and how to build it. */ + readonly body: ProtocolBody + /** Response side: streaming state machine. */ + readonly stream: ProtocolStream +} + +export interface ProtocolBody { + /** Schema for the validated provider-native body sent as the JSON request. */ + readonly schema: Schema.Codec + /** Build the provider-native body from a common `LLMRequest`. */ + readonly from: (request: LLMRequest) => Effect.Effect +} + +export interface ProtocolStream { + /** Schema for one decoded streaming event, decoded from a transport frame. */ + readonly event: Schema.Codec + /** Initial parser state. Called once per response. */ + readonly initial: () => State + /** Translate one event into emitted `LLMEvent`s plus the next state. */ + readonly step: (state: State, event: Event) => Effect.Effect], LLMError> + /** Optional request-completion signal for transports that do not end naturally. */ + readonly terminal?: (event: Event) => boolean + /** Optional flush emitted when the framed stream ends. */ + readonly onHalt?: (state: State) => ReadonlyArray +} + +/** + * Construct a `Protocol` from its body and stream pieces: + * + * - `body.schema` infers the provider-native request body shape. + * - `body.from` ties the common `LLMRequest` to the provider body. + * - `stream.event` infers the decoded streaming event and the wire frame. + * - `stream.initial`, `stream.step`, and `stream.onHalt` infer the parser state. + * + * Provider implementations should usually call `Protocol.make({ ... })` + * without explicit type arguments; the schemas and parser functions are the + * source of truth. The constructor remains as the public seam for future + * cross-cutting concerns such as tracing or instrumentation. + */ +export const make = ( + input: Protocol, +): Protocol => input + +export const jsonEvent = (schema: S) => Schema.fromJsonString(schema) + +export * as Protocol from "./protocol" diff --git a/packages/llm/src/route/transport/http.ts b/packages/llm/src/route/transport/http.ts new file mode 100644 index 0000000000..2159ce90b0 --- /dev/null +++ b/packages/llm/src/route/transport/http.ts @@ -0,0 +1,122 @@ +import { Effect, Stream } from "effect" +import { Headers, HttpClientRequest } from "effect/unstable/http" +import { Auth, type Auth as AuthDef } from "../auth" +import { type Endpoint, render as renderEndpoint } from "../endpoint" +import type { Framing } from "../framing" +import type { Transport } from "./index" +import * as ProviderShared from "../../protocols/shared" +import { mergeJsonRecords, type LLMRequest } from "../../schema" + +export interface JsonRequestInput { + readonly body: Body + readonly request: LLMRequest + readonly endpoint: Endpoint + readonly auth: AuthDef + readonly encodeBody: (body: Body) => string + readonly headers?: (input: { readonly request: LLMRequest }) => Record +} + +export interface JsonRequestParts { + readonly url: string + readonly jsonBody: Body | Record + readonly bodyText: string + readonly headers: Headers.Headers +} + +export interface HttpPrepared { + readonly request: HttpClientRequest.HttpClientRequest + readonly framing: Framing +} + +const applyQuery = (url: string, query: Record | undefined) => { + if (!query) return url + const next = new URL(url) + Object.entries(query).forEach(([key, value]) => next.searchParams.set(key, value)) + return next.toString() +} + +const bodyWithOverlay = (body: Body, request: LLMRequest, encodeBody: (body: Body) => string) => + Effect.gen(function* () { + if (request.http?.body === undefined) return { jsonBody: body, bodyText: encodeBody(body) } + if (ProviderShared.isRecord(body)) { + const overlaid = mergeJsonRecords(body, request.http.body) ?? {} + return { jsonBody: overlaid, bodyText: ProviderShared.encodeJson(overlaid) } + } + return yield* ProviderShared.invalidRequest("http.body can only overlay JSON object request bodies") + }) + +export const jsonRequestParts = (input: JsonRequestInput) => + Effect.gen(function* () { + const url = applyQuery( + renderEndpoint(input.endpoint, { request: input.request, body: input.body }).toString(), + input.request.http?.query, + ) + const body = yield* bodyWithOverlay(input.body, input.request, input.encodeBody) + const headers = yield* Auth.toEffect(Auth.isAuth(input.request.model.auth) ? input.request.model.auth : input.auth)( + { + request: input.request, + method: "POST", + url, + body: body.bodyText, + headers: Headers.fromInput({ + ...(input.headers?.({ request: input.request }) ?? {}), + ...input.request.model.headers, + ...input.request.http?.headers, + }), + }, + ) + return { url, jsonBody: body.jsonBody, bodyText: body.bodyText, headers } + }) + +export interface HttpJsonInput { + readonly endpoint: Endpoint + readonly auth?: AuthDef + readonly framing: Framing + readonly encodeBody: (body: Body) => string + readonly headers?: (input: { readonly request: LLMRequest }) => Record +} + +export type HttpJsonPatch = Partial> + +export interface HttpJsonTransport extends Transport, Frame> { + readonly with: (patch: HttpJsonPatch) => HttpJsonTransport +} + +export const httpJson = (input: HttpJsonInput): HttpJsonTransport => ({ + id: "http-json", + with: (patch) => httpJson({ ...input, ...patch }), + prepare: (body, request) => + jsonRequestParts({ + body, + request, + endpoint: input.endpoint, + auth: input.auth ?? Auth.bearer(), + encodeBody: input.encodeBody, + headers: input.headers, + }).pipe( + Effect.map((parts) => ({ + request: ProviderShared.jsonPost({ url: parts.url, body: parts.bodyText, headers: parts.headers }), + framing: input.framing, + })), + ), + frames: (prepared, request, runtime) => + Stream.unwrap( + runtime.http + .execute(prepared.request) + .pipe( + Effect.map((response) => + prepared.framing.frame( + response.stream.pipe( + Stream.mapError((error) => + ProviderShared.eventError( + `${request.model.provider}/${request.model.route}`, + `Failed to read ${request.model.provider}/${request.model.route} stream`, + ProviderShared.errorText(error), + ), + ), + ), + ), + ), + ), + ), +}) diff --git a/packages/llm/src/route/transport/index.ts b/packages/llm/src/route/transport/index.ts new file mode 100644 index 0000000000..f4d5fb29b7 --- /dev/null +++ b/packages/llm/src/route/transport/index.ts @@ -0,0 +1,22 @@ +import type { Effect, Stream } from "effect" +import type { Interface as RequestExecutorInterface } from "../executor" +import type { Interface as WebSocketExecutorInterface } from "./websocket" +import type { LLMError, LLMRequest } from "../../schema" + +export interface TransportRuntime { + readonly http: RequestExecutorInterface + readonly webSocket?: WebSocketExecutorInterface +} + +export interface Transport { + readonly id: string + readonly prepare: (body: Body, request: LLMRequest) => Effect.Effect + readonly frames: ( + prepared: Prepared, + request: LLMRequest, + runtime: TransportRuntime, + ) => Stream.Stream +} + +export * as HttpTransport from "./http" +export { WebSocketExecutor, WebSocketTransport } from "./websocket" diff --git a/packages/llm/src/route/transport/websocket.ts b/packages/llm/src/route/transport/websocket.ts new file mode 100644 index 0000000000..647a6db43d --- /dev/null +++ b/packages/llm/src/route/transport/websocket.ts @@ -0,0 +1,282 @@ +import { Cause, Context, Effect, Queue, Stream } from "effect" +import { Headers } from "effect/unstable/http" +import { Auth, type Auth as AuthDef } from "../auth" +import type { Endpoint } from "../endpoint" +import { LLMError, TransportReason, type LLMRequest } from "../../schema" +import * as HttpTransport from "./http" +import type { Transport } from "./index" + +export interface WebSocketRequest { + readonly url: string + readonly headers: Headers.Headers +} + +export interface WebSocketConnection { + readonly sendText: (message: string) => Effect.Effect + readonly messages: Stream.Stream + readonly close: Effect.Effect +} + +export interface Interface { + readonly open: (input: WebSocketRequest) => Effect.Effect +} + +type WebSocketConstructorWithHeaders = new ( + url: string, + options?: { readonly headers?: Headers.Headers }, +) => globalThis.WebSocket + +export class Service extends Context.Service()("@opencode/LLM/WebSocketExecutor") {} + +const transportError = ( + method: string, + message: string, + input: { readonly url?: string; readonly kind?: string } = {}, +) => + new LLMError({ + module: "WebSocketExecutor", + method, + reason: new TransportReason({ message, url: input.url, kind: input.kind }), + }) + +const eventMessage = (event: Event) => { + if ("message" in event && typeof event.message === "string") return event.message + return event.type +} + +const binaryMessage = (data: unknown) => { + if (data instanceof Uint8Array) return data + if (data instanceof ArrayBuffer) return new Uint8Array(data) + if (ArrayBuffer.isView(data)) return new Uint8Array(data.buffer, data.byteOffset, data.byteLength) + return undefined +} + +const waitOpen = (ws: globalThis.WebSocket, input: WebSocketRequest) => { + if (ws.readyState === globalThis.WebSocket.OPEN) return Effect.void + if (ws.readyState === globalThis.WebSocket.CLOSING || ws.readyState === globalThis.WebSocket.CLOSED) { + return Effect.fail( + transportError("open", `WebSocket closed before opening (state ${ws.readyState})`, { + url: input.url, + kind: "open", + }), + ) + } + return Effect.callback((resume, signal) => { + const cleanup = () => { + ws.removeEventListener("open", onOpen) + ws.removeEventListener("error", onError) + ws.removeEventListener("close", onClose) + signal.removeEventListener("abort", onAbort) + } + const onAbort = () => { + cleanup() + if (ws.readyState !== globalThis.WebSocket.CLOSED && ws.readyState !== globalThis.WebSocket.CLOSING) + ws.close(1000) + } + const onOpen = () => { + cleanup() + resume(Effect.void) + } + const onError = (event: Event) => { + cleanup() + resume( + Effect.fail( + transportError("open", `Failed to open WebSocket: ${eventMessage(event)}`, { url: input.url, kind: "open" }), + ), + ) + } + const onClose = (event: CloseEvent) => { + cleanup() + resume( + Effect.fail( + transportError("open", `WebSocket closed before opening with code ${event.code}`, { + url: input.url, + kind: "open", + }), + ), + ) + } + ws.addEventListener("open", onOpen, { once: true }) + ws.addEventListener("error", onError, { once: true }) + ws.addEventListener("close", onClose, { once: true }) + signal.addEventListener("abort", onAbort, { once: true }) + }) +} + +const webSocketUrl = (value: string) => + Effect.try({ + try: () => { + const url = new URL(value) + if (url.protocol === "https:") { + url.protocol = "wss:" + return url.toString() + } + if (url.protocol === "http:") { + url.protocol = "ws:" + return url.toString() + } + throw new Error(`Unsupported WebSocket URL protocol ${url.protocol}`) + }, + catch: (error) => + transportError("prepare", error instanceof Error ? error.message : "Invalid WebSocket URL", { + url: value, + kind: "websocket", + }), + }) + +export const open = (input: WebSocketRequest) => + Effect.try({ + try: () => + new (globalThis.WebSocket as unknown as WebSocketConstructorWithHeaders)(input.url, { headers: input.headers }), + catch: (error) => + transportError("open", error instanceof Error ? error.message : "Failed to construct WebSocket", { + url: input.url, + kind: "open", + }), + }).pipe(Effect.flatMap((ws) => fromWebSocket(ws, input))) + +export const fromWebSocket = ( + ws: globalThis.WebSocket, + input: WebSocketRequest, +): Effect.Effect => + Effect.gen(function* () { + yield* waitOpen(ws, input) + const messages = yield* Queue.bounded>(128) + + const onMessage = (event: MessageEvent) => { + if (typeof event.data === "string") return Queue.offerUnsafe(messages, event.data) + const binary = binaryMessage(event.data) + if (binary) return Queue.offerUnsafe(messages, binary) + Queue.failCauseUnsafe( + messages, + Cause.fail( + transportError("message", "Unsupported WebSocket message payload", { url: input.url, kind: "message" }), + ), + ) + } + const onError = (event: Event) => { + Queue.failCauseUnsafe( + messages, + Cause.fail( + transportError("message", `WebSocket error: ${eventMessage(event)}`, { url: input.url, kind: "message" }), + ), + ) + } + const onClose = (event: CloseEvent) => { + if (event.code === 1000 || event.code === 1005) return Queue.endUnsafe(messages) + Queue.failCauseUnsafe( + messages, + Cause.fail( + transportError("message", `WebSocket closed with code ${event.code}`, { url: input.url, kind: "close" }), + ), + ) + } + const cleanup = Effect.sync(() => { + ws.removeEventListener("message", onMessage) + ws.removeEventListener("error", onError) + ws.removeEventListener("close", onClose) + }).pipe(Effect.andThen(Queue.shutdown(messages))) + + ws.addEventListener("message", onMessage) + ws.addEventListener("error", onError) + ws.addEventListener("close", onClose) + + return { + sendText: (message) => + Effect.try({ + try: () => ws.send(message), + catch: (error) => + transportError("sendText", error instanceof Error ? error.message : "Failed to send WebSocket message", { + url: input.url, + kind: "write", + }), + }), + messages: Stream.fromQueue(messages), + close: cleanup.pipe( + Effect.andThen( + Effect.sync(() => { + if (ws.readyState === globalThis.WebSocket.CLOSED || ws.readyState === globalThis.WebSocket.CLOSING) return + ws.close(1000) + }), + ), + ), + } + }) + +export const messageText = (message: string | Uint8Array, decoder: TextDecoder) => + typeof message === "string" ? message : decoder.decode(message) + +export interface JsonPrepared { + readonly url: string + readonly headers: Headers.Headers + readonly message: string +} + +export interface JsonInput { + readonly endpoint: Endpoint + readonly auth?: AuthDef + readonly encodeBody: (body: Body) => string + readonly toMessage: (body: Body | Record) => Effect.Effect + readonly encodeMessage: (message: Message) => string + readonly headers?: (input: { readonly request: LLMRequest }) => Record +} + +export type JsonPatch = Partial> + +export interface JsonTransport extends Transport { + readonly with: (patch: JsonPatch) => JsonTransport +} + +export const json = (input: JsonInput): JsonTransport => ({ + id: "websocket-json", + with: (patch) => json({ ...input, ...patch }), + prepare: (body, request) => + Effect.gen(function* () { + const parts = yield* HttpTransport.jsonRequestParts({ + body, + request, + endpoint: input.endpoint, + auth: input.auth ?? Auth.bearer(), + encodeBody: input.encodeBody, + headers: input.headers, + }) + return { + url: yield* webSocketUrl(parts.url), + headers: parts.headers, + message: input.encodeMessage(yield* input.toMessage(parts.jsonBody)), + } + }), + frames: (prepared, _request, runtime) => { + const webSocket = runtime.webSocket + if (!webSocket) { + return Stream.fail( + transportError("json", "WebSocket JSON transport requires WebSocketExecutor.Service", { + url: prepared.url, + kind: "websocket", + }), + ) + } + const decoder = new TextDecoder() + return Stream.unwrap( + Effect.gen(function* () { + const connection = yield* Effect.acquireRelease( + webSocket.open({ url: prepared.url, headers: prepared.headers }), + (connection) => connection.close, + ) + yield* connection.sendText(prepared.message) + return connection.messages.pipe(Stream.map((message) => messageText(message, decoder))) + }), + ) + }, +}) + +export const WebSocketExecutor = { + Service, + open, + fromWebSocket, + messageText, +} as const + +export const WebSocketTransport = { + json, +} as const diff --git a/packages/llm/src/schema/errors.ts b/packages/llm/src/schema/errors.ts new file mode 100644 index 0000000000..9bcc8e1694 --- /dev/null +++ b/packages/llm/src/schema/errors.ts @@ -0,0 +1,202 @@ +import { Schema } from "effect" +import { ModelID, ProviderID, ProviderMetadata, RouteID } from "./ids" + +export class HttpRequestDetails extends Schema.Class("LLM.HttpRequestDetails")({ + method: Schema.String, + url: Schema.String, + headers: Schema.Record(Schema.String, Schema.String), +}) {} + +export class HttpResponseDetails extends Schema.Class("LLM.HttpResponseDetails")({ + status: Schema.Number, + headers: Schema.Record(Schema.String, Schema.String), +}) {} + +export class HttpRateLimitDetails extends Schema.Class("LLM.HttpRateLimitDetails")({ + retryAfterMs: Schema.optional(Schema.Number), + limit: Schema.optional(Schema.Record(Schema.String, Schema.String)), + remaining: Schema.optional(Schema.Record(Schema.String, Schema.String)), + reset: Schema.optional(Schema.Record(Schema.String, Schema.String)), +}) {} + +export class HttpContext extends Schema.Class("LLM.HttpContext")({ + request: HttpRequestDetails, + response: Schema.optional(HttpResponseDetails), + body: Schema.optional(Schema.String), + bodyTruncated: Schema.optional(Schema.Boolean), + requestId: Schema.optional(Schema.String), + rateLimit: Schema.optional(HttpRateLimitDetails), +}) {} + +export class InvalidRequestReason extends Schema.Class("LLM.Error.InvalidRequest")({ + _tag: Schema.tag("InvalidRequest"), + message: Schema.String, + parameter: Schema.optional(Schema.String), + providerMetadata: Schema.optional(ProviderMetadata), + http: Schema.optional(HttpContext), +}) { + get retryable() { + return false + } +} + +export class NoRouteReason extends Schema.Class("LLM.Error.NoRoute")({ + _tag: Schema.tag("NoRoute"), + route: RouteID, + provider: ProviderID, + model: ModelID, +}) { + get retryable() { + return false + } + + get message() { + return `No LLM route for ${this.provider}/${this.model} using ${this.route}` + } +} + +export class AuthenticationReason extends Schema.Class("LLM.Error.Authentication")({ + _tag: Schema.tag("Authentication"), + message: Schema.String, + kind: Schema.Literals(["missing", "invalid", "expired", "insufficient-permissions", "unknown"]), + providerMetadata: Schema.optional(ProviderMetadata), + http: Schema.optional(HttpContext), +}) { + get retryable() { + return false + } +} + +export class RateLimitReason extends Schema.Class("LLM.Error.RateLimit")({ + _tag: Schema.tag("RateLimit"), + message: Schema.String, + retryAfterMs: Schema.optional(Schema.Number), + rateLimit: Schema.optional(HttpRateLimitDetails), + providerMetadata: Schema.optional(ProviderMetadata), + http: Schema.optional(HttpContext), +}) { + get retryable() { + return true + } +} + +export class QuotaExceededReason extends Schema.Class("LLM.Error.QuotaExceeded")({ + _tag: Schema.tag("QuotaExceeded"), + message: Schema.String, + providerMetadata: Schema.optional(ProviderMetadata), + http: Schema.optional(HttpContext), +}) { + get retryable() { + return false + } +} + +export class ContentPolicyReason extends Schema.Class("LLM.Error.ContentPolicy")({ + _tag: Schema.tag("ContentPolicy"), + message: Schema.String, + providerMetadata: Schema.optional(ProviderMetadata), + http: Schema.optional(HttpContext), +}) { + get retryable() { + return false + } +} + +export class ProviderInternalReason extends Schema.Class("LLM.Error.ProviderInternal")({ + _tag: Schema.tag("ProviderInternal"), + message: Schema.String, + status: Schema.Number, + retryAfterMs: Schema.optional(Schema.Number), + providerMetadata: Schema.optional(ProviderMetadata), + http: Schema.optional(HttpContext), +}) { + get retryable() { + return true + } +} + +export class TransportReason extends Schema.Class("LLM.Error.Transport")({ + _tag: Schema.tag("Transport"), + message: Schema.String, + kind: Schema.optional(Schema.String), + url: Schema.optional(Schema.String), + http: Schema.optional(HttpContext), +}) { + get retryable() { + return false + } +} + +export class InvalidProviderOutputReason extends Schema.Class( + "LLM.Error.InvalidProviderOutput", +)({ + _tag: Schema.tag("InvalidProviderOutput"), + message: Schema.String, + route: Schema.optional(Schema.String), + raw: Schema.optional(Schema.String), + providerMetadata: Schema.optional(ProviderMetadata), +}) { + get retryable() { + return false + } +} + +export class UnknownProviderReason extends Schema.Class("LLM.Error.UnknownProvider")({ + _tag: Schema.tag("UnknownProvider"), + message: Schema.String, + status: Schema.optional(Schema.Number), + providerMetadata: Schema.optional(ProviderMetadata), + http: Schema.optional(HttpContext), +}) { + get retryable() { + return false + } +} + +export const LLMErrorReason = Schema.Union([ + InvalidRequestReason, + NoRouteReason, + AuthenticationReason, + RateLimitReason, + QuotaExceededReason, + ContentPolicyReason, + ProviderInternalReason, + TransportReason, + InvalidProviderOutputReason, + UnknownProviderReason, +]).pipe(Schema.toTaggedUnion("_tag")) +export type LLMErrorReason = Schema.Schema.Type + +export class LLMError extends Schema.TaggedErrorClass()("LLM.Error", { + module: Schema.String, + method: Schema.String, + reason: LLMErrorReason, +}) { + override readonly cause = this.reason + + get retryable() { + return this.reason.retryable + } + + get retryAfterMs() { + return "retryAfterMs" in this.reason ? this.reason.retryAfterMs : undefined + } + + override get message() { + return `${this.module}.${this.method}: ${this.reason.message}` + } +} + +/** + * Failure type for tool execute handlers. Handlers must map their internal + * errors to this shape; the runtime catches `ToolFailure`s and surfaces them + * as `tool-error` events plus a `tool-result` of `type: "error"` so the model + * can self-correct. + * + * Anything thrown or yielded by a handler that is not a `ToolFailure` is + * treated as a defect and fails the stream. + */ +export class ToolFailure extends Schema.TaggedErrorClass()("LLM.ToolFailure", { + message: Schema.String, + metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), +}) {} diff --git a/packages/llm/src/schema/events.ts b/packages/llm/src/schema/events.ts new file mode 100644 index 0000000000..2fa69370f4 --- /dev/null +++ b/packages/llm/src/schema/events.ts @@ -0,0 +1,237 @@ +import { Schema } from "effect" +import { FinishReason, ProtocolID, ProviderMetadata, RouteID } from "./ids" +import { ModelRef } from "./options" +import { ToolResultValue } from "./messages" + +export class Usage extends Schema.Class("LLM.Usage")({ + inputTokens: Schema.optional(Schema.Number), + outputTokens: Schema.optional(Schema.Number), + reasoningTokens: Schema.optional(Schema.Number), + cacheReadInputTokens: Schema.optional(Schema.Number), + cacheWriteInputTokens: Schema.optional(Schema.Number), + totalTokens: Schema.optional(Schema.Number), + native: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), +}) {} + +export const RequestStart = Schema.Struct({ + type: Schema.Literal("request-start"), + id: Schema.String, + model: ModelRef, +}).annotate({ identifier: "LLM.Event.RequestStart" }) +export type RequestStart = Schema.Schema.Type + +export const StepStart = Schema.Struct({ + type: Schema.Literal("step-start"), + index: Schema.Number, +}).annotate({ identifier: "LLM.Event.StepStart" }) +export type StepStart = Schema.Schema.Type + +export const TextStart = Schema.Struct({ + type: Schema.Literal("text-start"), + id: Schema.String, + providerMetadata: Schema.optional(ProviderMetadata), +}).annotate({ identifier: "LLM.Event.TextStart" }) +export type TextStart = Schema.Schema.Type + +export const TextDelta = Schema.Struct({ + type: Schema.Literal("text-delta"), + id: Schema.optional(Schema.String), + text: Schema.String, + providerMetadata: Schema.optional(ProviderMetadata), +}).annotate({ identifier: "LLM.Event.TextDelta" }) +export type TextDelta = Schema.Schema.Type + +export const TextEnd = Schema.Struct({ + type: Schema.Literal("text-end"), + id: Schema.String, + providerMetadata: Schema.optional(ProviderMetadata), +}).annotate({ identifier: "LLM.Event.TextEnd" }) +export type TextEnd = Schema.Schema.Type + +export const ReasoningDelta = Schema.Struct({ + type: Schema.Literal("reasoning-delta"), + id: Schema.optional(Schema.String), + text: Schema.String, + providerMetadata: Schema.optional(ProviderMetadata), +}).annotate({ identifier: "LLM.Event.ReasoningDelta" }) +export type ReasoningDelta = Schema.Schema.Type + +export const ToolInputDelta = Schema.Struct({ + type: Schema.Literal("tool-input-delta"), + id: Schema.String, + name: Schema.String, + text: Schema.String, + providerMetadata: Schema.optional(ProviderMetadata), +}).annotate({ identifier: "LLM.Event.ToolInputDelta" }) +export type ToolInputDelta = Schema.Schema.Type + +export const ToolCall = Schema.Struct({ + type: Schema.Literal("tool-call"), + id: Schema.String, + name: Schema.String, + input: Schema.Unknown, + providerExecuted: Schema.optional(Schema.Boolean), + providerMetadata: Schema.optional(ProviderMetadata), +}).annotate({ identifier: "LLM.Event.ToolCall" }) +export type ToolCall = Schema.Schema.Type + +export const ToolResult = Schema.Struct({ + type: Schema.Literal("tool-result"), + id: Schema.String, + name: Schema.String, + result: ToolResultValue, + providerExecuted: Schema.optional(Schema.Boolean), + providerMetadata: Schema.optional(ProviderMetadata), +}).annotate({ identifier: "LLM.Event.ToolResult" }) +export type ToolResult = Schema.Schema.Type + +export const ToolError = Schema.Struct({ + type: Schema.Literal("tool-error"), + id: Schema.String, + name: Schema.String, + message: Schema.String, + providerMetadata: Schema.optional(ProviderMetadata), +}).annotate({ identifier: "LLM.Event.ToolError" }) +export type ToolError = Schema.Schema.Type + +export const StepFinish = Schema.Struct({ + type: Schema.Literal("step-finish"), + index: Schema.Number, + reason: FinishReason, + usage: Schema.optional(Usage), + providerMetadata: Schema.optional(ProviderMetadata), +}).annotate({ identifier: "LLM.Event.StepFinish" }) +export type StepFinish = Schema.Schema.Type + +export const RequestFinish = Schema.Struct({ + type: Schema.Literal("request-finish"), + reason: FinishReason, + usage: Schema.optional(Usage), + providerMetadata: Schema.optional(ProviderMetadata), +}).annotate({ identifier: "LLM.Event.RequestFinish" }) +export type RequestFinish = Schema.Schema.Type + +export const ProviderErrorEvent = Schema.Struct({ + type: Schema.Literal("provider-error"), + message: Schema.String, + retryable: Schema.optional(Schema.Boolean), + providerMetadata: Schema.optional(ProviderMetadata), +}).annotate({ identifier: "LLM.Event.ProviderError" }) +export type ProviderErrorEvent = Schema.Schema.Type + +const llmEventTagged = Schema.Union([ + RequestStart, + StepStart, + TextStart, + TextDelta, + TextEnd, + ReasoningDelta, + ToolInputDelta, + ToolCall, + ToolResult, + ToolError, + StepFinish, + RequestFinish, + ProviderErrorEvent, +]).pipe(Schema.toTaggedUnion("type")) + +/** + * camelCase aliases for `LLMEvent.guards` (provided by `Schema.toTaggedUnion`). + * Lets consumers write `events.filter(LLMEvent.is.toolCall)` instead of + * `events.filter(LLMEvent.guards["tool-call"])`. + */ +export const LLMEvent = Object.assign(llmEventTagged, { + is: { + requestStart: llmEventTagged.guards["request-start"], + stepStart: llmEventTagged.guards["step-start"], + textStart: llmEventTagged.guards["text-start"], + textDelta: llmEventTagged.guards["text-delta"], + textEnd: llmEventTagged.guards["text-end"], + reasoningDelta: llmEventTagged.guards["reasoning-delta"], + toolInputDelta: llmEventTagged.guards["tool-input-delta"], + toolCall: llmEventTagged.guards["tool-call"], + toolResult: llmEventTagged.guards["tool-result"], + toolError: llmEventTagged.guards["tool-error"], + stepFinish: llmEventTagged.guards["step-finish"], + requestFinish: llmEventTagged.guards["request-finish"], + providerError: llmEventTagged.guards["provider-error"], + }, +}) +export type LLMEvent = Schema.Schema.Type + +export class PreparedRequest extends Schema.Class("LLM.PreparedRequest")({ + id: Schema.String, + route: RouteID, + protocol: ProtocolID, + model: ModelRef, + body: Schema.Unknown, + metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), +}) {} + +/** + * A `PreparedRequest` whose `body` is typed as `Body`. Use with the generic + * on `LLMClient.prepare(...)` when the caller knows which route their + * request will resolve to and wants its native shape statically exposed + * (debug UIs, request previews, plan rendering). + * + * The runtime body is identical — the route still emits `body: unknown` — so + * this is a type-level assertion the caller makes about what they expect to + * find. The prepare runtime does not validate the assertion. + */ +export type PreparedRequestOf = Omit & { + readonly body: Body +} + +const responseText = (events: ReadonlyArray) => + events + .filter(LLMEvent.is.textDelta) + .map((event) => event.text) + .join("") + +const responseReasoning = (events: ReadonlyArray) => + events + .filter(LLMEvent.is.reasoningDelta) + .map((event) => event.text) + .join("") + +const responseUsage = (events: ReadonlyArray) => + events.reduce( + (usage, event) => ("usage" in event && event.usage !== undefined ? event.usage : usage), + undefined, + ) + +export class LLMResponse extends Schema.Class("LLM.Response")({ + events: Schema.Array(LLMEvent), + usage: Schema.optional(Usage), +}) { + /** Concatenated assistant text assembled from streamed `text-delta` events. */ + get text() { + return responseText(this.events) + } + + /** Concatenated reasoning text assembled from streamed `reasoning-delta` events. */ + get reasoning() { + return responseReasoning(this.events) + } + + /** Completed tool calls emitted by the provider. */ + get toolCalls() { + return this.events.filter(LLMEvent.is.toolCall) + } +} + +export namespace LLMResponse { + export type Output = LLMResponse | { readonly events: ReadonlyArray; readonly usage?: Usage } + + /** Concatenate assistant text from a response or collected event list. */ + export const text = (response: Output) => responseText(response.events) + + /** Return response usage, falling back to the latest usage-bearing event. */ + export const usage = (response: Output) => response.usage ?? responseUsage(response.events) + + /** Return completed tool calls from a response or collected event list. */ + export const toolCalls = (response: Output) => response.events.filter(LLMEvent.is.toolCall) + + /** Concatenate reasoning text from a response or collected event list. */ + export const reasoning = (response: Output) => responseReasoning(response.events) +} diff --git a/packages/llm/src/schema/ids.ts b/packages/llm/src/schema/ids.ts new file mode 100644 index 0000000000..9261842770 --- /dev/null +++ b/packages/llm/src/schema/ids.ts @@ -0,0 +1,34 @@ +import { Schema } from "effect" + +/** Stable string identifier for a protocol implementation. */ +export const ProtocolID = Schema.String +export type ProtocolID = Schema.Schema.Type + +/** Stable string identifier for the runnable route. */ +export const RouteID = Schema.String +export type RouteID = Schema.Schema.Type + +export const ModelID = Schema.String.pipe(Schema.brand("LLM.ModelID")) +export type ModelID = typeof ModelID.Type + +export const ProviderID = Schema.String.pipe(Schema.brand("LLM.ProviderID")) +export type ProviderID = typeof ProviderID.Type + +export const ReasoningEfforts = ["none", "minimal", "low", "medium", "high", "xhigh", "max"] as const +export const ReasoningEffort = Schema.Literals(ReasoningEfforts) +export type ReasoningEffort = Schema.Schema.Type + +export const TextVerbosity = Schema.Literals(["low", "medium", "high"]) +export type TextVerbosity = Schema.Schema.Type + +export const MessageRole = Schema.Literals(["user", "assistant", "tool"]) +export type MessageRole = Schema.Schema.Type + +export const FinishReason = Schema.Literals(["stop", "length", "tool-calls", "content-filter", "error", "unknown"]) +export type FinishReason = Schema.Schema.Type + +export const JsonSchema = Schema.Record(Schema.String, Schema.Unknown) +export type JsonSchema = Schema.Schema.Type + +export const ProviderMetadata = Schema.Record(Schema.String, Schema.Record(Schema.String, Schema.Unknown)) +export type ProviderMetadata = Schema.Schema.Type diff --git a/packages/llm/src/schema/index.ts b/packages/llm/src/schema/index.ts new file mode 100644 index 0000000000..0c0fede8fa --- /dev/null +++ b/packages/llm/src/schema/index.ts @@ -0,0 +1,5 @@ +export * from "./ids" +export * from "./options" +export * from "./messages" +export * from "./events" +export * from "./errors" diff --git a/packages/llm/src/schema/messages.ts b/packages/llm/src/schema/messages.ts new file mode 100644 index 0000000000..3daf00bbc0 --- /dev/null +++ b/packages/llm/src/schema/messages.ts @@ -0,0 +1,234 @@ +import { Schema } from "effect" +import { JsonSchema, MessageRole, ProviderMetadata } from "./ids" +import { CacheHint, GenerationOptions, HttpOptions, ModelRef, ProviderOptions } from "./options" + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value) + +const systemPartSchema = Schema.Struct({ + type: Schema.Literal("text"), + text: Schema.String, + cache: Schema.optional(CacheHint), + metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), +}).annotate({ identifier: "LLM.SystemPart" }) +export type SystemPart = Schema.Schema.Type + +const makeSystemPart = (text: string): SystemPart => ({ type: "text", text }) + +export const SystemPart = Object.assign(systemPartSchema, { + make: makeSystemPart, + content: (input?: string | SystemPart | ReadonlyArray) => { + if (input === undefined) return [] + return typeof input === "string" ? [makeSystemPart(input)] : Array.isArray(input) ? [...input] : [input] + }, +}) + +export const TextPart = Schema.Struct({ + type: Schema.Literal("text"), + text: Schema.String, + cache: Schema.optional(CacheHint), + metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), + providerMetadata: Schema.optional(ProviderMetadata), +}).annotate({ identifier: "LLM.Content.Text" }) +export type TextPart = Schema.Schema.Type + +export const MediaPart = Schema.Struct({ + type: Schema.Literal("media"), + mediaType: Schema.String, + data: Schema.Union([Schema.String, Schema.Uint8Array]), + filename: Schema.optional(Schema.String), + metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), +}).annotate({ identifier: "LLM.Content.Media" }) +export type MediaPart = Schema.Schema.Type + +const isToolResultValue = (value: unknown): value is ToolResultValue => + isRecord(value) && (value.type === "text" || value.type === "json" || value.type === "error") && "value" in value + +export const ToolResultValue = Object.assign( + Schema.Struct({ + type: Schema.Literals(["json", "text", "error"]), + value: Schema.Unknown, + }).annotate({ identifier: "LLM.ToolResult" }), + { + make: (value: unknown, type: ToolResultValue["type"] = "json"): ToolResultValue => + isToolResultValue(value) ? value : { type, value }, + }, +) +export type ToolResultValue = Schema.Schema.Type + +export const ToolCallPart = Object.assign( + Schema.Struct({ + type: Schema.Literal("tool-call"), + id: Schema.String, + name: Schema.String, + input: Schema.Unknown, + providerExecuted: Schema.optional(Schema.Boolean), + metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), + providerMetadata: Schema.optional(ProviderMetadata), + }).annotate({ identifier: "LLM.Content.ToolCall" }), + { + make: (input: Omit): ToolCallPart => ({ type: "tool-call", ...input }), + }, +) +export type ToolCallPart = Schema.Schema.Type + +export const ToolResultPart = Object.assign( + Schema.Struct({ + type: Schema.Literal("tool-result"), + id: Schema.String, + name: Schema.String, + result: ToolResultValue, + providerExecuted: Schema.optional(Schema.Boolean), + metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), + providerMetadata: Schema.optional(ProviderMetadata), + }).annotate({ identifier: "LLM.Content.ToolResult" }), + { + make: ( + input: Omit & { + readonly result: unknown + readonly resultType?: ToolResultValue["type"] + }, + ): ToolResultPart => ({ + type: "tool-result", + id: input.id, + name: input.name, + result: ToolResultValue.make(input.result, input.resultType), + providerExecuted: input.providerExecuted, + metadata: input.metadata, + providerMetadata: input.providerMetadata, + }), + }, +) +export type ToolResultPart = Schema.Schema.Type + +export const ReasoningPart = Schema.Struct({ + type: Schema.Literal("reasoning"), + text: Schema.String, + encrypted: Schema.optional(Schema.String), + metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), + providerMetadata: Schema.optional(ProviderMetadata), +}).annotate({ identifier: "LLM.Content.Reasoning" }) +export type ReasoningPart = Schema.Schema.Type + +export const ContentPart = Schema.Union([TextPart, MediaPart, ToolCallPart, ToolResultPart, ReasoningPart]).pipe( + Schema.toTaggedUnion("type"), +) +export type ContentPart = Schema.Schema.Type + +export class Message extends Schema.Class("LLM.Message")({ + id: Schema.optional(Schema.String), + role: MessageRole, + content: Schema.Array(ContentPart), + metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), + native: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), +}) {} + +export namespace Message { + export type ContentInput = string | ContentPart | ReadonlyArray + export type Input = Omit[0], "content"> & { + readonly content: ContentInput + } + + export const text = (value: string): ContentPart => ({ type: "text", text: value }) + + export const content = (input: ContentInput) => + typeof input === "string" ? [text(input)] : Array.isArray(input) ? [...input] : [input] + + export const make = (input: Message | Input) => { + if (input instanceof Message) return input + return new Message({ ...input, content: content(input.content) }) + } + + export const user = (content: ContentInput) => make({ role: "user", content }) + + export const assistant = (content: ContentInput) => make({ role: "assistant", content }) + + export const tool = (result: ToolResultPart | Parameters[0]) => + make({ role: "tool", content: ["type" in result ? result : ToolResultPart.make(result)] }) +} + +export class ToolDefinition extends Schema.Class("LLM.ToolDefinition")({ + name: Schema.String, + description: Schema.String, + inputSchema: JsonSchema, + metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), + native: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), +}) {} + +export namespace ToolDefinition { + export type Input = ToolDefinition | ConstructorParameters[0] + + /** Normalize tool definition input into the canonical `ToolDefinition` class. */ + export const make = (input: Input) => (input instanceof ToolDefinition ? input : new ToolDefinition(input)) +} + +export class ToolChoice extends Schema.Class("LLM.ToolChoice")({ + type: Schema.Literals(["auto", "none", "required", "tool"]), + name: Schema.optional(Schema.String), +}) {} + +export namespace ToolChoice { + export type Mode = Exclude + export type Input = ToolChoice | ConstructorParameters[0] | ToolDefinition | string + + const isMode = (value: string): value is Mode => value === "auto" || value === "none" || value === "required" + + /** Select a specific named tool. */ + export const named = (value: string) => new ToolChoice({ type: "tool", name: value }) + + /** Normalize ergonomic tool-choice inputs into the canonical `ToolChoice` class. */ + export const make = (input: Input) => { + if (input instanceof ToolChoice) return input + if (input instanceof ToolDefinition) return named(input.name) + if (typeof input === "string") return isMode(input) ? new ToolChoice({ type: input }) : named(input) + return new ToolChoice(input) + } +} + +export const ResponseFormat = Schema.Union([ + Schema.Struct({ type: Schema.Literal("text") }), + Schema.Struct({ type: Schema.Literal("json"), schema: JsonSchema }), + Schema.Struct({ type: Schema.Literal("tool"), tool: ToolDefinition }), +]).pipe(Schema.toTaggedUnion("type")) +export type ResponseFormat = Schema.Schema.Type + +export class LLMRequest extends Schema.Class("LLM.Request")({ + id: Schema.optional(Schema.String), + model: ModelRef, + system: Schema.Array(SystemPart), + messages: Schema.Array(Message), + tools: Schema.Array(ToolDefinition), + toolChoice: Schema.optional(ToolChoice), + generation: Schema.optional(GenerationOptions), + providerOptions: Schema.optional(ProviderOptions), + http: Schema.optional(HttpOptions), + responseFormat: Schema.optional(ResponseFormat), + metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), +}) {} + +export namespace LLMRequest { + export type Input = ConstructorParameters[0] + + export const input = (request: LLMRequest): Input => ({ + id: request.id, + model: request.model, + system: request.system, + messages: request.messages, + tools: request.tools, + toolChoice: request.toolChoice, + generation: request.generation, + providerOptions: request.providerOptions, + http: request.http, + responseFormat: request.responseFormat, + metadata: request.metadata, + }) + + export const update = (request: LLMRequest, patch: Partial) => { + if (Object.keys(patch).length === 0) return request + return new LLMRequest({ + ...input(request), + ...patch, + model: patch.model ?? request.model, + }) + } +} diff --git a/packages/llm/src/schema/options.ts b/packages/llm/src/schema/options.ts new file mode 100644 index 0000000000..9a618aa8ae --- /dev/null +++ b/packages/llm/src/schema/options.ts @@ -0,0 +1,202 @@ +import { Schema } from "effect" +import { JsonSchema, ModelID, ProviderID, RouteID } from "./ids" + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value) + +export const mergeJsonRecords = ( + ...items: ReadonlyArray | undefined> +): Record | undefined => { + const defined = items.filter((item): item is Record => item !== undefined) + if (defined.length === 0) return undefined + if (defined.length === 1 && Object.values(defined[0]).every((value) => value !== undefined)) return defined[0] + const result: Record = {} + for (const item of defined) { + for (const [key, value] of Object.entries(item)) { + if (value === undefined) continue + result[key] = isRecord(result[key]) && isRecord(value) ? mergeJsonRecords(result[key], value) : value + } + } + return Object.keys(result).length === 0 ? undefined : result +} + +const mergeStringRecords = ( + ...items: ReadonlyArray | undefined> +): Record | undefined => { + const defined = items.filter((item): item is Record => item !== undefined) + if (defined.length === 0) return undefined + if (defined.length === 1) return defined[0] + const result = Object.fromEntries( + defined.flatMap((item) => + Object.entries(item).filter((entry): entry is [string, string] => entry[1] !== undefined), + ), + ) + return Object.keys(result).length === 0 ? undefined : result +} + +export const ProviderOptions = Schema.Record(Schema.String, Schema.Record(Schema.String, Schema.Unknown)) +export type ProviderOptions = Schema.Schema.Type + +export const mergeProviderOptions = ( + ...items: ReadonlyArray +): ProviderOptions | undefined => { + const result: Record> = {} + for (const item of items) { + if (!item) continue + for (const [provider, options] of Object.entries(item)) { + const merged = mergeJsonRecords(result[provider], options) + if (merged) result[provider] = merged + } + } + return Object.keys(result).length === 0 ? undefined : result +} + +export class HttpOptions extends Schema.Class("LLM.HttpOptions")({ + body: Schema.optional(JsonSchema), + headers: Schema.optional(Schema.Record(Schema.String, Schema.String)), + query: Schema.optional(Schema.Record(Schema.String, Schema.String)), +}) {} + +export namespace HttpOptions { + export type Input = HttpOptions | ConstructorParameters[0] + + /** Normalize HTTP option input into the canonical `HttpOptions` class. */ + export const make = (input: Input) => (input instanceof HttpOptions ? input : new HttpOptions(input)) +} + +export const mergeHttpOptions = (...items: ReadonlyArray): HttpOptions | undefined => { + const body = mergeJsonRecords(...items.map((item) => item?.body)) + const headers = mergeStringRecords(...items.map((item) => item?.headers)) + const query = mergeStringRecords(...items.map((item) => item?.query)) + if (!body && !headers && !query) return undefined + return new HttpOptions({ body, headers, query }) +} + +export class GenerationOptions extends Schema.Class("LLM.GenerationOptions")({ + maxTokens: Schema.optional(Schema.Number), + temperature: Schema.optional(Schema.Number), + topP: Schema.optional(Schema.Number), + topK: Schema.optional(Schema.Number), + frequencyPenalty: Schema.optional(Schema.Number), + presencePenalty: Schema.optional(Schema.Number), + seed: Schema.optional(Schema.Number), + stop: Schema.optional(Schema.Array(Schema.String)), +}) {} + +export namespace GenerationOptions { + export type Input = GenerationOptions | ConstructorParameters[0] + + /** Normalize generation option input into the canonical `GenerationOptions` class. */ + export const make = (input: Input = {}) => (input instanceof GenerationOptions ? input : new GenerationOptions(input)) +} + +export type GenerationOptionsFields = { + readonly maxTokens?: number + readonly temperature?: number + readonly topP?: number + readonly topK?: number + readonly frequencyPenalty?: number + readonly presencePenalty?: number + readonly seed?: number + readonly stop?: ReadonlyArray +} + +export type GenerationOptionsInput = GenerationOptions | GenerationOptionsFields + +const latestGeneration = ( + items: ReadonlyArray, + key: Key, +) => items.findLast((item) => item?.[key] !== undefined)?.[key] + +export const mergeGenerationOptions = (...items: ReadonlyArray) => { + const result = new GenerationOptions({ + maxTokens: latestGeneration(items, "maxTokens"), + temperature: latestGeneration(items, "temperature"), + topP: latestGeneration(items, "topP"), + topK: latestGeneration(items, "topK"), + frequencyPenalty: latestGeneration(items, "frequencyPenalty"), + presencePenalty: latestGeneration(items, "presencePenalty"), + seed: latestGeneration(items, "seed"), + stop: latestGeneration(items, "stop"), + }) + return Object.values(result).some((value) => value !== undefined) ? result : undefined +} + +export class ModelLimits extends Schema.Class("LLM.ModelLimits")({ + context: Schema.optional(Schema.Number), + output: Schema.optional(Schema.Number), +}) {} + +export namespace ModelLimits { + export type Input = ModelLimits | ConstructorParameters[0] + + /** Normalize model limit input into the canonical `ModelLimits` class. */ + export const make = (input: Input | undefined) => + input instanceof ModelLimits ? input : new ModelLimits(input ?? {}) +} + +export class ModelRef extends Schema.Class("LLM.ModelRef")({ + id: ModelID, + provider: ProviderID, + route: RouteID, + baseURL: Schema.String, + /** Provider-specific API key convenience. Provider helpers normalize this into `auth`. */ + apiKey: Schema.optional(Schema.String), + /** Optional transport auth policy. Opaque because it may contain functions. */ + auth: Schema.optional(Schema.Any), + headers: Schema.optional(Schema.Record(Schema.String, Schema.String)), + /** + * Query params appended to the request URL by `Endpoint.baseURL`. Used for + * deployment-level URL-scoped settings such as Azure's `api-version` or any + * provider that requires a per-request key in the URL. Generic concern, so + * lives as a typed first-class field instead of `native`. + */ + queryParams: Schema.optional(Schema.Record(Schema.String, Schema.String)), + limits: ModelLimits, + /** Provider-neutral generation defaults. Request-level values override them. */ + generation: Schema.optional(GenerationOptions), + /** Provider-owned typed-at-the-facade options for non-portable knobs. */ + providerOptions: Schema.optional(ProviderOptions), + /** Serializable raw HTTP overlays applied to the final outgoing request. */ + http: Schema.optional(HttpOptions), + /** + * Provider-specific opaque options. Reach for this only when the value is + * genuinely provider-private and does not fit a typed axis (e.g. Bedrock's + * `aws_credentials` / `aws_region` for SigV4). Anything used by more than + * one route should grow into a typed field instead. + */ + native: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), +}) {} + +export namespace ModelRef { + export type Input = ConstructorParameters[0] + + export const input = (model: ModelRef): Input => ({ + id: model.id, + provider: model.provider, + route: model.route, + baseURL: model.baseURL, + apiKey: model.apiKey, + auth: model.auth, + headers: model.headers, + queryParams: model.queryParams, + limits: model.limits, + generation: model.generation, + providerOptions: model.providerOptions, + http: model.http, + native: model.native, + }) + + export const update = (model: ModelRef, patch: Partial) => { + if (Object.keys(patch).length === 0) return model + return new ModelRef({ + ...input(model), + ...patch, + }) + } +} + +export class CacheHint extends Schema.Class("LLM.CacheHint")({ + type: Schema.Literals(["ephemeral", "persistent"]), + ttlSeconds: Schema.optional(Schema.Number), +}) {} diff --git a/packages/llm/src/tool-runtime.ts b/packages/llm/src/tool-runtime.ts new file mode 100644 index 0000000000..20e27379bd --- /dev/null +++ b/packages/llm/src/tool-runtime.ts @@ -0,0 +1,240 @@ +import { Effect, Stream } from "effect" +import type { Concurrency } from "effect/Types" +import { + type ContentPart, + type FinishReason, + type LLMError, + type LLMEvent, + LLMRequest, + Message, + type ProviderMetadata, + ToolCallPart, + ToolFailure, + ToolResultPart, + type ToolResultValue, +} from "./schema" +import { type AnyTool, type ExecutableTools, type Tools, toDefinitions } from "./tool" + +export interface RuntimeState { + readonly step: number + readonly request: LLMRequest +} + +export type StopCondition = (state: RuntimeState) => boolean + +export type ToolExecution = "auto" | "none" + +interface RunOptionsBase { + readonly request: LLMRequest + readonly concurrency?: Concurrency + readonly stopWhen?: StopCondition +} + +export type RunOptions = RunOptionsAuto | RunOptionsNone + +export interface RunOptionsAuto extends RunOptionsBase { + readonly request: LLMRequest + readonly tools: T + readonly toolExecution?: "auto" +} + +export interface RunOptionsNone extends RunOptionsBase { + readonly request: LLMRequest + readonly tools: T + /** Advertise tool schemas but leave model-emitted tool calls for the caller. */ + readonly toolExecution: "none" +} + +export type StreamOptions = RunOptions & { + readonly stream: (request: LLMRequest) => Stream.Stream +} + +export const stepCountIs = + (count: number): StopCondition => + (state) => + state.step + 1 >= count + +/** + * Run a model with typed tools. This helper owns tool orchestration, while the + * caller supplies the actual model stream function. It can advertise schemas + * only (`toolExecution: "none"`), execute one step, or continue model rounds + * when `stopWhen` is provided. + */ +export const stream = (options: StreamOptions): Stream.Stream => { + const concurrency = options.concurrency ?? 10 + const tools = options.tools as Tools + const runtimeTools = toDefinitions(tools) + const runtimeToolNames = new Set(runtimeTools.map((tool) => tool.name)) + const initialRequest = + runtimeTools.length === 0 + ? options.request + : LLMRequest.update(options.request, { + tools: [...options.request.tools.filter((tool) => !runtimeToolNames.has(tool.name)), ...runtimeTools], + }) + + const loop = (request: LLMRequest, step: number): Stream.Stream => + Stream.unwrap( + Effect.gen(function* () { + const state: StepState = { assistantContent: [], toolCalls: [], finishReason: undefined } + + const modelStream = options + .stream(request) + .pipe(Stream.tap((event) => Effect.sync(() => accumulate(state, event)))) + + const continuation = Stream.unwrap( + Effect.gen(function* () { + if (state.finishReason !== "tool-calls" || state.toolCalls.length === 0) return Stream.empty + if (options.toolExecution === "none") return Stream.empty + + const dispatched = yield* Effect.forEach( + state.toolCalls, + (call) => dispatch(tools, call).pipe(Effect.map((result) => [call, result] as const)), + { concurrency }, + ) + const resultStream = Stream.fromIterable(dispatched.flatMap(([call, result]) => emitEvents(call, result))) + + if (!options.stopWhen) return resultStream + if (options.stopWhen({ step, request })) return resultStream + + return resultStream.pipe(Stream.concat(loop(followUpRequest(request, state, dispatched), step + 1))) + }), + ) + + return modelStream.pipe(Stream.concat(continuation)) + }), + ) + + return loop(initialRequest, 0) +} + +interface StepState { + assistantContent: ContentPart[] + toolCalls: ToolCallPart[] + finishReason: FinishReason | undefined +} + +const accumulate = (state: StepState, event: LLMEvent) => { + if (event.type === "text-delta") { + appendStreamingText(state, "text", event.text, event.providerMetadata) + return + } + if (event.type === "reasoning-delta") { + appendStreamingText(state, "reasoning", event.text, event.providerMetadata) + return + } + if (event.type === "tool-call") { + const part = ToolCallPart.make({ + id: event.id, + name: event.name, + input: event.input, + providerExecuted: event.providerExecuted, + providerMetadata: event.providerMetadata, + }) + state.assistantContent.push(part) + if (!event.providerExecuted) state.toolCalls.push(part) + return + } + if (event.type === "tool-result" && event.providerExecuted) { + state.assistantContent.push( + ToolResultPart.make({ + id: event.id, + name: event.name, + result: event.result, + providerExecuted: true, + providerMetadata: event.providerMetadata, + }), + ) + return + } + if (event.type === "request-finish") { + state.finishReason = event.reason + } +} + +const sameProviderMetadata = (left: ProviderMetadata | undefined, right: ProviderMetadata | undefined) => + left === right || JSON.stringify(left) === JSON.stringify(right) + +const mergeProviderMetadata = (left: ProviderMetadata | undefined, right: ProviderMetadata | undefined) => { + if (!left) return right + if (!right) return left + return Object.fromEntries( + Array.from(new Set([...Object.keys(left), ...Object.keys(right)])).map((provider) => [ + provider, + { ...left[provider], ...right[provider] }, + ]), + ) +} + +const appendStreamingText = ( + state: StepState, + type: "text" | "reasoning", + text: string, + providerMetadata: ProviderMetadata | undefined, +) => { + const last = state.assistantContent.at(-1) + if (last?.type === type && text.length === 0) { + state.assistantContent[state.assistantContent.length - 1] = { + ...last, + providerMetadata: mergeProviderMetadata(last.providerMetadata, providerMetadata), + } + return + } + if (last?.type === type && sameProviderMetadata(last.providerMetadata, providerMetadata)) { + state.assistantContent[state.assistantContent.length - 1] = { ...last, text: `${last.text}${text}` } + return + } + state.assistantContent.push({ type, text, providerMetadata }) +} + +const dispatch = (tools: Tools, call: ToolCallPart): Effect.Effect => { + const tool = tools[call.name] + if (!tool) return Effect.succeed({ type: "error" as const, value: `Unknown tool: ${call.name}` }) + if (!tool.execute) + return Effect.succeed({ type: "error" as const, value: `Tool has no execute handler: ${call.name}` }) + + return decodeAndExecute(tool, call.input).pipe( + Effect.catchTag("LLM.ToolFailure", (failure) => + Effect.succeed({ type: "error" as const, value: failure.message } satisfies ToolResultValue), + ), + ) +} + +const decodeAndExecute = (tool: AnyTool, input: unknown): Effect.Effect => + tool._decode(input).pipe( + Effect.mapError((error) => new ToolFailure({ message: `Invalid tool input: ${error.message}` })), + Effect.flatMap((decoded) => tool.execute!(decoded)), + Effect.flatMap((value) => + tool._encode(value).pipe( + Effect.mapError( + (error) => + new ToolFailure({ + message: `Tool returned an invalid value for its success schema: ${error.message}`, + }), + ), + ), + ), + Effect.map((encoded): ToolResultValue => ({ type: "json", value: encoded })), + ) + +const emitEvents = (call: ToolCallPart, result: ToolResultValue): ReadonlyArray => + result.type === "error" + ? [ + { type: "tool-error", id: call.id, name: call.name, message: String(result.value) }, + { type: "tool-result", id: call.id, name: call.name, result }, + ] + : [{ type: "tool-result", id: call.id, name: call.name, result }] + +const followUpRequest = ( + request: LLMRequest, + state: StepState, + dispatched: ReadonlyArray, +) => + LLMRequest.update(request, { + messages: [ + ...request.messages, + Message.assistant(state.assistantContent), + ...dispatched.map(([call, result]) => Message.tool({ id: call.id, name: call.name, result })), + ], + }) + +export const ToolRuntime = { stream, stepCountIs } as const diff --git a/packages/llm/src/tool.ts b/packages/llm/src/tool.ts new file mode 100644 index 0000000000..311c8798b6 --- /dev/null +++ b/packages/llm/src/tool.ts @@ -0,0 +1,185 @@ +import { Effect, JsonSchema, Schema } from "effect" +import type { ToolDefinition as ToolDefinitionClass } from "./schema" +import { ToolDefinition, ToolFailure } from "./schema" + +/** + * Schema constraint for tool parameters / success values: no decoding or + * encoding services are allowed. Tools should be self-contained — anything + * beyond pure data conversion belongs in the handler closure. + */ +export type ToolSchema = Schema.Codec + +export type ToolExecute, Success extends ToolSchema> = ( + params: Schema.Schema.Type, +) => Effect.Effect, ToolFailure> + +/** + * A type-safe LLM tool. Each tool bundles its own description, parameter + * Schema and success Schema. The execute handler is optional: omit it when you + * only want to expose a tool schema to the model and handle tool calls outside + * this package. + * + * Errors must be expressed as `ToolFailure`. Unmapped errors and defects fail + * the stream. + * + * Internally each tool also carries memoized codecs and a precomputed + * `ToolDefinition` so the runtime doesn't rebuild them per invocation. + */ +export interface Tool, Success extends ToolSchema> { + readonly description: string + readonly parameters: Parameters + readonly success: Success + readonly execute?: ToolExecute + /** @internal */ + readonly _decode: (input: unknown) => Effect.Effect, Schema.SchemaError> + /** @internal */ + readonly _encode: (value: Schema.Schema.Type) => Effect.Effect + /** @internal */ + readonly _definition: ToolDefinitionClass +} + +export type AnyTool = Tool, ToolSchema> + +export type ExecutableTool, Success extends ToolSchema> = Tool< + Parameters, + Success +> & { + readonly execute: ToolExecute +} + +export type AnyExecutableTool = ExecutableTool, ToolSchema> + +export type ExecutableTools = Record + +type TypedToolConfig = { + readonly description: string + readonly parameters: ToolSchema + readonly success: ToolSchema + readonly execute?: ToolExecute, ToolSchema> +} + +type DynamicToolConfig = { + readonly description: string + readonly jsonSchema: JsonSchema.JsonSchema + readonly execute?: (params: unknown) => Effect.Effect +} + +/** + * Constructs a tool. Two input modes: + * + * 1. **Typed** — pass Effect `parameters` and `success` Schemas; inputs and + * outputs are statically typed and decoded/encoded automatically. + * + * ```ts + * Tool.make({ + * description: "Get current weather", + * parameters: Schema.Struct({ city: Schema.String }), + * success: Schema.Struct({ temperature: Schema.Number }), + * execute: ({ city }) => Effect.succeed({ temperature: 22 }), + * }) + * ``` + * + * 2. **Dynamic** — pass raw JSON Schema as `jsonSchema`. Use this when the + * schema comes from an external source (MCP server, plugin manifest, + * dynamic config) and is not known at compile time. Inputs are typed as + * `unknown`; the handler is responsible for any validation it needs. + * + * ```ts + * Tool.make({ + * description: "Look something up", + * jsonSchema: { type: "object", properties: { ... } }, + * execute: (params) => Effect.succeed(...), + * }) + * ``` + * + * In both modes the produced tool flows through `toDefinitions(...)` and the + * runtime identically. + */ +export function make, Success extends ToolSchema>(config: { + readonly description: string + readonly parameters: Parameters + readonly success: Success + readonly execute: ToolExecute +}): ExecutableTool +export function make, Success extends ToolSchema>(config: { + readonly description: string + readonly parameters: Parameters + readonly success: Success + readonly execute?: undefined +}): Tool +export function make(config: { + readonly description: string + readonly jsonSchema: JsonSchema.JsonSchema + readonly execute: (params: unknown) => Effect.Effect +}): AnyExecutableTool +export function make(config: { + readonly description: string + readonly jsonSchema: JsonSchema.JsonSchema + readonly execute?: undefined +}): AnyTool +export function make(config: TypedToolConfig | DynamicToolConfig): AnyTool { + if ("jsonSchema" in config) { + return { + description: config.description, + parameters: Schema.Unknown as ToolSchema, + success: Schema.Unknown as ToolSchema, + execute: config.execute, + _decode: Effect.succeed, + _encode: Effect.succeed, + _definition: new ToolDefinition({ + name: "", + description: config.description, + inputSchema: config.jsonSchema, + }), + } + } + return { + description: config.description, + parameters: config.parameters, + success: config.success, + execute: config.execute, + _decode: Schema.decodeUnknownEffect(config.parameters), + _encode: Schema.encodeEffect(config.success), + _definition: new ToolDefinition({ + name: "", + description: config.description, + inputSchema: toJsonSchema(config.parameters), + }), + } +} + +export const tool = make + +/** + * A record of named tools. The record key becomes the tool name on the wire. + */ +export type Tools = Record + +/** + * Convert a tools record into the `ToolDefinition[]` shape that + * `LLMRequest.tools` expects. The runtime calls this internally; consumers + * that build `LLMRequest` themselves can use it too. + * + * Tool names come from the record keys, so the per-tool cached + * `_definition` is rebuilt with the correct name here. The JSON Schema body + * is reused. + */ +export const toDefinitions = (tools: Tools): ReadonlyArray => + Object.entries(tools).map( + ([name, item]) => + new ToolDefinition({ + name, + description: item._definition.description, + inputSchema: item._definition.inputSchema, + }), + ) + +const toJsonSchema = (schema: Schema.Top): JsonSchema.JsonSchema => { + const document = Schema.toJsonSchemaDocument(schema) + if (Object.keys(document.definitions).length === 0) return document.schema + return { ...document.schema, $defs: document.definitions } +} + +export { ToolFailure } + +export * as Tool from "./tool" diff --git a/packages/llm/test/adapter.test.ts b/packages/llm/test/adapter.test.ts new file mode 100644 index 0000000000..191b8529c0 --- /dev/null +++ b/packages/llm/test/adapter.test.ts @@ -0,0 +1,175 @@ +import { describe, expect } from "bun:test" +import { Effect, Schema, Stream } from "effect" +import { LLM } from "../src" +import { Route, Endpoint, LLMClient, Protocol, type RouteModelInput, type FramingDef } from "../src/route" +import { ModelRef } from "../src/schema" +import { testEffect } from "./lib/effect" +import { dynamicResponse } from "./lib/http" + +const updateModel = (model: ModelRef, patch: Partial) => ModelRef.update(model, patch) + +const Json = Schema.fromJsonString(Schema.Unknown) +const encodeJson = Schema.encodeSync(Json) + +type FakeBody = { + readonly body: string +} + +const FakeEvent = Schema.Union([ + Schema.Struct({ type: Schema.Literal("text"), text: Schema.String }), + Schema.Struct({ type: Schema.Literal("finish"), reason: Schema.Literal("stop") }), +]) +type FakeEvent = Schema.Schema.Type +const decodeFakeEvents = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Array(FakeEvent))) + +const fakeFraming: FramingDef = { + id: "fake-json-array", + frame: (bytes) => + Stream.fromEffect( + bytes.pipe( + Stream.decodeText(), + Stream.runFold( + () => "", + (text, event) => text + event, + ), + Effect.flatMap(decodeFakeEvents), + Effect.orDie, + ), + ).pipe(Stream.flatMap(Stream.fromIterable)), +} + +const request = LLM.request({ + id: "req_1", + model: LLM.model({ + id: "fake-model", + provider: "fake-provider", + route: "fake", + baseURL: "https://fake.local", + }), + prompt: "hello", +}) + +const raiseEvent = (event: FakeEvent): import("../src/schema").LLMEvent => + event.type === "finish" ? { type: "request-finish", reason: event.reason } : { type: "text-delta", text: event.text } + +const fakeProtocol = Protocol.make({ + id: "fake", + body: { + schema: Schema.Struct({ + body: Schema.String, + }), + from: (request) => + Effect.succeed({ + body: [ + ...request.messages + .flatMap((message) => message.content) + .filter((part) => part.type === "text") + .map((part) => part.text), + ...request.tools.map((tool) => `tool:${tool.name}:${tool.description}`), + ].join("\n"), + }), + }, + stream: { + event: FakeEvent, + initial: () => undefined, + step: (state, event) => Effect.succeed([state, [raiseEvent(event)]] as const), + }, +}) + +const fake = Route.make({ + id: "fake", + protocol: fakeProtocol, + endpoint: Endpoint.path("/chat"), + framing: fakeFraming, +}) + +const gemini = Route.make({ + id: "gemini-fake", + protocol: fakeProtocol, + endpoint: Endpoint.path("/chat"), + framing: fakeFraming, +}) + +const echoLayer = dynamicResponse(({ text, respond }) => + Effect.succeed( + respond( + encodeJson([ + { type: "text", text: `echo:${text}` }, + { type: "finish", reason: "stop" }, + ]), + ), + ), +) + +const it = testEffect(echoLayer) + +describe("llm route", () => { + it.effect("stream and generate use the route pipeline", () => + Effect.gen(function* () { + const llm = yield* LLMClient.Service + const events = Array.from(yield* llm.stream(request).pipe(Stream.runCollect)) + const response = yield* llm.generate(request) + + expect(events.map((event) => event.type)).toEqual(["text-delta", "request-finish"]) + expect(response.events.map((event) => event.type)).toEqual(["text-delta", "request-finish"]) + }), + ) + + it.effect("selects routes by request route", () => + Effect.gen(function* () { + const llm = yield* LLMClient.Service + const prepared = yield* llm.prepare( + LLM.updateRequest(request, { model: updateModel(request.model, { route: "gemini-fake" }) }), + ) + + expect(prepared.route).toBe("gemini-fake") + }), + ) + + it.effect("maps model input before building refs", () => + Effect.gen(function* () { + const mapped = Route.model( + fake, + { provider: "fake-provider", baseURL: "https://fake.local" }, + { + mapInput: (input) => { + const { region, ...rest } = input + return { ...rest, native: { region } } + }, + }, + ) + + expect(mapped({ id: "fake-model", region: "us-east-1" }).native).toEqual({ region: "us-east-1" }) + }), + ) + + it.effect("rejects duplicate route ids", () => + Effect.gen(function* () { + expect(() => + Route.make({ + id: "fake", + protocol: Protocol.make({ + ...fakeProtocol, + body: { + ...fakeProtocol.body, + from: () => Effect.succeed({ body: "late-default" }), + }, + }), + endpoint: Endpoint.path("/chat"), + framing: fakeFraming, + }), + ).toThrow('Duplicate LLM route id "fake"') + }), + ) + + it.effect("rejects missing route", () => + Effect.gen(function* () { + const llm = yield* LLMClient.Service + const error = yield* llm + .prepare(LLM.updateRequest(request, { model: updateModel(request.model, { route: "missing" }) })) + .pipe(Effect.flip) + + expect(error.message).toContain("No LLM route") + }), + ) +}) diff --git a/packages/llm/test/auth-options.types.ts b/packages/llm/test/auth-options.types.ts new file mode 100644 index 0000000000..a44efa2274 --- /dev/null +++ b/packages/llm/test/auth-options.types.ts @@ -0,0 +1,100 @@ +import { Config } from "effect" +import type { Auth } from "../src/route/auth" +import type { ModelFactory } from "../src/route/auth-options" +import { Auth as RuntimeAuth } from "../src/route/auth" +import * as Azure from "../src/providers/azure" +import * as OpenAI from "../src/providers/openai" + +type BaseOptions = { + readonly baseURL?: string + readonly headers?: Record +} + +type Model = { + readonly id: string +} + +declare const auth: Auth +declare const optionalAuthModel: ModelFactory +declare const requiredAuthModel: ModelFactory +const configApiKey = Config.redacted("OPENAI_API_KEY") + +optionalAuthModel("gpt-4.1-mini") +optionalAuthModel("gpt-4.1-mini", {}) +optionalAuthModel("gpt-4.1-mini", { apiKey: "sk-test" }) +optionalAuthModel("gpt-4.1-mini", { apiKey: configApiKey }) +optionalAuthModel("gpt-4.1-mini", { auth }) +optionalAuthModel("gpt-4.1-mini", { auth, baseURL: "https://gateway.example.com/v1" }) +optionalAuthModel("gpt-4.1-mini", { apiKey: "sk-test", headers: { "x-source": "test" } }) + +// @ts-expect-error auth is an override, so apiKey cannot be supplied with it. +optionalAuthModel("gpt-4.1-mini", { apiKey: "sk-test", auth }) + +requiredAuthModel("custom-model", { apiKey: "key" }) +requiredAuthModel("custom-model", { apiKey: configApiKey }) +requiredAuthModel("custom-model", { auth }) +requiredAuthModel("custom-model", { auth, headers: { "x-tenant-id": "tenant" } }) + +// @ts-expect-error providers without config fallback need apiKey or auth. +requiredAuthModel("custom-model") + +// @ts-expect-error providers without config fallback need apiKey or auth. +requiredAuthModel("custom-model", {}) + +// @ts-expect-error auth is an override, so apiKey cannot be supplied with it. +requiredAuthModel("custom-model", { apiKey: "key", auth }) + +OpenAI.responses("gpt-4.1-mini") +OpenAI.responses("gpt-4.1-mini", {}) +OpenAI.responses("gpt-4.1-mini", { apiKey: "sk-test" }) +OpenAI.responses("gpt-4.1-mini", { apiKey: configApiKey }) +OpenAI.responses("gpt-4.1-mini", { auth: RuntimeAuth.bearer("oauth-token") }) +OpenAI.responses("gpt-4.1-mini", { + auth: RuntimeAuth.headers({ authorization: "Bearer gateway" }), + baseURL: "https://gateway.example.com/v1", +}) +OpenAI.responses("gpt-4.1-mini", { + generation: { maxTokens: 100 }, + providerOptions: { openai: { store: false } }, +}) + +// @ts-expect-error apiKey only accepts string, Redacted, or Config>. +OpenAI.responses("gpt-4.1-mini", { apiKey: 123 }) + +// @ts-expect-error provider helpers reject unknown top-level options. +OpenAI.responses("gpt-4.1-mini", { bogus: true }) + +// @ts-expect-error common generation options remain typed. +OpenAI.responses("gpt-4.1-mini", { generation: { maxTokens: "many" } }) + +// @ts-expect-error provider-native options remain typed. +OpenAI.responses("gpt-4.1-mini", { providerOptions: { openai: { store: "false" } } }) + +// @ts-expect-error auth is an override, so OpenAI rejects apiKey with auth. +OpenAI.responses("gpt-4.1-mini", { apiKey: "sk-test", auth: RuntimeAuth.bearer("oauth-token") }) + +OpenAI.chat("gpt-4.1-mini") +OpenAI.chat("gpt-4.1-mini", { apiKey: "sk-test" }) +OpenAI.chat("gpt-4.1-mini", { apiKey: configApiKey }) +OpenAI.chat("gpt-4.1-mini", { auth: RuntimeAuth.bearer("oauth-token") }) + +// @ts-expect-error auth is an override, so OpenAI Chat rejects apiKey with auth. +OpenAI.chat("gpt-4.1-mini", { apiKey: "sk-test", auth: RuntimeAuth.bearer("oauth-token") }) + +// @ts-expect-error Azure requires at least one of `resourceName` or `baseURL`. +Azure.responses("deployment") +Azure.responses("deployment", { apiKey: "azure-key", resourceName: "resource" }) +Azure.responses("deployment", { apiKey: configApiKey, resourceName: "resource" }) +Azure.responses("deployment", { auth: RuntimeAuth.header("api-key", "azure-key"), resourceName: "resource" }) + +// @ts-expect-error auth is an override, so Azure rejects apiKey with auth. +Azure.responses("deployment", { apiKey: "azure-key", auth: RuntimeAuth.header("api-key", "override") }) + +// @ts-expect-error Azure requires at least one of `resourceName` or `baseURL`. +Azure.chat("deployment") +Azure.chat("deployment", { apiKey: "azure-key", resourceName: "resource" }) +Azure.chat("deployment", { apiKey: configApiKey, resourceName: "resource" }) +Azure.chat("deployment", { auth: RuntimeAuth.header("api-key", "azure-key"), resourceName: "resource" }) + +// @ts-expect-error auth is an override, so Azure Chat rejects apiKey with auth. +Azure.chat("deployment", { apiKey: "azure-key", auth: RuntimeAuth.header("api-key", "override") }) diff --git a/packages/llm/test/auth.test.ts b/packages/llm/test/auth.test.ts new file mode 100644 index 0000000000..6b53f4d5eb --- /dev/null +++ b/packages/llm/test/auth.test.ts @@ -0,0 +1,101 @@ +import { describe, expect } from "bun:test" +import { ConfigProvider, Effect } from "effect" +import { Headers } from "effect/unstable/http" +import { LLM } from "../src" +import { Auth } from "../src/route/auth" +import { it } from "./lib/effect" + +const request = LLM.request({ + id: "req_auth", + model: LLM.model({ id: "fake-model", provider: "fake", route: "fake", baseURL: "https://fake.local" }), + prompt: "hello", +}) + +const input = { + request, + method: "POST" as const, + url: "https://example.test/v1/chat", + body: "{}", + headers: Headers.fromInput({ "x-existing": "yes" }), +} + +const withEnv = (env: Record) => Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env }))) + +describe("Auth", () => { + it.effect("renders a config credential as bearer auth", () => + Effect.gen(function* () { + const headers = yield* Auth.config("OPENAI_API_KEY") + .bearer() + .apply(input) + .pipe(withEnv({ OPENAI_API_KEY: "sk-test" })) + + expect(headers.authorization).toBe("Bearer sk-test") + expect(headers["x-existing"]).toBe("yes") + }), + ) + + it.effect("falls back between credential sources before rendering", () => + Effect.gen(function* () { + const headers = yield* Auth.config("PRIMARY_KEY") + .orElse(Auth.value("fallback-key")) + .pipe(Auth.header("x-api-key")) + .apply(input) + .pipe(withEnv({})) + + expect(headers["x-api-key"]).toBe("fallback-key") + expect(headers["x-existing"]).toBe("yes") + }), + ) + + it.effect("composes header auth in sequence", () => + Effect.gen(function* () { + const headers = yield* Auth.headers({ "x-tenant-id": "tenant-1" }) + .andThen(Auth.bearer("gateway-token")) + .apply(input) + + expect(headers["x-tenant-id"]).toBe("tenant-1") + expect(headers.authorization).toBe("Bearer gateway-token") + expect(headers["x-existing"]).toBe("yes") + }), + ) + + it.effect("renders a direct secret as a custom header", () => + Effect.gen(function* () { + const headers = yield* Auth.header("api-key", "direct-key").apply(input) + + expect(headers["api-key"]).toBe("direct-key") + expect(headers["x-existing"]).toBe("yes") + }), + ) + + it.effect("renders bearer auth into a custom header", () => + Effect.gen(function* () { + const headers = yield* Auth.bearerHeader("cf-aig-authorization", "gateway-token").apply(input) + + expect(headers["cf-aig-authorization"]).toBe("Bearer gateway-token") + expect(headers["x-existing"]).toBe("yes") + }), + ) + + it.effect("falls back between full auth values", () => + Effect.gen(function* () { + const headers = yield* Auth.config("OPENAI_API_KEY") + .bearer() + .orElse(Auth.headers({ authorization: "Bearer supplied" })) + .apply(input) + .pipe(withEnv({})) + + expect(headers.authorization).toBe("Bearer supplied") + expect(headers["x-existing"]).toBe("yes") + }), + ) + + it.effect("can intentionally leave auth untouched", () => + Effect.gen(function* () { + const headers = yield* Auth.none.apply(input) + + expect(headers.authorization).toBeUndefined() + expect(headers["x-existing"]).toBe("yes") + }), + ) +}) diff --git a/packages/llm/test/endpoint.test.ts b/packages/llm/test/endpoint.test.ts new file mode 100644 index 0000000000..f708a87ea0 --- /dev/null +++ b/packages/llm/test/endpoint.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, test } from "bun:test" +import { LLM } from "../src" +import { Endpoint } from "../src/route" + +const request = ( + input: { + readonly baseURL: string + readonly queryParams?: Record + }, +) => + LLM.request({ + model: LLM.model({ + id: "model-1", + provider: "test", + route: "test-route", + baseURL: input.baseURL, + queryParams: input.queryParams, + }), + prompt: "hello", + }) + +describe("Endpoint", () => { + test("appends a static path to the model's baseURL", () => { + const url = Endpoint.render(Endpoint.path("/chat"), { + request: request({ baseURL: "https://api.example.test/v1/" }), + body: {}, + }) + + expect(url.toString()).toBe("https://api.example.test/v1/chat") + }) + + test("model query params are appended to the rendered URL", () => { + const url = Endpoint.render(Endpoint.path("/chat?alt=sse"), { + request: request({ + baseURL: "https://custom.example.test/root/", + queryParams: { "api-version": "2026-01-01", alt: "json" }, + }), + body: {}, + }) + + expect(url.toString()).toBe("https://custom.example.test/root/chat?alt=json&api-version=2026-01-01") + }) + + test("path may be a function of the validated body", () => { + const url = Endpoint.render( + Endpoint.path<{ readonly modelId: string }>(({ body }) => `/model/${encodeURIComponent(body.modelId)}/converse-stream`), + { + request: request({ baseURL: "https://bedrock-runtime.us-east-1.amazonaws.com" }), + body: { modelId: "us.amazon.nova-micro-v1:0" }, + }, + ) + + expect(url.toString()).toBe( + "https://bedrock-runtime.us-east-1.amazonaws.com/model/us.amazon.nova-micro-v1%3A0/converse-stream", + ) + }) +}) diff --git a/packages/llm/test/executor.test.ts b/packages/llm/test/executor.test.ts new file mode 100644 index 0000000000..b294606ff3 --- /dev/null +++ b/packages/llm/test/executor.test.ts @@ -0,0 +1,416 @@ +import { describe, expect } from "bun:test" +import { Effect, Fiber, Layer, Random, Ref } from "effect" +import * as TestClock from "effect/testing/TestClock" +import { Headers, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" +import { LLM, LLMError } from "../src" +import { LLMClient, RequestExecutor } from "../src/route" +import * as OpenAIChat from "../src/protocols/openai-chat" +import { dynamicResponse } from "./lib/http" +import { deltaChunk } from "./lib/openai-chunks" +import { sseRaw } from "./lib/sse" +import { it } from "./lib/effect" + +const request = HttpClientRequest.post("https://provider.test/v1/chat?api_key=secret&key=secret&debug=1").pipe( + HttpClientRequest.setHeaders(Headers.fromInput({ authorization: "Bearer secret", "x-safe": "visible" })), +) + +const secretRequest = HttpClientRequest.post("https://provider.test/v1/chat?api_key=query-secret-123&debug=1").pipe( + HttpClientRequest.setHeaders(Headers.fromInput({ authorization: "Bearer header-secret-456" })), +) + +const responsesLayer = (responses: ReadonlyArray) => + RequestExecutor.layer.pipe( + Layer.provide( + Layer.unwrap( + Effect.gen(function* () { + const cursor = yield* Ref.make(0) + return Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.gen(function* () { + const index = yield* Ref.getAndUpdate(cursor, (value) => value + 1) + return HttpClientResponse.fromWeb(request, responses[index] ?? responses[responses.length - 1]) + }), + ), + ) + }), + ), + ), + ) + +const countedResponsesLayer = (attempts: Ref.Ref, responses: ReadonlyArray) => + RequestExecutor.layer.pipe( + Layer.provide( + Layer.unwrap( + Effect.gen(function* () { + const cursor = yield* Ref.make(0) + return Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.gen(function* () { + yield* Ref.update(attempts, (value) => value + 1) + const index = yield* Ref.getAndUpdate(cursor, (value) => value + 1) + return HttpClientResponse.fromWeb(request, responses[index] ?? responses[responses.length - 1]) + }), + ), + ) + }), + ), + ), + ) + +const randomMidpoint = { + nextDoubleUnsafe: () => 0.5, + nextIntUnsafe: () => 0, +} + +const expectLLMError = (error: unknown) => { + expect(error).toBeInstanceOf(LLMError) + if (!(error instanceof LLMError)) throw new Error("expected LLMError") + return error +} + +const errorHttp = (error: LLMError) => ("http" in error.reason ? error.reason.http : undefined) + +describe("RequestExecutor", () => { + it.effect("returns redacted diagnostics for retryable rate limits", () => + Effect.gen(function* () { + const executor = yield* RequestExecutor.Service + const error = yield* executor.execute(request).pipe(Effect.flip) + + expectLLMError(error) + expect(error).toMatchObject({ + retryable: true, + retryAfterMs: 0, + reason: { + _tag: "RateLimit", + rateLimit: { retryAfterMs: 0 }, + http: { + requestId: "req_123", + request: { + method: "POST", + url: "https://provider.test/v1/chat?api_key=%3Credacted%3E&key=%3Credacted%3E&debug=1", + headers: { authorization: "", "x-safe": "visible" }, + }, + response: { + status: 429, + headers: { + "retry-after-ms": "0", + "x-request-id": "req_123", + "x-api-key": "", + }, + }, + }, + }, + }) + expect(errorHttp(error)?.body).toBe("rate limited") + }).pipe( + Effect.provide( + responsesLayer([ + ...Array.from( + { length: 3 }, + () => + new Response("rate limited", { + status: 429, + headers: { "retry-after-ms": "0", "x-request-id": "req_123", "x-api-key": "secret" }, + }), + ), + ]), + ), + ), + ) + + it.effect("honors current redacted header names in diagnostics", () => + Effect.gen(function* () { + const executor = yield* RequestExecutor.Service + const error = yield* executor.execute(request).pipe(Effect.flip) + + expectLLMError(error) + expect(errorHttp(error)?.request.headers["x-safe"]).toBe("") + expect(errorHttp(error)?.response?.headers["x-safe"]).toBe("") + }).pipe( + Effect.provide(responsesLayer([new Response("bad", { status: 400, headers: { "x-safe": "response-secret" } })])), + Effect.provideService(Headers.CurrentRedactedNames, ["x-safe"]), + ), + ) + + it.effect("extracts OpenAI-style rate-limit diagnostics", () => + Effect.gen(function* () { + const executor = yield* RequestExecutor.Service + const error = yield* executor.execute(request).pipe(Effect.flip) + + expectLLMError(error) + expect(error.reason).toMatchObject({ _tag: "RateLimit" }) + expect(error.reason._tag === "RateLimit" ? error.reason.rateLimit : undefined).toEqual({ + retryAfterMs: 0, + limit: { requests: "500", tokens: "30000" }, + remaining: { requests: "499", tokens: "29900" }, + reset: { requests: "1s", tokens: "10s" }, + }) + }).pipe( + Effect.provide( + responsesLayer( + Array.from( + { length: 3 }, + () => + new Response("rate limited", { + status: 429, + headers: { + "retry-after-ms": "0", + "x-ratelimit-limit-requests": "500", + "x-ratelimit-limit-tokens": "30000", + "x-ratelimit-remaining-requests": "499", + "x-ratelimit-remaining-tokens": "29900", + "x-ratelimit-reset-requests": "1s", + "x-ratelimit-reset-tokens": "10s", + }, + }), + ), + ), + ), + ), + ) + + it.effect("extracts Anthropic-style rate-limit diagnostics", () => + Effect.gen(function* () { + const executor = yield* RequestExecutor.Service + const error = yield* executor.execute(request).pipe(Effect.flip) + + expectLLMError(error) + expect(error.reason).toMatchObject({ _tag: "ProviderInternal" }) + expect(errorHttp(error)?.rateLimit).toEqual({ + retryAfterMs: 0, + limit: { requests: "100", "input-tokens": "10000" }, + remaining: { requests: "12", "input-tokens": "9000" }, + reset: { requests: "2026-05-06T12:00:00Z", "input-tokens": "2026-05-06T12:00:10Z" }, + }) + }).pipe( + Effect.provide( + responsesLayer( + Array.from( + { length: 3 }, + () => + new Response("overloaded", { + status: 529, + headers: { + "retry-after-ms": "0", + "anthropic-ratelimit-requests-limit": "100", + "anthropic-ratelimit-requests-remaining": "12", + "anthropic-ratelimit-requests-reset": "2026-05-06T12:00:00Z", + "anthropic-ratelimit-input-tokens-limit": "10000", + "anthropic-ratelimit-input-tokens-remaining": "9000", + "anthropic-ratelimit-input-tokens-reset": "2026-05-06T12:00:10Z", + }, + }), + ), + ), + ), + ), + ) + + it.effect("retries retryable status responses before returning the stream", () => + Effect.gen(function* () { + const executor = yield* RequestExecutor.Service + const response = yield* executor.execute(request) + + expect(response.status).toBe(200) + expect(yield* response.text).toBe("ok") + }).pipe( + Effect.provide( + responsesLayer([ + new Response("busy", { status: 503, headers: { "retry-after-ms": "0" } }), + new Response("ok", { status: 200 }), + ]), + ), + ), + ) + + it.effect("marks 504 and 529 status responses retryable", () => + Effect.gen(function* () { + const failWith = (status: number) => + Effect.gen(function* () { + const executor = yield* RequestExecutor.Service + const error = yield* executor.execute(request).pipe(Effect.flip) + + expectLLMError(error) + expect(error.reason).toMatchObject({ _tag: "ProviderInternal", status }) + expect(error.retryable).toBe(true) + }).pipe( + Effect.provide( + responsesLayer( + Array.from( + { length: 3 }, + () => + new Response("retry", { + status, + headers: { "retry-after-ms": "0" }, + }), + ), + ), + ), + ) + + yield* failWith(504) + yield* failWith(529) + }), + ) + + it.effect("does not retry non-retryable status responses and truncates large bodies", () => + Effect.gen(function* () { + const executor = yield* RequestExecutor.Service + const error = yield* executor.execute(request).pipe(Effect.flip) + + expectLLMError(error) + expect(error.reason).toMatchObject({ _tag: "Authentication" }) + expect(error.retryable).toBe(false) + expect(errorHttp(error)?.bodyTruncated).toBe(true) + expect(errorHttp(error)?.body).toHaveLength(16_384) + }).pipe( + Effect.provide( + responsesLayer([ + new Response("x".repeat(20_000), { status: 401 }), + new Response("should not retry", { status: 200 }), + ]), + ), + ), + ) + + it.effect("redacts common secret fields in response bodies", () => + Effect.gen(function* () { + const executor = yield* RequestExecutor.Service + const error = yield* executor.execute(request).pipe(Effect.flip) + + expectLLMError(error) + expect(errorHttp(error)?.body).toContain('"key":""') + expect(errorHttp(error)?.body).toContain("api_key=") + expect(errorHttp(error)?.body).not.toContain("body-secret") + expect(errorHttp(error)?.body).not.toContain("query-secret") + }).pipe( + Effect.provide( + responsesLayer([ + new Response('{"error":{"message":"bad","key":"body-secret","detail":"api_key=query-secret"}}', { + status: 400, + }), + ]), + ), + ), + ) + + it.effect("redacts echoed request secret values in response bodies", () => + Effect.gen(function* () { + const executor = yield* RequestExecutor.Service + const error = yield* executor.execute(secretRequest).pipe(Effect.flip) + + expectLLMError(error) + expect(errorHttp(error)?.body).toContain("provider echoed ") + expect(errorHttp(error)?.body).toContain("authorization ") + expect(errorHttp(error)?.body).not.toContain("query-secret-123") + expect(errorHttp(error)?.body).not.toContain("header-secret-456") + }).pipe( + Effect.provide( + responsesLayer([ + new Response("provider echoed query-secret-123 and authorization header-secret-456", { status: 400 }), + ]), + ), + ), + ) + + it.effect("honors Retry-After delta seconds before retrying", () => + Effect.gen(function* () { + const attempts = yield* Ref.make(0) + return yield* Effect.gen(function* () { + const executor = yield* RequestExecutor.Service + const fiber = yield* executor.execute(request).pipe(Effect.forkChild) + + yield* Effect.yieldNow + expect(yield* Ref.get(attempts)).toBe(1) + + yield* TestClock.adjust(1_999) + yield* Effect.yieldNow + expect(yield* Ref.get(attempts)).toBe(1) + + yield* TestClock.adjust(1) + const response = yield* Fiber.join(fiber) + + expect(response.status).toBe(200) + expect(yield* Ref.get(attempts)).toBe(2) + }).pipe( + Effect.provide( + countedResponsesLayer(attempts, [ + new Response("busy", { status: 503, headers: { "retry-after": "2" } }), + new Response("ok", { status: 200 }), + ]), + ), + ) + }), + ) + + it.effect("uses exponential jittered delay when retry-after is absent", () => + Effect.gen(function* () { + const attempts = yield* Ref.make(0) + return yield* Effect.gen(function* () { + const executor = yield* RequestExecutor.Service + const fiber = yield* executor.execute(request).pipe(Effect.flip, Effect.forkChild) + + yield* Effect.yieldNow + expect(yield* Ref.get(attempts)).toBe(1) + + yield* TestClock.adjust(499) + yield* Effect.yieldNow + expect(yield* Ref.get(attempts)).toBe(1) + + yield* TestClock.adjust(1) + yield* Effect.yieldNow + expect(yield* Ref.get(attempts)).toBe(2) + + yield* TestClock.adjust(999) + yield* Effect.yieldNow + expect(yield* Ref.get(attempts)).toBe(2) + + yield* TestClock.adjust(1) + const error = yield* Fiber.join(fiber) + + expectLLMError(error) + expect(error.reason).toMatchObject({ _tag: "ProviderInternal" }) + expect(yield* Ref.get(attempts)).toBe(3) + }).pipe( + Effect.provide( + countedResponsesLayer(attempts, [ + new Response("busy", { status: 503 }), + new Response("still busy", { status: 503 }), + new Response("done retrying", { status: 503 }), + ]), + ), + ) + }).pipe(Effect.provideService(Random.Random, randomMidpoint)), + ) + + it.effect("does not retry after a successful response reaches stream parsing", () => + Effect.gen(function* () { + const attempts = yield* Ref.make(0) + const model = OpenAIChat.model({ id: "gpt-4o-mini", baseURL: "https://api.openai.test/v1" }) + const error = yield* LLMClient.generate(LLM.request({ model, prompt: "Say hello." })).pipe( + Effect.provide( + dynamicResponse((input) => + Ref.update(attempts, (value) => value + 1).pipe( + Effect.as( + input.respond( + sseRaw( + `data: ${JSON.stringify(deltaChunk({ role: "assistant", content: "Hello" }))}`, + "data: not-json", + ), + { headers: { "content-type": "text/event-stream" } }, + ), + ), + ), + ), + ), + Effect.flip, + ) + + expectLLMError(error) + expect(error.reason).toMatchObject({ _tag: "InvalidProviderOutput" }) + expect(yield* Ref.get(attempts)).toBe(1) + }), + ) +}) diff --git a/packages/llm/test/exports.test.ts b/packages/llm/test/exports.test.ts new file mode 100644 index 0000000000..237dadb27d --- /dev/null +++ b/packages/llm/test/exports.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, test } from "bun:test" +import { LLM, LLMClient, Provider } from "@opencode-ai/llm" +import { Route, Protocol } from "@opencode-ai/llm/route" +import { Provider as ProviderSubpath } from "@opencode-ai/llm/provider" +import { Cloudflare, OpenAI, OpenAICompatible, OpenRouter, XAI } from "@opencode-ai/llm/providers" +import * as GitHubCopilot from "@opencode-ai/llm/providers/github-copilot" +import { OpenAIChat, OpenAICompatibleChat, OpenAIResponses } from "@opencode-ai/llm/protocols" +import * as AnthropicMessages from "@opencode-ai/llm/protocols/anthropic-messages" + +describe("public exports", () => { + test("root exposes app-facing runtime APIs", () => { + expect(LLM.request).toBeFunction() + expect(LLMClient.Service).toBeFunction() + expect(LLMClient.layer).toBeDefined() + expect(Provider.make).toBeFunction() + expect(ProviderSubpath.make).toBe(Provider.make) + }) + + test("route barrel exposes route-authoring APIs", () => { + expect(Route.make).toBeFunction() + expect(Protocol.make).toBeFunction() + }) + + test("provider barrels expose user-facing facades", () => { + expect(OpenAI.model).toBeFunction() + expect(OpenAI.provider.model).toBe(OpenAI.model) + expect(OpenAI.apis.responses).toBe(OpenAI.responses) + expect(OpenAI.apis.responsesWebSocket).toBe(OpenAI.responsesWebSocket) + expect(OpenAICompatible.deepseek.model).toBeFunction() + expect(Cloudflare.model).toBeFunction() + expect(Cloudflare.provider.model).toBe(Cloudflare.model) + expect(Cloudflare.aiGateway).toBeFunction() + expect(Cloudflare.workersAI).toBeFunction() + expect(OpenRouter.model).toBeFunction() + expect(OpenRouter.provider.model).toBe(OpenRouter.model) + expect(XAI.model).toBeFunction() + expect(XAI.provider.model).toBe(XAI.model) + expect(XAI.apis.responses).toBe(XAI.responses) + expect(XAI.apis.chat).toBe(XAI.chat) + expect(XAI.responses("grok-4.3", { apiKey: "fixture" })).toMatchObject({ + route: "openai-responses", + }) + expect(XAI.chat("grok-4.3", { apiKey: "fixture" })).toMatchObject({ + route: "openai-compatible-chat", + }) + expect(GitHubCopilot.model).toBeFunction() + }) + + test("protocol barrels expose supported low-level routes", () => { + expect(OpenAIChat.route.id).toBe("openai-chat") + expect(OpenAICompatibleChat.route.id).toBe("openai-compatible-chat") + expect(OpenAIResponses.route.id).toBe("openai-responses") + expect(OpenAIResponses.webSocketRoute.id).toBe("openai-responses-websocket") + expect(AnthropicMessages.route.id).toBe("anthropic-messages") + }) +}) diff --git a/packages/llm/test/fixtures/recordings/anthropic-messages/accepts-malformed-assistant-tool-order-with-default-patch.json b/packages/llm/test/fixtures/recordings/anthropic-messages/accepts-malformed-assistant-tool-order-with-default-patch.json new file mode 100644 index 0000000000..7730485cb4 --- /dev/null +++ b/packages/llm/test/fixtures/recordings/anthropic-messages/accepts-malformed-assistant-tool-order-with-default-patch.json @@ -0,0 +1,29 @@ +{ + "version": 1, + "metadata": { + "name": "anthropic-messages/accepts-malformed-assistant-tool-order-with-default-patch", + "recordedAt": "2026-05-05T20:09:16.245Z", + "tags": ["prefix:anthropic-messages", "provider:anthropic", "protocol:anthropic-messages", "tool"] + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://api.anthropic.com/v1/messages", + "headers": { + "anthropic-version": "2023-06-01", + "content-type": "application/json" + }, + "body": "{\"model\":\"claude-haiku-4-5-20251001\",\"messages\":[{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"I will check the weather.\"}]},{\"role\":\"assistant\",\"content\":[{\"type\":\"tool_use\",\"id\":\"call_1\",\"name\":\"get_weather\",\"input\":{\"city\":\"Paris\"}}]},{\"role\":\"user\",\"content\":[{\"type\":\"tool_result\",\"tool_use_id\":\"call_1\",\"content\":\"{\\\"temperature\\\":\\\"72F\\\"}\"}]},{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Use that result to answer briefly.\",\"cache_control\":{\"type\":\"ephemeral\"}}]}],\"tools\":[{\"name\":\"get_weather\",\"description\":\"Get weather\",\"input_schema\":{\"type\":\"object\",\"properties\":{}}}],\"stream\":true,\"max_tokens\":4096}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream; charset=utf-8" + }, + "body": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_01SikJVFaMR1XLMtavUhvuog\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":638,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":1,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}} }\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"} }\n\nevent: ping\ndata: {\"type\": \"ping\"}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"The\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" weather in Paris is currently 72°F.\"} }\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null},\"usage\":{\"input_tokens\":638,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"output_tokens\":14} }\n\nevent: message_stop\ndata: {\"type\":\"message_stop\" }\n\n" + } + } + ] +} diff --git a/packages/llm/test/fixtures/recordings/anthropic-messages/claude-opus-4-7-drives-a-tool-loop.json b/packages/llm/test/fixtures/recordings/anthropic-messages/claude-opus-4-7-drives-a-tool-loop.json new file mode 100644 index 0000000000..316f4308fc --- /dev/null +++ b/packages/llm/test/fixtures/recordings/anthropic-messages/claude-opus-4-7-drives-a-tool-loop.json @@ -0,0 +1,56 @@ +{ + "version": 1, + "metadata": { + "name": "anthropic-messages/claude-opus-4-7-drives-a-tool-loop", + "recordedAt": "2026-05-03T19:59:44.186Z", + "tags": [ + "prefix:anthropic-messages", + "provider:anthropic", + "protocol:anthropic-messages", + "tool", + "tool-loop", + "golden", + "flagship" + ] + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://api.anthropic.com/v1/messages", + "headers": { + "anthropic-version": "2023-06-01", + "content-type": "application/json" + }, + "body": "{\"model\":\"claude-opus-4-7\",\"system\":[{\"type\":\"text\",\"text\":\"Use the get_weather tool, then answer in one short sentence.\"}],\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"What is the weather in Paris?\"}]}],\"tools\":[{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"input_schema\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}],\"stream\":true,\"max_tokens\":80}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream; charset=utf-8" + }, + "body": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"model\":\"claude-opus-4-7\",\"id\":\"msg_01DgAEgLgB1ZhavZon4qGE1t\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":798,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":0,\"service_tier\":\"standard\",\"inference_geo\":\"global\"}} }\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"tool_use\",\"id\":\"toolu_01M8nJQQMxqpv1VaPYuJKT4j\",\"name\":\"get_weather\",\"input\":{},\"caller\":{\"type\":\"direct\"}} }\n\nevent: ping\ndata: {\"type\": \"ping\"}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"{\\\"city\\\": \"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"\\\"Pa\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"ris\\\"}\"} }\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0 }\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"tool_use\",\"stop_sequence\":null,\"stop_details\":null},\"usage\":{\"input_tokens\":798,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"output_tokens\":66} }\n\nevent: message_stop\ndata: {\"type\":\"message_stop\" }\n\n" + } + }, + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://api.anthropic.com/v1/messages", + "headers": { + "anthropic-version": "2023-06-01", + "content-type": "application/json" + }, + "body": "{\"model\":\"claude-opus-4-7\",\"system\":[{\"type\":\"text\",\"text\":\"Use the get_weather tool, then answer in one short sentence.\"}],\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"What is the weather in Paris?\"}]},{\"role\":\"assistant\",\"content\":[{\"type\":\"tool_use\",\"id\":\"toolu_01M8nJQQMxqpv1VaPYuJKT4j\",\"name\":\"get_weather\",\"input\":{\"city\":\"Paris\"}}]},{\"role\":\"user\",\"content\":[{\"type\":\"tool_result\",\"tool_use_id\":\"toolu_01M8nJQQMxqpv1VaPYuJKT4j\",\"content\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}]}],\"tools\":[{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"input_schema\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}],\"stream\":true,\"max_tokens\":80}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream; charset=utf-8" + }, + "body": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"model\":\"claude-opus-4-7\",\"id\":\"msg_011KJqj32QjkrUAiBFxhmEoG\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":895,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":5,\"service_tier\":\"standard\",\"inference_geo\":\"global\"}} }\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"} }\n\nevent: ping\ndata: {\"type\": \"ping\"}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"Paris is curr\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"ently sunny at 22°C.\"} }\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0 }\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null},\"usage\":{\"input_tokens\":895,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"output_tokens\":19}}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\"}\n\n" + } + } + ] +} diff --git a/packages/llm/test/fixtures/recordings/anthropic-messages/rejects-malformed-assistant-tool-order-without-patch.json b/packages/llm/test/fixtures/recordings/anthropic-messages/rejects-malformed-assistant-tool-order-without-patch.json new file mode 100644 index 0000000000..cd0990cec5 --- /dev/null +++ b/packages/llm/test/fixtures/recordings/anthropic-messages/rejects-malformed-assistant-tool-order-without-patch.json @@ -0,0 +1,29 @@ +{ + "version": 1, + "metadata": { + "name": "anthropic-messages/rejects-malformed-assistant-tool-order-without-patch", + "recordedAt": "2026-05-05T20:08:42.597Z", + "tags": ["prefix:anthropic-messages", "provider:anthropic", "protocol:anthropic-messages", "tool", "sad-path"] + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://api.anthropic.com/v1/messages", + "headers": { + "anthropic-version": "2023-06-01", + "content-type": "application/json" + }, + "body": "{\"model\":\"claude-haiku-4-5-20251001\",\"messages\":[{\"role\":\"assistant\",\"content\":[{\"type\":\"tool_use\",\"id\":\"call_1\",\"name\":\"get_weather\",\"input\":{\"city\":\"Paris\"}},{\"type\":\"text\",\"text\":\"I will check the weather.\"}]},{\"role\":\"user\",\"content\":[{\"type\":\"tool_result\",\"tool_use_id\":\"call_1\",\"content\":\"{\\\"temperature\\\":\\\"72F\\\"}\"}]},{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Use that result to answer briefly.\"}]}],\"tools\":[{\"name\":\"get_weather\",\"description\":\"Get weather\",\"input_schema\":{\"type\":\"object\",\"properties\":{}}}],\"stream\":true,\"max_tokens\":4096}" + }, + "response": { + "status": 400, + "headers": { + "content-type": "application/json" + }, + "body": "{\"type\":\"error\",\"error\":{\"type\":\"invalid_request_error\",\"message\":\"messages.1: `tool_use` ids were found without `tool_result` blocks immediately after: call_1. Each `tool_use` block must have a corresponding `tool_result` block in the next message.\"},\"request_id\":\"req_011Cak2XdJgnzxKCY2BC2Beh\"}" + } + } + ] +} diff --git a/packages/llm/test/fixtures/recordings/anthropic-messages/streams-text.json b/packages/llm/test/fixtures/recordings/anthropic-messages/streams-text.json new file mode 100644 index 0000000000..e80a0dac34 --- /dev/null +++ b/packages/llm/test/fixtures/recordings/anthropic-messages/streams-text.json @@ -0,0 +1,29 @@ +{ + "version": 1, + "metadata": { + "name": "anthropic-messages/streams-text", + "recordedAt": "2026-04-28T21:18:45.535Z", + "tags": ["prefix:anthropic-messages", "provider:anthropic", "protocol:anthropic-messages"] + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://api.anthropic.com/v1/messages", + "headers": { + "anthropic-version": "2023-06-01", + "content-type": "application/json" + }, + "body": "{\"model\":\"claude-haiku-4-5-20251001\",\"system\":[{\"type\":\"text\",\"text\":\"You are concise.\"}],\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Reply with exactly: Hello!\"}]}],\"stream\":true,\"max_tokens\":20,\"temperature\":0}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream; charset=utf-8" + }, + "body": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_01UodR8c3ezAK8rAfi8HAs8g\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":18,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":2,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}} }\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"} }\n\nevent: ping\ndata: {\"type\": \"ping\"}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"Hello!\"} }\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0 }\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null},\"usage\":{\"input_tokens\":18,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"output_tokens\":5} }\n\nevent: message_stop\ndata: {\"type\":\"message_stop\" }\n\n" + } + } + ] +} diff --git a/packages/llm/test/fixtures/recordings/anthropic-messages/streams-tool-call.json b/packages/llm/test/fixtures/recordings/anthropic-messages/streams-tool-call.json new file mode 100644 index 0000000000..ef8f69c21d --- /dev/null +++ b/packages/llm/test/fixtures/recordings/anthropic-messages/streams-tool-call.json @@ -0,0 +1,29 @@ +{ + "version": 1, + "metadata": { + "name": "anthropic-messages/streams-tool-call", + "recordedAt": "2026-04-28T21:18:46.878Z", + "tags": ["prefix:anthropic-messages", "provider:anthropic", "protocol:anthropic-messages", "tool"] + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://api.anthropic.com/v1/messages", + "headers": { + "anthropic-version": "2023-06-01", + "content-type": "application/json" + }, + "body": "{\"model\":\"claude-haiku-4-5-20251001\",\"system\":[{\"type\":\"text\",\"text\":\"Call tools exactly as requested.\"}],\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Call get_weather with city exactly Paris.\"}]}],\"tools\":[{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"input_schema\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}],\"tool_choice\":{\"type\":\"tool\",\"name\":\"get_weather\"},\"stream\":true,\"max_tokens\":80,\"temperature\":0}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream; charset=utf-8" + }, + "body": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_01RYgU7NUPMK4B9v8S7gVpCS\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":677,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":16,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}} }\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"tool_use\",\"id\":\"toolu_012rmAruviySvUXSjgCPWVRu\",\"name\":\"get_weather\",\"input\":{},\"caller\":{\"type\":\"direct\"}} }\n\nevent: ping\ndata: {\"type\": \"ping\"}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"{\\\"city\\\":\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\" \\\"Paris\\\"}\"} }\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0 }\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"tool_use\",\"stop_sequence\":null,\"stop_details\":null},\"usage\":{\"input_tokens\":677,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"output_tokens\":33} }\n\nevent: message_stop\ndata: {\"type\":\"message_stop\" }\n\n" + } + } + ] +} diff --git a/packages/llm/test/fixtures/recordings/bedrock-converse/drives-a-tool-loop.json b/packages/llm/test/fixtures/recordings/bedrock-converse/drives-a-tool-loop.json new file mode 100644 index 0000000000..26eca01609 --- /dev/null +++ b/packages/llm/test/fixtures/recordings/bedrock-converse/drives-a-tool-loop.json @@ -0,0 +1,55 @@ +{ + "version": 1, + "metadata": { + "name": "bedrock-converse/drives-a-tool-loop", + "recordedAt": "2026-05-03T20:01:48.334Z", + "tags": [ + "prefix:bedrock-converse", + "provider:amazon-bedrock", + "protocol:bedrock-converse", + "tool", + "tool-loop", + "golden" + ] + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://bedrock-runtime.us-east-1.amazonaws.com/model/us.amazon.nova-micro-v1%3A0/converse-stream", + "headers": { + "content-type": "application/json" + }, + "body": "{\"modelId\":\"us.amazon.nova-micro-v1:0\",\"messages\":[{\"role\":\"user\",\"content\":[{\"text\":\"What is the weather in Paris?\"}]}],\"system\":[{\"text\":\"Use the get_weather tool, then answer in one short sentence.\"}],\"inferenceConfig\":{\"maxTokens\":80,\"temperature\":0},\"toolConfig\":{\"tools\":[{\"toolSpec\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"inputSchema\":{\"json\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}}}]}}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "application/vnd.amazon.eventstream" + }, + "body": "AAAAtwAAAFJCoDu1CzpldmVudC10eXBlBwAMbWVzc2FnZVN0YXJ0DTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVowMTIzNDUiLCJyb2xlIjoiYXNzaXN0YW50In1xBrKfAAAA0gAAAFdjGDcHCzpldmVudC10eXBlBwARY29udGVudEJsb2NrRGVsdGENOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24NOm1lc3NhZ2UtdHlwZQcABWV2ZW50eyJjb250ZW50QmxvY2tJbmRleCI6MCwiZGVsdGEiOnsidGV4dCI6Ijx0aGlua2luZyJ9LCJwIjoiYWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXpBQkNERUZHSElKS0xNTk9QUVJTVFVWIn17Hkd0AAAAuQAAAFeN+nFbCzpldmVudC10eXBlBwARY29udGVudEJsb2NrRGVsdGENOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24NOm1lc3NhZ2UtdHlwZQcABWV2ZW50eyJjb250ZW50QmxvY2tJbmRleCI6MCwiZGVsdGEiOnsidGV4dCI6Ij4ifSwicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6QUJDREUifXAgJvgAAADMAAAAV7zIHuQLOmV2ZW50LXR5cGUHABFjb250ZW50QmxvY2tEZWx0YQ06Y29udGVudC10eXBlBwAQYXBwbGljYXRpb24vanNvbg06bWVzc2FnZS10eXBlBwAFZXZlbnR7ImNvbnRlbnRCbG9ja0luZGV4IjowLCJkZWx0YSI6eyJ0ZXh0IjoiIFRvIn0sInAiOiJhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ekFCQ0RFRkdISUpLTE1OT1BRUlNUVVYifaOASr0AAACrAAAAV5fatbkLOmV2ZW50LXR5cGUHABFjb250ZW50QmxvY2tEZWx0YQ06Y29udGVudC10eXBlBwAQYXBwbGljYXRpb24vanNvbg06bWVzc2FnZS10eXBlBwAFZXZlbnR7ImNvbnRlbnRCbG9ja0luZGV4IjowLCJkZWx0YSI6eyJ0ZXh0IjoiIGRldGVybWluZSJ9LCJwIjoiYWJjZGVmZ2gifQUyd0MAAADQAAAAVxnYZGcLOmV2ZW50LXR5cGUHABFjb250ZW50QmxvY2tEZWx0YQ06Y29udGVudC10eXBlBwAQYXBwbGljYXRpb24vanNvbg06bWVzc2FnZS10eXBlBwAFZXZlbnR7ImNvbnRlbnRCbG9ja0luZGV4IjowLCJkZWx0YSI6eyJ0ZXh0IjoiIHRoZSJ9LCJwIjoiYWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXpBQkNERUZHSElKS0xNTk9QUVJTVFVWV1hZIn0ZHcgRAAAAxwAAAFfLGC/1CzpldmVudC10eXBlBwARY29udGVudEJsb2NrRGVsdGENOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24NOm1lc3NhZ2UtdHlwZQcABWV2ZW50eyJjb250ZW50QmxvY2tJbmRleCI6MCwiZGVsdGEiOnsidGV4dCI6IiB3ZWF0aGVyIn0sInAiOiJhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ekFCQ0RFRkdISUpLTCJ9QpgceQAAALsAAABX9zoiOws6ZXZlbnQtdHlwZQcAEWNvbnRlbnRCbG9ja0RlbHRhDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsiY29udGVudEJsb2NrSW5kZXgiOjAsImRlbHRhIjp7InRleHQiOiIgaW4ifSwicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6QUJDREUifRLNLa0AAACkAAAAVxWKImgLOmV2ZW50LXR5cGUHABFjb250ZW50QmxvY2tEZWx0YQ06Y29udGVudC10eXBlBwAQYXBwbGljYXRpb24vanNvbg06bWVzc2FnZS10eXBlBwAFZXZlbnR7ImNvbnRlbnRCbG9ja0luZGV4IjowLCJkZWx0YSI6eyJ0ZXh0IjoiIFBhcmlzIn0sInAiOiJhYmNkZSJ9QOSGZQAAAKgAAABX0HrPaQs6ZXZlbnQtdHlwZQcAEWNvbnRlbnRCbG9ja0RlbHRhDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsiY29udGVudEJsb2NrSW5kZXgiOjAsImRlbHRhIjp7InRleHQiOiIsIn0sInAiOiJhYmNkZWZnaGlqa2xtbiJ9bgd/VgAAALAAAABXgOoTKgs6ZXZlbnQtdHlwZQcAEWNvbnRlbnRCbG9ja0RlbHRhDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsiY29udGVudEJsb2NrSW5kZXgiOjAsImRlbHRhIjp7InRleHQiOiIgSSJ9LCJwIjoiYWJjZGVmZ2hpamtsbW5vcHFyc3R1In3RkbiWAAAA0QAAAFckuE3XCzpldmVudC10eXBlBwARY29udGVudEJsb2NrRGVsdGENOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24NOm1lc3NhZ2UtdHlwZQcABWV2ZW50eyJjb250ZW50QmxvY2tJbmRleCI6MCwiZGVsdGEiOnsidGV4dCI6IiB3aWxsIn0sInAiOiJhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ekFCQ0RFRkdISUpLTE1OT1BRUlNUVVZXWFkifa2kMpYAAACfAAAAV8N7q/8LOmV2ZW50LXR5cGUHABFjb250ZW50QmxvY2tEZWx0YQ06Y29udGVudC10eXBlBwAQYXBwbGljYXRpb24vanNvbg06bWVzc2FnZS10eXBlBwAFZXZlbnR7ImNvbnRlbnRCbG9ja0luZGV4IjowLCJkZWx0YSI6eyJ0ZXh0IjoiIHVzZSJ9LCJwIjoiYWIifWRVyJsAAADFAAAAV7HYfJULOmV2ZW50LXR5cGUHABFjb250ZW50QmxvY2tEZWx0YQ06Y29udGVudC10eXBlBwAQYXBwbGljYXRpb24vanNvbg06bWVzc2FnZS10eXBlBwAFZXZlbnR7ImNvbnRlbnRCbG9ja0luZGV4IjowLCJkZWx0YSI6eyJ0ZXh0IjoiIHRoZSJ9LCJwIjoiYWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXpBQkNERUZHSElKS0xNTiJ99QGTXwAAALwAAABXRRr+Kws6ZXZlbnQtdHlwZQcAEWNvbnRlbnRCbG9ja0RlbHRhDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsiY29udGVudEJsb2NrSW5kZXgiOjAsImRlbHRhIjp7InRleHQiOiIgZ2V0In0sInAiOiJhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ekFCQ0RFIn3A1pHkAAAArAAAAFcl+mmpCzpldmVudC10eXBlBwARY29udGVudEJsb2NrRGVsdGENOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24NOm1lc3NhZ2UtdHlwZQcABWV2ZW50eyJjb250ZW50QmxvY2tJbmRleCI6MCwiZGVsdGEiOnsidGV4dCI6Il8ifSwicCI6ImFiY2RlZmdoaWprbG1ub3BxciJ9Jl4BhgAAAMwAAABXvMge5As6ZXZlbnQtdHlwZQcAEWNvbnRlbnRCbG9ja0RlbHRhDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsiY29udGVudEJsb2NrSW5kZXgiOjAsImRlbHRhIjp7InRleHQiOiJ3ZWF0aGVyIn0sInAiOiJhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ekFCQ0RFRkdISUpLTE1OT1BRUiJ9zDOXNgAAANMAAABXXngetws6ZXZlbnQtdHlwZQcAEWNvbnRlbnRCbG9ja0RlbHRhDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsiY29udGVudEJsb2NrSW5kZXgiOjAsImRlbHRhIjp7InRleHQiOiIgdG9vbCJ9LCJwIjoiYWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXpBQkNERUZHSElKS0xNTk9QUVJTVFVWV1hZWjAifYuc7T0AAADXAAAAV6v4uHcLOmV2ZW50LXR5cGUHABFjb250ZW50QmxvY2tEZWx0YQ06Y29udGVudC10eXBlBwAQYXBwbGljYXRpb24vanNvbg06bWVzc2FnZS10eXBlBwAFZXZlbnR7ImNvbnRlbnRCbG9ja0luZGV4IjowLCJkZWx0YSI6eyJ0ZXh0IjoiIGFuZCJ9LCJwIjoiYWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXpBQkNERUZHSElKS0xNTk9QUVJTVFVWV1hZWjAxMjM0NSJ9Z1WRPAAAANYAAABXlpiRxws6ZXZlbnQtdHlwZQcAEWNvbnRlbnRCbG9ja0RlbHRhDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsiY29udGVudEJsb2NrSW5kZXgiOjAsImRlbHRhIjp7InRleHQiOiIgcHJvdmlkZSJ9LCJwIjoiYWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXpBQkNERUZHSElKS0xNTk9QUVJTVFVWV1hZWjAifWuffy4AAACiAAAAV5rK18gLOmV2ZW50LXR5cGUHABFjb250ZW50QmxvY2tEZWx0YQ06Y29udGVudC10eXBlBwAQYXBwbGljYXRpb24vanNvbg06bWVzc2FnZS10eXBlBwAFZXZlbnR7ImNvbnRlbnRCbG9ja0luZGV4IjowLCJkZWx0YSI6eyJ0ZXh0IjoiIHRoZSJ9LCJwIjoiYWJjZGUifR59TKYAAADUAAAAV+xYwqcLOmV2ZW50LXR5cGUHABFjb250ZW50QmxvY2tEZWx0YQ06Y29udGVudC10eXBlBwAQYXBwbGljYXRpb24vanNvbg06bWVzc2FnZS10eXBlBwAFZXZlbnR7ImNvbnRlbnRCbG9ja0luZGV4IjowLCJkZWx0YSI6eyJ0ZXh0IjoiIGNpdHkifSwicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVowMSJ9JF6q4AAAANQAAABX7FjCpws6ZXZlbnQtdHlwZQcAEWNvbnRlbnRCbG9ja0RlbHRhDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsiY29udGVudEJsb2NrSW5kZXgiOjAsImRlbHRhIjp7InRleHQiOiIgYXMifSwicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVowMTIzIn3T44iVAAAA1gAAAFeWmJHHCzpldmVudC10eXBlBwARY29udGVudEJsb2NrRGVsdGENOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24NOm1lc3NhZ2UtdHlwZQcABWV2ZW50eyJjb250ZW50QmxvY2tJbmRleCI6MCwiZGVsdGEiOnsidGV4dCI6IiBcIiJ9LCJwIjoiYWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXpBQkNERUZHSElKS0xNTk9QUVJTVFVWV1hZWjAxMjM0NSJ9T89b0AAAANkAAABXFMgGFgs6ZXZlbnQtdHlwZQcAEWNvbnRlbnRCbG9ja0RlbHRhDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsiY29udGVudEJsb2NrSW5kZXgiOjAsImRlbHRhIjp7InRleHQiOiJQYXJpcyJ9LCJwIjoiYWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXpBQkNERUZHSElKS0xNTk9QUVJTVFVWV1hZWjAxMjM0NTYifYX0tNEAAAClAAAAVyjqC9gLOmV2ZW50LXR5cGUHABFjb250ZW50QmxvY2tEZWx0YQ06Y29udGVudC10eXBlBwAQYXBwbGljYXRpb24vanNvbg06bWVzc2FnZS10eXBlBwAFZXZlbnR7ImNvbnRlbnRCbG9ja0luZGV4IjowLCJkZWx0YSI6eyJ0ZXh0IjoiXCIuIn0sInAiOiJhYmNkZWZnaGkifUbVohIAAAC9AAAAV3h615sLOmV2ZW50LXR5cGUHABFjb250ZW50QmxvY2tEZWx0YQ06Y29udGVudC10eXBlBwAQYXBwbGljYXRpb24vanNvbg06bWVzc2FnZS10eXBlBwAFZXZlbnR7ImNvbnRlbnRCbG9ja0luZGV4IjowLCJkZWx0YSI6eyJ0ZXh0IjoiIDwvIn0sInAiOiJhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ekFCQ0RFRkcifU+fapUAAADEAAAAV4y4VSULOmV2ZW50LXR5cGUHABFjb250ZW50QmxvY2tEZWx0YQ06Y29udGVudC10eXBlBwAQYXBwbGljYXRpb24vanNvbg06bWVzc2FnZS10eXBlBwAFZXZlbnR7ImNvbnRlbnRCbG9ja0luZGV4IjowLCJkZWx0YSI6eyJ0ZXh0IjoidGhpbmtpbmcifSwicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6QUJDREVGR0hJIn0npV45AAAAoQAAAFfdaq0YCzpldmVudC10eXBlBwARY29udGVudEJsb2NrRGVsdGENOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24NOm1lc3NhZ2UtdHlwZQcABWV2ZW50eyJjb250ZW50QmxvY2tJbmRleCI6MCwiZGVsdGEiOnsidGV4dCI6Ij5cbiJ9LCJwIjoiYWJjZGUifXpOZ6MAAACtAAAAVm+dcI8LOmV2ZW50LXR5cGUHABBjb250ZW50QmxvY2tTdG9wDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsiY29udGVudEJsb2NrSW5kZXgiOjAsInAiOiJhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ekFCQ0RFRkdISUpLTE1OTyJ9wp8EHgAAAQwAAABXnoElmgs6ZXZlbnQtdHlwZQcAEWNvbnRlbnRCbG9ja1N0YXJ0DTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsiY29udGVudEJsb2NrSW5kZXgiOjEsInAiOiJhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ekFCQ0RFRkdISUpLTE1OT1BRUlNUVSIsInN0YXJ0Ijp7InRvb2xVc2UiOnsibmFtZSI6ImdldF93ZWF0aGVyIiwidG9vbFVzZUlkIjoidG9vbHVzZV9hOG5sZjJicUdMY1p2YVNvQnBRMXNIIn19fY7FuJUAAADLAAAAVw7owvQLOmV2ZW50LXR5cGUHABFjb250ZW50QmxvY2tEZWx0YQ06Y29udGVudC10eXBlBwAQYXBwbGljYXRpb24vanNvbg06bWVzc2FnZS10eXBlBwAFZXZlbnR7ImNvbnRlbnRCbG9ja0luZGV4IjoxLCJkZWx0YSI6eyJ0b29sVXNlIjp7ImlucHV0Ijoie1wiY2l0eVwiOlwiUGFyaXNcIn0ifX0sInAiOiJhYmNkZWZnaGlqa2xtbm9wcSJ9r3QETwAAALQAAABWAm2FfAs6ZXZlbnQtdHlwZQcAEGNvbnRlbnRCbG9ja1N0b3ANOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24NOm1lc3NhZ2UtdHlwZQcABWV2ZW50eyJjb250ZW50QmxvY2tJbmRleCI6MSwicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6QUJDREVGR0hJSktMTU5PUFFSU1RVViJ9shQTDgAAAKUAAABRwYmu7Qs6ZXZlbnQtdHlwZQcAC21lc3NhZ2VTdG9wDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6QUJDREVGR0hJSiIsInN0b3BSZWFzb24iOiJ0b29sX3VzZSJ9i4+/2gAAAO4AAABOY6LKQAs6ZXZlbnQtdHlwZQcACG1ldGFkYXRhDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsibWV0cmljcyI6eyJsYXRlbmN5TXMiOjQ5OX0sInAiOiJhYmNkZWZnaGlqa2xtbm9wcXJzdHV2dyIsInVzYWdlIjp7ImlucHV0VG9rZW5zIjo0MjUsIm91dHB1dFRva2VucyI6NDUsInNlcnZlclRvb2xVc2FnZSI6e30sInRvdGFsVG9rZW5zIjo0NzB9fSAjG74=", + "bodyEncoding": "base64" + } + }, + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://bedrock-runtime.us-east-1.amazonaws.com/model/us.amazon.nova-micro-v1%3A0/converse-stream", + "headers": { + "content-type": "application/json" + }, + "body": "{\"modelId\":\"us.amazon.nova-micro-v1:0\",\"messages\":[{\"role\":\"user\",\"content\":[{\"text\":\"What is the weather in Paris?\"}]},{\"role\":\"assistant\",\"content\":[{\"text\":\" To determine the weather in Paris, I will use the get_weather tool and provide the city as \\\"Paris\\\". \\n\"},{\"toolUse\":{\"toolUseId\":\"tooluse_a8nlf2bqGLcZvaSoBpQ1sH\",\"name\":\"get_weather\",\"input\":{\"city\":\"Paris\"}}}]},{\"role\":\"user\",\"content\":[{\"toolResult\":{\"toolUseId\":\"tooluse_a8nlf2bqGLcZvaSoBpQ1sH\",\"content\":[{\"json\":{\"temperature\":22,\"condition\":\"sunny\"}}],\"status\":\"success\"}}]}],\"system\":[{\"text\":\"Use the get_weather tool, then answer in one short sentence.\"}],\"inferenceConfig\":{\"maxTokens\":80,\"temperature\":0},\"toolConfig\":{\"tools\":[{\"toolSpec\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"inputSchema\":{\"json\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}}}]}}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "application/vnd.amazon.eventstream" + }, + "body": "AAAAgQAAAFJswXaTCzpldmVudC10eXBlBwAMbWVzc2FnZVN0YXJ0DTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsicCI6ImFiY2QiLCJyb2xlIjoiYXNzaXN0YW50In31EqAFAAAAoQAAAFfdaq0YCzpldmVudC10eXBlBwARY29udGVudEJsb2NrRGVsdGENOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24NOm1lc3NhZ2UtdHlwZQcABWV2ZW50eyJjb250ZW50QmxvY2tJbmRleCI6MCwiZGVsdGEiOnsidGV4dCI6IlRoZSJ9LCJwIjoiYWJjZGUifZ8hzYkAAACmAAAAV29KcQgLOmV2ZW50LXR5cGUHABFjb250ZW50QmxvY2tEZWx0YQ06Y29udGVudC10eXBlBwAQYXBwbGljYXRpb24vanNvbg06bWVzc2FnZS10eXBlBwAFZXZlbnR7ImNvbnRlbnRCbG9ja0luZGV4IjowLCJkZWx0YSI6eyJ0ZXh0IjoiIHdlYXRoZXIifSwicCI6ImFiY2RlIn0dzksTAAAAsQAAAFe9ijqaCzpldmVudC10eXBlBwARY29udGVudEJsb2NrRGVsdGENOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24NOm1lc3NhZ2UtdHlwZQcABWV2ZW50eyJjb250ZW50QmxvY2tJbmRleCI6MCwiZGVsdGEiOnsidGV4dCI6IiBpbiJ9LCJwIjoiYWJjZGVmZ2hpamtsbW5vcHFyc3R1In1AJhvbAAAAqgAAAFequpwJCzpldmVudC10eXBlBwARY29udGVudEJsb2NrRGVsdGENOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24NOm1lc3NhZ2UtdHlwZQcABWV2ZW50eyJjb250ZW50QmxvY2tJbmRleCI6MCwiZGVsdGEiOnsidGV4dCI6IiBQYXJpcyJ9LCJwIjoiYWJjZGVmZ2hpamsifQpyKMQAAADBAAAAV0RY2lULOmV2ZW50LXR5cGUHABFjb250ZW50QmxvY2tEZWx0YQ06Y29udGVudC10eXBlBwAQYXBwbGljYXRpb24vanNvbg06bWVzc2FnZS10eXBlBwAFZXZlbnR7ImNvbnRlbnRCbG9ja0luZGV4IjowLCJkZWx0YSI6eyJ0ZXh0IjoiIGlzIn0sInAiOiJhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ekFCQ0RFRkdISUpLIn1gvC8JAAAA2QAAAFcUyAYWCzpldmVudC10eXBlBwARY29udGVudEJsb2NrRGVsdGENOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24NOm1lc3NhZ2UtdHlwZQcABWV2ZW50eyJjb250ZW50QmxvY2tJbmRleCI6MCwiZGVsdGEiOnsidGV4dCI6IiBzdW5ueSJ9LCJwIjoiYWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXpBQkNERUZHSElKS0xNTk9QUVJTVFVWV1hZWjAxMjM0NSJ9j+j/gQAAAK8AAABXYloTeQs6ZXZlbnQtdHlwZQcAEWNvbnRlbnRCbG9ja0RlbHRhDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsiY29udGVudEJsb2NrSW5kZXgiOjAsImRlbHRhIjp7InRleHQiOiIgd2l0aCJ9LCJwIjoiYWJjZGVmZ2hpamtsbW5vcHEifRRyjnsAAACyAAAAV/oqQEoLOmV2ZW50LXR5cGUHABFjb250ZW50QmxvY2tEZWx0YQ06Y29udGVudC10eXBlBwAQYXBwbGljYXRpb24vanNvbg06bWVzc2FnZS10eXBlBwAFZXZlbnR7ImNvbnRlbnRCbG9ja0luZGV4IjowLCJkZWx0YSI6eyJ0ZXh0IjoiIGEifSwicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3In2kLJI+AAAAuAAAAFewmljrCzpldmVudC10eXBlBwARY29udGVudEJsb2NrRGVsdGENOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24NOm1lc3NhZ2UtdHlwZQcABWV2ZW50eyJjb250ZW50QmxvY2tJbmRleCI6MCwiZGVsdGEiOnsidGV4dCI6IiB0ZW1wZXJhdHVyZSJ9LCJwIjoiYWJjZGVmZ2hpamtsbW5vcHFycyJ9JuTWEQAAAKEAAABX3WqtGAs6ZXZlbnQtdHlwZQcAEWNvbnRlbnRCbG9ja0RlbHRhDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsiY29udGVudEJsb2NrSW5kZXgiOjAsImRlbHRhIjp7InRleHQiOiIgb2YifSwicCI6ImFiY2RlIn1Uu0Z+AAAAmwAAAFc2+w0/CzpldmVudC10eXBlBwARY29udGVudEJsb2NrRGVsdGENOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24NOm1lc3NhZ2UtdHlwZQcABWV2ZW50eyJjb250ZW50QmxvY2tJbmRleCI6MCwiZGVsdGEiOnsidGV4dCI6IiJ9LCJwIjoiYWIifaR9kNQAAAC4AAAAV7CaWOsLOmV2ZW50LXR5cGUHABFjb250ZW50QmxvY2tEZWx0YQ06Y29udGVudC10eXBlBwAQYXBwbGljYXRpb24vanNvbg06bWVzc2FnZS10eXBlBwAFZXZlbnR7ImNvbnRlbnRCbG9ja0luZGV4IjowLCJkZWx0YSI6eyJ0ZXh0IjoiIDIifSwicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6QUJDIn04fpEGAAAApQAAAFco6gvYCzpldmVudC10eXBlBwARY29udGVudEJsb2NrRGVsdGENOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24NOm1lc3NhZ2UtdHlwZQcABWV2ZW50eyJjb250ZW50QmxvY2tJbmRleCI6MCwiZGVsdGEiOnsidGV4dCI6IjIifSwicCI6ImFiY2RlZmdoaWprIn0ws3/UAAAA1gAAAFeWmJHHCzpldmVudC10eXBlBwARY29udGVudEJsb2NrRGVsdGENOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24NOm1lc3NhZ2UtdHlwZQcABWV2ZW50eyJjb250ZW50QmxvY2tJbmRleCI6MCwiZGVsdGEiOnsidGV4dCI6IiBkZWdyZWVzIn0sInAiOiJhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ekFCQ0RFRkdISUpLTE1OT1BRUlNUVVZXWFlaMCJ9q7xKeQAAAJ8AAABXw3ur/ws6ZXZlbnQtdHlwZQcAEWNvbnRlbnRCbG9ja0RlbHRhDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsiY29udGVudEJsb2NrSW5kZXgiOjAsImRlbHRhIjp7InRleHQiOiIuIn0sInAiOiJhYmNkZSJ9t7YAjQAAAMUAAABXsdh8lQs6ZXZlbnQtdHlwZQcAEWNvbnRlbnRCbG9ja0RlbHRhDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsiY29udGVudEJsb2NrSW5kZXgiOjAsImRlbHRhIjp7InRleHQiOiIifSwicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6QUJDREVGR0hJSktMTU5PUFFSIn1NJJR+AAAAsQAAAFbKjQoMCzpldmVudC10eXBlBwAQY29udGVudEJsb2NrU3RvcA06Y29udGVudC10eXBlBwAQYXBwbGljYXRpb24vanNvbg06bWVzc2FnZS10eXBlBwAFZXZlbnR7ImNvbnRlbnRCbG9ja0luZGV4IjowLCJwIjoiYWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXpBQkNERUZHSElKS0xNTk9QUVJTIn1DzHT/AAAAiAAAAFH42EVYCzpldmVudC10eXBlBwALbWVzc2FnZVN0b3ANOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24NOm1lc3NhZ2UtdHlwZQcABWV2ZW50eyJwIjoiYWJjZGVmZyIsInN0b3BSZWFzb24iOiJlbmRfdHVybiJ9rwP92gAAAOAAAABO3JJ0IQs6ZXZlbnQtdHlwZQcACG1ldGFkYXRhDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsibWV0cmljcyI6eyJsYXRlbmN5TXMiOjM4MX0sInAiOiJhYmNkZWZnaGkiLCJ1c2FnZSI6eyJpbnB1dFRva2VucyI6NTEwLCJvdXRwdXRUb2tlbnMiOjE2LCJzZXJ2ZXJUb29sVXNhZ2UiOnt9LCJ0b3RhbFRva2VucyI6NTI2fX2ZCNET", + "bodyEncoding": "base64" + } + } + ] +} diff --git a/packages/llm/test/fixtures/recordings/bedrock-converse/streams-a-tool-call.json b/packages/llm/test/fixtures/recordings/bedrock-converse/streams-a-tool-call.json new file mode 100644 index 0000000000..4f22ce22da --- /dev/null +++ b/packages/llm/test/fixtures/recordings/bedrock-converse/streams-a-tool-call.json @@ -0,0 +1,29 @@ +{ + "version": 1, + "metadata": { + "name": "bedrock-converse/streams-a-tool-call", + "recordedAt": "2026-04-28T21:18:46.929Z", + "tags": ["prefix:bedrock-converse", "provider:amazon-bedrock", "protocol:bedrock-converse", "tool"] + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://bedrock-runtime.us-east-1.amazonaws.com/model/us.amazon.nova-micro-v1%3A0/converse-stream", + "headers": { + "content-type": "application/json" + }, + "body": "{\"modelId\":\"us.amazon.nova-micro-v1:0\",\"messages\":[{\"role\":\"user\",\"content\":[{\"text\":\"Call get_weather with city exactly Paris.\"}]}],\"system\":[{\"text\":\"Call tools exactly as requested.\"}],\"inferenceConfig\":{\"maxTokens\":80,\"temperature\":0},\"toolConfig\":{\"tools\":[{\"toolSpec\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"inputSchema\":{\"json\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}}}],\"toolChoice\":{\"tool\":{\"name\":\"get_weather\"}}}}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "application/vnd.amazon.eventstream" + }, + "body": "AAAAuQAAAFL9kIXUCzpldmVudC10eXBlBwAMbWVzc2FnZVN0YXJ0DTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVowMTIzNDU2NyIsInJvbGUiOiJhc3Npc3RhbnQifWf51EkAAAEMAAAAV56BJZoLOmV2ZW50LXR5cGUHABFjb250ZW50QmxvY2tTdGFydA06Y29udGVudC10eXBlBwAQYXBwbGljYXRpb24vanNvbg06bWVzc2FnZS10eXBlBwAFZXZlbnR7ImNvbnRlbnRCbG9ja0luZGV4IjowLCJwIjoiYWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXpBQkNERUZHSElKS0xNTk9QUVJTVFUiLCJzdGFydCI6eyJ0b29sVXNlIjp7Im5hbWUiOiJnZXRfd2VhdGhlciIsInRvb2xVc2VJZCI6InRvb2x1c2VfNmExcFB2bmM5OUdMS08zS0drVUEyTiJ9fX2LR7PFAAAA4gAAAFfCOY+BCzpldmVudC10eXBlBwARY29udGVudEJsb2NrRGVsdGENOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24NOm1lc3NhZ2UtdHlwZQcABWV2ZW50eyJjb250ZW50QmxvY2tJbmRleCI6MCwiZGVsdGEiOnsidG9vbFVzZSI6eyJpbnB1dCI6IntcImNpdHlcIjpcIlBhcmlzXCJ9In19LCJwIjoiYWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXpBQkNERUZHSElKS0xNTiJ9RkW+2gAAAIcAAABW5OxHKgs6ZXZlbnQtdHlwZQcAEGNvbnRlbnRCbG9ja1N0b3ANOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24NOm1lc3NhZ2UtdHlwZQcABWV2ZW50eyJjb250ZW50QmxvY2tJbmRleCI6MCwicCI6ImFiYyJ9y6nrtwAAAK4AAABRtlmf/As6ZXZlbnQtdHlwZQcAC21lc3NhZ2VTdG9wDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6QUJDREVGR0hJSktMTU5PUFFSUyIsInN0b3BSZWFzb24iOiJ0b29sX3VzZSJ9MTlQawAAAOIAAABOplInQQs6ZXZlbnQtdHlwZQcACG1ldGFkYXRhDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsibWV0cmljcyI6eyJsYXRlbmN5TXMiOjM1NX0sInAiOiJhYmNkZWZnaGlqayIsInVzYWdlIjp7ImlucHV0VG9rZW5zIjo0MTksIm91dHB1dFRva2VucyI6MTYsInNlcnZlclRvb2xVc2FnZSI6e30sInRvdGFsVG9rZW5zIjo0MzV9fU1tVJc=", + "bodyEncoding": "base64" + } + } + ] +} diff --git a/packages/llm/test/fixtures/recordings/bedrock-converse/streams-text.json b/packages/llm/test/fixtures/recordings/bedrock-converse/streams-text.json new file mode 100644 index 0000000000..7eaacec02b --- /dev/null +++ b/packages/llm/test/fixtures/recordings/bedrock-converse/streams-text.json @@ -0,0 +1,29 @@ +{ + "version": 1, + "metadata": { + "name": "bedrock-converse/streams-text", + "recordedAt": "2026-04-28T21:18:46.553Z", + "tags": ["prefix:bedrock-converse", "provider:amazon-bedrock", "protocol:bedrock-converse"] + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://bedrock-runtime.us-east-1.amazonaws.com/model/us.amazon.nova-micro-v1%3A0/converse-stream", + "headers": { + "content-type": "application/json" + }, + "body": "{\"modelId\":\"us.amazon.nova-micro-v1:0\",\"messages\":[{\"role\":\"user\",\"content\":[{\"text\":\"Say hello.\"}]}],\"system\":[{\"text\":\"Reply with the single word 'Hello'.\"}],\"inferenceConfig\":{\"maxTokens\":16,\"temperature\":0}}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "application/vnd.amazon.eventstream" + }, + "body": "AAAAmQAAAFI8UarQCzpldmVudC10eXBlBwAMbWVzc2FnZVN0YXJ0DTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6QUIiLCJyb2xlIjoiYXNzaXN0YW50In3SL1jNAAAAvQAAAFd4etebCzpldmVudC10eXBlBwARY29udGVudEJsb2NrRGVsdGENOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24NOm1lc3NhZ2UtdHlwZQcABWV2ZW50eyJjb250ZW50QmxvY2tJbmRleCI6MCwiZGVsdGEiOnsidGV4dCI6IkhlbGxvIn0sInAiOiJhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ekFCQ0RFIn2B0NR6AAAAxgAAAFf2eAZFCzpldmVudC10eXBlBwARY29udGVudEJsb2NrRGVsdGENOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24NOm1lc3NhZ2UtdHlwZQcABWV2ZW50eyJjb250ZW50QmxvY2tJbmRleCI6MCwiZGVsdGEiOnsidGV4dCI6IiJ9LCJwIjoiYWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXpBQkNERUZHSElKS0xNTk9QUVJTIn3XaHMvAAAAhwAAAFbk7EcqCzpldmVudC10eXBlBwAQY29udGVudEJsb2NrU3RvcA06Y29udGVudC10eXBlBwAQYXBwbGljYXRpb24vanNvbg06bWVzc2FnZS10eXBlBwAFZXZlbnR7ImNvbnRlbnRCbG9ja0luZGV4IjowLCJwIjoiYWJjIn3Lqeu3AAAAjwAAAFFK+JlICzpldmVudC10eXBlBwALbWVzc2FnZVN0b3ANOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24NOm1lc3NhZ2UtdHlwZQcABWV2ZW50eyJwIjoiYWJjZGVmZ2hpamtsbW4iLCJzdG9wUmVhc29uIjoiZW5kX3R1cm4ifZ+RQqEAAAECAAAATkXaMzsLOmV2ZW50LXR5cGUHAAhtZXRhZGF0YQ06Y29udGVudC10eXBlBwAQYXBwbGljYXRpb24vanNvbg06bWVzc2FnZS10eXBlBwAFZXZlbnR7Im1ldHJpY3MiOnsibGF0ZW5jeU1zIjozMDZ9LCJwIjoiYWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXpBQkNERUZHSElKS0xNTk9QUVJTVCIsInVzYWdlIjp7ImlucHV0VG9rZW5zIjoxMiwib3V0cHV0VG9rZW5zIjoyLCJzZXJ2ZXJUb29sVXNhZ2UiOnt9LCJ0b3RhbFRva2VucyI6MTR9fSnnkUk=", + "bodyEncoding": "base64" + } + } + ] +} diff --git a/packages/llm/test/fixtures/recordings/cloudflare-ai-gateway/cloudflare-ai-gateway-workers-ai-gpt-oss-20b-tools-tool-call.json b/packages/llm/test/fixtures/recordings/cloudflare-ai-gateway/cloudflare-ai-gateway-workers-ai-gpt-oss-20b-tools-tool-call.json new file mode 100644 index 0000000000..80ade53b9c --- /dev/null +++ b/packages/llm/test/fixtures/recordings/cloudflare-ai-gateway/cloudflare-ai-gateway-workers-ai-gpt-oss-20b-tools-tool-call.json @@ -0,0 +1,38 @@ +{ + "version": 1, + "metadata": { + "name": "cloudflare-ai-gateway/cloudflare-ai-gateway-workers-ai-gpt-oss-20b-tools-tool-call", + "recordedAt": "2026-05-08T17:20:08.287Z", + "provider": "cloudflare-ai-gateway", + "route": "cloudflare-ai-gateway", + "transport": "http", + "model": "workers-ai/@cf/openai/gpt-oss-20b", + "tags": [ + "prefix:cloudflare-ai-gateway", + "provider:cloudflare-ai-gateway", + "tool", + "tool-call", + "golden" + ] + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://gateway.ai.cloudflare.com/v1/{account}/{gateway}/compat/chat/completions", + "headers": { + "content-type": "application/json" + }, + "body": "{\"model\":\"workers-ai/@cf/openai/gpt-oss-20b\",\"messages\":[{\"role\":\"system\",\"content\":\"Call tools exactly as requested.\"},{\"role\":\"user\",\"content\":\"Call get_weather with city exactly Paris.\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}}],\"tool_choice\":{\"type\":\"function\",\"function\":{\"name\":\"get_weather\"}},\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":120,\"temperature\":0}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream" + }, + "body": "data: {\"id\":\"id-1778260808196\",\"created\":1778260808,\"model\":\"@cf/openai/gpt-oss-20b\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"id-1778260808196\",\"created\":1778260808,\"model\":\"@cf/openai/gpt-oss-20b\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\"\"},\"logprobs\":null,\"finish_reason\":null,\"token_ids\":null}]}\n\ndata: {\"id\":\"id-1778260808196\",\"created\":1778260808,\"model\":\"@cf/openai/gpt-oss-20b\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\"We\"},\"logprobs\":null,\"finish_reason\":null,\"token_ids\":null}]}\n\ndata: {\"id\":\"id-1778260808196\",\"created\":1778260808,\"model\":\"@cf/openai/gpt-oss-20b\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" need\"},\"logprobs\":null,\"finish_reason\":null,\"token_ids\":null}]}\n\ndata: {\"id\":\"id-1778260808196\",\"created\":1778260808,\"model\":\"@cf/openai/gpt-oss-20b\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" to\"},\"logprobs\":null,\"finish_reason\":null,\"token_ids\":null}]}\n\ndata: {\"id\":\"id-1778260808196\",\"created\":1778260808,\"model\":\"@cf/openai/gpt-oss-20b\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" call\"},\"logprobs\":null,\"finish_reason\":null,\"token_ids\":null}]}\n\ndata: {\"id\":\"id-1778260808196\",\"created\":1778260808,\"model\":\"@cf/openai/gpt-oss-20b\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" the\"},\"logprobs\":null,\"finish_reason\":null,\"token_ids\":null}]}\n\ndata: {\"id\":\"id-1778260808196\",\"created\":1778260808,\"model\":\"@cf/openai/gpt-oss-20b\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" function\"},\"logprobs\":null,\"finish_reason\":null,\"token_ids\":null}]}\n\ndata: {\"id\":\"id-1778260808196\",\"created\":1778260808,\"model\":\"@cf/openai/gpt-oss-20b\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" get\"},\"logprobs\":null,\"finish_reason\":null,\"token_ids\":null}]}\n\ndata: {\"id\":\"id-1778260808196\",\"created\":1778260808,\"model\":\"@cf/openai/gpt-oss-20b\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\"_weather\"},\"logprobs\":null,\"finish_reason\":null,\"token_ids\":null}]}\n\ndata: {\"id\":\"id-1778260808196\",\"created\":1778260808,\"model\":\"@cf/openai/gpt-oss-20b\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" with\"},\"logprobs\":null,\"finish_reason\":null,\"token_ids\":null}]}\n\ndata: {\"id\":\"id-1778260808196\",\"created\":1778260808,\"model\":\"@cf/openai/gpt-oss-20b\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" city\"},\"logprobs\":null,\"finish_reason\":null,\"token_ids\":null}]}\n\ndata: {\"id\":\"id-1778260808196\",\"created\":1778260808,\"model\":\"@cf/openai/gpt-oss-20b\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" \\\"\"},\"logprobs\":null,\"finish_reason\":null,\"token_ids\":null}]}\n\ndata: {\"id\":\"id-1778260808196\",\"created\":1778260808,\"model\":\"@cf/openai/gpt-oss-20b\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\"Paris\"},\"logprobs\":null,\"finish_reason\":null,\"token_ids\":null}]}\n\ndata: {\"id\":\"id-1778260808196\",\"created\":1778260808,\"model\":\"@cf/openai/gpt-oss-20b\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\"\\\".\"},\"logprobs\":null,\"finish_reason\":null,\"token_ids\":null}]}\n\ndata: {\"id\":\"id-1778260808196\",\"created\":1778260808,\"model\":\"@cf/openai/gpt-oss-20b\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"id\":\"chatcmpl-tool-b975da5af1f843e095ba7062d8e108ba\",\"type\":\"function\",\"index\":0,\"function\":{\"name\":\"get_weather\",\"arguments\":\"\"}}]},\"logprobs\":null,\"finish_reason\":null,\"token_ids\":null}]}\n\ndata: {\"id\":\"id-1778260808196\",\"created\":1778260808,\"model\":\"@cf/openai/gpt-oss-20b\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"{\\\"\"}}]},\"logprobs\":null,\"finish_reason\":null,\"token_ids\":null}]}\n\ndata: {\"id\":\"id-1778260808196\",\"created\":1778260808,\"model\":\"@cf/openai/gpt-oss-20b\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"city\"}}]},\"logprobs\":null,\"finish_reason\":null,\"token_ids\":null}]}\n\ndata: {\"id\":\"id-1778260808196\",\"created\":1778260808,\"model\":\"@cf/openai/gpt-oss-20b\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"\\\":\\\"\"}}]},\"logprobs\":null,\"finish_reason\":null,\"token_ids\":null}]}\n\ndata: {\"id\":\"id-1778260808196\",\"created\":1778260808,\"model\":\"@cf/openai/gpt-oss-20b\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"Paris\"}}]},\"logprobs\":null,\"finish_reason\":null,\"token_ids\":null}]}\n\ndata: {\"id\":\"id-1778260808196\",\"created\":1778260808,\"model\":\"@cf/openai/gpt-oss-20b\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"\\\"}\"}}]},\"logprobs\":null,\"finish_reason\":null,\"token_ids\":null}]}\n\ndata: {\"id\":\"id-1778260808196\",\"created\":1778260808,\"model\":\"@cf/openai/gpt-oss-20b\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{},\"logprobs\":null,\"finish_reason\":\"tool_calls\",\"stop_reason\":200012,\"token_ids\":null}]}\n\ndata: {\"id\":\"id-1778260808196\",\"object\":\"chat.completion.chunk\",\"created\":1778260808,\"model\":\"@cf/openai/gpt-oss-20b\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"tool_calls\"}],\"usage\":{\"prompt_tokens\":136,\"total_tokens\":173,\"completion_tokens\":37}}\n\ndata: {\"id\":\"id-1778260808196\",\"object\":\"chat.completion.chunk\",\"created\":1778260808,\"model\":\"@cf/openai/gpt-oss-20b\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"tool_calls\"}],\"usage\":{\"prompt_tokens\":136,\"completion_tokens\":37,\"total_tokens\":173,\"prompt_tokens_details\":{\"cached_tokens\":0}}}\n\ndata: [DONE]\n\n" + } + } + ] +} diff --git a/packages/llm/test/fixtures/recordings/cloudflare-ai-gateway/cloudflare-ai-gateway-workers-ai-llama-3-1-8b-text.json b/packages/llm/test/fixtures/recordings/cloudflare-ai-gateway/cloudflare-ai-gateway-workers-ai-llama-3-1-8b-text.json new file mode 100644 index 0000000000..ff535b578b --- /dev/null +++ b/packages/llm/test/fixtures/recordings/cloudflare-ai-gateway/cloudflare-ai-gateway-workers-ai-llama-3-1-8b-text.json @@ -0,0 +1,37 @@ +{ + "version": 1, + "metadata": { + "name": "cloudflare-ai-gateway/cloudflare-ai-gateway-workers-ai-llama-3-1-8b-text", + "recordedAt": "2026-05-08T15:55:48.952Z", + "provider": "cloudflare-ai-gateway", + "route": "cloudflare-ai-gateway", + "transport": "http", + "model": "workers-ai/@cf/meta/llama-3.1-8b-instruct", + "tags": [ + "prefix:cloudflare-ai-gateway", + "provider:cloudflare-ai-gateway", + "text", + "golden" + ] + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://gateway.ai.cloudflare.com/v1/{account}/{gateway}/compat/chat/completions", + "headers": { + "content-type": "application/json" + }, + "body": "{\"model\":\"workers-ai/@cf/meta/llama-3.1-8b-instruct\",\"messages\":[{\"role\":\"system\",\"content\":\"You are concise.\"},{\"role\":\"user\",\"content\":\"Reply exactly with: Hello!\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":40,\"temperature\":0}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream" + }, + "body": "data: {\"id\":\"id-1778255748911\",\"created\":1778255748,\"model\":\"@cf/meta/llama-3.1-8b-instruct\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"Hello\"}}]}\n\ndata: {\"id\":\"id-1778255748911\",\"created\":1778255748,\"model\":\"@cf/meta/llama-3.1-8b-instruct\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"!\"}}]}\n\ndata: {\"id\":\"id-1778255748911\",\"object\":\"chat.completion.chunk\",\"created\":1778255748,\"model\":\"@cf/meta/llama-3.1-8b-instruct\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":45,\"completion_tokens\":2,\"total_tokens\":47}}\n\ndata: {\"id\":\"id-1778255748911\",\"object\":\"chat.completion.chunk\",\"created\":1778255748,\"model\":\"@cf/meta/llama-3.1-8b-instruct\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":0,\"completion_tokens\":0,\"total_tokens\":0,\"prompt_tokens_details\":{\"cached_tokens\":0}}}\n\ndata: [DONE]\n\n" + } + } + ] +} diff --git a/packages/llm/test/fixtures/recordings/cloudflare-workers-ai/cloudflare-workers-ai-gpt-oss-20b-tools-tool-call.json b/packages/llm/test/fixtures/recordings/cloudflare-workers-ai/cloudflare-workers-ai-gpt-oss-20b-tools-tool-call.json new file mode 100644 index 0000000000..2c973bffe1 --- /dev/null +++ b/packages/llm/test/fixtures/recordings/cloudflare-workers-ai/cloudflare-workers-ai-gpt-oss-20b-tools-tool-call.json @@ -0,0 +1,38 @@ +{ + "version": 1, + "metadata": { + "name": "cloudflare-workers-ai/cloudflare-workers-ai-gpt-oss-20b-tools-tool-call", + "recordedAt": "2026-05-08T17:20:14.106Z", + "provider": "cloudflare-workers-ai", + "route": "cloudflare-workers-ai", + "transport": "http", + "model": "@cf/openai/gpt-oss-20b", + "tags": [ + "prefix:cloudflare-workers-ai", + "provider:cloudflare-workers-ai", + "tool", + "tool-call", + "golden" + ] + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://api.cloudflare.com/client/v4/accounts/{account}/ai/v1/chat/completions", + "headers": { + "content-type": "application/json" + }, + "body": "{\"model\":\"@cf/openai/gpt-oss-20b\",\"messages\":[{\"role\":\"system\",\"content\":\"Call tools exactly as requested.\"},{\"role\":\"user\",\"content\":\"Call get_weather with city exactly Paris.\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}}],\"tool_choice\":{\"type\":\"function\",\"function\":{\"name\":\"get_weather\"}},\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":120,\"temperature\":0}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream" + }, + "body": "data: {\"id\":\"id-1778260814069\",\"created\":1778260814,\"model\":\"@cf/openai/gpt-oss-20b\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"id-1778260814069\",\"created\":1778260814,\"model\":\"@cf/openai/gpt-oss-20b\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\"\"},\"logprobs\":null,\"finish_reason\":null,\"token_ids\":null}]}\n\ndata: {\"id\":\"id-1778260814069\",\"created\":1778260814,\"model\":\"@cf/openai/gpt-oss-20b\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\"We\"},\"logprobs\":null,\"finish_reason\":null,\"token_ids\":null}]}\n\ndata: {\"id\":\"id-1778260814069\",\"created\":1778260814,\"model\":\"@cf/openai/gpt-oss-20b\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" need\"},\"logprobs\":null,\"finish_reason\":null,\"token_ids\":null}]}\n\ndata: {\"id\":\"id-1778260814069\",\"created\":1778260814,\"model\":\"@cf/openai/gpt-oss-20b\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" to\"},\"logprobs\":null,\"finish_reason\":null,\"token_ids\":null}]}\n\ndata: {\"id\":\"id-1778260814069\",\"created\":1778260814,\"model\":\"@cf/openai/gpt-oss-20b\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" call\"},\"logprobs\":null,\"finish_reason\":null,\"token_ids\":null}]}\n\ndata: {\"id\":\"id-1778260814069\",\"created\":1778260814,\"model\":\"@cf/openai/gpt-oss-20b\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" the\"},\"logprobs\":null,\"finish_reason\":null,\"token_ids\":null}]}\n\ndata: {\"id\":\"id-1778260814069\",\"created\":1778260814,\"model\":\"@cf/openai/gpt-oss-20b\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" function\"},\"logprobs\":null,\"finish_reason\":null,\"token_ids\":null}]}\n\ndata: {\"id\":\"id-1778260814069\",\"created\":1778260814,\"model\":\"@cf/openai/gpt-oss-20b\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" get\"},\"logprobs\":null,\"finish_reason\":null,\"token_ids\":null}]}\n\ndata: {\"id\":\"id-1778260814069\",\"created\":1778260814,\"model\":\"@cf/openai/gpt-oss-20b\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\"_weather\"},\"logprobs\":null,\"finish_reason\":null,\"token_ids\":null}]}\n\ndata: {\"id\":\"id-1778260814069\",\"created\":1778260814,\"model\":\"@cf/openai/gpt-oss-20b\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" with\"},\"logprobs\":null,\"finish_reason\":null,\"token_ids\":null}]}\n\ndata: {\"id\":\"id-1778260814069\",\"created\":1778260814,\"model\":\"@cf/openai/gpt-oss-20b\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" city\"},\"logprobs\":null,\"finish_reason\":null,\"token_ids\":null}]}\n\ndata: {\"id\":\"id-1778260814069\",\"created\":1778260814,\"model\":\"@cf/openai/gpt-oss-20b\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" \\\"\"},\"logprobs\":null,\"finish_reason\":null,\"token_ids\":null}]}\n\ndata: {\"id\":\"id-1778260814069\",\"created\":1778260814,\"model\":\"@cf/openai/gpt-oss-20b\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\"Paris\"},\"logprobs\":null,\"finish_reason\":null,\"token_ids\":null}]}\n\ndata: {\"id\":\"id-1778260814069\",\"created\":1778260814,\"model\":\"@cf/openai/gpt-oss-20b\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\"\\\".\"},\"logprobs\":null,\"finish_reason\":null,\"token_ids\":null}]}\n\ndata: {\"id\":\"id-1778260814069\",\"created\":1778260814,\"model\":\"@cf/openai/gpt-oss-20b\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"id\":\"chatcmpl-tool-ed7127682c90443da222d0f8c607b5d5\",\"type\":\"function\",\"index\":0,\"function\":{\"name\":\"get_weather\",\"arguments\":\"\"}}]},\"logprobs\":null,\"finish_reason\":null,\"token_ids\":null}]}\n\ndata: {\"id\":\"id-1778260814069\",\"created\":1778260814,\"model\":\"@cf/openai/gpt-oss-20b\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"{\\\"\"}}]},\"logprobs\":null,\"finish_reason\":null,\"token_ids\":null}]}\n\ndata: {\"id\":\"id-1778260814069\",\"created\":1778260814,\"model\":\"@cf/openai/gpt-oss-20b\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"city\"}}]},\"logprobs\":null,\"finish_reason\":null,\"token_ids\":null}]}\n\ndata: {\"id\":\"id-1778260814069\",\"created\":1778260814,\"model\":\"@cf/openai/gpt-oss-20b\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"\\\":\\\"\"}}]},\"logprobs\":null,\"finish_reason\":null,\"token_ids\":null}]}\n\ndata: {\"id\":\"id-1778260814069\",\"created\":1778260814,\"model\":\"@cf/openai/gpt-oss-20b\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"Paris\"}}]},\"logprobs\":null,\"finish_reason\":null,\"token_ids\":null}]}\n\ndata: {\"id\":\"id-1778260814069\",\"created\":1778260814,\"model\":\"@cf/openai/gpt-oss-20b\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"\\\"}\"}}]},\"logprobs\":null,\"finish_reason\":null,\"token_ids\":null}]}\n\ndata: {\"id\":\"id-1778260814069\",\"created\":1778260814,\"model\":\"@cf/openai/gpt-oss-20b\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{},\"logprobs\":null,\"finish_reason\":null,\"stop_reason\":200012,\"token_ids\":null}]}\n\ndata: {\"id\":\"id-1778260814069\",\"object\":\"chat.completion.chunk\",\"created\":1778260814,\"model\":\"@cf/openai/gpt-oss-20b\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"tool_calls\"}],\"usage\":{\"prompt_tokens\":136,\"total_tokens\":173,\"completion_tokens\":37}}\n\ndata: {\"id\":\"id-1778260814069\",\"object\":\"chat.completion.chunk\",\"created\":1778260814,\"model\":\"@cf/openai/gpt-oss-20b\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"tool_calls\"}],\"usage\":{\"prompt_tokens\":136,\"completion_tokens\":37,\"total_tokens\":173,\"prompt_tokens_details\":{\"cached_tokens\":0}}}\n\ndata: [DONE]\n\n" + } + } + ] +} diff --git a/packages/llm/test/fixtures/recordings/cloudflare-workers-ai/cloudflare-workers-ai-llama-3-1-8b-text.json b/packages/llm/test/fixtures/recordings/cloudflare-workers-ai/cloudflare-workers-ai-llama-3-1-8b-text.json new file mode 100644 index 0000000000..4ed314e15f --- /dev/null +++ b/packages/llm/test/fixtures/recordings/cloudflare-workers-ai/cloudflare-workers-ai-llama-3-1-8b-text.json @@ -0,0 +1,37 @@ +{ + "version": 1, + "metadata": { + "name": "cloudflare-workers-ai/cloudflare-workers-ai-llama-3-1-8b-text", + "recordedAt": "2026-05-08T15:56:18.284Z", + "provider": "cloudflare-workers-ai", + "route": "cloudflare-workers-ai", + "transport": "http", + "model": "@cf/meta/llama-3.1-8b-instruct", + "tags": [ + "prefix:cloudflare-workers-ai", + "provider:cloudflare-workers-ai", + "text", + "golden" + ] + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://api.cloudflare.com/client/v4/accounts/{account}/ai/v1/chat/completions", + "headers": { + "content-type": "application/json" + }, + "body": "{\"model\":\"@cf/meta/llama-3.1-8b-instruct\",\"messages\":[{\"role\":\"system\",\"content\":\"You are concise.\"},{\"role\":\"user\",\"content\":\"Reply exactly with: Hello!\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":40,\"temperature\":0}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream" + }, + "body": "data: {\"id\":\"id-1778255778230\",\"created\":1778255778,\"model\":\"@cf/meta/llama-3.1-8b-instruct\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"Hello\"}}]}\n\ndata: {\"id\":\"id-1778255778230\",\"created\":1778255778,\"model\":\"@cf/meta/llama-3.1-8b-instruct\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"!\"}}]}\n\ndata: {\"id\":\"id-1778255778230\",\"object\":\"chat.completion.chunk\",\"created\":1778255778,\"model\":\"@cf/meta/llama-3.1-8b-instruct\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":45,\"completion_tokens\":2,\"total_tokens\":47}}\n\ndata: {\"id\":\"id-1778255778230\",\"object\":\"chat.completion.chunk\",\"created\":1778255778,\"model\":\"@cf/meta/llama-3.1-8b-instruct\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":0,\"completion_tokens\":0,\"total_tokens\":0,\"prompt_tokens_details\":{\"cached_tokens\":0}}}\n\ndata: [DONE]\n\n" + } + } + ] +} diff --git a/packages/llm/test/fixtures/recordings/gemini/streams-text.json b/packages/llm/test/fixtures/recordings/gemini/streams-text.json new file mode 100644 index 0000000000..7f0e6b390e --- /dev/null +++ b/packages/llm/test/fixtures/recordings/gemini/streams-text.json @@ -0,0 +1,28 @@ +{ + "version": 1, + "metadata": { + "name": "gemini/streams-text", + "recordedAt": "2026-04-28T21:18:47.483Z", + "tags": ["prefix:gemini", "provider:google", "protocol:gemini"] + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse", + "headers": { + "content-type": "application/json" + }, + "body": "{\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"Reply with exactly: Hello!\"}]}],\"systemInstruction\":{\"parts\":[{\"text\":\"You are concise.\"}]},\"generationConfig\":{\"maxOutputTokens\":80,\"temperature\":0}}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream" + }, + "body": "data: {\"candidates\": [{\"content\": {\"parts\": [{\"text\": \"Hello!\"}],\"role\": \"model\"},\"finishReason\": \"STOP\",\"index\": 0}],\"usageMetadata\": {\"promptTokenCount\": 11,\"candidatesTokenCount\": 2,\"totalTokenCount\": 29,\"promptTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 11}],\"thoughtsTokenCount\": 16},\"modelVersion\": \"gemini-2.5-flash\",\"responseId\": \"NyTxaczMAZ-b_uMP6u--iQg\"}\r\n\r\n" + } + } + ] +} diff --git a/packages/llm/test/fixtures/recordings/gemini/streams-tool-call.json b/packages/llm/test/fixtures/recordings/gemini/streams-tool-call.json new file mode 100644 index 0000000000..a526910f0d --- /dev/null +++ b/packages/llm/test/fixtures/recordings/gemini/streams-tool-call.json @@ -0,0 +1,28 @@ +{ + "version": 1, + "metadata": { + "name": "gemini/streams-tool-call", + "recordedAt": "2026-04-28T21:18:48.285Z", + "tags": ["prefix:gemini", "provider:google", "protocol:gemini", "tool"] + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse", + "headers": { + "content-type": "application/json" + }, + "body": "{\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"Call get_weather with city exactly Paris.\"}]}],\"systemInstruction\":{\"parts\":[{\"text\":\"Call tools exactly as requested.\"}]},\"tools\":[{\"functionDeclarations\":[{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"required\":[\"city\"],\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}}}}]}],\"toolConfig\":{\"functionCallingConfig\":{\"mode\":\"ANY\",\"allowedFunctionNames\":[\"get_weather\"]}},\"generationConfig\":{\"maxOutputTokens\":80,\"temperature\":0}}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream" + }, + "body": "data: {\"candidates\": [{\"content\": {\"parts\": [{\"functionCall\": {\"name\": \"get_weather\",\"args\": {\"city\": \"Paris\"}},\"thoughtSignature\": \"CiQBDDnWx5RcSsS1UMbykQ5HWlrMu6wrxXGUhmZ0uRKLaMhDZaEKXwEMOdbHVoJAlfbOQyKB378pDZ/gkjWr3HP+dWw1us1kMG22g4G3oJvuTq/SrWS+7KYtSlvOxCKhW2l/2/TczpyGyGmANmsusDcxF1SKOYA5/8Hg0nI24MAlT3+91V/MCoUBAQw51seClFLy3E71v2H44F1kpmjgz8FeTRZofrjbaazfrT+w8Yxgdr3UgGagLMY4OadZemQTWckq9IAqRum78hrBg6NGtQvn15SbtfTNqI4PcxX/+qPo4/g4/ZT5kVORDhVqO8BVP/RA5GQ3ce3sRK8hSkvQlXSoXIPpHh6x7hBezIGXzw==\"}],\"role\": \"model\"},\"finishReason\": \"STOP\",\"index\": 0,\"finishMessage\": \"Model generated function call(s).\"}],\"usageMetadata\": {\"promptTokenCount\": 55,\"candidatesTokenCount\": 15,\"totalTokenCount\": 115,\"promptTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 55}],\"thoughtsTokenCount\": 45},\"modelVersion\": \"gemini-2.5-flash\",\"responseId\": \"NyTxaYuTJ_OW_uMPgIPKgAg\"}\r\n\r\n" + } + } + ] +} diff --git a/packages/llm/test/fixtures/recordings/openai-chat/continues-after-tool-result.json b/packages/llm/test/fixtures/recordings/openai-chat/continues-after-tool-result.json new file mode 100644 index 0000000000..7c02a93f0b --- /dev/null +++ b/packages/llm/test/fixtures/recordings/openai-chat/continues-after-tool-result.json @@ -0,0 +1,28 @@ +{ + "version": 1, + "metadata": { + "name": "openai-chat/continues-after-tool-result", + "recordedAt": "2026-05-06T01:33:31.878Z", + "tags": ["prefix:openai-chat", "provider:openai", "protocol:openai-chat", "tool"] + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://api.openai.com/v1/chat/completions", + "headers": { + "content-type": "application/json" + }, + "body": "{\"model\":\"gpt-4o-mini\",\"messages\":[{\"role\":\"system\",\"content\":\"Answer using only the provided tool result.\"},{\"role\":\"user\",\"content\":\"What is the weather in Paris?\"},{\"role\":\"assistant\",\"content\":null,\"tool_calls\":[{\"id\":\"call_weather\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\"}}]},{\"role\":\"tool\",\"tool_call_id\":\"call_weather\",\"content\":\"{\\\"forecast\\\":\\\"sunny\\\",\\\"temperature_c\\\":22}\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":40,\"temperature\":0}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream; charset=utf-8" + }, + "body": "data: {\"id\":\"chatcmpl-DcLQhErGVsn8x3hNFmX5A0yM0T9Km\",\"object\":\"chat.completion.chunk\",\"created\":1778031211,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_57133166c6\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\",\"refusal\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"gJ6VDZ2ZE\"}\n\ndata: {\"id\":\"chatcmpl-DcLQhErGVsn8x3hNFmX5A0yM0T9Km\",\"object\":\"chat.completion.chunk\",\"created\":1778031211,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_57133166c6\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"The\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"B2pU6Neg\"}\n\ndata: {\"id\":\"chatcmpl-DcLQhErGVsn8x3hNFmX5A0yM0T9Km\",\"object\":\"chat.completion.chunk\",\"created\":1778031211,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_57133166c6\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" weather\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"sa2\"}\n\ndata: {\"id\":\"chatcmpl-DcLQhErGVsn8x3hNFmX5A0yM0T9Km\",\"object\":\"chat.completion.chunk\",\"created\":1778031211,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_57133166c6\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" in\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"ENFjAfta\"}\n\ndata: {\"id\":\"chatcmpl-DcLQhErGVsn8x3hNFmX5A0yM0T9Km\",\"object\":\"chat.completion.chunk\",\"created\":1778031211,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_57133166c6\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" Paris\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"E1Kbi\"}\n\ndata: {\"id\":\"chatcmpl-DcLQhErGVsn8x3hNFmX5A0yM0T9Km\",\"object\":\"chat.completion.chunk\",\"created\":1778031211,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_57133166c6\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" is\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"NWj8HasA\"}\n\ndata: {\"id\":\"chatcmpl-DcLQhErGVsn8x3hNFmX5A0yM0T9Km\",\"object\":\"chat.completion.chunk\",\"created\":1778031211,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_57133166c6\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" sunny\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"irmMg\"}\n\ndata: {\"id\":\"chatcmpl-DcLQhErGVsn8x3hNFmX5A0yM0T9Km\",\"object\":\"chat.completion.chunk\",\"created\":1778031211,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_57133166c6\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" with\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"3eCMq6\"}\n\ndata: {\"id\":\"chatcmpl-DcLQhErGVsn8x3hNFmX5A0yM0T9Km\",\"object\":\"chat.completion.chunk\",\"created\":1778031211,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_57133166c6\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" a\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"XKMqPUsnt\"}\n\ndata: {\"id\":\"chatcmpl-DcLQhErGVsn8x3hNFmX5A0yM0T9Km\",\"object\":\"chat.completion.chunk\",\"created\":1778031211,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_57133166c6\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" temperature\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"BFVrBA09z9Y3lAC\"}\n\ndata: {\"id\":\"chatcmpl-DcLQhErGVsn8x3hNFmX5A0yM0T9Km\",\"object\":\"chat.completion.chunk\",\"created\":1778031211,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_57133166c6\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" of\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"AwG4puOX\"}\n\ndata: {\"id\":\"chatcmpl-DcLQhErGVsn8x3hNFmX5A0yM0T9Km\",\"object\":\"chat.completion.chunk\",\"created\":1778031211,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_57133166c6\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" \"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"pKQU39KXN6\"}\n\ndata: {\"id\":\"chatcmpl-DcLQhErGVsn8x3hNFmX5A0yM0T9Km\",\"object\":\"chat.completion.chunk\",\"created\":1778031211,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_57133166c6\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"22\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"xeTNA1JuE\"}\n\ndata: {\"id\":\"chatcmpl-DcLQhErGVsn8x3hNFmX5A0yM0T9Km\",\"object\":\"chat.completion.chunk\",\"created\":1778031211,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_57133166c6\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"°C\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"kNilBK4Nm\"}\n\ndata: {\"id\":\"chatcmpl-DcLQhErGVsn8x3hNFmX5A0yM0T9Km\",\"object\":\"chat.completion.chunk\",\"created\":1778031211,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_57133166c6\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\".\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"BrXQlZOd1Q\"}\n\ndata: {\"id\":\"chatcmpl-DcLQhErGVsn8x3hNFmX5A0yM0T9Km\",\"object\":\"chat.completion.chunk\",\"created\":1778031211,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_57133166c6\",\"choices\":[{\"index\":0,\"delta\":{},\"logprobs\":null,\"finish_reason\":\"stop\"}],\"usage\":null,\"obfuscation\":\"lzLXy\"}\n\ndata: {\"id\":\"chatcmpl-DcLQhErGVsn8x3hNFmX5A0yM0T9Km\",\"object\":\"chat.completion.chunk\",\"created\":1778031211,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_57133166c6\",\"choices\":[],\"usage\":{\"prompt_tokens\":59,\"completion_tokens\":14,\"total_tokens\":73,\"prompt_tokens_details\":{\"cached_tokens\":0,\"audio_tokens\":0},\"completion_tokens_details\":{\"reasoning_tokens\":0,\"audio_tokens\":0,\"accepted_prediction_tokens\":0,\"rejected_prediction_tokens\":0}},\"obfuscation\":\"5z1JJjgtey\"}\n\ndata: [DONE]\n\n" + } + } + ] +} diff --git a/packages/llm/test/fixtures/recordings/openai-chat/drives-a-tool-loop-end-to-end.json b/packages/llm/test/fixtures/recordings/openai-chat/drives-a-tool-loop-end-to-end.json new file mode 100644 index 0000000000..fdc5fa7916 --- /dev/null +++ b/packages/llm/test/fixtures/recordings/openai-chat/drives-a-tool-loop-end-to-end.json @@ -0,0 +1,46 @@ +{ + "version": 1, + "metadata": { + "name": "openai-chat/drives-a-tool-loop-end-to-end", + "recordedAt": "2026-05-06T01:33:29.747Z", + "tags": ["prefix:openai-chat", "provider:openai", "protocol:openai-chat", "tool", "tool-loop"] + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://api.openai.com/v1/chat/completions", + "headers": { + "content-type": "application/json" + }, + "body": "{\"model\":\"gpt-4o-mini\",\"messages\":[{\"role\":\"system\",\"content\":\"Use the get_weather tool, then answer in one short sentence.\"},{\"role\":\"user\",\"content\":\"What is the weather in Paris?\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":80,\"temperature\":0}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream; charset=utf-8" + }, + "body": "data: {\"id\":\"chatcmpl-DcLQeieQn9xQe2QqsLPi7rN15bnJF\",\"object\":\"chat.completion.chunk\",\"created\":1778031208,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_99cf176092\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":null,\"tool_calls\":[{\"index\":0,\"id\":\"call_tyZNHs2AudCbG4XJUEmX5Waw\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"\"}}],\"refusal\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"ayQl\"}\n\ndata: {\"id\":\"chatcmpl-DcLQeieQn9xQe2QqsLPi7rN15bnJF\",\"object\":\"chat.completion.chunk\",\"created\":1778031208,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_99cf176092\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"{\\\"\"}}]},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"TWZNUL5mYYtjWu\"}\n\ndata: {\"id\":\"chatcmpl-DcLQeieQn9xQe2QqsLPi7rN15bnJF\",\"object\":\"chat.completion.chunk\",\"created\":1778031208,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_99cf176092\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"city\"}}]},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"QidSCtgZRvDHL\"}\n\ndata: {\"id\":\"chatcmpl-DcLQeieQn9xQe2QqsLPi7rN15bnJF\",\"object\":\"chat.completion.chunk\",\"created\":1778031208,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_99cf176092\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"\\\":\\\"\"}}]},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"nupQO1L4GdWo\"}\n\ndata: {\"id\":\"chatcmpl-DcLQeieQn9xQe2QqsLPi7rN15bnJF\",\"object\":\"chat.completion.chunk\",\"created\":1778031208,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_99cf176092\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"Paris\"}}]},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"3W5B3hzGrFvl\"}\n\ndata: {\"id\":\"chatcmpl-DcLQeieQn9xQe2QqsLPi7rN15bnJF\",\"object\":\"chat.completion.chunk\",\"created\":1778031208,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_99cf176092\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"\\\"}\"}}]},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"JgscYuZR4Lmp5S\"}\n\ndata: {\"id\":\"chatcmpl-DcLQeieQn9xQe2QqsLPi7rN15bnJF\",\"object\":\"chat.completion.chunk\",\"created\":1778031208,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_99cf176092\",\"choices\":[{\"index\":0,\"delta\":{},\"logprobs\":null,\"finish_reason\":\"tool_calls\"}],\"usage\":null,\"obfuscation\":\"BtZF5TaQjX3UwLN\"}\n\ndata: {\"id\":\"chatcmpl-DcLQeieQn9xQe2QqsLPi7rN15bnJF\",\"object\":\"chat.completion.chunk\",\"created\":1778031208,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_99cf176092\",\"choices\":[],\"usage\":{\"prompt_tokens\":64,\"completion_tokens\":14,\"total_tokens\":78,\"prompt_tokens_details\":{\"cached_tokens\":0,\"audio_tokens\":0},\"completion_tokens_details\":{\"reasoning_tokens\":0,\"audio_tokens\":0,\"accepted_prediction_tokens\":0,\"rejected_prediction_tokens\":0}},\"obfuscation\":\"bZ51l7ptxM\"}\n\ndata: [DONE]\n\n" + } + }, + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://api.openai.com/v1/chat/completions", + "headers": { + "content-type": "application/json" + }, + "body": "{\"model\":\"gpt-4o-mini\",\"messages\":[{\"role\":\"system\",\"content\":\"Use the get_weather tool, then answer in one short sentence.\"},{\"role\":\"user\",\"content\":\"What is the weather in Paris?\"},{\"role\":\"assistant\",\"content\":null,\"tool_calls\":[{\"id\":\"call_tyZNHs2AudCbG4XJUEmX5Waw\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\"}}]},{\"role\":\"tool\",\"tool_call_id\":\"call_tyZNHs2AudCbG4XJUEmX5Waw\",\"content\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":80,\"temperature\":0}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream; charset=utf-8" + }, + "body": "data: {\"id\":\"chatcmpl-DcLQfUuhXefq7QDmGNhpEN5IqEKMM\",\"object\":\"chat.completion.chunk\",\"created\":1778031209,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_99cf176092\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\",\"refusal\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"SCCu2B8Ri\"}\n\ndata: {\"id\":\"chatcmpl-DcLQfUuhXefq7QDmGNhpEN5IqEKMM\",\"object\":\"chat.completion.chunk\",\"created\":1778031209,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_99cf176092\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"The\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"vuE4h8te\"}\n\ndata: {\"id\":\"chatcmpl-DcLQfUuhXefq7QDmGNhpEN5IqEKMM\",\"object\":\"chat.completion.chunk\",\"created\":1778031209,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_99cf176092\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" weather\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"uzt\"}\n\ndata: {\"id\":\"chatcmpl-DcLQfUuhXefq7QDmGNhpEN5IqEKMM\",\"object\":\"chat.completion.chunk\",\"created\":1778031209,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_99cf176092\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" in\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"4vVdGuJc\"}\n\ndata: {\"id\":\"chatcmpl-DcLQfUuhXefq7QDmGNhpEN5IqEKMM\",\"object\":\"chat.completion.chunk\",\"created\":1778031209,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_99cf176092\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" Paris\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"hAfFt\"}\n\ndata: {\"id\":\"chatcmpl-DcLQfUuhXefq7QDmGNhpEN5IqEKMM\",\"object\":\"chat.completion.chunk\",\"created\":1778031209,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_99cf176092\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" is\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"uuNXNXne\"}\n\ndata: {\"id\":\"chatcmpl-DcLQfUuhXefq7QDmGNhpEN5IqEKMM\",\"object\":\"chat.completion.chunk\",\"created\":1778031209,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_99cf176092\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" sunny\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"HRMlI\"}\n\ndata: {\"id\":\"chatcmpl-DcLQfUuhXefq7QDmGNhpEN5IqEKMM\",\"object\":\"chat.completion.chunk\",\"created\":1778031209,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_99cf176092\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" with\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"Ii1R2u\"}\n\ndata: {\"id\":\"chatcmpl-DcLQfUuhXefq7QDmGNhpEN5IqEKMM\",\"object\":\"chat.completion.chunk\",\"created\":1778031209,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_99cf176092\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" a\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"ay3ddthfT\"}\n\ndata: {\"id\":\"chatcmpl-DcLQfUuhXefq7QDmGNhpEN5IqEKMM\",\"object\":\"chat.completion.chunk\",\"created\":1778031209,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_99cf176092\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" temperature\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"PtxyVsfiluBGiWj\"}\n\ndata: {\"id\":\"chatcmpl-DcLQfUuhXefq7QDmGNhpEN5IqEKMM\",\"object\":\"chat.completion.chunk\",\"created\":1778031209,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_99cf176092\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" of\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"WuI4V7O6\"}\n\ndata: {\"id\":\"chatcmpl-DcLQfUuhXefq7QDmGNhpEN5IqEKMM\",\"object\":\"chat.completion.chunk\",\"created\":1778031209,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_99cf176092\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" \"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"Z5wHwpykrS\"}\n\ndata: {\"id\":\"chatcmpl-DcLQfUuhXefq7QDmGNhpEN5IqEKMM\",\"object\":\"chat.completion.chunk\",\"created\":1778031209,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_99cf176092\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"22\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"Fi66TTzMb\"}\n\ndata: {\"id\":\"chatcmpl-DcLQfUuhXefq7QDmGNhpEN5IqEKMM\",\"object\":\"chat.completion.chunk\",\"created\":1778031209,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_99cf176092\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"°C\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"AFnwTAm2P\"}\n\ndata: {\"id\":\"chatcmpl-DcLQfUuhXefq7QDmGNhpEN5IqEKMM\",\"object\":\"chat.completion.chunk\",\"created\":1778031209,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_99cf176092\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\".\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"xW7U4YToVK\"}\n\ndata: {\"id\":\"chatcmpl-DcLQfUuhXefq7QDmGNhpEN5IqEKMM\",\"object\":\"chat.completion.chunk\",\"created\":1778031209,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_99cf176092\",\"choices\":[{\"index\":0,\"delta\":{},\"logprobs\":null,\"finish_reason\":\"stop\"}],\"usage\":null,\"obfuscation\":\"O0Tks\"}\n\ndata: {\"id\":\"chatcmpl-DcLQfUuhXefq7QDmGNhpEN5IqEKMM\",\"object\":\"chat.completion.chunk\",\"created\":1778031209,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_99cf176092\",\"choices\":[],\"usage\":{\"prompt_tokens\":96,\"completion_tokens\":15,\"total_tokens\":111,\"prompt_tokens_details\":{\"cached_tokens\":0,\"audio_tokens\":0},\"completion_tokens_details\":{\"reasoning_tokens\":0,\"audio_tokens\":0,\"accepted_prediction_tokens\":0,\"rejected_prediction_tokens\":0}},\"obfuscation\":\"advcu5qYJ\"}\n\ndata: [DONE]\n\n" + } + } + ] +} diff --git a/packages/llm/test/fixtures/recordings/openai-chat/streams-text.json b/packages/llm/test/fixtures/recordings/openai-chat/streams-text.json new file mode 100644 index 0000000000..c86a29a462 --- /dev/null +++ b/packages/llm/test/fixtures/recordings/openai-chat/streams-text.json @@ -0,0 +1,28 @@ +{ + "version": 1, + "metadata": { + "name": "openai-chat/streams-text", + "recordedAt": "2026-05-06T01:33:30.542Z", + "tags": ["prefix:openai-chat", "provider:openai", "protocol:openai-chat"] + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://api.openai.com/v1/chat/completions", + "headers": { + "content-type": "application/json" + }, + "body": "{\"model\":\"gpt-4o-mini\",\"messages\":[{\"role\":\"system\",\"content\":\"You are concise.\"},{\"role\":\"user\",\"content\":\"Say hello in one short sentence.\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":20,\"temperature\":0}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream; charset=utf-8" + }, + "body": "data: {\"id\":\"chatcmpl-DcLQgbFetadY4JFl0fHK0g7OYsCOL\",\"object\":\"chat.completion.chunk\",\"created\":1778031210,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_57133166c6\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\",\"refusal\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"g9SWm2h6J\"}\n\ndata: {\"id\":\"chatcmpl-DcLQgbFetadY4JFl0fHK0g7OYsCOL\",\"object\":\"chat.completion.chunk\",\"created\":1778031210,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_57133166c6\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"Hello\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"lVzwlh\"}\n\ndata: {\"id\":\"chatcmpl-DcLQgbFetadY4JFl0fHK0g7OYsCOL\",\"object\":\"chat.completion.chunk\",\"created\":1778031210,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_57133166c6\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"!\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"onzhziaLGv\"}\n\ndata: {\"id\":\"chatcmpl-DcLQgbFetadY4JFl0fHK0g7OYsCOL\",\"object\":\"chat.completion.chunk\",\"created\":1778031210,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_57133166c6\",\"choices\":[{\"index\":0,\"delta\":{},\"logprobs\":null,\"finish_reason\":\"stop\"}],\"usage\":null,\"obfuscation\":\"LzUj1\"}\n\ndata: {\"id\":\"chatcmpl-DcLQgbFetadY4JFl0fHK0g7OYsCOL\",\"object\":\"chat.completion.chunk\",\"created\":1778031210,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_57133166c6\",\"choices\":[],\"usage\":{\"prompt_tokens\":22,\"completion_tokens\":2,\"total_tokens\":24,\"prompt_tokens_details\":{\"cached_tokens\":0,\"audio_tokens\":0},\"completion_tokens_details\":{\"reasoning_tokens\":0,\"audio_tokens\":0,\"accepted_prediction_tokens\":0,\"rejected_prediction_tokens\":0}},\"obfuscation\":\"emMuPcvvOkI\"}\n\ndata: [DONE]\n\n" + } + } + ] +} diff --git a/packages/llm/test/fixtures/recordings/openai-chat/streams-tool-call.json b/packages/llm/test/fixtures/recordings/openai-chat/streams-tool-call.json new file mode 100644 index 0000000000..fef4d8cd14 --- /dev/null +++ b/packages/llm/test/fixtures/recordings/openai-chat/streams-tool-call.json @@ -0,0 +1,28 @@ +{ + "version": 1, + "metadata": { + "name": "openai-chat/streams-tool-call", + "recordedAt": "2026-05-06T01:33:31.127Z", + "tags": ["prefix:openai-chat", "provider:openai", "protocol:openai-chat", "tool"] + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://api.openai.com/v1/chat/completions", + "headers": { + "content-type": "application/json" + }, + "body": "{\"model\":\"gpt-4o-mini\",\"messages\":[{\"role\":\"system\",\"content\":\"Call tools exactly as requested.\"},{\"role\":\"user\",\"content\":\"Call get_weather with city exactly Paris.\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}}],\"tool_choice\":{\"type\":\"function\",\"function\":{\"name\":\"get_weather\"}},\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":80,\"temperature\":0}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream; charset=utf-8" + }, + "body": "data: {\"id\":\"chatcmpl-DcLQgGuIIwnMHqZMRCOwZMLir5SkK\",\"object\":\"chat.completion.chunk\",\"created\":1778031210,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_d0a1738203\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":null,\"tool_calls\":[{\"index\":0,\"id\":\"call_5wBV98AvGPwOyC6a2HtKh85w\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"\"}}],\"refusal\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"hrw8\"}\n\ndata: {\"id\":\"chatcmpl-DcLQgGuIIwnMHqZMRCOwZMLir5SkK\",\"object\":\"chat.completion.chunk\",\"created\":1778031210,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_d0a1738203\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"{\\\"\"}}]},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"MzOlaTohF20Sbb\"}\n\ndata: {\"id\":\"chatcmpl-DcLQgGuIIwnMHqZMRCOwZMLir5SkK\",\"object\":\"chat.completion.chunk\",\"created\":1778031210,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_d0a1738203\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"city\"}}]},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"QuYBQ5vYEUVxR\"}\n\ndata: {\"id\":\"chatcmpl-DcLQgGuIIwnMHqZMRCOwZMLir5SkK\",\"object\":\"chat.completion.chunk\",\"created\":1778031210,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_d0a1738203\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"\\\":\\\"\"}}]},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"spyXlsV2hl6l\"}\n\ndata: {\"id\":\"chatcmpl-DcLQgGuIIwnMHqZMRCOwZMLir5SkK\",\"object\":\"chat.completion.chunk\",\"created\":1778031210,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_d0a1738203\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"Paris\"}}]},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"Db1cjFKa6YAI\"}\n\ndata: {\"id\":\"chatcmpl-DcLQgGuIIwnMHqZMRCOwZMLir5SkK\",\"object\":\"chat.completion.chunk\",\"created\":1778031210,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_d0a1738203\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"\\\"}\"}}]},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"oPu35nrhXcjTL5\"}\n\ndata: {\"id\":\"chatcmpl-DcLQgGuIIwnMHqZMRCOwZMLir5SkK\",\"object\":\"chat.completion.chunk\",\"created\":1778031210,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_d0a1738203\",\"choices\":[{\"index\":0,\"delta\":{},\"logprobs\":null,\"finish_reason\":\"stop\"}],\"usage\":null,\"obfuscation\":\"63TVy\"}\n\ndata: {\"id\":\"chatcmpl-DcLQgGuIIwnMHqZMRCOwZMLir5SkK\",\"object\":\"chat.completion.chunk\",\"created\":1778031210,\"model\":\"gpt-4o-mini-2024-07-18\",\"service_tier\":\"default\",\"system_fingerprint\":\"fp_d0a1738203\",\"choices\":[],\"usage\":{\"prompt_tokens\":67,\"completion_tokens\":5,\"total_tokens\":72,\"prompt_tokens_details\":{\"cached_tokens\":0,\"audio_tokens\":0},\"completion_tokens_details\":{\"reasoning_tokens\":0,\"audio_tokens\":0,\"accepted_prediction_tokens\":0,\"rejected_prediction_tokens\":0}},\"obfuscation\":\"NxJjur40z4H\"}\n\ndata: [DONE]\n\n" + } + } + ] +} diff --git a/packages/llm/test/fixtures/recordings/openai-compatible-chat/deepseek-streams-text.json b/packages/llm/test/fixtures/recordings/openai-compatible-chat/deepseek-streams-text.json new file mode 100644 index 0000000000..a71b1121cb --- /dev/null +++ b/packages/llm/test/fixtures/recordings/openai-compatible-chat/deepseek-streams-text.json @@ -0,0 +1,28 @@ +{ + "version": 1, + "metadata": { + "name": "openai-compatible-chat/deepseek-streams-text", + "recordedAt": "2026-04-28T21:18:49.498Z", + "tags": ["prefix:openai-compatible-chat", "protocol:openai-compatible-chat", "provider:deepseek"] + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://api.deepseek.com/v1/chat/completions", + "headers": { + "content-type": "application/json" + }, + "body": "{\"model\":\"deepseek-chat\",\"messages\":[{\"role\":\"system\",\"content\":\"You are concise.\"},{\"role\":\"user\",\"content\":\"Reply with exactly: Hello!\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":20,\"temperature\":0}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream; charset=utf-8" + }, + "body": "data: {\"id\":\"0c811926-1e0c-4160-baf8-6e71247c8ad7\",\"object\":\"chat.completion.chunk\",\"created\":1777411128,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_058df29938_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"0c811926-1e0c-4160-baf8-6e71247c8ad7\",\"object\":\"chat.completion.chunk\",\"created\":1777411128,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_058df29938_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"Hello\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"0c811926-1e0c-4160-baf8-6e71247c8ad7\",\"object\":\"chat.completion.chunk\",\"created\":1777411128,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_058df29938_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"!\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"0c811926-1e0c-4160-baf8-6e71247c8ad7\",\"object\":\"chat.completion.chunk\",\"created\":1777411128,\"model\":\"deepseek-v4-flash\",\"system_fingerprint\":\"fp_058df29938_prod0820_fp8_kvcache_20260402\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\"},\"logprobs\":null,\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":14,\"completion_tokens\":2,\"total_tokens\":16,\"prompt_tokens_details\":{\"cached_tokens\":0},\"prompt_cache_hit_tokens\":0,\"prompt_cache_miss_tokens\":14}}\n\ndata: [DONE]\n\n" + } + } + ] +} diff --git a/packages/llm/test/fixtures/recordings/openai-compatible-chat/groq-llama-3-3-70b-drives-a-tool-loop.json b/packages/llm/test/fixtures/recordings/openai-compatible-chat/groq-llama-3-3-70b-drives-a-tool-loop.json new file mode 100644 index 0000000000..403260b88b --- /dev/null +++ b/packages/llm/test/fixtures/recordings/openai-compatible-chat/groq-llama-3-3-70b-drives-a-tool-loop.json @@ -0,0 +1,53 @@ +{ + "version": 1, + "metadata": { + "name": "openai-compatible-chat/groq-llama-3-3-70b-drives-a-tool-loop", + "recordedAt": "2026-05-06T01:35:06.032Z", + "tags": [ + "prefix:openai-compatible-chat", + "protocol:openai-compatible-chat", + "provider:groq", + "tool", + "tool-loop", + "golden" + ] + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://api.groq.com/openai/v1/chat/completions", + "headers": { + "content-type": "application/json" + }, + "body": "{\"model\":\"llama-3.3-70b-versatile\",\"messages\":[{\"role\":\"system\",\"content\":\"Use the get_weather tool, then answer in one short sentence.\"},{\"role\":\"user\",\"content\":\"What is the weather in Paris?\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":80,\"temperature\":0}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream" + }, + "body": "data: {\"id\":\"chatcmpl-74a8ff95-296e-4c98-8e51-4b23d5d7f261\",\"object\":\"chat.completion.chunk\",\"created\":1778031305,\"model\":\"llama-3.3-70b-versatile\",\"system_fingerprint\":\"fp_43d97c5965\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":null},\"logprobs\":null,\"finish_reason\":null}],\"x_groq\":{\"id\":\"req_01kqxes90afm8r12en80ez1vhw\",\"seed\":1587279809}}\n\ndata: {\"id\":\"chatcmpl-74a8ff95-296e-4c98-8e51-4b23d5d7f261\",\"object\":\"chat.completion.chunk\",\"created\":1778031305,\"model\":\"llama-3.3-70b-versatile\",\"system_fingerprint\":\"fp_43d97c5965\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"id\":\"4vgxtgdfg\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\"},\"index\":0}]},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-74a8ff95-296e-4c98-8e51-4b23d5d7f261\",\"object\":\"chat.completion.chunk\",\"created\":1778031305,\"model\":\"llama-3.3-70b-versatile\",\"system_fingerprint\":\"fp_43d97c5965\",\"choices\":[{\"index\":0,\"delta\":{},\"logprobs\":null,\"finish_reason\":\"tool_calls\"}],\"x_groq\":{\"id\":\"req_01kqxes90afm8r12en80ez1vhw\",\"usage\":{\"queue_time\":0.036768035,\"prompt_tokens\":237,\"prompt_time\":0.012356963,\"completion_tokens\":14,\"completion_time\":0.047052437,\"total_tokens\":251,\"total_time\":0.0594094}},\"usage\":{\"queue_time\":0.036768035,\"prompt_tokens\":237,\"prompt_time\":0.012356963,\"completion_tokens\":14,\"completion_time\":0.047052437,\"total_tokens\":251,\"total_time\":0.0594094}}\n\ndata: {\"id\":\"chatcmpl-74a8ff95-296e-4c98-8e51-4b23d5d7f261\",\"object\":\"chat.completion.chunk\",\"created\":1778031305,\"model\":\"llama-3.3-70b-versatile\",\"system_fingerprint\":\"fp_43d97c5965\",\"choices\":[],\"usage\":{\"queue_time\":0.036768035,\"prompt_tokens\":237,\"prompt_time\":0.012356963,\"completion_tokens\":14,\"completion_time\":0.047052437,\"total_tokens\":251,\"total_time\":0.0594094},\"service_tier\":\"on_demand\"}\n\ndata: [DONE]\n\n" + } + }, + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://api.groq.com/openai/v1/chat/completions", + "headers": { + "content-type": "application/json" + }, + "body": "{\"model\":\"llama-3.3-70b-versatile\",\"messages\":[{\"role\":\"system\",\"content\":\"Use the get_weather tool, then answer in one short sentence.\"},{\"role\":\"user\",\"content\":\"What is the weather in Paris?\"},{\"role\":\"assistant\",\"content\":null,\"tool_calls\":[{\"id\":\"4vgxtgdfg\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\"}}]},{\"role\":\"tool\",\"tool_call_id\":\"4vgxtgdfg\",\"content\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":80,\"temperature\":0}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream" + }, + "body": "data: {\"id\":\"chatcmpl-52c0acaf-3f4b-45c8-8aa5-93a3b6adb045\",\"object\":\"chat.completion.chunk\",\"created\":1778031305,\"model\":\"llama-3.3-70b-versatile\",\"system_fingerprint\":\"fp_43d97c5965\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\"},\"logprobs\":null,\"finish_reason\":null}],\"x_groq\":{\"id\":\"req_01kqxes966fm8r4q94e70a83gn\",\"seed\":524268521}}\n\ndata: {\"id\":\"chatcmpl-52c0acaf-3f4b-45c8-8aa5-93a3b6adb045\",\"object\":\"chat.completion.chunk\",\"created\":1778031305,\"model\":\"llama-3.3-70b-versatile\",\"system_fingerprint\":\"fp_43d97c5965\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"The\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-52c0acaf-3f4b-45c8-8aa5-93a3b6adb045\",\"object\":\"chat.completion.chunk\",\"created\":1778031305,\"model\":\"llama-3.3-70b-versatile\",\"system_fingerprint\":\"fp_43d97c5965\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" weather\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-52c0acaf-3f4b-45c8-8aa5-93a3b6adb045\",\"object\":\"chat.completion.chunk\",\"created\":1778031305,\"model\":\"llama-3.3-70b-versatile\",\"system_fingerprint\":\"fp_43d97c5965\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" in\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-52c0acaf-3f4b-45c8-8aa5-93a3b6adb045\",\"object\":\"chat.completion.chunk\",\"created\":1778031305,\"model\":\"llama-3.3-70b-versatile\",\"system_fingerprint\":\"fp_43d97c5965\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" Paris\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-52c0acaf-3f4b-45c8-8aa5-93a3b6adb045\",\"object\":\"chat.completion.chunk\",\"created\":1778031305,\"model\":\"llama-3.3-70b-versatile\",\"system_fingerprint\":\"fp_43d97c5965\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" is\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-52c0acaf-3f4b-45c8-8aa5-93a3b6adb045\",\"object\":\"chat.completion.chunk\",\"created\":1778031306,\"model\":\"llama-3.3-70b-versatile\",\"system_fingerprint\":\"fp_43d97c5965\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" sunny\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-52c0acaf-3f4b-45c8-8aa5-93a3b6adb045\",\"object\":\"chat.completion.chunk\",\"created\":1778031306,\"model\":\"llama-3.3-70b-versatile\",\"system_fingerprint\":\"fp_43d97c5965\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" with\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-52c0acaf-3f4b-45c8-8aa5-93a3b6adb045\",\"object\":\"chat.completion.chunk\",\"created\":1778031306,\"model\":\"llama-3.3-70b-versatile\",\"system_fingerprint\":\"fp_43d97c5965\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" a\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-52c0acaf-3f4b-45c8-8aa5-93a3b6adb045\",\"object\":\"chat.completion.chunk\",\"created\":1778031306,\"model\":\"llama-3.3-70b-versatile\",\"system_fingerprint\":\"fp_43d97c5965\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" temperature\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-52c0acaf-3f4b-45c8-8aa5-93a3b6adb045\",\"object\":\"chat.completion.chunk\",\"created\":1778031306,\"model\":\"llama-3.3-70b-versatile\",\"system_fingerprint\":\"fp_43d97c5965\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" of\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-52c0acaf-3f4b-45c8-8aa5-93a3b6adb045\",\"object\":\"chat.completion.chunk\",\"created\":1778031306,\"model\":\"llama-3.3-70b-versatile\",\"system_fingerprint\":\"fp_43d97c5965\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" \"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-52c0acaf-3f4b-45c8-8aa5-93a3b6adb045\",\"object\":\"chat.completion.chunk\",\"created\":1778031306,\"model\":\"llama-3.3-70b-versatile\",\"system_fingerprint\":\"fp_43d97c5965\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"22\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-52c0acaf-3f4b-45c8-8aa5-93a3b6adb045\",\"object\":\"chat.completion.chunk\",\"created\":1778031306,\"model\":\"llama-3.3-70b-versatile\",\"system_fingerprint\":\"fp_43d97c5965\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" degrees\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-52c0acaf-3f4b-45c8-8aa5-93a3b6adb045\",\"object\":\"chat.completion.chunk\",\"created\":1778031306,\"model\":\"llama-3.3-70b-versatile\",\"system_fingerprint\":\"fp_43d97c5965\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\".\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-52c0acaf-3f4b-45c8-8aa5-93a3b6adb045\",\"object\":\"chat.completion.chunk\",\"created\":1778031306,\"model\":\"llama-3.3-70b-versatile\",\"system_fingerprint\":\"fp_43d97c5965\",\"choices\":[{\"index\":0,\"delta\":{},\"logprobs\":null,\"finish_reason\":\"stop\"}],\"x_groq\":{\"id\":\"req_01kqxes966fm8r4q94e70a83gn\",\"usage\":{\"queue_time\":0.036680462,\"prompt_tokens\":270,\"prompt_time\":0.014468555,\"completion_tokens\":15,\"completion_time\":0.057896947,\"total_tokens\":285,\"total_time\":0.072365502}},\"usage\":{\"queue_time\":0.036680462,\"prompt_tokens\":270,\"prompt_time\":0.014468555,\"completion_tokens\":15,\"completion_time\":0.057896947,\"total_tokens\":285,\"total_time\":0.072365502}}\n\ndata: {\"id\":\"chatcmpl-52c0acaf-3f4b-45c8-8aa5-93a3b6adb045\",\"object\":\"chat.completion.chunk\",\"created\":1778031306,\"model\":\"llama-3.3-70b-versatile\",\"system_fingerprint\":\"fp_43d97c5965\",\"choices\":[],\"usage\":{\"queue_time\":0.036680462,\"prompt_tokens\":270,\"prompt_time\":0.014468555,\"completion_tokens\":15,\"completion_time\":0.057896947,\"total_tokens\":285,\"total_time\":0.072365502},\"service_tier\":\"on_demand\"}\n\ndata: [DONE]\n\n" + } + } + ] +} diff --git a/packages/llm/test/fixtures/recordings/openai-compatible-chat/groq-streams-text.json b/packages/llm/test/fixtures/recordings/openai-compatible-chat/groq-streams-text.json new file mode 100644 index 0000000000..561dbfda06 --- /dev/null +++ b/packages/llm/test/fixtures/recordings/openai-compatible-chat/groq-streams-text.json @@ -0,0 +1,28 @@ +{ + "version": 1, + "metadata": { + "name": "openai-compatible-chat/groq-streams-text", + "recordedAt": "2026-05-06T01:35:05.532Z", + "tags": ["prefix:openai-compatible-chat", "protocol:openai-compatible-chat", "provider:groq"] + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://api.groq.com/openai/v1/chat/completions", + "headers": { + "content-type": "application/json" + }, + "body": "{\"model\":\"llama-3.3-70b-versatile\",\"messages\":[{\"role\":\"system\",\"content\":\"You are concise.\"},{\"role\":\"user\",\"content\":\"Reply with exactly: Hello!\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":20,\"temperature\":0}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream" + }, + "body": "data: {\"id\":\"chatcmpl-dd5aae9f-7032-44a7-aca8-01027903b4c9\",\"object\":\"chat.completion.chunk\",\"created\":1778031305,\"model\":\"llama-3.3-70b-versatile\",\"system_fingerprint\":\"fp_d42c28f9ce\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\"},\"logprobs\":null,\"finish_reason\":null}],\"x_groq\":{\"id\":\"req_01kqxes8r3fmja0yhxvt665m6h\",\"seed\":687314058}}\n\ndata: {\"id\":\"chatcmpl-dd5aae9f-7032-44a7-aca8-01027903b4c9\",\"object\":\"chat.completion.chunk\",\"created\":1778031305,\"model\":\"llama-3.3-70b-versatile\",\"system_fingerprint\":\"fp_d42c28f9ce\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"Hello\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-dd5aae9f-7032-44a7-aca8-01027903b4c9\",\"object\":\"chat.completion.chunk\",\"created\":1778031305,\"model\":\"llama-3.3-70b-versatile\",\"system_fingerprint\":\"fp_d42c28f9ce\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"!\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-dd5aae9f-7032-44a7-aca8-01027903b4c9\",\"object\":\"chat.completion.chunk\",\"created\":1778031305,\"model\":\"llama-3.3-70b-versatile\",\"system_fingerprint\":\"fp_d42c28f9ce\",\"choices\":[{\"index\":0,\"delta\":{},\"logprobs\":null,\"finish_reason\":\"stop\"}],\"x_groq\":{\"id\":\"req_01kqxes8r3fmja0yhxvt665m6h\",\"usage\":{\"queue_time\":0.0381395,\"prompt_tokens\":45,\"prompt_time\":0.003985297,\"completion_tokens\":3,\"completion_time\":0.014171875,\"total_tokens\":48,\"total_time\":0.018157172}},\"usage\":{\"queue_time\":0.0381395,\"prompt_tokens\":45,\"prompt_time\":0.003985297,\"completion_tokens\":3,\"completion_time\":0.014171875,\"total_tokens\":48,\"total_time\":0.018157172}}\n\ndata: {\"id\":\"chatcmpl-dd5aae9f-7032-44a7-aca8-01027903b4c9\",\"object\":\"chat.completion.chunk\",\"created\":1778031305,\"model\":\"llama-3.3-70b-versatile\",\"system_fingerprint\":\"fp_d42c28f9ce\",\"choices\":[],\"usage\":{\"queue_time\":0.0381395,\"prompt_tokens\":45,\"prompt_time\":0.003985297,\"completion_tokens\":3,\"completion_time\":0.014171875,\"total_tokens\":48,\"total_time\":0.018157172},\"service_tier\":\"on_demand\"}\n\ndata: [DONE]\n\n" + } + } + ] +} diff --git a/packages/llm/test/fixtures/recordings/openai-compatible-chat/groq-streams-tool-call.json b/packages/llm/test/fixtures/recordings/openai-compatible-chat/groq-streams-tool-call.json new file mode 100644 index 0000000000..70e9a765d2 --- /dev/null +++ b/packages/llm/test/fixtures/recordings/openai-compatible-chat/groq-streams-tool-call.json @@ -0,0 +1,28 @@ +{ + "version": 1, + "metadata": { + "name": "openai-compatible-chat/groq-streams-tool-call", + "recordedAt": "2026-05-06T01:35:05.706Z", + "tags": ["prefix:openai-compatible-chat", "protocol:openai-compatible-chat", "provider:groq", "tool"] + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://api.groq.com/openai/v1/chat/completions", + "headers": { + "content-type": "application/json" + }, + "body": "{\"model\":\"llama-3.3-70b-versatile\",\"messages\":[{\"role\":\"system\",\"content\":\"Call tools exactly as requested.\"},{\"role\":\"user\",\"content\":\"Call get_weather with city exactly Paris.\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}}],\"tool_choice\":{\"type\":\"function\",\"function\":{\"name\":\"get_weather\"}},\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":80,\"temperature\":0}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream" + }, + "body": "data: {\"id\":\"chatcmpl-05380361-f8e4-444a-ae80-296b4d1d46f7\",\"object\":\"chat.completion.chunk\",\"created\":1778031305,\"model\":\"llama-3.3-70b-versatile\",\"system_fingerprint\":\"fp_0761e44d7b\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":null},\"logprobs\":null,\"finish_reason\":null}],\"x_groq\":{\"id\":\"req_01kqxes8v4fm7baf4smt42f0qn\",\"seed\":1846647562}}\n\ndata: {\"id\":\"chatcmpl-05380361-f8e4-444a-ae80-296b4d1d46f7\",\"object\":\"chat.completion.chunk\",\"created\":1778031305,\"model\":\"llama-3.3-70b-versatile\",\"system_fingerprint\":\"fp_0761e44d7b\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"id\":\"mcf2d8nn1\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\"},\"index\":0}]},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-05380361-f8e4-444a-ae80-296b4d1d46f7\",\"object\":\"chat.completion.chunk\",\"created\":1778031305,\"model\":\"llama-3.3-70b-versatile\",\"system_fingerprint\":\"fp_0761e44d7b\",\"choices\":[{\"index\":0,\"delta\":{},\"logprobs\":null,\"finish_reason\":\"tool_calls\"}],\"x_groq\":{\"id\":\"req_01kqxes8v4fm7baf4smt42f0qn\",\"usage\":{\"queue_time\":0.07684935,\"prompt_tokens\":249,\"prompt_time\":0.014815006,\"completion_tokens\":10,\"completion_time\":0.036435756,\"total_tokens\":259,\"total_time\":0.051250762}},\"usage\":{\"queue_time\":0.07684935,\"prompt_tokens\":249,\"prompt_time\":0.014815006,\"completion_tokens\":10,\"completion_time\":0.036435756,\"total_tokens\":259,\"total_time\":0.051250762}}\n\ndata: {\"id\":\"chatcmpl-05380361-f8e4-444a-ae80-296b4d1d46f7\",\"object\":\"chat.completion.chunk\",\"created\":1778031305,\"model\":\"llama-3.3-70b-versatile\",\"system_fingerprint\":\"fp_0761e44d7b\",\"choices\":[],\"usage\":{\"queue_time\":0.07684935,\"prompt_tokens\":249,\"prompt_time\":0.014815006,\"completion_tokens\":10,\"completion_time\":0.036435756,\"total_tokens\":259,\"total_time\":0.051250762},\"service_tier\":\"on_demand\"}\n\ndata: [DONE]\n\n" + } + } + ] +} diff --git a/packages/llm/test/fixtures/recordings/openai-compatible-chat/openrouter-claude-opus-4-7-drives-a-tool-loop.json b/packages/llm/test/fixtures/recordings/openai-compatible-chat/openrouter-claude-opus-4-7-drives-a-tool-loop.json new file mode 100644 index 0000000000..e67d280678 --- /dev/null +++ b/packages/llm/test/fixtures/recordings/openai-compatible-chat/openrouter-claude-opus-4-7-drives-a-tool-loop.json @@ -0,0 +1,54 @@ +{ + "version": 1, + "metadata": { + "name": "openai-compatible-chat/openrouter-claude-opus-4-7-drives-a-tool-loop", + "recordedAt": "2026-05-06T01:35:14.282Z", + "tags": [ + "prefix:openai-compatible-chat", + "protocol:openai-compatible-chat", + "provider:openrouter", + "tool", + "tool-loop", + "golden", + "flagship" + ] + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://openrouter.ai/api/v1/chat/completions", + "headers": { + "content-type": "application/json" + }, + "body": "{\"model\":\"anthropic/claude-opus-4.7\",\"messages\":[{\"role\":\"system\",\"content\":\"Use the get_weather tool exactly once, then answer in one short sentence.\"},{\"role\":\"user\",\"content\":\"What is the weather in Paris?\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":80,\"temperature\":0}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream" + }, + "body": ": OPENROUTER PROCESSING\n\n: OPENROUTER PROCESSING\n\n: OPENROUTER PROCESSING\n\ndata: {\"id\":\"gen-1778031311-S3NlfYGRwAnOoPoNrThK\",\"object\":\"chat.completion.chunk\",\"created\":1778031311,\"model\":\"anthropic/claude-4.7-opus-20260416\",\"provider\":\"Amazon Bedrock\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"role\":\"assistant\",\"tool_calls\":[{\"index\":0,\"id\":\"toolu_bdrk_01AVRkzbigpMbNJ3zjnuQ6ZE\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"\"}}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031311-S3NlfYGRwAnOoPoNrThK\",\"object\":\"chat.completion.chunk\",\"created\":1778031311,\"model\":\"anthropic/claude-4.7-opus-20260416\",\"provider\":\"Amazon Bedrock\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"role\":\"assistant\",\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"\"}}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031311-S3NlfYGRwAnOoPoNrThK\",\"object\":\"chat.completion.chunk\",\"created\":1778031311,\"model\":\"anthropic/claude-4.7-opus-20260416\",\"provider\":\"Amazon Bedrock\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"role\":\"assistant\",\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"\"}}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031311-S3NlfYGRwAnOoPoNrThK\",\"object\":\"chat.completion.chunk\",\"created\":1778031311,\"model\":\"anthropic/claude-4.7-opus-20260416\",\"provider\":\"Amazon Bedrock\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"role\":\"assistant\",\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"{\\\"city\\\": \\\"P\"}}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031311-S3NlfYGRwAnOoPoNrThK\",\"object\":\"chat.completion.chunk\",\"created\":1778031311,\"model\":\"anthropic/claude-4.7-opus-20260416\",\"provider\":\"Amazon Bedrock\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"role\":\"assistant\",\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"ari\"}}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031311-S3NlfYGRwAnOoPoNrThK\",\"object\":\"chat.completion.chunk\",\"created\":1778031311,\"model\":\"anthropic/claude-4.7-opus-20260416\",\"provider\":\"Amazon Bedrock\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"role\":\"assistant\",\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"s\\\"}\"}}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031311-S3NlfYGRwAnOoPoNrThK\",\"object\":\"chat.completion.chunk\",\"created\":1778031311,\"model\":\"anthropic/claude-4.7-opus-20260416\",\"provider\":\"Amazon Bedrock\",\"service_tier\":\"standard\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\"},\"finish_reason\":\"tool_calls\",\"native_finish_reason\":\"tool_use\"}]}\n\ndata: {\"id\":\"gen-1778031311-S3NlfYGRwAnOoPoNrThK\",\"object\":\"chat.completion.chunk\",\"created\":1778031311,\"model\":\"anthropic/claude-4.7-opus-20260416\",\"provider\":\"Amazon Bedrock\",\"service_tier\":\"standard\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\"},\"finish_reason\":\"tool_calls\",\"native_finish_reason\":\"tool_use\"}],\"usage\":{\"prompt_tokens\":802,\"completion_tokens\":66,\"total_tokens\":868,\"cost\":0.00566,\"is_byok\":false,\"prompt_tokens_details\":{\"cached_tokens\":0,\"cache_write_tokens\":0,\"audio_tokens\":0,\"video_tokens\":0},\"cost_details\":{\"upstream_inference_cost\":0.00566,\"upstream_inference_prompt_cost\":0.00401,\"upstream_inference_completions_cost\":0.00165},\"completion_tokens_details\":{\"reasoning_tokens\":0,\"image_tokens\":0,\"audio_tokens\":0}}}\n\ndata: [DONE]\n\n" + } + }, + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://openrouter.ai/api/v1/chat/completions", + "headers": { + "content-type": "application/json" + }, + "body": "{\"model\":\"anthropic/claude-opus-4.7\",\"messages\":[{\"role\":\"system\",\"content\":\"Use the get_weather tool exactly once, then answer in one short sentence.\"},{\"role\":\"user\",\"content\":\"What is the weather in Paris?\"},{\"role\":\"assistant\",\"content\":null,\"tool_calls\":[{\"id\":\"toolu_bdrk_01AVRkzbigpMbNJ3zjnuQ6ZE\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\"}}]},{\"role\":\"tool\",\"tool_call_id\":\"toolu_bdrk_01AVRkzbigpMbNJ3zjnuQ6ZE\",\"content\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":80,\"temperature\":0}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream" + }, + "body": ": OPENROUTER PROCESSING\n\ndata: {\"id\":\"gen-1778031313-XM4XZGmFyt6jg3GZ772w\",\"object\":\"chat.completion.chunk\",\"created\":1778031313,\"model\":\"anthropic/claude-4.7-opus-20260416\",\"provider\":\"Amazon Bedrock\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"It\",\"role\":\"assistant\"},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031313-XM4XZGmFyt6jg3GZ772w\",\"object\":\"chat.completion.chunk\",\"created\":1778031313,\"model\":\"anthropic/claude-4.7-opus-20260416\",\"provider\":\"Amazon Bedrock\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"'s sunny and\",\"role\":\"assistant\"},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031313-XM4XZGmFyt6jg3GZ772w\",\"object\":\"chat.completion.chunk\",\"created\":1778031313,\"model\":\"anthropic/claude-4.7-opus-20260416\",\"provider\":\"Amazon Bedrock\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" 22°C in\",\"role\":\"assistant\"},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031313-XM4XZGmFyt6jg3GZ772w\",\"object\":\"chat.completion.chunk\",\"created\":1778031313,\"model\":\"anthropic/claude-4.7-opus-20260416\",\"provider\":\"Amazon Bedrock\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" Paris.\",\"role\":\"assistant\"},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031313-XM4XZGmFyt6jg3GZ772w\",\"object\":\"chat.completion.chunk\",\"created\":1778031313,\"model\":\"anthropic/claude-4.7-opus-20260416\",\"provider\":\"Amazon Bedrock\",\"service_tier\":\"standard\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\"},\"finish_reason\":\"stop\",\"native_finish_reason\":\"end_turn\"}]}\n\ndata: {\"id\":\"gen-1778031313-XM4XZGmFyt6jg3GZ772w\",\"object\":\"chat.completion.chunk\",\"created\":1778031313,\"model\":\"anthropic/claude-4.7-opus-20260416\",\"provider\":\"Amazon Bedrock\",\"service_tier\":\"standard\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\"},\"finish_reason\":\"stop\",\"native_finish_reason\":\"end_turn\"}],\"usage\":{\"prompt_tokens\":899,\"completion_tokens\":19,\"total_tokens\":918,\"cost\":0.00497,\"is_byok\":false,\"prompt_tokens_details\":{\"cached_tokens\":0,\"cache_write_tokens\":0,\"audio_tokens\":0,\"video_tokens\":0},\"cost_details\":{\"upstream_inference_cost\":0.00497,\"upstream_inference_prompt_cost\":0.004495,\"upstream_inference_completions_cost\":0.000475},\"completion_tokens_details\":{\"reasoning_tokens\":0,\"image_tokens\":0,\"audio_tokens\":0}}}\n\ndata: [DONE]\n\n" + } + } + ] +} diff --git a/packages/llm/test/fixtures/recordings/openai-compatible-chat/openrouter-gpt-4o-mini-drives-a-tool-loop.json b/packages/llm/test/fixtures/recordings/openai-compatible-chat/openrouter-gpt-4o-mini-drives-a-tool-loop.json new file mode 100644 index 0000000000..7883285e58 --- /dev/null +++ b/packages/llm/test/fixtures/recordings/openai-compatible-chat/openrouter-gpt-4o-mini-drives-a-tool-loop.json @@ -0,0 +1,53 @@ +{ + "version": 1, + "metadata": { + "name": "openai-compatible-chat/openrouter-gpt-4o-mini-drives-a-tool-loop", + "recordedAt": "2026-05-06T01:35:08.922Z", + "tags": [ + "prefix:openai-compatible-chat", + "protocol:openai-compatible-chat", + "provider:openrouter", + "tool", + "tool-loop", + "golden" + ] + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://openrouter.ai/api/v1/chat/completions", + "headers": { + "content-type": "application/json" + }, + "body": "{\"model\":\"openai/gpt-4o-mini\",\"messages\":[{\"role\":\"system\",\"content\":\"Use the get_weather tool exactly once, then answer in one short sentence.\"},{\"role\":\"user\",\"content\":\"What is the weather in Paris?\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":80,\"temperature\":0}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream" + }, + "body": ": OPENROUTER PROCESSING\n\ndata: {\"id\":\"gen-1778031307-FcHCDYW9unDVyRRL841T\",\"object\":\"chat.completion.chunk\",\"created\":1778031307,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"OpenAI\",\"system_fingerprint\":\"fp_7e69b4ef44\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"role\":\"assistant\",\"tool_calls\":[{\"index\":0,\"id\":\"call_S63bjYITINemSHZ4Uqns7PIu\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"\"}}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031307-FcHCDYW9unDVyRRL841T\",\"object\":\"chat.completion.chunk\",\"created\":1778031307,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"OpenAI\",\"system_fingerprint\":\"fp_7e69b4ef44\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"role\":\"assistant\",\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"\"}}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031307-FcHCDYW9unDVyRRL841T\",\"object\":\"chat.completion.chunk\",\"created\":1778031307,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"OpenAI\",\"system_fingerprint\":\"fp_7e69b4ef44\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"role\":\"assistant\",\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"{\\\"\"}}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031307-FcHCDYW9unDVyRRL841T\",\"object\":\"chat.completion.chunk\",\"created\":1778031307,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"OpenAI\",\"system_fingerprint\":\"fp_7e69b4ef44\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"role\":\"assistant\",\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"city\"}}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031307-FcHCDYW9unDVyRRL841T\",\"object\":\"chat.completion.chunk\",\"created\":1778031307,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"OpenAI\",\"system_fingerprint\":\"fp_7e69b4ef44\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"role\":\"assistant\",\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"\\\":\\\"\"}}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031307-FcHCDYW9unDVyRRL841T\",\"object\":\"chat.completion.chunk\",\"created\":1778031307,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"OpenAI\",\"system_fingerprint\":\"fp_7e69b4ef44\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"role\":\"assistant\",\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"Paris\"}}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031307-FcHCDYW9unDVyRRL841T\",\"object\":\"chat.completion.chunk\",\"created\":1778031307,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"OpenAI\",\"system_fingerprint\":\"fp_7e69b4ef44\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"role\":\"assistant\",\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"\\\"}\"}}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031307-FcHCDYW9unDVyRRL841T\",\"object\":\"chat.completion.chunk\",\"created\":1778031307,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"OpenAI\",\"system_fingerprint\":\"fp_7e69b4ef44\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\"},\"finish_reason\":\"tool_calls\",\"native_finish_reason\":\"tool_calls\"}]}\n\ndata: {\"id\":\"gen-1778031307-FcHCDYW9unDVyRRL841T\",\"object\":\"chat.completion.chunk\",\"created\":1778031307,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"OpenAI\",\"system_fingerprint\":\"fp_7e69b4ef44\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\"},\"finish_reason\":\"tool_calls\",\"native_finish_reason\":\"tool_calls\"}],\"usage\":{\"prompt_tokens\":66,\"completion_tokens\":14,\"total_tokens\":80,\"cost\":0.0000183,\"is_byok\":false,\"prompt_tokens_details\":{\"cached_tokens\":0,\"cache_write_tokens\":0,\"audio_tokens\":0,\"video_tokens\":0},\"cost_details\":{\"upstream_inference_cost\":0.0000183,\"upstream_inference_prompt_cost\":0.0000099,\"upstream_inference_completions_cost\":0.0000084},\"completion_tokens_details\":{\"reasoning_tokens\":0,\"image_tokens\":0,\"audio_tokens\":0}}}\n\ndata: [DONE]\n\n" + } + }, + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://openrouter.ai/api/v1/chat/completions", + "headers": { + "content-type": "application/json" + }, + "body": "{\"model\":\"openai/gpt-4o-mini\",\"messages\":[{\"role\":\"system\",\"content\":\"Use the get_weather tool exactly once, then answer in one short sentence.\"},{\"role\":\"user\",\"content\":\"What is the weather in Paris?\"},{\"role\":\"assistant\",\"content\":null,\"tool_calls\":[{\"id\":\"call_S63bjYITINemSHZ4Uqns7PIu\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\"}}]},{\"role\":\"tool\",\"tool_call_id\":\"call_S63bjYITINemSHZ4Uqns7PIu\",\"content\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":80,\"temperature\":0}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream" + }, + "body": ": OPENROUTER PROCESSING\n\ndata: {\"id\":\"gen-1778031308-uNHYY6MdDXOs0BYMXXVb\",\"object\":\"chat.completion.chunk\",\"created\":1778031308,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"OpenAI\",\"system_fingerprint\":\"fp_7e69b4ef44\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"The\",\"role\":\"assistant\"},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031308-uNHYY6MdDXOs0BYMXXVb\",\"object\":\"chat.completion.chunk\",\"created\":1778031308,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"OpenAI\",\"system_fingerprint\":\"fp_7e69b4ef44\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" weather\",\"role\":\"assistant\"},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031308-uNHYY6MdDXOs0BYMXXVb\",\"object\":\"chat.completion.chunk\",\"created\":1778031308,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"OpenAI\",\"system_fingerprint\":\"fp_7e69b4ef44\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" in\",\"role\":\"assistant\"},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031308-uNHYY6MdDXOs0BYMXXVb\",\"object\":\"chat.completion.chunk\",\"created\":1778031308,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"OpenAI\",\"system_fingerprint\":\"fp_7e69b4ef44\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" Paris\",\"role\":\"assistant\"},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031308-uNHYY6MdDXOs0BYMXXVb\",\"object\":\"chat.completion.chunk\",\"created\":1778031308,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"OpenAI\",\"system_fingerprint\":\"fp_7e69b4ef44\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" is\",\"role\":\"assistant\"},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031308-uNHYY6MdDXOs0BYMXXVb\",\"object\":\"chat.completion.chunk\",\"created\":1778031308,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"OpenAI\",\"system_fingerprint\":\"fp_7e69b4ef44\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" sunny\",\"role\":\"assistant\"},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031308-uNHYY6MdDXOs0BYMXXVb\",\"object\":\"chat.completion.chunk\",\"created\":1778031308,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"OpenAI\",\"system_fingerprint\":\"fp_7e69b4ef44\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" with\",\"role\":\"assistant\"},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031308-uNHYY6MdDXOs0BYMXXVb\",\"object\":\"chat.completion.chunk\",\"created\":1778031308,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"OpenAI\",\"system_fingerprint\":\"fp_7e69b4ef44\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" a\",\"role\":\"assistant\"},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031308-uNHYY6MdDXOs0BYMXXVb\",\"object\":\"chat.completion.chunk\",\"created\":1778031308,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"OpenAI\",\"system_fingerprint\":\"fp_7e69b4ef44\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" temperature\",\"role\":\"assistant\"},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031308-uNHYY6MdDXOs0BYMXXVb\",\"object\":\"chat.completion.chunk\",\"created\":1778031308,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"OpenAI\",\"system_fingerprint\":\"fp_7e69b4ef44\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" of\",\"role\":\"assistant\"},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031308-uNHYY6MdDXOs0BYMXXVb\",\"object\":\"chat.completion.chunk\",\"created\":1778031308,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"OpenAI\",\"system_fingerprint\":\"fp_7e69b4ef44\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" \",\"role\":\"assistant\"},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031308-uNHYY6MdDXOs0BYMXXVb\",\"object\":\"chat.completion.chunk\",\"created\":1778031308,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"OpenAI\",\"system_fingerprint\":\"fp_7e69b4ef44\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"22\",\"role\":\"assistant\"},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031308-uNHYY6MdDXOs0BYMXXVb\",\"object\":\"chat.completion.chunk\",\"created\":1778031308,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"OpenAI\",\"system_fingerprint\":\"fp_7e69b4ef44\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"°C\",\"role\":\"assistant\"},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031308-uNHYY6MdDXOs0BYMXXVb\",\"object\":\"chat.completion.chunk\",\"created\":1778031308,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"OpenAI\",\"system_fingerprint\":\"fp_7e69b4ef44\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\".\",\"role\":\"assistant\"},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031308-uNHYY6MdDXOs0BYMXXVb\",\"object\":\"chat.completion.chunk\",\"created\":1778031308,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"OpenAI\",\"system_fingerprint\":\"fp_7e69b4ef44\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\"},\"finish_reason\":\"stop\",\"native_finish_reason\":\"stop\"}]}\n\ndata: {\"id\":\"gen-1778031308-uNHYY6MdDXOs0BYMXXVb\",\"object\":\"chat.completion.chunk\",\"created\":1778031308,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"OpenAI\",\"system_fingerprint\":\"fp_7e69b4ef44\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\"},\"finish_reason\":\"stop\",\"native_finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":98,\"completion_tokens\":15,\"total_tokens\":113,\"cost\":0.0000237,\"is_byok\":false,\"prompt_tokens_details\":{\"cached_tokens\":0,\"cache_write_tokens\":0,\"audio_tokens\":0,\"video_tokens\":0},\"cost_details\":{\"upstream_inference_cost\":0.0000237,\"upstream_inference_prompt_cost\":0.0000147,\"upstream_inference_completions_cost\":0.000009},\"completion_tokens_details\":{\"reasoning_tokens\":0,\"image_tokens\":0,\"audio_tokens\":0}}}\n\ndata: [DONE]\n\n" + } + } + ] +} diff --git a/packages/llm/test/fixtures/recordings/openai-compatible-chat/openrouter-gpt-5-5-drives-a-tool-loop.json b/packages/llm/test/fixtures/recordings/openai-compatible-chat/openrouter-gpt-5-5-drives-a-tool-loop.json new file mode 100644 index 0000000000..e1cbab70fa --- /dev/null +++ b/packages/llm/test/fixtures/recordings/openai-compatible-chat/openrouter-gpt-5-5-drives-a-tool-loop.json @@ -0,0 +1,54 @@ +{ + "version": 1, + "metadata": { + "name": "openai-compatible-chat/openrouter-gpt-5-5-drives-a-tool-loop", + "recordedAt": "2026-05-06T01:35:11.662Z", + "tags": [ + "prefix:openai-compatible-chat", + "protocol:openai-compatible-chat", + "provider:openrouter", + "tool", + "tool-loop", + "golden", + "flagship" + ] + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://openrouter.ai/api/v1/chat/completions", + "headers": { + "content-type": "application/json" + }, + "body": "{\"model\":\"openai/gpt-5.5\",\"messages\":[{\"role\":\"system\",\"content\":\"Use the get_weather tool exactly once, then answer in one short sentence.\"},{\"role\":\"user\",\"content\":\"What is the weather in Paris?\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":80,\"temperature\":0}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream" + }, + "body": ": OPENROUTER PROCESSING\n\n: OPENROUTER PROCESSING\n\ndata: {\"id\":\"gen-1778031308-dVa9axcHcOlG9GcilZkz\",\"object\":\"chat.completion.chunk\",\"created\":1778031308,\"model\":\"openai/gpt-5.5-20260423\",\"provider\":\"OpenAI\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"role\":\"assistant\",\"tool_calls\":[{\"index\":0,\"id\":\"call_4A7V7UN36HXCUUn8qAOQaKGw\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"\"}}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031308-dVa9axcHcOlG9GcilZkz\",\"object\":\"chat.completion.chunk\",\"created\":1778031308,\"model\":\"openai/gpt-5.5-20260423\",\"provider\":\"OpenAI\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"role\":\"assistant\",\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"\"}}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031308-dVa9axcHcOlG9GcilZkz\",\"object\":\"chat.completion.chunk\",\"created\":1778031308,\"model\":\"openai/gpt-5.5-20260423\",\"provider\":\"OpenAI\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"role\":\"assistant\",\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"{\\\"\"}}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031308-dVa9axcHcOlG9GcilZkz\",\"object\":\"chat.completion.chunk\",\"created\":1778031308,\"model\":\"openai/gpt-5.5-20260423\",\"provider\":\"OpenAI\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"role\":\"assistant\",\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"city\"}}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031308-dVa9axcHcOlG9GcilZkz\",\"object\":\"chat.completion.chunk\",\"created\":1778031308,\"model\":\"openai/gpt-5.5-20260423\",\"provider\":\"OpenAI\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"role\":\"assistant\",\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"\\\":\\\"\"}}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031308-dVa9axcHcOlG9GcilZkz\",\"object\":\"chat.completion.chunk\",\"created\":1778031308,\"model\":\"openai/gpt-5.5-20260423\",\"provider\":\"OpenAI\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"role\":\"assistant\",\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"Paris\"}}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031308-dVa9axcHcOlG9GcilZkz\",\"object\":\"chat.completion.chunk\",\"created\":1778031308,\"model\":\"openai/gpt-5.5-20260423\",\"provider\":\"OpenAI\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"role\":\"assistant\",\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"\\\"}\"}}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031308-dVa9axcHcOlG9GcilZkz\",\"object\":\"chat.completion.chunk\",\"created\":1778031308,\"model\":\"openai/gpt-5.5-20260423\",\"provider\":\"OpenAI\",\"service_tier\":\"default\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\"},\"finish_reason\":\"tool_calls\",\"native_finish_reason\":\"completed\"}]}\n\ndata: {\"id\":\"gen-1778031308-dVa9axcHcOlG9GcilZkz\",\"object\":\"chat.completion.chunk\",\"created\":1778031308,\"model\":\"openai/gpt-5.5-20260423\",\"provider\":\"OpenAI\",\"service_tier\":\"default\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\"},\"finish_reason\":\"tool_calls\",\"native_finish_reason\":\"completed\"}],\"usage\":{\"prompt_tokens\":69,\"completion_tokens\":18,\"total_tokens\":87,\"cost\":0.000885,\"is_byok\":false,\"prompt_tokens_details\":{\"cached_tokens\":0,\"cache_write_tokens\":0,\"audio_tokens\":0,\"video_tokens\":0},\"cost_details\":{\"upstream_inference_cost\":0.000885,\"upstream_inference_prompt_cost\":0.000345,\"upstream_inference_completions_cost\":0.00054},\"completion_tokens_details\":{\"reasoning_tokens\":0,\"image_tokens\":0,\"audio_tokens\":0}}}\n\ndata: [DONE]\n\n" + } + }, + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://openrouter.ai/api/v1/chat/completions", + "headers": { + "content-type": "application/json" + }, + "body": "{\"model\":\"openai/gpt-5.5\",\"messages\":[{\"role\":\"system\",\"content\":\"Use the get_weather tool exactly once, then answer in one short sentence.\"},{\"role\":\"user\",\"content\":\"What is the weather in Paris?\"},{\"role\":\"assistant\",\"content\":null,\"tool_calls\":[{\"id\":\"call_4A7V7UN36HXCUUn8qAOQaKGw\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\"}}]},{\"role\":\"tool\",\"tool_call_id\":\"call_4A7V7UN36HXCUUn8qAOQaKGw\",\"content\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":80,\"temperature\":0}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream" + }, + "body": ": OPENROUTER PROCESSING\n\n: OPENROUTER PROCESSING\n\ndata: {\"id\":\"gen-1778031310-JUYfFzDbun699uUYoA4N\",\"object\":\"chat.completion.chunk\",\"created\":1778031310,\"model\":\"openai/gpt-5.5-20260423\",\"provider\":\"OpenAI\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"Paris\",\"role\":\"assistant\"},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031310-JUYfFzDbun699uUYoA4N\",\"object\":\"chat.completion.chunk\",\"created\":1778031310,\"model\":\"openai/gpt-5.5-20260423\",\"provider\":\"OpenAI\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" is\",\"role\":\"assistant\"},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031310-JUYfFzDbun699uUYoA4N\",\"object\":\"chat.completion.chunk\",\"created\":1778031310,\"model\":\"openai/gpt-5.5-20260423\",\"provider\":\"OpenAI\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" sunny\",\"role\":\"assistant\"},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031310-JUYfFzDbun699uUYoA4N\",\"object\":\"chat.completion.chunk\",\"created\":1778031310,\"model\":\"openai/gpt-5.5-20260423\",\"provider\":\"OpenAI\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" and\",\"role\":\"assistant\"},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031310-JUYfFzDbun699uUYoA4N\",\"object\":\"chat.completion.chunk\",\"created\":1778031310,\"model\":\"openai/gpt-5.5-20260423\",\"provider\":\"OpenAI\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" \",\"role\":\"assistant\"},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031310-JUYfFzDbun699uUYoA4N\",\"object\":\"chat.completion.chunk\",\"created\":1778031310,\"model\":\"openai/gpt-5.5-20260423\",\"provider\":\"OpenAI\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"22\",\"role\":\"assistant\"},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031310-JUYfFzDbun699uUYoA4N\",\"object\":\"chat.completion.chunk\",\"created\":1778031310,\"model\":\"openai/gpt-5.5-20260423\",\"provider\":\"OpenAI\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"°C\",\"role\":\"assistant\"},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031310-JUYfFzDbun699uUYoA4N\",\"object\":\"chat.completion.chunk\",\"created\":1778031310,\"model\":\"openai/gpt-5.5-20260423\",\"provider\":\"OpenAI\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\".\",\"role\":\"assistant\"},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031310-JUYfFzDbun699uUYoA4N\",\"object\":\"chat.completion.chunk\",\"created\":1778031310,\"model\":\"openai/gpt-5.5-20260423\",\"provider\":\"OpenAI\",\"service_tier\":\"default\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\"},\"finish_reason\":\"stop\",\"native_finish_reason\":\"completed\"}]}\n\ndata: {\"id\":\"gen-1778031310-JUYfFzDbun699uUYoA4N\",\"object\":\"chat.completion.chunk\",\"created\":1778031310,\"model\":\"openai/gpt-5.5-20260423\",\"provider\":\"OpenAI\",\"service_tier\":\"default\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\"},\"finish_reason\":\"stop\",\"native_finish_reason\":\"completed\"}],\"usage\":{\"prompt_tokens\":108,\"completion_tokens\":12,\"total_tokens\":120,\"cost\":0.0009,\"is_byok\":false,\"prompt_tokens_details\":{\"cached_tokens\":0,\"cache_write_tokens\":0,\"audio_tokens\":0,\"video_tokens\":0},\"cost_details\":{\"upstream_inference_cost\":0.0009,\"upstream_inference_prompt_cost\":0.00054,\"upstream_inference_completions_cost\":0.00036},\"completion_tokens_details\":{\"reasoning_tokens\":0,\"image_tokens\":0,\"audio_tokens\":0}}}\n\ndata: [DONE]\n\n" + } + } + ] +} diff --git a/packages/llm/test/fixtures/recordings/openai-compatible-chat/openrouter-streams-text.json b/packages/llm/test/fixtures/recordings/openai-compatible-chat/openrouter-streams-text.json new file mode 100644 index 0000000000..1a95146931 --- /dev/null +++ b/packages/llm/test/fixtures/recordings/openai-compatible-chat/openrouter-streams-text.json @@ -0,0 +1,28 @@ +{ + "version": 1, + "metadata": { + "name": "openai-compatible-chat/openrouter-streams-text", + "recordedAt": "2026-05-06T01:35:06.767Z", + "tags": ["prefix:openai-compatible-chat", "protocol:openai-compatible-chat", "provider:openrouter"] + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://openrouter.ai/api/v1/chat/completions", + "headers": { + "content-type": "application/json" + }, + "body": "{\"model\":\"openai/gpt-4o-mini\",\"messages\":[{\"role\":\"system\",\"content\":\"You are concise.\"},{\"role\":\"user\",\"content\":\"Reply with exactly: Hello!\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":20,\"temperature\":0}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream" + }, + "body": ": OPENROUTER PROCESSING\n\ndata: {\"id\":\"gen-1778031306-UD7bR0I1JNCsPvVzlXat\",\"object\":\"chat.completion.chunk\",\"created\":1778031306,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"Azure\",\"system_fingerprint\":\"fp_eb37e061ec\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"Hello\",\"role\":\"assistant\"},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031306-UD7bR0I1JNCsPvVzlXat\",\"object\":\"chat.completion.chunk\",\"created\":1778031306,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"Azure\",\"system_fingerprint\":\"fp_eb37e061ec\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"!\",\"role\":\"assistant\"},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031306-UD7bR0I1JNCsPvVzlXat\",\"object\":\"chat.completion.chunk\",\"created\":1778031306,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"Azure\",\"system_fingerprint\":\"fp_eb37e061ec\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\"},\"finish_reason\":\"stop\",\"native_finish_reason\":\"stop\"}]}\n\ndata: {\"id\":\"gen-1778031306-UD7bR0I1JNCsPvVzlXat\",\"object\":\"chat.completion.chunk\",\"created\":1778031306,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"Azure\",\"system_fingerprint\":\"fp_eb37e061ec\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\"},\"finish_reason\":\"stop\",\"native_finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":21,\"completion_tokens\":3,\"total_tokens\":24,\"cost\":0.00000495,\"is_byok\":false,\"prompt_tokens_details\":{\"cached_tokens\":0,\"cache_write_tokens\":0,\"audio_tokens\":0,\"video_tokens\":0},\"cost_details\":{\"upstream_inference_cost\":0.00000495,\"upstream_inference_prompt_cost\":0.00000315,\"upstream_inference_completions_cost\":0.0000018},\"completion_tokens_details\":{\"reasoning_tokens\":0,\"image_tokens\":0,\"audio_tokens\":0}}}\n\ndata: [DONE]\n\n" + } + } + ] +} diff --git a/packages/llm/test/fixtures/recordings/openai-compatible-chat/openrouter-streams-tool-call.json b/packages/llm/test/fixtures/recordings/openai-compatible-chat/openrouter-streams-tool-call.json new file mode 100644 index 0000000000..36d0ad99c5 --- /dev/null +++ b/packages/llm/test/fixtures/recordings/openai-compatible-chat/openrouter-streams-tool-call.json @@ -0,0 +1,28 @@ +{ + "version": 1, + "metadata": { + "name": "openai-compatible-chat/openrouter-streams-tool-call", + "recordedAt": "2026-05-06T01:35:07.466Z", + "tags": ["prefix:openai-compatible-chat", "protocol:openai-compatible-chat", "provider:openrouter", "tool"] + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://openrouter.ai/api/v1/chat/completions", + "headers": { + "content-type": "application/json" + }, + "body": "{\"model\":\"openai/gpt-4o-mini\",\"messages\":[{\"role\":\"system\",\"content\":\"Call tools exactly as requested.\"},{\"role\":\"user\",\"content\":\"Call get_weather with city exactly Paris.\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}}],\"tool_choice\":{\"type\":\"function\",\"function\":{\"name\":\"get_weather\"}},\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":80,\"temperature\":0}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream" + }, + "body": ": OPENROUTER PROCESSING\n\ndata: {\"id\":\"gen-1778031306-HYzOq04JIk1hZQ4iaNjD\",\"object\":\"chat.completion.chunk\",\"created\":1778031306,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"OpenAI\",\"system_fingerprint\":\"fp_b6580bbee1\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"role\":\"assistant\",\"tool_calls\":[{\"index\":0,\"id\":\"call_L7mHMq49ZSUTBHjLJfBIP2eT\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"\"}}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031306-HYzOq04JIk1hZQ4iaNjD\",\"object\":\"chat.completion.chunk\",\"created\":1778031306,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"OpenAI\",\"system_fingerprint\":\"fp_b6580bbee1\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"role\":\"assistant\",\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"\"}}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031306-HYzOq04JIk1hZQ4iaNjD\",\"object\":\"chat.completion.chunk\",\"created\":1778031306,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"OpenAI\",\"system_fingerprint\":\"fp_b6580bbee1\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"role\":\"assistant\",\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"{\\\"\"}}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031306-HYzOq04JIk1hZQ4iaNjD\",\"object\":\"chat.completion.chunk\",\"created\":1778031306,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"OpenAI\",\"system_fingerprint\":\"fp_b6580bbee1\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"role\":\"assistant\",\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"city\"}}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031306-HYzOq04JIk1hZQ4iaNjD\",\"object\":\"chat.completion.chunk\",\"created\":1778031306,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"OpenAI\",\"system_fingerprint\":\"fp_b6580bbee1\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"role\":\"assistant\",\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"\\\":\\\"\"}}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031306-HYzOq04JIk1hZQ4iaNjD\",\"object\":\"chat.completion.chunk\",\"created\":1778031306,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"OpenAI\",\"system_fingerprint\":\"fp_b6580bbee1\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"role\":\"assistant\",\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"Paris\"}}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031306-HYzOq04JIk1hZQ4iaNjD\",\"object\":\"chat.completion.chunk\",\"created\":1778031306,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"OpenAI\",\"system_fingerprint\":\"fp_b6580bbee1\",\"choices\":[{\"index\":0,\"delta\":{\"content\":null,\"role\":\"assistant\",\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"\\\"}\"}}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1778031306-HYzOq04JIk1hZQ4iaNjD\",\"object\":\"chat.completion.chunk\",\"created\":1778031306,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"OpenAI\",\"system_fingerprint\":\"fp_b6580bbee1\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\"},\"finish_reason\":\"tool_calls\",\"native_finish_reason\":\"stop\"}]}\n\ndata: {\"id\":\"gen-1778031306-HYzOq04JIk1hZQ4iaNjD\",\"object\":\"chat.completion.chunk\",\"created\":1778031306,\"model\":\"openai/gpt-4o-mini\",\"provider\":\"OpenAI\",\"system_fingerprint\":\"fp_b6580bbee1\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\"},\"finish_reason\":\"tool_calls\",\"native_finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":67,\"completion_tokens\":5,\"total_tokens\":72,\"cost\":0.00001305,\"is_byok\":false,\"prompt_tokens_details\":{\"cached_tokens\":0,\"cache_write_tokens\":0,\"audio_tokens\":0,\"video_tokens\":0},\"cost_details\":{\"upstream_inference_cost\":0.00001305,\"upstream_inference_prompt_cost\":0.00001005,\"upstream_inference_completions_cost\":0.000003},\"completion_tokens_details\":{\"reasoning_tokens\":0,\"image_tokens\":0,\"audio_tokens\":0}}}\n\ndata: [DONE]\n\n" + } + } + ] +} diff --git a/packages/llm/test/fixtures/recordings/openai-compatible-chat/togetherai-streams-text.json b/packages/llm/test/fixtures/recordings/openai-compatible-chat/togetherai-streams-text.json new file mode 100644 index 0000000000..640565b14f --- /dev/null +++ b/packages/llm/test/fixtures/recordings/openai-compatible-chat/togetherai-streams-text.json @@ -0,0 +1,28 @@ +{ + "version": 1, + "metadata": { + "name": "openai-compatible-chat/togetherai-streams-text", + "recordedAt": "2026-04-28T21:18:55.266Z", + "tags": ["prefix:openai-compatible-chat", "protocol:openai-compatible-chat", "provider:togetherai"] + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://api.together.xyz/v1/chat/completions", + "headers": { + "content-type": "application/json" + }, + "body": "{\"model\":\"meta-llama/Llama-3.3-70B-Instruct-Turbo\",\"messages\":[{\"role\":\"system\",\"content\":\"You are concise.\"},{\"role\":\"user\",\"content\":\"Reply with exactly: Hello!\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":20,\"temperature\":0}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream;charset=utf-8" + }, + "body": "data: {\"id\":\"ogzjdpL-6Ng1vN-9f391a08f8af75e1\",\"object\":\"chat.completion.chunk\",\"created\":1777411129,\"choices\":[{\"index\":0,\"text\":\"Hello\",\"logprobs\":null,\"finish_reason\":null,\"seed\":null,\"delta\":{\"token_id\":9906,\"role\":\"assistant\",\"content\":\"Hello\"}}],\"model\":\"meta-llama/Llama-3.3-70B-Instruct-Turbo\",\"usage\":null}\n\ndata: {\"id\":\"ogzjdpL-6Ng1vN-9f391a08f8af75e1\",\"object\":\"chat.completion.chunk\",\"created\":1777411129,\"choices\":[{\"index\":0,\"text\":\"!\",\"logprobs\":null,\"finish_reason\":null,\"seed\":null,\"delta\":{\"token_id\":null,\"role\":\"assistant\",\"content\":\"!\"}}],\"model\":\"meta-llama/Llama-3.3-70B-Instruct-Turbo\",\"usage\":null}\n\ndata: {\"id\":\"ogzjdpL-6Ng1vN-9f391a08f8af75e1\",\"object\":\"chat.completion.chunk\",\"created\":1777411129,\"choices\":[{\"index\":0,\"text\":\"\",\"logprobs\":null,\"finish_reason\":\"stop\",\"seed\":15924764223251450000,\"delta\":{\"token_id\":128009,\"role\":\"assistant\",\"content\":\"\"}}],\"model\":\"meta-llama/Llama-3.3-70B-Instruct-Turbo\",\"usage\":{\"prompt_tokens\":45,\"completion_tokens\":3,\"total_tokens\":48,\"cached_tokens\":0}}\n\ndata: [DONE]\n\n" + } + } + ] +} diff --git a/packages/llm/test/fixtures/recordings/openai-compatible-chat/togetherai-streams-tool-call.json b/packages/llm/test/fixtures/recordings/openai-compatible-chat/togetherai-streams-tool-call.json new file mode 100644 index 0000000000..6c1d9c1a7f --- /dev/null +++ b/packages/llm/test/fixtures/recordings/openai-compatible-chat/togetherai-streams-tool-call.json @@ -0,0 +1,28 @@ +{ + "version": 1, + "metadata": { + "name": "openai-compatible-chat/togetherai-streams-tool-call", + "recordedAt": "2026-04-28T21:18:59.123Z", + "tags": ["prefix:openai-compatible-chat", "protocol:openai-compatible-chat", "provider:togetherai", "tool"] + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://api.together.xyz/v1/chat/completions", + "headers": { + "content-type": "application/json" + }, + "body": "{\"model\":\"meta-llama/Llama-3.3-70B-Instruct-Turbo\",\"messages\":[{\"role\":\"system\",\"content\":\"Call tools exactly as requested.\"},{\"role\":\"user\",\"content\":\"Call get_weather with city exactly Paris.\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}}],\"tool_choice\":{\"type\":\"function\",\"function\":{\"name\":\"get_weather\"}},\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":80,\"temperature\":0}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream;charset=utf-8" + }, + "body": "data: {\"id\":\"ogzjfRD-6Ng1vN-9f391a2bb8ca75e1\",\"object\":\"chat.completion.chunk\",\"created\":1777411135,\"choices\":[{\"index\":0,\"role\":\"assistant\",\"text\":\"\",\"logprobs\":null,\"finish_reason\":null,\"delta\":{\"token_id\":null,\"role\":\"assistant\",\"content\":\"\"}}],\"model\":\"meta-llama/Llama-3.3-70B-Instruct-Turbo\"}\n\ndata: {\"id\":\"ogzjfRD-6Ng1vN-9f391a2bb8ca75e1\",\"object\":\"chat.completion.chunk\",\"created\":1777411135,\"choices\":[{\"index\":0,\"text\":\"\",\"logprobs\":null,\"finish_reason\":null,\"delta\":{\"token_id\":null,\"role\":\"assistant\",\"content\":\"\",\"tool_calls\":[{\"index\":0,\"id\":\"call_yu1mxtmex7x48nximi9c8jpo\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"\"}}]}}],\"model\":\"meta-llama/Llama-3.3-70B-Instruct-Turbo\"}\n\ndata: {\"id\":\"ogzjfRD-6Ng1vN-9f391a2bb8ca75e1\",\"object\":\"chat.completion.chunk\",\"created\":1777411135,\"choices\":[{\"index\":0,\"text\":\"\",\"logprobs\":null,\"finish_reason\":\"tool_calls\",\"delta\":{\"token_id\":null,\"role\":\"assistant\",\"content\":\"\",\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\"}}]}}],\"model\":\"meta-llama/Llama-3.3-70B-Instruct-Turbo\"}\n\ndata: {\"id\":\"ogzjfRD-6Ng1vN-9f391a2bb8ca75e1\",\"object\":\"chat.completion.chunk\",\"created\":1777411135,\"choices\":[{\"index\":0,\"text\":\"\",\"logprobs\":null,\"finish_reason\":\"tool_calls\",\"seed\":9033012299842426000,\"delta\":{\"token_id\":128009,\"role\":\"assistant\",\"content\":\"\"}}],\"model\":\"meta-llama/Llama-3.3-70B-Instruct-Turbo\",\"usage\":{\"prompt_tokens\":194,\"completion_tokens\":19,\"total_tokens\":213,\"cached_tokens\":0}}\n\ndata: [DONE]\n\n" + } + } + ] +} diff --git a/packages/llm/test/fixtures/recordings/openai-responses/gpt-5-5-drives-a-tool-loop.json b/packages/llm/test/fixtures/recordings/openai-responses/gpt-5-5-drives-a-tool-loop.json new file mode 100644 index 0000000000..a3f2e014df --- /dev/null +++ b/packages/llm/test/fixtures/recordings/openai-responses/gpt-5-5-drives-a-tool-loop.json @@ -0,0 +1,54 @@ +{ + "version": 1, + "metadata": { + "name": "openai-responses/gpt-5-5-drives-a-tool-loop", + "recordedAt": "2026-05-06T00:26:15.209Z", + "tags": [ + "prefix:openai-responses", + "provider:openai", + "protocol:openai-responses", + "tool", + "tool-loop", + "golden", + "flagship" + ] + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://api.openai.com/v1/responses", + "headers": { + "content-type": "application/json" + }, + "body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"system\",\"content\":\"Use the get_weather tool, then answer in one short sentence.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]}],\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}],\"stream\":true,\"max_output_tokens\":80}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream; charset=utf-8" + }, + "body": "event: response.created\ndata: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_01394305fdec6fdd0069fa8aa414cc81a1908662495e7c9bd9\",\"object\":\"response\",\"created_at\":1778027172,\"status\":\"in_progress\",\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":80,\"max_tool_calls\":null,\"model\":\"gpt-5.5-2026-04-23\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"effort\":\"medium\",\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":true,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":\"auto\",\"tools\":[{\"type\":\"function\",\"description\":\"Get current weather for a city.\",\"name\":\"get_weather\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":true}],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":0}\n\nevent: response.in_progress\ndata: {\"type\":\"response.in_progress\",\"response\":{\"id\":\"resp_01394305fdec6fdd0069fa8aa414cc81a1908662495e7c9bd9\",\"object\":\"response\",\"created_at\":1778027172,\"status\":\"in_progress\",\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":80,\"max_tool_calls\":null,\"model\":\"gpt-5.5-2026-04-23\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"effort\":\"medium\",\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":true,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":\"auto\",\"tools\":[{\"type\":\"function\",\"description\":\"Get current weather for a city.\",\"name\":\"get_weather\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":true}],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":1}\n\nevent: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"item\":{\"id\":\"fc_01394305fdec6fdd0069fa8aa51a3881a1a2e74c58f5c368d4\",\"type\":\"function_call\",\"status\":\"in_progress\",\"arguments\":\"\",\"call_id\":\"call_JCuVTkQxVB3cCmFWx52adJKZ\",\"name\":\"get_weather\"},\"output_index\":0,\"sequence_number\":2}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"{\\\"\",\"item_id\":\"fc_01394305fdec6fdd0069fa8aa51a3881a1a2e74c58f5c368d4\",\"obfuscation\":\"5DTUG002eUNyAN\",\"output_index\":0,\"sequence_number\":3}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"city\",\"item_id\":\"fc_01394305fdec6fdd0069fa8aa51a3881a1a2e74c58f5c368d4\",\"obfuscation\":\"cbezJUlKOHJ8\",\"output_index\":0,\"sequence_number\":4}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"\\\":\\\"\",\"item_id\":\"fc_01394305fdec6fdd0069fa8aa51a3881a1a2e74c58f5c368d4\",\"obfuscation\":\"Du6y75R0eXTqj\",\"output_index\":0,\"sequence_number\":5}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"Paris\",\"item_id\":\"fc_01394305fdec6fdd0069fa8aa51a3881a1a2e74c58f5c368d4\",\"obfuscation\":\"dHUPwHp6aIB\",\"output_index\":0,\"sequence_number\":6}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"\\\"}\",\"item_id\":\"fc_01394305fdec6fdd0069fa8aa51a3881a1a2e74c58f5c368d4\",\"obfuscation\":\"4A6QSCyeBQa1fC\",\"output_index\":0,\"sequence_number\":7}\n\nevent: response.function_call_arguments.done\ndata: {\"type\":\"response.function_call_arguments.done\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\",\"item_id\":\"fc_01394305fdec6fdd0069fa8aa51a3881a1a2e74c58f5c368d4\",\"output_index\":0,\"sequence_number\":8}\n\nevent: response.output_item.done\ndata: {\"type\":\"response.output_item.done\",\"item\":{\"id\":\"fc_01394305fdec6fdd0069fa8aa51a3881a1a2e74c58f5c368d4\",\"type\":\"function_call\",\"status\":\"completed\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\",\"call_id\":\"call_JCuVTkQxVB3cCmFWx52adJKZ\",\"name\":\"get_weather\"},\"output_index\":0,\"sequence_number\":9}\n\nevent: response.completed\ndata: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_01394305fdec6fdd0069fa8aa414cc81a1908662495e7c9bd9\",\"object\":\"response\",\"created_at\":1778027172,\"status\":\"completed\",\"background\":false,\"completed_at\":1778027173,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":80,\"max_tool_calls\":null,\"model\":\"gpt-5.5-2026-04-23\",\"moderation\":null,\"output\":[{\"id\":\"fc_01394305fdec6fdd0069fa8aa51a3881a1a2e74c58f5c368d4\",\"type\":\"function_call\",\"status\":\"completed\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\",\"call_id\":\"call_JCuVTkQxVB3cCmFWx52adJKZ\",\"name\":\"get_weather\"}],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"effort\":\"medium\",\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"default\",\"store\":true,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":\"auto\",\"tools\":[{\"type\":\"function\",\"description\":\"Get current weather for a city.\",\"name\":\"get_weather\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":true}],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":{\"input_tokens\":67,\"input_tokens_details\":{\"cached_tokens\":0},\"output_tokens\":18,\"output_tokens_details\":{\"reasoning_tokens\":0},\"total_tokens\":85},\"user\":null,\"metadata\":{}},\"sequence_number\":10}\n\n" + } + }, + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://api.openai.com/v1/responses", + "headers": { + "content-type": "application/json" + }, + "body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"system\",\"content\":\"Use the get_weather tool, then answer in one short sentence.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]},{\"type\":\"function_call\",\"call_id\":\"call_JCuVTkQxVB3cCmFWx52adJKZ\",\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\"},{\"type\":\"function_call_output\",\"call_id\":\"call_JCuVTkQxVB3cCmFWx52adJKZ\",\"output\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}],\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}],\"stream\":true,\"max_output_tokens\":80}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream; charset=utf-8" + }, + "body": "event: response.created\ndata: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_00daac70c40e5f4c0069fa8aa5a58c819db01baef7149e9043\",\"object\":\"response\",\"created_at\":1778027173,\"status\":\"in_progress\",\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":80,\"max_tool_calls\":null,\"model\":\"gpt-5.5-2026-04-23\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"effort\":\"medium\",\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":true,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":\"auto\",\"tools\":[{\"type\":\"function\",\"description\":\"Get current weather for a city.\",\"name\":\"get_weather\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":true}],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":0}\n\nevent: response.in_progress\ndata: {\"type\":\"response.in_progress\",\"response\":{\"id\":\"resp_00daac70c40e5f4c0069fa8aa5a58c819db01baef7149e9043\",\"object\":\"response\",\"created_at\":1778027173,\"status\":\"in_progress\",\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":80,\"max_tool_calls\":null,\"model\":\"gpt-5.5-2026-04-23\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"effort\":\"medium\",\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":true,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":\"auto\",\"tools\":[{\"type\":\"function\",\"description\":\"Get current weather for a city.\",\"name\":\"get_weather\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":true}],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":1}\n\nevent: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"item\":{\"id\":\"msg_00daac70c40e5f4c0069fa8aa697a8819daf6660168cb19951\",\"type\":\"message\",\"status\":\"in_progress\",\"content\":[],\"phase\":\"final_answer\",\"role\":\"assistant\"},\"output_index\":0,\"sequence_number\":2}\n\nevent: response.content_part.added\ndata: {\"type\":\"response.content_part.added\",\"content_index\":0,\"item_id\":\"msg_00daac70c40e5f4c0069fa8aa697a8819daf6660168cb19951\",\"output_index\":0,\"part\":{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"\"},\"sequence_number\":3}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"It\",\"item_id\":\"msg_00daac70c40e5f4c0069fa8aa697a8819daf6660168cb19951\",\"logprobs\":[],\"obfuscation\":\"chiK1sgLg8rTyK\",\"output_index\":0,\"sequence_number\":4}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"’s\",\"item_id\":\"msg_00daac70c40e5f4c0069fa8aa697a8819daf6660168cb19951\",\"logprobs\":[],\"obfuscation\":\"ltAaX7wDQM1X8W\",\"output_index\":0,\"sequence_number\":5}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" sunny\",\"item_id\":\"msg_00daac70c40e5f4c0069fa8aa697a8819daf6660168cb19951\",\"logprobs\":[],\"obfuscation\":\"a6nggmY4w0\",\"output_index\":0,\"sequence_number\":6}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" and\",\"item_id\":\"msg_00daac70c40e5f4c0069fa8aa697a8819daf6660168cb19951\",\"logprobs\":[],\"obfuscation\":\"Fm6HNREc68IM\",\"output_index\":0,\"sequence_number\":7}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" \",\"item_id\":\"msg_00daac70c40e5f4c0069fa8aa697a8819daf6660168cb19951\",\"logprobs\":[],\"obfuscation\":\"AvKNavT4eKhSpud\",\"output_index\":0,\"sequence_number\":8}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"22\",\"item_id\":\"msg_00daac70c40e5f4c0069fa8aa697a8819daf6660168cb19951\",\"logprobs\":[],\"obfuscation\":\"xfJpoPh3ZBNXow\",\"output_index\":0,\"sequence_number\":9}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"°C\",\"item_id\":\"msg_00daac70c40e5f4c0069fa8aa697a8819daf6660168cb19951\",\"logprobs\":[],\"obfuscation\":\"PbrlZXftzmtJBV\",\"output_index\":0,\"sequence_number\":10}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" in\",\"item_id\":\"msg_00daac70c40e5f4c0069fa8aa697a8819daf6660168cb19951\",\"logprobs\":[],\"obfuscation\":\"PLrf8voVO2egp\",\"output_index\":0,\"sequence_number\":11}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" Paris\",\"item_id\":\"msg_00daac70c40e5f4c0069fa8aa697a8819daf6660168cb19951\",\"logprobs\":[],\"obfuscation\":\"U4wLv1H29b\",\"output_index\":0,\"sequence_number\":12}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\".\",\"item_id\":\"msg_00daac70c40e5f4c0069fa8aa697a8819daf6660168cb19951\",\"logprobs\":[],\"obfuscation\":\"1n14oh7kAoCuo4f\",\"output_index\":0,\"sequence_number\":13}\n\nevent: response.output_text.done\ndata: {\"type\":\"response.output_text.done\",\"content_index\":0,\"item_id\":\"msg_00daac70c40e5f4c0069fa8aa697a8819daf6660168cb19951\",\"logprobs\":[],\"output_index\":0,\"sequence_number\":14,\"text\":\"It’s sunny and 22°C in Paris.\"}\n\nevent: response.content_part.done\ndata: {\"type\":\"response.content_part.done\",\"content_index\":0,\"item_id\":\"msg_00daac70c40e5f4c0069fa8aa697a8819daf6660168cb19951\",\"output_index\":0,\"part\":{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"It’s sunny and 22°C in Paris.\"},\"sequence_number\":15}\n\nevent: response.output_item.done\ndata: {\"type\":\"response.output_item.done\",\"item\":{\"id\":\"msg_00daac70c40e5f4c0069fa8aa697a8819daf6660168cb19951\",\"type\":\"message\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"It’s sunny and 22°C in Paris.\"}],\"phase\":\"final_answer\",\"role\":\"assistant\"},\"output_index\":0,\"sequence_number\":16}\n\nevent: response.completed\ndata: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_00daac70c40e5f4c0069fa8aa5a58c819db01baef7149e9043\",\"object\":\"response\",\"created_at\":1778027173,\"status\":\"completed\",\"background\":false,\"completed_at\":1778027174,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":80,\"max_tool_calls\":null,\"model\":\"gpt-5.5-2026-04-23\",\"moderation\":null,\"output\":[{\"id\":\"msg_00daac70c40e5f4c0069fa8aa697a8819daf6660168cb19951\",\"type\":\"message\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"It’s sunny and 22°C in Paris.\"}],\"phase\":\"final_answer\",\"role\":\"assistant\"}],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"effort\":\"medium\",\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"default\",\"store\":true,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":\"auto\",\"tools\":[{\"type\":\"function\",\"description\":\"Get current weather for a city.\",\"name\":\"get_weather\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":true}],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":{\"input_tokens\":106,\"input_tokens_details\":{\"cached_tokens\":0},\"output_tokens\":14,\"output_tokens_details\":{\"reasoning_tokens\":0},\"total_tokens\":120},\"user\":null,\"metadata\":{}},\"sequence_number\":17}\n\n" + } + } + ] +} diff --git a/packages/llm/test/fixtures/recordings/openai-responses/gpt-5-5-streams-text.json b/packages/llm/test/fixtures/recordings/openai-responses/gpt-5-5-streams-text.json new file mode 100644 index 0000000000..92c7b7e0f1 --- /dev/null +++ b/packages/llm/test/fixtures/recordings/openai-responses/gpt-5-5-streams-text.json @@ -0,0 +1,28 @@ +{ + "version": 1, + "metadata": { + "name": "openai-responses/gpt-5-5-streams-text", + "recordedAt": "2026-05-06T00:26:10.447Z", + "tags": ["prefix:openai-responses", "provider:openai", "protocol:openai-responses", "flagship"] + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://api.openai.com/v1/responses", + "headers": { + "content-type": "application/json" + }, + "body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"system\",\"content\":\"You are concise.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply with exactly: Hello!\"}]}],\"stream\":true,\"max_output_tokens\":80}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream; charset=utf-8" + }, + "body": "event: response.created\ndata: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_0ea948e2f42449980069fa8aa0e4b4819ca3395b74c53c13fa\",\"object\":\"response\",\"created_at\":1778027168,\"status\":\"in_progress\",\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":80,\"max_tool_calls\":null,\"model\":\"gpt-5.5-2026-04-23\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"effort\":\"medium\",\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":true,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":\"auto\",\"tools\":[],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":0}\n\nevent: response.in_progress\ndata: {\"type\":\"response.in_progress\",\"response\":{\"id\":\"resp_0ea948e2f42449980069fa8aa0e4b4819ca3395b74c53c13fa\",\"object\":\"response\",\"created_at\":1778027168,\"status\":\"in_progress\",\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":80,\"max_tool_calls\":null,\"model\":\"gpt-5.5-2026-04-23\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"effort\":\"medium\",\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":true,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":\"auto\",\"tools\":[],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":1}\n\nevent: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"item\":{\"id\":\"rs_0ea948e2f42449980069fa8aa1d588819cbbcb9b056624d27c\",\"type\":\"reasoning\",\"summary\":[]},\"output_index\":0,\"sequence_number\":2}\n\nevent: response.output_item.done\ndata: {\"type\":\"response.output_item.done\",\"item\":{\"id\":\"rs_0ea948e2f42449980069fa8aa1d588819cbbcb9b056624d27c\",\"type\":\"reasoning\",\"summary\":[]},\"output_index\":0,\"sequence_number\":3}\n\nevent: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"item\":{\"id\":\"msg_0ea948e2f42449980069fa8aa20e38819cbf5be70e4d02a1c7\",\"type\":\"message\",\"status\":\"in_progress\",\"content\":[],\"phase\":\"final_answer\",\"role\":\"assistant\"},\"output_index\":1,\"sequence_number\":4}\n\nevent: response.content_part.added\ndata: {\"type\":\"response.content_part.added\",\"content_index\":0,\"item_id\":\"msg_0ea948e2f42449980069fa8aa20e38819cbf5be70e4d02a1c7\",\"output_index\":1,\"part\":{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"\"},\"sequence_number\":5}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"Hello\",\"item_id\":\"msg_0ea948e2f42449980069fa8aa20e38819cbf5be70e4d02a1c7\",\"logprobs\":[],\"obfuscation\":\"VTjmFwAGgIo\",\"output_index\":1,\"sequence_number\":6}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"!\",\"item_id\":\"msg_0ea948e2f42449980069fa8aa20e38819cbf5be70e4d02a1c7\",\"logprobs\":[],\"obfuscation\":\"PfjFymS7MZa7aYf\",\"output_index\":1,\"sequence_number\":7}\n\nevent: response.output_text.done\ndata: {\"type\":\"response.output_text.done\",\"content_index\":0,\"item_id\":\"msg_0ea948e2f42449980069fa8aa20e38819cbf5be70e4d02a1c7\",\"logprobs\":[],\"output_index\":1,\"sequence_number\":8,\"text\":\"Hello!\"}\n\nevent: response.content_part.done\ndata: {\"type\":\"response.content_part.done\",\"content_index\":0,\"item_id\":\"msg_0ea948e2f42449980069fa8aa20e38819cbf5be70e4d02a1c7\",\"output_index\":1,\"part\":{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"Hello!\"},\"sequence_number\":9}\n\nevent: response.output_item.done\ndata: {\"type\":\"response.output_item.done\",\"item\":{\"id\":\"msg_0ea948e2f42449980069fa8aa20e38819cbf5be70e4d02a1c7\",\"type\":\"message\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"Hello!\"}],\"phase\":\"final_answer\",\"role\":\"assistant\"},\"output_index\":1,\"sequence_number\":10}\n\nevent: response.completed\ndata: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_0ea948e2f42449980069fa8aa0e4b4819ca3395b74c53c13fa\",\"object\":\"response\",\"created_at\":1778027168,\"status\":\"completed\",\"background\":false,\"completed_at\":1778027170,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":80,\"max_tool_calls\":null,\"model\":\"gpt-5.5-2026-04-23\",\"moderation\":null,\"output\":[{\"id\":\"rs_0ea948e2f42449980069fa8aa1d588819cbbcb9b056624d27c\",\"type\":\"reasoning\",\"summary\":[]},{\"id\":\"msg_0ea948e2f42449980069fa8aa20e38819cbf5be70e4d02a1c7\",\"type\":\"message\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"Hello!\"}],\"phase\":\"final_answer\",\"role\":\"assistant\"}],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"effort\":\"medium\",\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"default\",\"store\":true,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":\"auto\",\"tools\":[],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":{\"input_tokens\":20,\"input_tokens_details\":{\"cached_tokens\":0},\"output_tokens\":18,\"output_tokens_details\":{\"reasoning_tokens\":10},\"total_tokens\":38},\"user\":null,\"metadata\":{}},\"sequence_number\":11}\n\n" + } + } + ] +} diff --git a/packages/llm/test/fixtures/recordings/openai-responses/gpt-5-5-streams-tool-call.json b/packages/llm/test/fixtures/recordings/openai-responses/gpt-5-5-streams-tool-call.json new file mode 100644 index 0000000000..172b8407e6 --- /dev/null +++ b/packages/llm/test/fixtures/recordings/openai-responses/gpt-5-5-streams-tool-call.json @@ -0,0 +1,28 @@ +{ + "version": 1, + "metadata": { + "name": "openai-responses/gpt-5-5-streams-tool-call", + "recordedAt": "2026-05-06T00:26:12.011Z", + "tags": ["prefix:openai-responses", "provider:openai", "protocol:openai-responses", "tool", "flagship"] + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://api.openai.com/v1/responses", + "headers": { + "content-type": "application/json" + }, + "body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"system\",\"content\":\"Call tools exactly as requested.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Call get_weather with city exactly Paris.\"}]}],\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}],\"tool_choice\":{\"type\":\"function\",\"name\":\"get_weather\"},\"stream\":true,\"max_output_tokens\":80}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream; charset=utf-8" + }, + "body": "event: response.created\ndata: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_05200a06f78f5b310069fa8aa28134819eba958e34eb1db6ae\",\"object\":\"response\",\"created_at\":1778027170,\"status\":\"in_progress\",\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":80,\"max_tool_calls\":null,\"model\":\"gpt-5.5-2026-04-23\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"effort\":\"medium\",\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":true,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":{\"type\":\"function\",\"name\":\"get_weather\"},\"tools\":[{\"type\":\"function\",\"description\":\"Get current weather for a city.\",\"name\":\"get_weather\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":true}],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":0}\n\nevent: response.in_progress\ndata: {\"type\":\"response.in_progress\",\"response\":{\"id\":\"resp_05200a06f78f5b310069fa8aa28134819eba958e34eb1db6ae\",\"object\":\"response\",\"created_at\":1778027170,\"status\":\"in_progress\",\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":80,\"max_tool_calls\":null,\"model\":\"gpt-5.5-2026-04-23\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"effort\":\"medium\",\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":true,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":{\"type\":\"function\",\"name\":\"get_weather\"},\"tools\":[{\"type\":\"function\",\"description\":\"Get current weather for a city.\",\"name\":\"get_weather\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":true}],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":1}\n\nevent: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"item\":{\"id\":\"fc_05200a06f78f5b310069fa8aa37ca8819e9f131e85e47bcff9\",\"type\":\"function_call\",\"status\":\"in_progress\",\"arguments\":\"\",\"call_id\":\"call_ZAbAwsIFeJSyPqz3HaHRXBSn\",\"name\":\"get_weather\"},\"output_index\":0,\"sequence_number\":2}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"{\\\"\",\"item_id\":\"fc_05200a06f78f5b310069fa8aa37ca8819e9f131e85e47bcff9\",\"obfuscation\":\"X7dp3R85iTgHxP\",\"output_index\":0,\"sequence_number\":3}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"city\",\"item_id\":\"fc_05200a06f78f5b310069fa8aa37ca8819e9f131e85e47bcff9\",\"obfuscation\":\"ECfxJgedKWUn\",\"output_index\":0,\"sequence_number\":4}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"\\\":\\\"\",\"item_id\":\"fc_05200a06f78f5b310069fa8aa37ca8819e9f131e85e47bcff9\",\"obfuscation\":\"BYRjhhZxbw5AR\",\"output_index\":0,\"sequence_number\":5}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"Paris\",\"item_id\":\"fc_05200a06f78f5b310069fa8aa37ca8819e9f131e85e47bcff9\",\"obfuscation\":\"lmbnKOW4qyI\",\"output_index\":0,\"sequence_number\":6}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"\\\"}\",\"item_id\":\"fc_05200a06f78f5b310069fa8aa37ca8819e9f131e85e47bcff9\",\"obfuscation\":\"2PHhvsR2H0PNaP\",\"output_index\":0,\"sequence_number\":7}\n\nevent: response.function_call_arguments.done\ndata: {\"type\":\"response.function_call_arguments.done\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\",\"item_id\":\"fc_05200a06f78f5b310069fa8aa37ca8819e9f131e85e47bcff9\",\"output_index\":0,\"sequence_number\":8}\n\nevent: response.output_item.done\ndata: {\"type\":\"response.output_item.done\",\"item\":{\"id\":\"fc_05200a06f78f5b310069fa8aa37ca8819e9f131e85e47bcff9\",\"type\":\"function_call\",\"status\":\"completed\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\",\"call_id\":\"call_ZAbAwsIFeJSyPqz3HaHRXBSn\",\"name\":\"get_weather\"},\"output_index\":0,\"sequence_number\":9}\n\nevent: response.completed\ndata: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_05200a06f78f5b310069fa8aa28134819eba958e34eb1db6ae\",\"object\":\"response\",\"created_at\":1778027170,\"status\":\"completed\",\"background\":false,\"completed_at\":1778027171,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":80,\"max_tool_calls\":null,\"model\":\"gpt-5.5-2026-04-23\",\"moderation\":null,\"output\":[{\"id\":\"fc_05200a06f78f5b310069fa8aa37ca8819e9f131e85e47bcff9\",\"type\":\"function_call\",\"status\":\"completed\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\",\"call_id\":\"call_ZAbAwsIFeJSyPqz3HaHRXBSn\",\"name\":\"get_weather\"}],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"effort\":\"medium\",\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"default\",\"store\":true,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":{\"type\":\"function\",\"name\":\"get_weather\"},\"tools\":[{\"type\":\"function\",\"description\":\"Get current weather for a city.\",\"name\":\"get_weather\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":true}],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":{\"input_tokens\":61,\"input_tokens_details\":{\"cached_tokens\":0},\"output_tokens\":18,\"output_tokens_details\":{\"reasoning_tokens\":0},\"total_tokens\":79},\"user\":null,\"metadata\":{}},\"sequence_number\":10}\n\n" + } + } + ] +} diff --git a/packages/llm/test/generate-object.test.ts b/packages/llm/test/generate-object.test.ts new file mode 100644 index 0000000000..a9e6b5bf7a --- /dev/null +++ b/packages/llm/test/generate-object.test.ts @@ -0,0 +1,182 @@ +import { describe, expect, test } from "bun:test" +import { Effect, Schema } from "effect" +import { LLM } from "../src" +import * as OpenAIChat from "../src/protocols/openai-chat" +import { Tool, toDefinitions } from "../src/tool" +import { it } from "./lib/effect" +import { dynamicResponse } from "./lib/http" +import { finishChunk, toolCallChunk } from "./lib/openai-chunks" +import { sseEvents } from "./lib/sse" + +type OpenAIChatBody = { + readonly tool_choice?: unknown + readonly tools?: ReadonlyArray<{ + readonly function: { + readonly parameters: unknown + } + }> +} + +const model = OpenAIChat.model({ + id: "gpt-4o-mini", + baseURL: "https://api.openai.test/v1/", + headers: { authorization: "Bearer test" }, +}) + +const Json = Schema.fromJsonString(Schema.Unknown) +const decodeJson = Schema.decodeUnknownSync(Json) +const decodeBody = (text: string): OpenAIChatBody => decodeJson(text) as OpenAIChatBody + +describe("Tool.make (dynamic JSON Schema)", () => { + test("forwards JSON Schema and description through toDefinitions", () => { + const jsonSchema = { + type: "object" as const, + properties: { city: { type: "string" } }, + required: ["city"], + } + const lookup = Tool.make({ + description: "Look up something", + jsonSchema, + execute: () => Effect.succeed({ ok: true }), + }) + const [definition] = toDefinitions({ lookup }) + expect(definition?.name).toBe("lookup") + expect(definition?.description).toBe("Look up something") + expect(definition?.inputSchema).toEqual(jsonSchema) + }) + + test("execute receives the raw input untouched", async () => { + const seen: unknown[] = [] + const tool = Tool.make({ + description: "echo", + jsonSchema: { type: "object" }, + execute: (params) => + Effect.sync(() => { + seen.push(params) + return { ok: true } + }), + }) + const result = await Effect.runPromise(tool.execute({ hello: "world" })) + expect(seen).toEqual([{ hello: "world" }]) + expect(result).toEqual({ ok: true }) + }) +}) + +describe("LLM.generateObject", () => { + it.effect("forces a synthetic tool call and decodes the input", () => + Effect.gen(function* () { + const bodies: OpenAIChatBody[] = [] + const layer = dynamicResponse((input) => + Effect.sync(() => { + bodies.push(decodeBody(input.text)) + return input.respond( + sseEvents( + toolCallChunk("call_1", "generate_object", '{"city":"Paris","temp":22}'), + finishChunk("tool_calls"), + ), + { headers: { "content-type": "text/event-stream" } }, + ) + }), + ) + + const response = yield* LLM.generateObject({ + model, + prompt: "Return a structured weather report.", + schema: Schema.Struct({ city: Schema.String, temp: Schema.Number }), + }).pipe(Effect.provide(layer)) + + expect(response.object).toEqual({ city: "Paris", temp: 22 }) + expect(response.response.toolCalls).toHaveLength(1) + expect(bodies).toHaveLength(1) + expect(bodies[0].tool_choice).toEqual({ type: "function", function: { name: "generate_object" } }) + const tool = bodies[0].tools?.[0] + expect(bodies[0].tools).toHaveLength(1) + expect(tool).toMatchObject({ + type: "function", + function: { name: "generate_object" }, + }) + const params = tool?.function.parameters as { + readonly type?: unknown + readonly required?: unknown + readonly properties?: Record + } + expect(params.type).toBe("object") + expect(params.required).toEqual(["city", "temp"]) + expect(params.properties?.city).toMatchObject({ type: "string" }) + expect(params.properties?.temp).toBeDefined() + }), + ) + + it.effect("accepts a raw JSON Schema and returns the input untouched", () => + Effect.gen(function* () { + const bodies: OpenAIChatBody[] = [] + const layer = dynamicResponse((input) => + Effect.sync(() => { + bodies.push(decodeBody(input.text)) + return input.respond( + sseEvents(toolCallChunk("call_1", "generate_object", '{"name":"Ada","age":30}'), finishChunk("tool_calls")), + { headers: { "content-type": "text/event-stream" } }, + ) + }), + ) + + const response = yield* LLM.generateObject({ + model, + prompt: "Extract the user.", + jsonSchema: { + type: "object", + properties: { name: { type: "string" }, age: { type: "number" } }, + required: ["name", "age"], + }, + }).pipe(Effect.provide(layer)) + + expect(response.object).toEqual({ name: "Ada", age: 30 }) + expect(bodies[0].tools?.[0]?.function.parameters).toEqual({ + type: "object", + properties: { name: { type: "string" }, age: { type: "number" } }, + required: ["name", "age"], + }) + }), + ) + + it.effect("fails when the model does not call the synthetic tool", () => + Effect.gen(function* () { + const layer = dynamicResponse((input) => + Effect.sync(() => + input.respond(sseEvents({ id: "x", choices: [{ delta: { content: "no thanks" }, finish_reason: "stop" }] }), { + headers: { "content-type": "text/event-stream" }, + }), + ), + ) + + const exit = yield* LLM.generateObject({ + model, + prompt: "Return a structured value.", + schema: Schema.Struct({ value: Schema.Number }), + }).pipe(Effect.provide(layer), Effect.exit) + + expect(exit._tag).toBe("Failure") + }), + ) + + it.effect("fails with a decode error when the tool input does not match the schema", () => + Effect.gen(function* () { + const layer = dynamicResponse((input) => + Effect.sync(() => + input.respond( + sseEvents(toolCallChunk("call_1", "generate_object", '{"value":"not-a-number"}'), finishChunk("tool_calls")), + { headers: { "content-type": "text/event-stream" } }, + ), + ), + ) + + const exit = yield* LLM.generateObject({ + model, + prompt: "Return a structured value.", + schema: Schema.Struct({ value: Schema.Number }), + }).pipe(Effect.provide(layer), Effect.exit) + + expect(exit._tag).toBe("Failure") + }), + ) +}) diff --git a/packages/llm/test/lib/effect.ts b/packages/llm/test/lib/effect.ts new file mode 100644 index 0000000000..05cf017b2b --- /dev/null +++ b/packages/llm/test/lib/effect.ts @@ -0,0 +1,50 @@ +import { test, type TestOptions } from "bun:test" +import { Cause, Effect, Exit, Layer } from "effect" +import type * as Scope from "effect/Scope" +import * as TestClock from "effect/testing/TestClock" +import * as TestConsole from "effect/testing/TestConsole" + +type Body = Effect.Effect | (() => Effect.Effect) + +const body = (value: Body) => Effect.suspend(() => (typeof value === "function" ? value() : value)) + +const run = (value: Body, layer: Layer.Layer) => + Effect.gen(function* () { + const exit = yield* body(value).pipe(Effect.scoped, Effect.provide(layer), Effect.exit) + if (Exit.isFailure(exit)) { + for (const err of Cause.prettyErrors(exit.cause)) { + yield* Effect.logError(err) + } + } + return yield* exit + }).pipe(Effect.runPromise) + +const make = (testLayer: Layer.Layer, liveLayer: Layer.Layer) => { + const effect = (name: string, value: Body, opts?: number | TestOptions) => + test(name, () => run(value, testLayer), opts) + + effect.only = (name: string, value: Body, opts?: number | TestOptions) => + test.only(name, () => run(value, testLayer), opts) + + effect.skip = (name: string, value: Body, opts?: number | TestOptions) => + test.skip(name, () => run(value, testLayer), opts) + + const live = (name: string, value: Body, opts?: number | TestOptions) => + test(name, () => run(value, liveLayer), opts) + + live.only = (name: string, value: Body, opts?: number | TestOptions) => + test.only(name, () => run(value, liveLayer), opts) + + live.skip = (name: string, value: Body, opts?: number | TestOptions) => + test.skip(name, () => run(value, liveLayer), opts) + + return { effect, live } +} + +const testEnv = Layer.mergeAll(TestConsole.layer, TestClock.layer()) +const liveEnv = TestConsole.layer + +export const it = make(testEnv, liveEnv) + +export const testEffect = (layer: Layer.Layer) => + make(Layer.provideMerge(layer, testEnv), Layer.provideMerge(layer, liveEnv)) diff --git a/packages/llm/test/lib/http.ts b/packages/llm/test/lib/http.ts new file mode 100644 index 0000000000..cfe7e6883b --- /dev/null +++ b/packages/llm/test/lib/http.ts @@ -0,0 +1,96 @@ +import { Effect, Layer, Ref } from "effect" +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" +import { LLMClient, RequestExecutor } from "../../src/route" +import type { Service as LLMClientService } from "../../src/route/client" +import type { Service as RequestExecutorService } from "../../src/route/executor" + +export type HandlerInput = { + readonly request: HttpClientRequest.HttpClientRequest + readonly text: string + readonly respond: ( + body: ConstructorParameters[0], + init?: ResponseInit, + ) => HttpClientResponse.HttpClientResponse +} + +export type Handler = (input: HandlerInput) => Effect.Effect + +const handlerLayer = (handler: Handler): Layer.Layer => + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.gen(function* () { + const web = yield* HttpClientRequest.toWeb(request).pipe(Effect.orDie) + const text = yield* Effect.promise(() => web.text()) + return yield* handler({ + request, + text, + respond: (body, init) => HttpClientResponse.fromWeb(request, new Response(body, init)), + }) + }), + ), + ) + +export type RuntimeEnv = RequestExecutorService | LLMClientService + +export const runtimeLayer = (layer: Layer.Layer): Layer.Layer => { + const requestExecutorLayer = RequestExecutor.layer.pipe(Layer.provide(layer)) + const llmClientLayer = LLMClient.layer.pipe(Layer.provide(requestExecutorLayer)) + return Layer.mergeAll(requestExecutorLayer, llmClientLayer) +} + +const SSE_HEADERS = { "content-type": "text/event-stream" } as const + +/** + * Layer that returns a single fixed response body. Use for stream-parser + * fixture tests where the request shape is irrelevant. The body type widens + * to whatever `Response` accepts so binary fixtures (`Uint8Array`, + * `ReadableStream`, etc.) flow through without casts. + */ +export const fixedResponse = ( + body: ConstructorParameters[0], + init: ResponseInit = { headers: SSE_HEADERS }, +) => runtimeLayer(handlerLayer((input) => Effect.succeed(input.respond(body, init)))) + +/** + * Layer that builds a response per request. Useful for echo servers. + */ +export const dynamicResponse = (handler: Handler) => runtimeLayer(handlerLayer(handler)) + +/** + * Layer that emits the supplied SSE chunks and then aborts mid-stream. Used to + * exercise transport errors that surface during parsing. + */ +export const truncatedStream = (chunks: ReadonlyArray) => + dynamicResponse((input) => + Effect.sync(() => { + const encoder = new TextEncoder() + const stream = new ReadableStream({ + start(controller) { + for (const chunk of chunks) controller.enqueue(encoder.encode(chunk)) + controller.error(new Error("connection reset")) + }, + }) + return input.respond(stream, { headers: SSE_HEADERS }) + }), + ) + +/** + * Layer that returns successive bodies on each request. Useful for scripting + * multi-step model exchanges (e.g. tool-call loops). The last body in the + * array is reused if the test makes more requests than scripted. + */ +export const scriptedResponses = (bodies: ReadonlyArray, init: ResponseInit = { headers: SSE_HEADERS }) => { + if (bodies.length === 0) throw new Error("scriptedResponses requires at least one body") + return Layer.unwrap( + Effect.gen(function* () { + const cursor = yield* Ref.make(0) + return dynamicResponse((input) => + Effect.gen(function* () { + const index = yield* Ref.getAndUpdate(cursor, (n) => n + 1) + return input.respond(bodies[index] ?? bodies[bodies.length - 1], init) + }), + ) + }), + ) +} diff --git a/packages/llm/test/lib/openai-chunks.ts b/packages/llm/test/lib/openai-chunks.ts new file mode 100644 index 0000000000..77a7c919e1 --- /dev/null +++ b/packages/llm/test/lib/openai-chunks.ts @@ -0,0 +1,27 @@ +/** + * Shared chunk shapes for OpenAI Chat / OpenAI-compatible Chat fixture tests. + * Multiple test files build the same `{ id, choices: [{ delta, finish_reason }], usage }` + * envelope; consolidating here keeps tool-call event shapes consistent. + */ + +const FIXTURE_ID = "chatcmpl_fixture" + +export const deltaChunk = (delta: object, finishReason: string | null = null) => ({ + id: FIXTURE_ID, + choices: [{ delta, finish_reason: finishReason }], + usage: null, +}) + +export const usageChunk = (usage: object) => ({ + id: FIXTURE_ID, + choices: [], + usage, +}) + +export const finishChunk = (reason: string) => deltaChunk({}, reason) + +export const toolCallChunk = (id: string, name: string, args: string, index = 0) => + deltaChunk({ + role: "assistant", + tool_calls: [{ index, id, function: { name, arguments: args } }], + }) diff --git a/packages/llm/test/lib/sse.ts b/packages/llm/test/lib/sse.ts new file mode 100644 index 0000000000..80b275d296 --- /dev/null +++ b/packages/llm/test/lib/sse.ts @@ -0,0 +1,17 @@ +/** + * Helpers for building deterministic SSE bodies in tests. + * + * Inline template-literal SSE strings are hard to write and review when chunks + * contain JSON; this helper accepts plain values and serializes them, so test + * authors only think about the chunk shapes, not the wire format. + */ +export const sseEvents = (...chunks: ReadonlyArray): string => + `${chunks.map(formatChunk).join("")}data: [DONE]\n\n` + +const formatChunk = (chunk: unknown) => `data: ${typeof chunk === "string" ? chunk : JSON.stringify(chunk)}\n\n` + +/** + * Build an SSE body from already-serialized strings (used when the chunk shape + * itself is part of what's being tested, e.g. malformed chunks). + */ +export const sseRaw = (...lines: ReadonlyArray): string => lines.map((line) => `${line}\n\n`).join("") diff --git a/packages/llm/test/lib/tool-runtime.ts b/packages/llm/test/lib/tool-runtime.ts new file mode 100644 index 0000000000..a12941603a --- /dev/null +++ b/packages/llm/test/lib/tool-runtime.ts @@ -0,0 +1,9 @@ +import { Stream } from "effect" +import { LLMClient } from "../../src/route" +import type { Tools } from "../../src/tool" +import type { RunOptions } from "../../src/tool-runtime" + +type CompatRunOptions = RunOptions & { readonly maxSteps?: number } + +export const runTools = (options: CompatRunOptions) => + LLMClient.stream({ ...options, stopWhen: options.stopWhen ?? LLMClient.stepCountIs(options.maxSteps ?? 10) }) diff --git a/packages/llm/test/llm.test.ts b/packages/llm/test/llm.test.ts new file mode 100644 index 0000000000..9380e554bf --- /dev/null +++ b/packages/llm/test/llm.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, test } from "bun:test" +import { LLM, LLMResponse } from "../src" +import { LLMRequest, Message, ModelRef, ToolChoice, ToolDefinition } from "../src/schema" + +describe("llm constructors", () => { + test("builds canonical schema classes from ergonomic input", () => { + const request = LLM.request({ + id: "req_1", + model: LLM.model({ id: "fake-model", provider: "fake", route: "openai-chat", baseURL: "https://fake.local" }), + system: "You are concise.", + prompt: "Say hello.", + }) + + expect(request).toBeInstanceOf(LLMRequest) + expect(request.model).toBeInstanceOf(ModelRef) + expect(request.messages[0]).toBeInstanceOf(Message) + expect(request.system).toEqual([{ type: "text", text: "You are concise." }]) + expect(request.messages[0]?.content).toEqual([{ type: "text", text: "Say hello." }]) + expect(request.generation).toBeUndefined() + expect(request.tools).toEqual([]) + }) + + test("updates requests without spreading schema class instances", () => { + const base = LLM.request({ + id: "req_1", + model: LLM.model({ id: "fake-model", provider: "fake", route: "openai-chat", baseURL: "https://fake.local" }), + prompt: "Say hello.", + }) + const updated = LLM.updateRequest(base, { + generation: { maxTokens: 20 }, + messages: [...base.messages, LLM.assistant("Hi.")], + }) + + expect(updated).toBeInstanceOf(LLMRequest) + expect(updated.id).toBe("req_1") + expect(updated.model).toEqual(base.model) + expect(updated.generation).toEqual({ maxTokens: 20 }) + expect(updated.messages.map((message) => message.role)).toEqual(["user", "assistant"]) + }) + + test("keeps request options separate from model defaults", () => { + const request = LLM.request({ + model: LLM.model({ + id: "fake-model", + provider: "fake", + route: "openai-chat", + baseURL: "https://fake.local", + generation: { maxTokens: 100, temperature: 1 }, + providerOptions: { openai: { store: false, metadata: { model: true } } }, + http: { body: { metadata: { model: true } }, headers: { "x-shared": "model" }, query: { model: "1" } }, + }), + prompt: "Say hello.", + generation: { temperature: 0 }, + providerOptions: { openai: { store: true, metadata: { request: true } } }, + http: { body: { metadata: { request: true } }, headers: { "x-shared": "request" }, query: { request: "1" } }, + }) + + expect(request.generation).toEqual({ temperature: 0 }) + expect(request.providerOptions).toEqual({ openai: { store: true, metadata: { request: true } } }) + expect(request.http).toEqual({ + body: { metadata: { request: true } }, + headers: { "x-shared": "request" }, + query: { request: "1" }, + }) + }) + + test("updates canonical requests from the request datatype", () => { + const base = LLM.request({ + id: "req_1", + model: LLM.model({ id: "fake-model", provider: "fake", route: "openai-chat", baseURL: "https://fake.local" }), + prompt: "Say hello.", + }) + const updated = LLMRequest.update(base, { messages: [...base.messages, LLM.assistant("Hi.")] }) + + expect(updated).toBeInstanceOf(LLMRequest) + expect(updated.id).toBe("req_1") + expect(LLMRequest.input(updated).id).toBe("req_1") + expect(updated.messages.map((message) => message.role)).toEqual(["user", "assistant"]) + expect(LLMRequest.update(updated, {})).toBe(updated) + }) + + test("updates canonical models from the model datatype", () => { + const base = LLM.model({ id: "fake-model", provider: "fake", route: "openai-chat", baseURL: "https://fake.local" }) + const updated = ModelRef.update(base, { route: "openai-responses" }) + + expect(updated).toBeInstanceOf(ModelRef) + expect(String(updated.id)).toBe("fake-model") + expect(updated.route).toBe("openai-responses") + expect(String(ModelRef.input(updated).provider)).toBe("fake") + expect(ModelRef.update(updated, {})).toBe(updated) + }) + + test("builds tool choices from names and tools", () => { + const tool = LLM.toolDefinition({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }) + + expect(tool).toBeInstanceOf(ToolDefinition) + expect(LLM.toolChoice("lookup")).toEqual(new ToolChoice({ type: "tool", name: "lookup" })) + expect(LLM.toolChoiceName("required")).toEqual(new ToolChoice({ type: "tool", name: "required" })) + expect(LLM.toolChoice(tool)).toEqual(new ToolChoice({ type: "tool", name: "lookup" })) + }) + + test("builds tool choice modes from reserved strings", () => { + expect(LLM.toolChoice("auto")).toEqual(new ToolChoice({ type: "auto" })) + expect(LLM.toolChoice("none")).toEqual(new ToolChoice({ type: "none" })) + expect(LLM.toolChoice("required")).toEqual(new ToolChoice({ type: "required" })) + expect( + LLM.request({ + model: LLM.model({ id: "fake-model", provider: "fake", route: "openai-chat", baseURL: "https://fake.local" }), + prompt: "Use tools if needed.", + toolChoice: "required", + }).toolChoice, + ).toEqual(new ToolChoice({ type: "required" })) + }) + + test("builds assistant tool calls and tool result messages", () => { + const call = LLM.toolCall({ id: "call_1", name: "lookup", input: { query: "weather" } }) + const result = LLM.toolResult({ id: "call_1", name: "lookup", result: { temperature: 72 } }) + + expect(LLM.assistant([call]).content).toEqual([call]) + expect(LLM.toolMessage(result).content).toEqual([ + { type: "tool-result", id: "call_1", name: "lookup", result: { type: "json", value: { temperature: 72 } } }, + ]) + }) + + test("extracts output text from response events", () => { + expect( + LLMResponse.text({ + events: [ + { type: "text-delta", text: "hi" }, + { type: "request-finish", reason: "stop" }, + ], + }), + ).toBe("hi") + }) +}) diff --git a/packages/llm/test/provider.types.ts b/packages/llm/test/provider.types.ts new file mode 100644 index 0000000000..a04ce8bc60 --- /dev/null +++ b/packages/llm/test/provider.types.ts @@ -0,0 +1,39 @@ +import { Provider } from "../src/provider" +import { ProviderID, type ModelRef } from "../src/schema" + +declare const model: (id: string) => ModelRef +declare const requiredModel: (id: string, options: { readonly baseURL: string }) => ModelRef +declare const chat: (id: string, options: { readonly apiKey: string }) => ModelRef + +Provider.make({ + id: ProviderID.make("example"), + model, +}) + +Provider.make({ + id: ProviderID.make("bad"), + model, + // @ts-expect-error provider definitions should not grow accidental top-level fields. + routes: [], +}) + +const requiredProvider = Provider.make({ + id: ProviderID.make("required"), + model: requiredModel, +}) + +requiredProvider.model("custom", { baseURL: "https://example.com/v1" }) + +// @ts-expect-error Provider.make preserves required model options. +requiredProvider.model("custom") + +const multiApiProvider = Provider.make({ + id: ProviderID.make("multi-api"), + model, + apis: { chat }, +}) + +multiApiProvider.apis.chat("chat-model", { apiKey: "key" }) + +// @ts-expect-error Provider.make preserves API-specific option types. +multiApiProvider.apis.chat("chat-model") diff --git a/packages/llm/test/provider/anthropic-messages.recorded.test.ts b/packages/llm/test/provider/anthropic-messages.recorded.test.ts new file mode 100644 index 0000000000..a8d87c46ff --- /dev/null +++ b/packages/llm/test/provider/anthropic-messages.recorded.test.ts @@ -0,0 +1,46 @@ +import { describe, expect } from "bun:test" +import { Effect } from "effect" +import { LLM, LLMError } from "../../src" +import { LLMClient } from "../../src/route" +import * as AnthropicMessages from "../../src/protocols/anthropic-messages" +import { weatherToolName } from "../recorded-scenarios" +import { recordedTests } from "../recorded-test" + +const model = AnthropicMessages.model({ + id: "claude-haiku-4-5-20251001", + apiKey: process.env.ANTHROPIC_API_KEY ?? "fixture", +}) + +const malformedToolOrderRequest = LLM.request({ + id: "recorded_anthropic_malformed_tool_order", + model, + messages: [ + LLM.assistant([ + LLM.toolCall({ id: "call_1", name: weatherToolName, input: { city: "Paris" } }), + { type: "text", text: "I will check the weather." }, + ]), + LLM.toolMessage({ id: "call_1", name: weatherToolName, result: { temperature: "72F" } }), + LLM.user("Use that result to answer briefly."), + ], + tools: [{ name: weatherToolName, description: "Get weather", inputSchema: { type: "object", properties: {} } }], +}) + +const recorded = recordedTests({ + prefix: "anthropic-messages", + provider: "anthropic", + protocol: "anthropic-messages", + requires: ["ANTHROPIC_API_KEY"], + options: { requestHeaders: ["content-type", "anthropic-version"] }, +}) + +describe("Anthropic Messages sad-path recorded", () => { + recorded.effect.with("rejects malformed assistant tool order", { tags: ["tool", "sad-path"] }, () => + Effect.gen(function* () { + const error = yield* LLMClient.generate(malformedToolOrderRequest).pipe(Effect.flip) + + expect(error).toBeInstanceOf(LLMError) + expect(error.reason).toMatchObject({ _tag: "InvalidRequest" }) + expect(error.message).toContain("HTTP 400") + }), + ) +}) diff --git a/packages/llm/test/provider/anthropic-messages.test.ts b/packages/llm/test/provider/anthropic-messages.test.ts new file mode 100644 index 0000000000..263828a0ad --- /dev/null +++ b/packages/llm/test/provider/anthropic-messages.test.ts @@ -0,0 +1,377 @@ +import { describe, expect } from "bun:test" +import { Effect } from "effect" +import { CacheHint, LLM, LLMError } from "../../src" +import { LLMClient } from "../../src/route" +import * as AnthropicMessages from "../../src/protocols/anthropic-messages" +import { it } from "../lib/effect" +import { fixedResponse } from "../lib/http" +import { sseEvents } from "../lib/sse" + +const model = AnthropicMessages.model({ + id: "claude-sonnet-4-5", + baseURL: "https://api.anthropic.test/v1/", + headers: { "x-api-key": "test" }, +}) + +const request = LLM.request({ + id: "req_1", + model, + system: { type: "text", text: "You are concise.", cache: new CacheHint({ type: "ephemeral" }) }, + prompt: "Say hello.", + generation: { maxTokens: 20, temperature: 0 }, +}) + +describe("Anthropic Messages route", () => { + it.effect("prepares Anthropic Messages target", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare(request) + + expect(prepared.body).toEqual({ + model: "claude-sonnet-4-5", + system: [{ type: "text", text: "You are concise.", cache_control: { type: "ephemeral" } }], + messages: [{ role: "user", content: [{ type: "text", text: "Say hello." }] }], + stream: true, + max_tokens: 20, + temperature: 0, + }) + }), + ) + + it.effect("prepares tool call and tool result messages", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + id: "req_tool_result", + model, + messages: [ + LLM.user("What is the weather?"), + LLM.assistant([LLM.toolCall({ id: "call_1", name: "lookup", input: { query: "weather" } })]), + LLM.toolMessage({ id: "call_1", name: "lookup", result: { forecast: "sunny" } }), + ], + }), + ) + + expect(prepared.body).toEqual({ + model: "claude-sonnet-4-5", + messages: [ + { role: "user", content: [{ type: "text", text: "What is the weather?" }] }, + { + role: "assistant", + content: [{ type: "tool_use", id: "call_1", name: "lookup", input: { query: "weather" } }], + }, + { role: "user", content: [{ type: "tool_result", tool_use_id: "call_1", content: '{"forecast":"sunny"}' }] }, + ], + stream: true, + max_tokens: 4096, + }) + }), + ) + + it.effect("lowers preserved Anthropic reasoning signature metadata", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + model, + messages: [ + LLM.assistant([ + { type: "reasoning", text: "thinking", providerMetadata: { anthropic: { signature: "sig_1" } } }, + ]), + ], + }), + ) + + expect(prepared.body).toMatchObject({ + messages: [{ role: "assistant", content: [{ type: "thinking", thinking: "thinking", signature: "sig_1" }] }], + }) + }), + ) + + it.effect("parses text, reasoning, and usage stream fixtures", () => + Effect.gen(function* () { + const body = sseEvents( + { type: "message_start", message: { usage: { input_tokens: 5, cache_read_input_tokens: 1 } } }, + { type: "content_block_start", index: 0, content_block: { type: "text", text: "" } }, + { type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "Hello" } }, + { type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "!" } }, + { type: "content_block_stop", index: 0 }, + { type: "content_block_start", index: 1, content_block: { type: "thinking", thinking: "" } }, + { type: "content_block_delta", index: 1, delta: { type: "thinking_delta", thinking: "thinking" } }, + { type: "content_block_delta", index: 1, delta: { type: "signature_delta", signature: "sig_1" } }, + { type: "content_block_stop", index: 1 }, + { + type: "message_delta", + delta: { stop_reason: "end_turn", stop_sequence: "\n\nHuman:" }, + usage: { output_tokens: 2 }, + }, + { type: "message_stop" }, + ) + const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body))) + + expect(response.text).toBe("Hello!") + expect(response.reasoning).toBe("thinking") + expect(response.usage).toMatchObject({ + inputTokens: 5, + outputTokens: 2, + cacheReadInputTokens: 1, + totalTokens: 7, + }) + expect(response.events.find((event) => event.type === "reasoning-delta" && event.text === "")).toMatchObject({ + providerMetadata: { anthropic: { signature: "sig_1" } }, + }) + expect(response.events.at(-1)).toMatchObject({ + type: "request-finish", + reason: "stop", + providerMetadata: { anthropic: { stopSequence: "\n\nHuman:" } }, + }) + }), + ) + + it.effect("assembles streamed tool call input", () => + Effect.gen(function* () { + const body = sseEvents( + { type: "message_start", message: { usage: { input_tokens: 5 } } }, + { type: "content_block_start", index: 0, content_block: { type: "tool_use", id: "call_1", name: "lookup" } }, + { type: "content_block_delta", index: 0, delta: { type: "input_json_delta", partial_json: '{"query"' } }, + { type: "content_block_delta", index: 0, delta: { type: "input_json_delta", partial_json: ':"weather"}' } }, + { type: "content_block_stop", index: 0 }, + { type: "message_delta", delta: { stop_reason: "tool_use" }, usage: { output_tokens: 1 } }, + ) + const response = yield* LLMClient.generate( + LLM.updateRequest(request, { + tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }], + }), + ).pipe(Effect.provide(fixedResponse(body))) + + expect(response.toolCalls).toEqual([ + { type: "tool-call", id: "call_1", name: "lookup", input: { query: "weather" } }, + ]) + expect(response.events).toEqual([ + { type: "tool-input-delta", id: "call_1", name: "lookup", text: '{"query"' }, + { type: "tool-input-delta", id: "call_1", name: "lookup", text: ':"weather"}' }, + { type: "tool-call", id: "call_1", name: "lookup", input: { query: "weather" } }, + { + type: "request-finish", + reason: "tool-calls", + usage: { inputTokens: 5, outputTokens: 1, totalTokens: 6, native: { input_tokens: 5, output_tokens: 1 } }, + }, + ]) + }), + ) + + it.effect("emits provider-error events for mid-stream provider errors", () => + Effect.gen(function* () { + const response = yield* LLMClient.generate(request).pipe( + Effect.provide( + fixedResponse(sseEvents({ type: "error", error: { type: "overloaded_error", message: "Overloaded" } })), + ), + ) + + expect(response.events).toEqual([{ type: "provider-error", message: "Overloaded" }]) + }), + ) + + it.effect("fails HTTP provider errors before stream parsing", () => + Effect.gen(function* () { + const error = yield* LLMClient.generate(request).pipe( + Effect.provide( + fixedResponse('{"type":"error","error":{"type":"invalid_request_error","message":"Bad request"}}', { + status: 400, + headers: { "content-type": "application/json" }, + }), + ), + Effect.flip, + ) + + expect(error).toBeInstanceOf(LLMError) + expect(error.reason).toMatchObject({ _tag: "InvalidRequest" }) + expect(error.message).toContain("HTTP 400") + }), + ) + + it.effect("decodes server_tool_use + web_search_tool_result as provider-executed events", () => + Effect.gen(function* () { + const body = sseEvents( + { type: "message_start", message: { usage: { input_tokens: 5 } } }, + { + type: "content_block_start", + index: 0, + content_block: { type: "server_tool_use", id: "srvtoolu_abc", name: "web_search" }, + }, + { + type: "content_block_delta", + index: 0, + delta: { type: "input_json_delta", partial_json: '{"query":"effect 4"}' }, + }, + { type: "content_block_stop", index: 0 }, + { + type: "content_block_start", + index: 1, + content_block: { + type: "web_search_tool_result", + tool_use_id: "srvtoolu_abc", + content: [{ type: "web_search_result", url: "https://example.com", title: "Example" }], + }, + }, + { type: "content_block_stop", index: 1 }, + { type: "content_block_start", index: 2, content_block: { type: "text", text: "" } }, + { type: "content_block_delta", index: 2, delta: { type: "text_delta", text: "Found it." } }, + { type: "content_block_stop", index: 2 }, + { type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 8 } }, + ) + const response = yield* LLMClient.generate( + LLM.updateRequest(request, { + tools: [{ name: "web_search", description: "Web search", inputSchema: { type: "object" } }], + }), + ).pipe(Effect.provide(fixedResponse(body))) + + const toolCall = response.events.find((event) => event.type === "tool-call") + expect(toolCall).toEqual({ + type: "tool-call", + id: "srvtoolu_abc", + name: "web_search", + input: { query: "effect 4" }, + providerExecuted: true, + }) + const toolResult = response.events.find((event) => event.type === "tool-result") + expect(toolResult).toEqual({ + type: "tool-result", + id: "srvtoolu_abc", + name: "web_search", + result: { type: "json", value: [{ type: "web_search_result", url: "https://example.com", title: "Example" }] }, + providerExecuted: true, + providerMetadata: { anthropic: { blockType: "web_search_tool_result" } }, + }) + expect(response.text).toBe("Found it.") + expect(response.events.at(-1)).toMatchObject({ type: "request-finish", reason: "stop" }) + }), + ) + + it.effect("decodes web_search_tool_result_error as provider-executed error result", () => + Effect.gen(function* () { + const body = sseEvents( + { type: "message_start", message: { usage: { input_tokens: 5 } } }, + { + type: "content_block_start", + index: 0, + content_block: { type: "server_tool_use", id: "srvtoolu_x", name: "web_search" }, + }, + { type: "content_block_delta", index: 0, delta: { type: "input_json_delta", partial_json: '{"query":"q"}' } }, + { type: "content_block_stop", index: 0 }, + { + type: "content_block_start", + index: 1, + content_block: { + type: "web_search_tool_result", + tool_use_id: "srvtoolu_x", + content: { type: "web_search_tool_result_error", error_code: "max_uses_exceeded" }, + }, + }, + { type: "content_block_stop", index: 1 }, + { type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 1 } }, + ) + const response = yield* LLMClient.generate( + LLM.updateRequest(request, { + tools: [{ name: "web_search", description: "Web search", inputSchema: { type: "object" } }], + }), + ).pipe(Effect.provide(fixedResponse(body))) + + const toolResult = response.events.find((event) => event.type === "tool-result") + expect(toolResult).toMatchObject({ + type: "tool-result", + id: "srvtoolu_x", + name: "web_search", + result: { type: "error" }, + providerExecuted: true, + }) + }), + ) + + it.effect("round-trips provider-executed assistant content into server tool blocks", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + id: "req_round_trip", + model, + messages: [ + LLM.user("Search for something."), + LLM.assistant([ + { + type: "tool-call", + id: "srvtoolu_abc", + name: "web_search", + input: { query: "effect 4" }, + providerExecuted: true, + }, + { + type: "tool-result", + id: "srvtoolu_abc", + name: "web_search", + result: { type: "json", value: [{ url: "https://example.com" }] }, + providerExecuted: true, + }, + { type: "text", text: "Found it." }, + ]), + LLM.user("Thanks."), + ], + }), + ) + + expect(prepared.body).toMatchObject({ + messages: [ + { role: "user", content: [{ type: "text", text: "Search for something." }] }, + { + role: "assistant", + content: [ + { type: "server_tool_use", id: "srvtoolu_abc", name: "web_search", input: { query: "effect 4" } }, + { + type: "web_search_tool_result", + tool_use_id: "srvtoolu_abc", + content: [{ url: "https://example.com" }], + }, + { type: "text", text: "Found it." }, + ], + }, + { role: "user", content: [{ type: "text", text: "Thanks." }] }, + ], + }) + }), + ) + + it.effect("rejects round-trip for unknown server tool names", () => + Effect.gen(function* () { + const error = yield* LLMClient.prepare( + LLM.request({ + id: "req_unknown_server_tool", + model, + messages: [ + LLM.assistant([ + { + type: "tool-result", + id: "srvtoolu_abc", + name: "future_server_tool", + result: { type: "json", value: {} }, + providerExecuted: true, + }, + ]), + ], + }), + ).pipe(Effect.flip) + + expect(error.message).toContain("future_server_tool") + }), + ) + + it.effect("rejects unsupported user media content", () => + Effect.gen(function* () { + const error = yield* LLMClient.prepare( + LLM.request({ + id: "req_media", + model, + messages: [LLM.user({ type: "media", mediaType: "image/png", data: "AAECAw==" })], + }), + ).pipe(Effect.flip) + + expect(error.message).toContain("Anthropic Messages user messages only support text content for now") + }), + ) +}) diff --git a/packages/llm/test/provider/bedrock-converse.test.ts b/packages/llm/test/provider/bedrock-converse.test.ts new file mode 100644 index 0000000000..28be714bdf --- /dev/null +++ b/packages/llm/test/provider/bedrock-converse.test.ts @@ -0,0 +1,533 @@ +import { EventStreamCodec } from "@smithy/eventstream-codec" +import { fromUtf8, toUtf8 } from "@smithy/util-utf8" +import { describe, expect } from "bun:test" +import { Effect } from "effect" +import { CacheHint, LLM } from "../../src" +import { LLMClient } from "../../src/route" +import * as BedrockConverse from "../../src/protocols/bedrock-converse" +import { it } from "../lib/effect" +import { fixedResponse } from "../lib/http" +import { + eventSummary, + expectWeatherToolLoop, + runWeatherToolLoop, + weatherTool, + weatherToolLoopRequest, + weatherToolName, +} from "../recorded-scenarios" +import { recordedTests } from "../recorded-test" + +const codec = new EventStreamCodec(toUtf8, fromUtf8) +const utf8Encoder = new TextEncoder() + +// Build a single AWS event-stream frame for a Converse stream event. Each +// frame carries `:message-type=event` + `:event-type=` headers and a +// JSON payload body. +const eventFrame = (type: string, payload: object) => + codec.encode({ + headers: { + ":message-type": { type: "string", value: "event" }, + ":event-type": { type: "string", value: type }, + ":content-type": { type: "string", value: "application/json" }, + }, + body: utf8Encoder.encode(JSON.stringify(payload)), + }) + +const concat = (frames: ReadonlyArray) => { + const total = frames.reduce((sum, frame) => sum + frame.length, 0) + const out = new Uint8Array(total) + let offset = 0 + for (const frame of frames) { + out.set(frame, offset) + offset += frame.length + } + return out +} + +const eventStreamBody = (...payloads: ReadonlyArray) => + concat(payloads.map(([type, payload]) => eventFrame(type, payload))) + +// Override the default SSE content-type with the binary event-stream type so +// the cassette layer treats the body as bytes when recording. +const fixedBytes = (bytes: Uint8Array) => + fixedResponse(bytes.slice().buffer, { headers: { "content-type": "application/vnd.amazon.eventstream" } }) + +const model = BedrockConverse.model({ + id: "anthropic.claude-3-5-sonnet-20240620-v1:0", + baseURL: "https://bedrock-runtime.test", + apiKey: "test-bearer", +}) + +const baseRequest = LLM.request({ + id: "req_1", + model, + system: "You are concise.", + prompt: "Say hello.", + generation: { maxTokens: 64, temperature: 0 }, +}) + +describe("Bedrock Converse route", () => { + it.effect("prepares Converse target with system, inference config, and messages", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare(baseRequest) + + expect(prepared.body).toEqual({ + modelId: "anthropic.claude-3-5-sonnet-20240620-v1:0", + system: [{ text: "You are concise." }], + messages: [{ role: "user", content: [{ text: "Say hello." }] }], + inferenceConfig: { maxTokens: 64, temperature: 0 }, + }) + }), + ) + + it.effect("prepares tool config with toolSpec and toolChoice", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.updateRequest(baseRequest, { + tools: [ + { + name: "lookup", + description: "Lookup data", + inputSchema: { type: "object", properties: { query: { type: "string" } }, required: ["query"] }, + }, + ], + toolChoice: LLM.toolChoice({ type: "required" }), + }), + ) + + expect(prepared.body).toMatchObject({ + toolConfig: { + tools: [ + { + toolSpec: { + name: "lookup", + description: "Lookup data", + inputSchema: { + json: { type: "object", properties: { query: { type: "string" } }, required: ["query"] }, + }, + }, + }, + ], + toolChoice: { any: {} }, + }, + }) + }), + ) + + it.effect("lowers assistant tool-call + tool-result message history", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + id: "req_history", + model, + messages: [ + LLM.user("What is the weather?"), + LLM.assistant([LLM.toolCall({ id: "tool_1", name: "lookup", input: { query: "weather" } })]), + LLM.toolMessage({ id: "tool_1", name: "lookup", result: { forecast: "sunny" } }), + ], + }), + ) + + expect(prepared.body).toMatchObject({ + messages: [ + { role: "user", content: [{ text: "What is the weather?" }] }, + { + role: "assistant", + content: [{ toolUse: { toolUseId: "tool_1", name: "lookup", input: { query: "weather" } } }], + }, + { + role: "user", + content: [ + { + toolResult: { + toolUseId: "tool_1", + content: [{ json: { forecast: "sunny" } }], + status: "success", + }, + }, + ], + }, + ], + }) + }), + ) + + it.effect("decodes text-delta + messageStop + metadata usage from binary event stream", () => + Effect.gen(function* () { + const body = eventStreamBody( + ["messageStart", { role: "assistant" }], + ["contentBlockDelta", { contentBlockIndex: 0, delta: { text: "Hello" } }], + ["contentBlockDelta", { contentBlockIndex: 0, delta: { text: "!" } }], + ["contentBlockStop", { contentBlockIndex: 0 }], + ["messageStop", { stopReason: "end_turn" }], + ["metadata", { usage: { inputTokens: 5, outputTokens: 2, totalTokens: 7 } }], + ) + const response = yield* LLMClient.generate(baseRequest).pipe(Effect.provide(fixedBytes(body))) + + expect(response.text).toBe("Hello!") + const finishes = response.events.filter((event) => event.type === "request-finish") + // Bedrock splits the finish across `messageStop` (carries reason) and + // `metadata` (carries usage). We consolidate them into a single + // terminal `request-finish` event with both. + expect(finishes).toHaveLength(1) + expect(finishes[0]).toMatchObject({ type: "request-finish", reason: "stop" }) + expect(response.usage).toMatchObject({ + inputTokens: 5, + outputTokens: 2, + totalTokens: 7, + }) + }), + ) + + it.effect("assembles streamed tool call input", () => + Effect.gen(function* () { + const body = eventStreamBody( + ["messageStart", { role: "assistant" }], + [ + "contentBlockStart", + { + contentBlockIndex: 0, + start: { toolUse: { toolUseId: "tool_1", name: "lookup" } }, + }, + ], + ["contentBlockDelta", { contentBlockIndex: 0, delta: { toolUse: { input: '{"query"' } } }], + ["contentBlockDelta", { contentBlockIndex: 0, delta: { toolUse: { input: ':"weather"}' } } }], + ["contentBlockStop", { contentBlockIndex: 0 }], + ["messageStop", { stopReason: "tool_use" }], + ) + const response = yield* LLMClient.generate( + LLM.updateRequest(baseRequest, { + tools: [{ name: "lookup", description: "Lookup", inputSchema: { type: "object" } }], + }), + ).pipe(Effect.provide(fixedBytes(body))) + + expect(response.toolCalls).toEqual([ + { type: "tool-call", id: "tool_1", name: "lookup", input: { query: "weather" } }, + ]) + const events = response.events.filter((event) => event.type === "tool-input-delta") + expect(events).toEqual([ + { type: "tool-input-delta", id: "tool_1", name: "lookup", text: '{"query"' }, + { type: "tool-input-delta", id: "tool_1", name: "lookup", text: ':"weather"}' }, + ]) + expect(response.events.at(-1)).toMatchObject({ type: "request-finish", reason: "tool-calls" }) + }), + ) + + it.effect("decodes reasoning deltas", () => + Effect.gen(function* () { + const body = eventStreamBody( + ["messageStart", { role: "assistant" }], + ["contentBlockDelta", { contentBlockIndex: 0, delta: { reasoningContent: { text: "Let me think." } } }], + ["contentBlockStop", { contentBlockIndex: 0 }], + ["messageStop", { stopReason: "end_turn" }], + ) + const response = yield* LLMClient.generate(baseRequest).pipe(Effect.provide(fixedBytes(body))) + + expect(response.reasoning).toBe("Let me think.") + }), + ) + + it.effect("emits provider-error for throttlingException", () => + Effect.gen(function* () { + const body = eventStreamBody( + ["messageStart", { role: "assistant" }], + ["throttlingException", { message: "Slow down" }], + ) + const response = yield* LLMClient.generate(baseRequest).pipe(Effect.provide(fixedBytes(body))) + + expect(response.events.find((event) => event.type === "provider-error")).toEqual({ + type: "provider-error", + message: "Slow down", + retryable: true, + }) + }), + ) + + it.effect("rejects requests with no auth path", () => + Effect.gen(function* () { + const unsignedModel = BedrockConverse.model({ + id: "anthropic.claude-3-5-sonnet-20240620-v1:0", + baseURL: "https://bedrock-runtime.test", + }) + const error = yield* LLMClient.generate(LLM.updateRequest(baseRequest, { model: unsignedModel })).pipe( + Effect.provide(fixedBytes(eventStreamBody(["messageStop", { stopReason: "end_turn" }]))), + Effect.flip, + ) + + expect(error.message).toContain("Bedrock Converse requires either model.apiKey") + }), + ) + + it.effect("signs requests with SigV4 when AWS credentials are provided (deterministic plumbing check)", () => + Effect.gen(function* () { + const signed = BedrockConverse.model({ + id: "anthropic.claude-3-5-sonnet-20240620-v1:0", + baseURL: "https://bedrock-runtime.test", + credentials: { + region: "us-east-1", + accessKeyId: "AKIAIOSFODNN7EXAMPLE", + secretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + }, + }) + const prepared = yield* LLMClient.prepare(LLM.updateRequest(baseRequest, { model: signed })) + + expect(prepared.route).toBe("bedrock-converse") + // The prepare phase doesn't sign — toHttp does. We assert the credential + // is plumbed onto the model native field for the signer to find. + expect(prepared.model.native).toMatchObject({ + aws_credentials: { region: "us-east-1", accessKeyId: "AKIAIOSFODNN7EXAMPLE" }, + aws_region: "us-east-1", + }) + }), + ) + + it.effect("emits cachePoint markers after system, user-text, and assistant-text with cache hints", () => + Effect.gen(function* () { + const cache = new CacheHint({ type: "ephemeral" }) + const prepared = yield* LLMClient.prepare( + LLM.request({ + id: "req_cache", + model, + system: [{ type: "text", text: "System prefix.", cache }], + messages: [ + LLM.user([{ type: "text", text: "User prefix.", cache }]), + LLM.assistant([{ type: "text", text: "Assistant prefix.", cache }]), + ], + generation: { maxTokens: 16, temperature: 0 }, + }), + ) + + expect(prepared.body).toMatchObject({ + // System: text block followed by cachePoint marker. + system: [{ text: "System prefix." }, { cachePoint: { type: "default" } }], + messages: [ + { + role: "user", + content: [{ text: "User prefix." }, { cachePoint: { type: "default" } }], + }, + { + role: "assistant", + content: [{ text: "Assistant prefix." }, { cachePoint: { type: "default" } }], + }, + ], + }) + }), + ) + + it.effect("does not emit cachePoint when no cache hint is set", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare(baseRequest) + expect(prepared.body).toMatchObject({ + system: [{ text: "You are concise." }], + messages: [{ role: "user", content: [{ text: "Say hello." }] }], + }) + }), + ) + + it.effect("lowers image media into Bedrock image blocks", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + id: "req_image", + model, + messages: [ + LLM.user([ + { type: "text", text: "What is in this image?" }, + { type: "media", mediaType: "image/png", data: "AAAA" }, + { type: "media", mediaType: "image/jpeg", data: "BBBB" }, + { type: "media", mediaType: "image/jpg", data: "CCCC" }, + { type: "media", mediaType: "image/webp", data: "DDDD" }, + ]), + ], + }), + ) + + expect(prepared.body).toMatchObject({ + messages: [ + { + role: "user", + content: [ + { text: "What is in this image?" }, + { image: { format: "png", source: { bytes: "AAAA" } } }, + { image: { format: "jpeg", source: { bytes: "BBBB" } } }, + // image/jpg is a non-standard alias; we map it to jpeg. + { image: { format: "jpeg", source: { bytes: "CCCC" } } }, + { image: { format: "webp", source: { bytes: "DDDD" } } }, + ], + }, + ], + }) + }), + ) + + it.effect("base64-encodes Uint8Array image bytes", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + id: "req_image_bytes", + model, + messages: [LLM.user([{ type: "media", mediaType: "image/png", data: new Uint8Array([1, 2, 3, 4, 5]) }])], + }), + ) + + // Buffer.from([1,2,3,4,5]).toString("base64") === "AQIDBAU=" + expect(prepared.body).toMatchObject({ + messages: [ + { + role: "user", + content: [{ image: { format: "png", source: { bytes: "AQIDBAU=" } } }], + }, + ], + }) + }), + ) + + it.effect("lowers document media into Bedrock document blocks with format and name", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + id: "req_doc", + model, + messages: [ + LLM.user([ + { type: "media", mediaType: "application/pdf", data: "PDFDATA", filename: "report.pdf" }, + { type: "media", mediaType: "text/csv", data: "CSVDATA" }, + ]), + ], + }), + ) + + expect(prepared.body).toMatchObject({ + messages: [ + { + role: "user", + content: [ + // Filename round-trips when supplied. + { document: { format: "pdf", name: "report.pdf", source: { bytes: "PDFDATA" } } }, + // Falls back to a stable placeholder when filename is missing. + { document: { format: "csv", name: "document.csv", source: { bytes: "CSVDATA" } } }, + ], + }, + ], + }) + }), + ) + + it.effect("rejects unsupported image media types", () => + Effect.gen(function* () { + const error = yield* LLMClient.prepare( + LLM.request({ + id: "req_bad_image", + model, + messages: [LLM.user([{ type: "media", mediaType: "image/svg+xml", data: "x" }])], + }), + ).pipe(Effect.flip) + + expect(error.message).toContain("Bedrock Converse does not support image media type image/svg+xml") + }), + ) + + it.effect("rejects unsupported document media types", () => + Effect.gen(function* () { + const error = yield* LLMClient.prepare( + LLM.request({ + id: "req_bad_doc", + model, + messages: [LLM.user([{ type: "media", mediaType: "application/x-tar", data: "x", filename: "a.tar" }])], + }), + ).pipe(Effect.flip) + + expect(error.message).toContain("Bedrock Converse does not support media type application/x-tar") + }), + ) +}) + +// Live recorded integration tests. Run with `RECORD=true AWS_ACCESS_KEY_ID=... +// AWS_SECRET_ACCESS_KEY=... [AWS_SESSION_TOKEN=...] bun run test ...` to refresh +// cassettes; replay is the default and works without credentials. +// +// Region is pinned to us-east-1 in tests so the request URL is stable across +// machines on replay. If you need to record from a different region (e.g. your +// account has access elsewhere), pass `BEDROCK_RECORDING_REGION=eu-west-1` — +// but then commit the resulting cassette and others should record from the +// same region too. +const RECORDING_REGION = process.env.BEDROCK_RECORDING_REGION ?? "us-east-1" + +const recordedModel = () => + BedrockConverse.model({ + // Most newer Anthropic models on Bedrock require a cross-region inference + // profile (`us.` prefix). Nova does not require an Anthropic use-case form + // and is on-demand-throughput accessible by default for most accounts. + id: process.env.BEDROCK_MODEL_ID ?? "us.amazon.nova-micro-v1:0", + credentials: { + region: RECORDING_REGION, + accessKeyId: process.env.AWS_ACCESS_KEY_ID ?? "fixture", + secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY ?? "fixture", + sessionToken: process.env.AWS_SESSION_TOKEN, + }, + }) + +const recorded = recordedTests({ + prefix: "bedrock-converse", + provider: "amazon-bedrock", + protocol: "bedrock-converse", + requires: ["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY"], +}) + +describe("Bedrock Converse recorded", () => { + recorded.effect("streams text", () => + Effect.gen(function* () { + const llm = yield* LLMClient.Service + const response = yield* llm.generate( + LLM.request({ + id: "recorded_bedrock_text", + model: recordedModel(), + system: "Reply with the single word 'Hello'.", + prompt: "Say hello.", + generation: { maxTokens: 16, temperature: 0 }, + }), + ) + + expect(eventSummary(response.events)).toEqual([ + { type: "text", value: "Hello" }, + { type: "finish", reason: "stop", usage: { inputTokens: 12, outputTokens: 2, totalTokens: 14 } }, + ]) + }), + ) + + recorded.effect.with("streams a tool call", { tags: ["tool"] }, () => + Effect.gen(function* () { + const llm = yield* LLMClient.Service + const response = yield* llm.generate( + LLM.request({ + id: "recorded_bedrock_tool_call", + model: recordedModel(), + system: "Call tools exactly as requested.", + prompt: "Call get_weather with city exactly Paris.", + tools: [weatherTool], + toolChoice: LLM.toolChoice(weatherTool), + generation: { maxTokens: 80, temperature: 0 }, + }), + ) + + expect(eventSummary(response.events)).toEqual([ + { type: "tool-call", name: weatherToolName, input: { city: "Paris" } }, + { type: "finish", reason: "tool-calls", usage: { inputTokens: 419, outputTokens: 16, totalTokens: 435 } }, + ]) + }), + ) + + recorded.effect.with("drives a tool loop", { tags: ["tool", "tool-loop", "golden"] }, () => + Effect.gen(function* () { + const llm = yield* LLMClient.Service + expectWeatherToolLoop( + yield* runWeatherToolLoop( + weatherToolLoopRequest({ + id: "recorded_bedrock_tool_loop", + model: recordedModel(), + }), + ), + ) + }), + ) +}) diff --git a/packages/llm/test/provider/cloudflare.test.ts b/packages/llm/test/provider/cloudflare.test.ts new file mode 100644 index 0000000000..00b69fa18c --- /dev/null +++ b/packages/llm/test/provider/cloudflare.test.ts @@ -0,0 +1,232 @@ +import { describe, expect } from "bun:test" +import { ConfigProvider, Effect, Schema } from "effect" +import { HttpClientRequest } from "effect/unstable/http" +import { LLM } from "../../src" +import * as Cloudflare from "../../src/providers/cloudflare" +import { LLMClient } from "../../src/route" +import { it } from "../lib/effect" +import { dynamicResponse } from "../lib/http" +import { sseEvents } from "../lib/sse" + +const Json = Schema.fromJsonString(Schema.Unknown) +const decodeJson = Schema.decodeUnknownSync(Json) +const withEnv = (env: Record) => Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env }))) + +const deltaChunk = (delta: object, finishReason: string | null = null) => ({ + id: "chatcmpl_fixture", + choices: [{ delta, finish_reason: finishReason }], + usage: null, +}) + +describe("Cloudflare", () => { + it.effect("prepares AI Gateway models through the OpenAI-compatible Chat protocol", () => + Effect.gen(function* () { + const model = Cloudflare.aiGateway("workers-ai/@cf/meta/llama-3.3-70b-instruct", { + accountId: "test-account", + gatewayId: "test-gateway", + apiKey: "test-token", + }) + + expect(model).toMatchObject({ + id: "workers-ai/@cf/meta/llama-3.3-70b-instruct", + provider: "cloudflare-ai-gateway", + route: "cloudflare-ai-gateway", + baseURL: "https://gateway.ai.cloudflare.com/v1/test-account/test-gateway/compat", + }) + + const prepared = yield* LLMClient.prepare(LLM.request({ model, prompt: "Say hello." })) + + expect(prepared.route).toBe("cloudflare-ai-gateway") + expect(prepared.body).toMatchObject({ + model: "workers-ai/@cf/meta/llama-3.3-70b-instruct", + messages: [{ role: "user", content: "Say hello." }], + stream: true, + }) + }), + ) + + it.effect("posts to the derived gateway endpoint with bearer auth", () => + Effect.gen(function* () { + const response = yield* LLM.generate( + LLM.request({ + model: Cloudflare.aiGateway("openai/gpt-4o-mini", { + accountId: "test-account", + gatewayId: "test-gateway", + apiKey: "test-token", + }), + prompt: "Say hello.", + }), + ).pipe( + Effect.provide( + dynamicResponse((input) => + Effect.gen(function* () { + const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie) + expect(web.url).toBe( + "https://gateway.ai.cloudflare.com/v1/test-account/test-gateway/compat/chat/completions", + ) + expect(web.headers.get("authorization")).toBe("Bearer test-token") + expect(decodeJson(input.text)).toMatchObject({ + model: "openai/gpt-4o-mini", + stream: true, + messages: [{ role: "user", content: "Say hello." }], + }) + return input.respond( + sseEvents(deltaChunk({ role: "assistant", content: "Hello" }), deltaChunk({}, "stop")), + { headers: { "content-type": "text/event-stream" } }, + ) + }), + ), + ), + ) + + expect(response.text).toBe("Hello") + }), + ) + + it.effect("defaults AI Gateway id to default when omitted or blank", () => + Effect.gen(function* () { + expect( + Cloudflare.aiGateway("workers-ai/@cf/meta/llama-3.3-70b-instruct", { + accountId: "test-account", + gatewayId: "", + gatewayApiKey: "test-token", + }).baseURL, + ).toBe("https://gateway.ai.cloudflare.com/v1/test-account/default/compat") + }), + ) + + it.effect("supports authenticated AI Gateway plus upstream provider auth", () => + Effect.gen(function* () { + yield* LLM.generate( + LLM.request({ + model: Cloudflare.aiGateway("openai/gpt-4o-mini", { + accountId: "test-account", + gatewayApiKey: "gateway-token", + apiKey: "provider-token", + }), + prompt: "Say hello.", + }), + ).pipe( + Effect.provide( + dynamicResponse((input) => + Effect.gen(function* () { + const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie) + expect(web.url).toBe("https://gateway.ai.cloudflare.com/v1/test-account/default/compat/chat/completions") + expect(web.headers.get("cf-aig-authorization")).toBe("Bearer gateway-token") + expect(web.headers.get("authorization")).toBe("Bearer provider-token") + return input.respond( + sseEvents(deltaChunk({ role: "assistant", content: "Hello" }), deltaChunk({}, "stop")), + { headers: { "content-type": "text/event-stream" } }, + ) + }), + ), + ), + ) + }), + ) + + it.effect("allows a fully configured baseURL override", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + model: Cloudflare.aiGateway("openai/gpt-4o-mini", { + baseURL: "https://gateway.proxy.test/v1/custom/compat", + apiKey: "test-token", + }), + prompt: "Say hello.", + }), + ) + + expect(prepared.model.baseURL).toBe("https://gateway.proxy.test/v1/custom/compat") + }), + ) + + it.effect("prepares direct Workers AI models through the OpenAI-compatible Chat protocol", () => + Effect.gen(function* () { + const model = Cloudflare.workersAI("@cf/meta/llama-3.1-8b-instruct", { + accountId: "test-account", + apiKey: "test-token", + }) + + expect(model).toMatchObject({ + id: "@cf/meta/llama-3.1-8b-instruct", + provider: "cloudflare-workers-ai", + route: "cloudflare-workers-ai", + baseURL: "https://api.cloudflare.com/client/v4/accounts/test-account/ai/v1", + }) + + const prepared = yield* LLMClient.prepare(LLM.request({ model, prompt: "Say hello." })) + + expect(prepared.route).toBe("cloudflare-workers-ai") + expect(prepared.body).toMatchObject({ + model: "@cf/meta/llama-3.1-8b-instruct", + messages: [{ role: "user", content: "Say hello." }], + stream: true, + }) + }), + ) + + it.effect("posts direct Workers AI requests to the account endpoint with bearer auth", () => + Effect.gen(function* () { + const response = yield* LLM.generate( + LLM.request({ + model: Cloudflare.workersAI("@cf/meta/llama-3.1-8b-instruct", { + accountId: "test-account", + apiKey: "test-token", + }), + prompt: "Say hello.", + }), + ).pipe( + Effect.provide( + dynamicResponse((input) => + Effect.gen(function* () { + const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie) + expect(web.url).toBe( + "https://api.cloudflare.com/client/v4/accounts/test-account/ai/v1/chat/completions", + ) + expect(web.headers.get("authorization")).toBe("Bearer test-token") + expect(decodeJson(input.text)).toMatchObject({ + model: "@cf/meta/llama-3.1-8b-instruct", + stream: true, + messages: [{ role: "user", content: "Say hello." }], + }) + return input.respond( + sseEvents(deltaChunk({ role: "assistant", content: "Hello" }), deltaChunk({}, "stop")), + { headers: { "content-type": "text/event-stream" } }, + ) + }), + ), + ), + ) + + expect(response.text).toBe("Hello") + }), + ) + + it.effect("supports direct Workers AI token aliases through auth config", () => + Effect.gen(function* () { + yield* LLM.generate( + LLM.request({ + model: Cloudflare.workersAI("@cf/meta/llama-3.1-8b-instruct", { + accountId: "test-account", + }), + prompt: "Say hello.", + }), + ).pipe( + withEnv({ CLOUDFLARE_WORKERS_AI_TOKEN: "test-token" }), + Effect.provide( + dynamicResponse((input) => + Effect.gen(function* () { + const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie) + expect(web.headers.get("authorization")).toBe("Bearer test-token") + return input.respond( + sseEvents(deltaChunk({ role: "assistant", content: "Hello" }), deltaChunk({}, "stop")), + { headers: { "content-type": "text/event-stream" } }, + ) + }), + ), + ), + ) + }), + ) +}) diff --git a/packages/llm/test/provider/gemini.test.ts b/packages/llm/test/provider/gemini.test.ts new file mode 100644 index 0000000000..a80ab740c3 --- /dev/null +++ b/packages/llm/test/provider/gemini.test.ts @@ -0,0 +1,360 @@ +import { describe, expect } from "bun:test" +import { Effect } from "effect" +import { LLM, LLMError } from "../../src" +import { LLMClient } from "../../src/route" +import * as Gemini from "../../src/protocols/gemini" +import { it } from "../lib/effect" +import { fixedResponse } from "../lib/http" +import { sseEvents, sseRaw } from "../lib/sse" + +const model = Gemini.model({ + id: "gemini-2.5-flash", + baseURL: "https://generativelanguage.test/v1beta/", + headers: { "x-goog-api-key": "test" }, +}) + +const request = LLM.request({ + id: "req_1", + model, + system: "You are concise.", + prompt: "Say hello.", + generation: { maxTokens: 20, temperature: 0 }, +}) + +describe("Gemini route", () => { + it.effect("prepares Gemini target", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare(request) + + expect(prepared.body).toEqual({ + contents: [{ role: "user", parts: [{ text: "Say hello." }] }], + systemInstruction: { parts: [{ text: "You are concise." }] }, + generationConfig: { maxOutputTokens: 20, temperature: 0 }, + }) + }), + ) + + it.effect("prepares multimodal user input and tool history", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + id: "req_tool_result", + model, + tools: [ + { + name: "lookup", + description: "Lookup data", + inputSchema: { type: "object", properties: { query: { type: "string" } } }, + }, + ], + toolChoice: { type: "tool", name: "lookup" }, + messages: [ + LLM.user([ + { type: "text", text: "What is in this image?" }, + { type: "media", mediaType: "image/png", data: "AAECAw==" }, + ]), + LLM.assistant([LLM.toolCall({ id: "call_1", name: "lookup", input: { query: "weather" } })]), + LLM.toolMessage({ id: "call_1", name: "lookup", result: { forecast: "sunny" } }), + ], + }), + ) + + expect(prepared.body).toEqual({ + contents: [ + { + role: "user", + parts: [{ text: "What is in this image?" }, { inlineData: { mimeType: "image/png", data: "AAECAw==" } }], + }, + { + role: "model", + parts: [{ functionCall: { name: "lookup", args: { query: "weather" } } }], + }, + { + role: "user", + parts: [ + { functionResponse: { name: "lookup", response: { name: "lookup", content: '{"forecast":"sunny"}' } } }, + ], + }, + ], + tools: [ + { + functionDeclarations: [ + { + name: "lookup", + description: "Lookup data", + parameters: { type: "object", properties: { query: { type: "string" } } }, + }, + ], + }, + ], + toolConfig: { functionCallingConfig: { mode: "ANY", allowedFunctionNames: ["lookup"] } }, + }) + }), + ) + + it.effect("omits tools when tool choice is none", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + id: "req_no_tools", + model, + prompt: "Say hello.", + tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }], + toolChoice: { type: "none" }, + }), + ) + + expect(prepared.body).toEqual({ + contents: [{ role: "user", parts: [{ text: "Say hello." }] }], + }) + }), + ) + + it.effect("sanitizes integer enums, dangling required, untyped arrays, and scalar object keys", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + id: "req_schema_patch", + model, + prompt: "Use the tool.", + tools: [ + { + name: "lookup", + description: "Lookup data", + inputSchema: { + type: "object", + required: ["status", "missing"], + properties: { + status: { type: "integer", enum: [1, 2] }, + tags: { type: "array" }, + name: { type: "string", properties: { ignored: { type: "string" } }, required: ["ignored"] }, + }, + }, + }, + ], + }), + ) + + expect(prepared.body).toMatchObject({ + tools: [ + { + functionDeclarations: [ + { + parameters: { + type: "object", + required: ["status"], + properties: { + status: { type: "string", enum: ["1", "2"] }, + tags: { type: "array", items: { type: "string" } }, + name: { type: "string" }, + }, + }, + }, + ], + }, + ], + }) + }), + ) + + it.effect("parses text, reasoning, and usage stream fixtures", () => + Effect.gen(function* () { + const body = sseEvents( + { + candidates: [ + { + content: { role: "model", parts: [{ text: "thinking", thought: true }] }, + }, + ], + }, + { + candidates: [ + { + content: { role: "model", parts: [{ text: "Hello" }] }, + }, + ], + }, + { + candidates: [ + { + content: { role: "model", parts: [{ text: "!" }] }, + finishReason: "STOP", + }, + ], + }, + { + usageMetadata: { + promptTokenCount: 5, + candidatesTokenCount: 2, + totalTokenCount: 7, + thoughtsTokenCount: 1, + cachedContentTokenCount: 1, + }, + }, + ) + const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body))) + + expect(response.text).toBe("Hello!") + expect(response.reasoning).toBe("thinking") + expect(response.usage).toMatchObject({ + inputTokens: 5, + outputTokens: 2, + reasoningTokens: 1, + cacheReadInputTokens: 1, + totalTokens: 7, + }) + expect(response.events).toEqual([ + { type: "reasoning-delta", text: "thinking" }, + { type: "text-delta", text: "Hello" }, + { type: "text-delta", text: "!" }, + { + type: "request-finish", + reason: "stop", + usage: { + inputTokens: 5, + outputTokens: 2, + reasoningTokens: 1, + cacheReadInputTokens: 1, + totalTokens: 7, + native: { + promptTokenCount: 5, + candidatesTokenCount: 2, + totalTokenCount: 7, + thoughtsTokenCount: 1, + cachedContentTokenCount: 1, + }, + }, + }, + ]) + }), + ) + + it.effect("emits streamed tool calls and maps finish reason", () => + Effect.gen(function* () { + const body = sseEvents({ + candidates: [ + { + content: { + role: "model", + parts: [{ functionCall: { name: "lookup", args: { query: "weather" } } }], + }, + finishReason: "STOP", + }, + ], + usageMetadata: { promptTokenCount: 5, candidatesTokenCount: 1 }, + }) + const response = yield* LLMClient.generate( + LLM.updateRequest(request, { + tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }], + }), + ).pipe(Effect.provide(fixedResponse(body))) + + expect(response.toolCalls).toEqual([ + { type: "tool-call", id: "tool_0", name: "lookup", input: { query: "weather" } }, + ]) + expect(response.events).toEqual([ + { type: "tool-call", id: "tool_0", name: "lookup", input: { query: "weather" } }, + { + type: "request-finish", + reason: "tool-calls", + usage: { + inputTokens: 5, + outputTokens: 1, + totalTokens: 6, + native: { promptTokenCount: 5, candidatesTokenCount: 1 }, + }, + }, + ]) + }), + ) + + it.effect("assigns unique ids to multiple streamed tool calls", () => + Effect.gen(function* () { + const body = sseEvents({ + candidates: [ + { + content: { + role: "model", + parts: [ + { functionCall: { name: "lookup", args: { query: "weather" } } }, + { functionCall: { name: "lookup", args: { query: "news" } } }, + ], + }, + finishReason: "STOP", + }, + ], + }) + const response = yield* LLMClient.generate( + LLM.updateRequest(request, { + tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }], + }), + ).pipe(Effect.provide(fixedResponse(body))) + + expect(response.toolCalls).toEqual([ + { type: "tool-call", id: "tool_0", name: "lookup", input: { query: "weather" } }, + { type: "tool-call", id: "tool_1", name: "lookup", input: { query: "news" } }, + ]) + expect(response.events.at(-1)).toMatchObject({ type: "request-finish", reason: "tool-calls" }) + }), + ) + + it.effect("maps length and content-filter finish reasons", () => + Effect.gen(function* () { + const length = yield* LLMClient.generate(request).pipe( + Effect.provide( + fixedResponse( + sseEvents({ candidates: [{ content: { role: "model", parts: [] }, finishReason: "MAX_TOKENS" }] }), + ), + ), + ) + const filtered = yield* LLMClient.generate(request).pipe( + Effect.provide( + fixedResponse(sseEvents({ candidates: [{ content: { role: "model", parts: [] }, finishReason: "SAFETY" }] })), + ), + ) + + expect(length.events).toEqual([{ type: "request-finish", reason: "length" }]) + expect(filtered.events).toEqual([{ type: "request-finish", reason: "content-filter" }]) + }), + ) + + it.effect("leaves total usage undefined when component counts are missing", () => + Effect.gen(function* () { + const response = yield* LLMClient.generate(request).pipe( + Effect.provide(fixedResponse(sseEvents({ usageMetadata: { thoughtsTokenCount: 1 } }))), + ) + + expect(response.usage).toMatchObject({ reasoningTokens: 1 }) + expect(response.usage?.totalTokens).toBeUndefined() + }), + ) + + it.effect("fails invalid stream events", () => + Effect.gen(function* () { + const error = yield* LLMClient.generate(request).pipe( + Effect.provide(fixedResponse(sseRaw("data: {not json}"))), + Effect.flip, + ) + + expect(error).toBeInstanceOf(LLMError) + expect(error.reason).toMatchObject({ _tag: "InvalidProviderOutput" }) + expect(error.message).toContain("Invalid google/gemini stream event") + }), + ) + + it.effect("rejects unsupported assistant media content", () => + Effect.gen(function* () { + const error = yield* LLMClient.prepare( + LLM.request({ + id: "req_media", + model, + messages: [LLM.assistant({ type: "media", mediaType: "image/png", data: "AAECAw==" })], + }), + ).pipe(Effect.flip) + + expect(error.message).toContain( + "Gemini assistant messages only support text, reasoning, and tool-call content for now", + ) + }), + ) +}) diff --git a/packages/llm/test/provider/golden.recorded.test.ts b/packages/llm/test/provider/golden.recorded.test.ts new file mode 100644 index 0000000000..0e1151b7af --- /dev/null +++ b/packages/llm/test/provider/golden.recorded.test.ts @@ -0,0 +1,215 @@ +import * as AnthropicMessages from "../../src/protocols/anthropic-messages" +import * as Gemini from "../../src/protocols/gemini" +import * as OpenAIChat from "../../src/protocols/openai-chat" +import * as OpenAIResponses from "../../src/protocols/openai-responses" +import * as Cloudflare from "../../src/providers/cloudflare" +import * as OpenAI from "../../src/providers/openai" +import * as OpenAICompatible from "../../src/providers/openai-compatible" +import * as OpenRouter from "../../src/providers/openrouter" +import * as XAI from "../../src/providers/xai" +import { describeRecordedGoldenScenarios } from "../recorded-golden" + +const openAIChat = OpenAIChat.model({ id: "gpt-4o-mini", apiKey: process.env.OPENAI_API_KEY ?? "fixture" }) +const openAIResponses = OpenAIResponses.model({ id: "gpt-5.5", apiKey: process.env.OPENAI_API_KEY ?? "fixture" }) +const openAIResponsesWebSocket = OpenAI.responsesWebSocket("gpt-4.1-mini", { + apiKey: process.env.OPENAI_API_KEY ?? "fixture", +}) +const anthropicHaiku = AnthropicMessages.model({ + id: "claude-haiku-4-5-20251001", + apiKey: process.env.ANTHROPIC_API_KEY ?? "fixture", +}) +const anthropicOpus = AnthropicMessages.model({ + id: "claude-opus-4-7", + apiKey: process.env.ANTHROPIC_API_KEY ?? "fixture", +}) +const gemini = Gemini.model({ id: "gemini-2.5-flash", apiKey: process.env.GOOGLE_GENERATIVE_AI_API_KEY ?? "fixture" }) +const xaiBasic = XAI.model("grok-3-mini", { apiKey: process.env.XAI_API_KEY ?? "fixture" }) +const xaiFlagship = XAI.model("grok-4.3", { apiKey: process.env.XAI_API_KEY ?? "fixture" }) +const cloudflareAIGatewayWorkers = Cloudflare.aiGateway("workers-ai/@cf/meta/llama-3.1-8b-instruct", { + accountId: process.env.CLOUDFLARE_ACCOUNT_ID ?? "fixture-account", + gatewayId: + process.env.CLOUDFLARE_GATEWAY_ID && process.env.CLOUDFLARE_GATEWAY_ID !== process.env.CLOUDFLARE_ACCOUNT_ID + ? process.env.CLOUDFLARE_GATEWAY_ID + : undefined, + gatewayApiKey: process.env.CLOUDFLARE_API_TOKEN ?? "fixture", +}) +const cloudflareAIGatewayWorkersTools = Cloudflare.aiGateway("workers-ai/@cf/openai/gpt-oss-20b", { + accountId: process.env.CLOUDFLARE_ACCOUNT_ID ?? "fixture-account", + gatewayId: + process.env.CLOUDFLARE_GATEWAY_ID && process.env.CLOUDFLARE_GATEWAY_ID !== process.env.CLOUDFLARE_ACCOUNT_ID + ? process.env.CLOUDFLARE_GATEWAY_ID + : undefined, + gatewayApiKey: process.env.CLOUDFLARE_API_TOKEN ?? "fixture", +}) +const cloudflareWorkersAI = Cloudflare.workersAI("@cf/meta/llama-3.1-8b-instruct", { + accountId: process.env.CLOUDFLARE_ACCOUNT_ID ?? "fixture-account", + apiKey: process.env.CLOUDFLARE_API_KEY ?? "fixture", +}) +const cloudflareWorkersAITools = Cloudflare.workersAI("@cf/openai/gpt-oss-20b", { + accountId: process.env.CLOUDFLARE_ACCOUNT_ID ?? "fixture-account", + apiKey: process.env.CLOUDFLARE_API_KEY ?? "fixture", +}) +const deepseek = OpenAICompatible.deepseek.model("deepseek-chat", { apiKey: process.env.DEEPSEEK_API_KEY ?? "fixture" }) +const together = OpenAICompatible.togetherai.model("meta-llama/Llama-3.3-70B-Instruct-Turbo", { + apiKey: process.env.TOGETHER_AI_API_KEY ?? "fixture", +}) +const groq = OpenAICompatible.groq.model("llama-3.3-70b-versatile", { apiKey: process.env.GROQ_API_KEY ?? "fixture" }) +const openrouter = OpenRouter.model("openai/gpt-4o-mini", { apiKey: process.env.OPENROUTER_API_KEY ?? "fixture" }) +const openrouterGpt55 = OpenRouter.model("openai/gpt-5.5", { apiKey: process.env.OPENROUTER_API_KEY ?? "fixture" }) +const openrouterOpus = OpenRouter.model("anthropic/claude-opus-4.7", { + apiKey: process.env.OPENROUTER_API_KEY ?? "fixture", +}) + +const redactCloudflareURL = (url: string) => + url + .replace(/\/client\/v4\/accounts\/[^/]+\/ai\/v1\//, "/client/v4/accounts/{account}/ai/v1/") + .replace(/\/v1\/[^/]+\/[^/]+\/compat\//, "/v1/{account}/{gateway}/compat/") + +const cloudflareOptions = { + redact: { url: redactCloudflareURL }, +} + +describeRecordedGoldenScenarios([ + { + name: "OpenAI Chat gpt-4o-mini", + prefix: "openai-chat", + model: openAIChat, + requires: ["OPENAI_API_KEY"], + scenarios: ["text", "tool-call", "tool-loop"], + }, + { + name: "OpenAI Responses gpt-5.5", + prefix: "openai-responses", + model: openAIResponses, + requires: ["OPENAI_API_KEY"], + tags: ["flagship"], + scenarios: [ + { id: "text", temperature: false }, + { id: "tool-call", temperature: false }, + { id: "tool-loop", temperature: false }, + ], + }, + { + name: "OpenAI Responses WebSocket gpt-4.1-mini", + prefix: "openai-responses-websocket", + model: openAIResponsesWebSocket, + transport: "websocket", + requires: ["OPENAI_API_KEY"], + scenarios: ["tool-loop"], + }, + { + name: "Anthropic Haiku 4.5", + prefix: "anthropic-messages", + model: anthropicHaiku, + requires: ["ANTHROPIC_API_KEY"], + options: { requestHeaders: ["content-type", "anthropic-version"] }, + scenarios: ["text", "tool-call"], + }, + { + name: "Anthropic Opus 4.7", + prefix: "anthropic-messages", + model: anthropicOpus, + requires: ["ANTHROPIC_API_KEY"], + tags: ["flagship"], + options: { requestHeaders: ["content-type", "anthropic-version"] }, + scenarios: [{ id: "tool-loop", temperature: false }], + }, + { + name: "Gemini 2.5 Flash", + prefix: "gemini", + model: gemini, + requires: ["GOOGLE_GENERATIVE_AI_API_KEY"], + scenarios: [{ id: "text", maxTokens: 80 }, "tool-call"], + }, + { + name: "xAI Grok 3 Mini", + prefix: "xai", + model: xaiBasic, + requires: ["XAI_API_KEY"], + scenarios: ["text", "tool-call"], + }, + { + name: "xAI Grok 4.3", + prefix: "xai", + model: xaiFlagship, + requires: ["XAI_API_KEY"], + tags: ["flagship"], + scenarios: [{ id: "tool-loop", timeout: 30_000 }], + }, + { + name: "Cloudflare AI Gateway Workers AI Llama 3.1 8B", + prefix: "cloudflare-ai-gateway", + model: cloudflareAIGatewayWorkers, + requires: ["CLOUDFLARE_ACCOUNT_ID", "CLOUDFLARE_API_TOKEN"], + options: cloudflareOptions, + scenarios: ["text"], + }, + { + name: "Cloudflare AI Gateway Workers AI GPT OSS 20B Tools", + prefix: "cloudflare-ai-gateway", + model: cloudflareAIGatewayWorkersTools, + requires: ["CLOUDFLARE_ACCOUNT_ID", "CLOUDFLARE_API_TOKEN"], + options: cloudflareOptions, + scenarios: [{ id: "tool-call", maxTokens: 120 }], + }, + { + name: "Cloudflare Workers AI Llama 3.1 8B", + prefix: "cloudflare-workers-ai", + model: cloudflareWorkersAI, + requires: ["CLOUDFLARE_ACCOUNT_ID", "CLOUDFLARE_API_KEY"], + options: cloudflareOptions, + scenarios: ["text"], + }, + { + name: "Cloudflare Workers AI GPT OSS 20B Tools", + prefix: "cloudflare-workers-ai", + model: cloudflareWorkersAITools, + requires: ["CLOUDFLARE_ACCOUNT_ID", "CLOUDFLARE_API_KEY"], + options: cloudflareOptions, + scenarios: [{ id: "tool-call", maxTokens: 120 }], + }, + { + name: "DeepSeek Chat", + prefix: "openai-compatible-chat", + model: deepseek, + requires: ["DEEPSEEK_API_KEY"], + scenarios: ["text"], + }, + { + name: "TogetherAI Llama 3.3 70B", + prefix: "openai-compatible-chat", + model: together, + requires: ["TOGETHER_AI_API_KEY"], + scenarios: ["text", "tool-call"], + }, + { + name: "Groq Llama 3.3 70B", + prefix: "openai-compatible-chat", + model: groq, + requires: ["GROQ_API_KEY"], + scenarios: ["text", "tool-call", { id: "tool-loop", timeout: 30_000 }], + }, + { + name: "OpenRouter gpt-4o-mini", + prefix: "openai-compatible-chat", + model: openrouter, + requires: ["OPENROUTER_API_KEY"], + scenarios: ["text", "tool-call", "tool-loop"], + }, + { + name: "OpenRouter gpt-5.5", + prefix: "openai-compatible-chat", + model: openrouterGpt55, + requires: ["OPENROUTER_API_KEY"], + tags: ["flagship"], + scenarios: ["tool-loop"], + }, + { + name: "OpenRouter Claude Opus 4.7", + prefix: "openai-compatible-chat", + model: openrouterOpus, + requires: ["OPENROUTER_API_KEY"], + tags: ["flagship"], + scenarios: ["tool-loop"], + }, +]) diff --git a/packages/llm/test/provider/openai-chat.test.ts b/packages/llm/test/provider/openai-chat.test.ts new file mode 100644 index 0000000000..0998401094 --- /dev/null +++ b/packages/llm/test/provider/openai-chat.test.ts @@ -0,0 +1,355 @@ +import { describe, expect } from "bun:test" +import { Effect, Schema, Stream } from "effect" +import { HttpClientRequest } from "effect/unstable/http" +import { LLM, LLMError } from "../../src" +import * as Azure from "../../src/providers/azure" +import * as OpenAI from "../../src/providers/openai" +import * as OpenAIChat from "../../src/protocols/openai-chat" +import { LLMClient } from "../../src/route" +import { it } from "../lib/effect" +import { dynamicResponse, fixedResponse, truncatedStream } from "../lib/http" +import { deltaChunk, usageChunk } from "../lib/openai-chunks" +import { sseEvents } from "../lib/sse" + +const TargetJson = Schema.fromJsonString(Schema.Unknown) +const encodeJson = Schema.encodeSync(TargetJson) +const decodeJson = Schema.decodeUnknownSync(TargetJson) + +const model = OpenAIChat.model({ + id: "gpt-4o-mini", + baseURL: "https://api.openai.test/v1/", + headers: { authorization: "Bearer test" }, +}) + +const request = LLM.request({ + id: "req_1", + model, + system: "You are concise.", + prompt: "Say hello.", + generation: { maxTokens: 20, temperature: 0 }, +}) + +describe("OpenAI Chat route", () => { + it.effect("prepares OpenAI Chat payload", () => + Effect.gen(function* () { + // Pass the OpenAIChat payload type so `prepared.body` is statically + // typed to the route's native shape — the assertions below read field + // names without `unknown` casts. + const prepared = yield* LLMClient.prepare(request) + const _typed: { readonly model: string; readonly stream: true } = prepared.body + + expect(prepared.body).toEqual({ + model: "gpt-4o-mini", + messages: [ + { role: "system", content: "You are concise." }, + { role: "user", content: "Say hello." }, + ], + stream: true, + stream_options: { include_usage: true }, + max_tokens: 20, + temperature: 0, + }) + }), + ) + + it.effect("maps OpenAI provider options to Chat options", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + model: OpenAI.chat("gpt-4o-mini", { baseURL: "https://api.openai.test/v1/" }), + prompt: "think", + providerOptions: { openai: { reasoningEffort: "low" } }, + }), + ) + + expect(prepared.body.store).toBe(false) + expect(prepared.body.reasoning_effort).toBe("low") + }), + ) + + it.effect("adds native query params to the Chat Completions URL", () => + LLMClient.generate( + LLM.updateRequest(request, { model: OpenAIChat.model({ ...model, queryParams: { "api-version": "v1" } }) }), + ).pipe( + Effect.provide( + dynamicResponse((input) => + Effect.gen(function* () { + const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie) + expect(web.url).toBe("https://api.openai.test/v1/chat/completions?api-version=v1") + return input.respond(sseEvents(deltaChunk({}, "stop")), { + headers: { "content-type": "text/event-stream" }, + }) + }), + ), + ), + ), + ) + + it.effect("uses Azure api-key header for static OpenAI Chat keys", () => + LLMClient.generate( + LLM.updateRequest(request, { + model: Azure.chat("gpt-4o-mini", { + baseURL: "https://opencode-test.openai.azure.com/openai/v1/", + apiKey: "azure-key", + headers: { authorization: "Bearer stale" }, + }), + }), + ).pipe( + Effect.provide( + dynamicResponse((input) => + Effect.gen(function* () { + const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie) + expect(web.headers.get("api-key")).toBe("azure-key") + expect(web.headers.get("authorization")).toBeNull() + return input.respond(sseEvents(deltaChunk({}, "stop")), { + headers: { "content-type": "text/event-stream" }, + }) + }), + ), + ), + ), + ) + + it.effect("applies serializable HTTP overlays after payload lowering", () => + LLMClient.generate( + LLM.updateRequest(request, { + model: OpenAIChat.model({ ...model, apiKey: "fresh-key", headers: { authorization: "Bearer stale" } }), + http: { + body: { metadata: { source: "test" } }, + headers: { authorization: "Bearer request", "x-custom": "yes" }, + query: { debug: "1" }, + }, + }), + ).pipe( + Effect.provide( + dynamicResponse((input) => + Effect.gen(function* () { + const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie) + expect(web.url).toBe("https://api.openai.test/v1/chat/completions?debug=1") + expect(web.headers.get("authorization")).toBe("Bearer fresh-key") + expect(web.headers.get("x-custom")).toBe("yes") + expect(decodeJson(input.text)).toMatchObject({ + stream: true, + stream_options: { include_usage: true }, + metadata: { source: "test" }, + }) + return input.respond(sseEvents(deltaChunk({}, "stop")), { + headers: { "content-type": "text/event-stream" }, + }) + }), + ), + ), + ), + ) + + it.effect("prepares assistant tool-call and tool-result messages", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + id: "req_tool_result", + model, + messages: [ + LLM.user("What is the weather?"), + LLM.assistant([LLM.toolCall({ id: "call_1", name: "lookup", input: { query: "weather" } })]), + LLM.toolMessage({ id: "call_1", name: "lookup", result: { forecast: "sunny" } }), + ], + }), + ) + + expect(prepared.body).toEqual({ + model: "gpt-4o-mini", + messages: [ + { role: "user", content: "What is the weather?" }, + { + role: "assistant", + content: null, + tool_calls: [ + { + id: "call_1", + type: "function", + function: { name: "lookup", arguments: encodeJson({ query: "weather" }) }, + }, + ], + }, + { role: "tool", tool_call_id: "call_1", content: encodeJson({ forecast: "sunny" }) }, + ], + stream: true, + stream_options: { include_usage: true }, + }) + }), + ) + + it.effect("rejects unsupported user media content", () => + Effect.gen(function* () { + const error = yield* LLMClient.prepare( + LLM.request({ + id: "req_media", + model, + messages: [LLM.user({ type: "media", mediaType: "image/png", data: "AAECAw==" })], + }), + ).pipe(Effect.flip) + + expect(error.message).toContain("OpenAI Chat user messages only support text content for now") + }), + ) + + it.effect("rejects unsupported assistant reasoning content", () => + Effect.gen(function* () { + const error = yield* LLMClient.prepare( + LLM.request({ + id: "req_reasoning", + model, + messages: [LLM.assistant({ type: "reasoning", text: "hidden" })], + }), + ).pipe(Effect.flip) + + expect(error.message).toContain("OpenAI Chat assistant messages only support text and tool-call content for now") + }), + ) + + it.effect("parses text and usage stream fixtures", () => + Effect.gen(function* () { + const body = sseEvents( + deltaChunk({ role: "assistant", content: "Hello" }), + deltaChunk({ content: "!" }), + deltaChunk({}, "stop"), + usageChunk({ + prompt_tokens: 5, + completion_tokens: 2, + total_tokens: 7, + prompt_tokens_details: { cached_tokens: 1 }, + completion_tokens_details: { reasoning_tokens: 0 }, + }), + ) + const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body))) + + expect(response.text).toBe("Hello!") + expect(response.events).toEqual([ + { type: "text-delta", text: "Hello" }, + { type: "text-delta", text: "!" }, + { + type: "request-finish", + reason: "stop", + usage: { + inputTokens: 5, + outputTokens: 2, + reasoningTokens: 0, + cacheReadInputTokens: 1, + totalTokens: 7, + native: { + prompt_tokens: 5, + completion_tokens: 2, + total_tokens: 7, + prompt_tokens_details: { cached_tokens: 1 }, + completion_tokens_details: { reasoning_tokens: 0 }, + }, + }, + }, + ]) + }), + ) + + it.effect("assembles streamed tool call input", () => + Effect.gen(function* () { + const body = sseEvents( + deltaChunk({ + role: "assistant", + tool_calls: [{ index: 0, id: "call_1", function: { name: "lookup", arguments: '{"query"' } }], + }), + deltaChunk({ tool_calls: [{ index: 0, function: { arguments: ':"weather"}' } }] }), + deltaChunk({}, "tool_calls"), + ) + const response = yield* LLMClient.generate( + LLM.updateRequest(request, { + tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }], + }), + ).pipe(Effect.provide(fixedResponse(body))) + + expect(response.events).toEqual([ + { type: "tool-input-delta", id: "call_1", name: "lookup", text: '{"query"' }, + { type: "tool-input-delta", id: "call_1", name: "lookup", text: ':"weather"}' }, + { type: "tool-call", id: "call_1", name: "lookup", input: { query: "weather" } }, + { type: "request-finish", reason: "tool-calls", usage: undefined }, + ]) + }), + ) + + it.effect("does not finalize streamed tool calls without a finish reason", () => + Effect.gen(function* () { + const body = sseEvents( + deltaChunk({ + role: "assistant", + tool_calls: [{ index: 0, id: "call_1", function: { name: "lookup", arguments: '{"query"' } }], + }), + deltaChunk({ tool_calls: [{ index: 0, function: { arguments: ':"weather"}' } }] }), + ) + const response = yield* LLMClient.generate( + LLM.updateRequest(request, { + tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }], + }), + ).pipe(Effect.provide(fixedResponse(body))) + + expect(response.events).toEqual([ + { type: "tool-input-delta", id: "call_1", name: "lookup", text: '{"query"' }, + { type: "tool-input-delta", id: "call_1", name: "lookup", text: ':"weather"}' }, + ]) + expect(response.toolCalls).toEqual([]) + }), + ) + + it.effect("fails on malformed stream events", () => + Effect.gen(function* () { + const body = sseEvents(deltaChunk({ content: 123 })) + const error = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body)), Effect.flip) + + expect(error.message).toContain("Invalid openai/openai-chat stream event") + }), + ) + + it.effect("surfaces transport errors that occur mid-stream", () => + Effect.gen(function* () { + const layer = truncatedStream([ + `data: ${JSON.stringify(deltaChunk({ role: "assistant", content: "Hello" }))}\n\n`, + ]) + const error = yield* LLMClient.generate(request).pipe(Effect.provide(layer), Effect.flip) + + expect(error.message).toContain("Failed to read openai/openai-chat stream") + }), + ) + + it.effect("fails HTTP provider errors before stream parsing", () => + Effect.gen(function* () { + const error = yield* LLMClient.generate(request).pipe( + Effect.provide( + fixedResponse('{"error":{"message":"Bad request","type":"invalid_request_error"}}', { + status: 400, + headers: { "content-type": "application/json" }, + }), + ), + Effect.flip, + ) + + expect(error).toBeInstanceOf(LLMError) + expect(error.reason).toMatchObject({ _tag: "InvalidRequest" }) + expect(error.message).toContain("HTTP 400") + }), + ) + + it.effect("short-circuits the upstream stream when the consumer takes a prefix", () => + Effect.gen(function* () { + // The body has more chunks than we'll consume. If `Stream.take(1)` did + // not interrupt the upstream HTTP body the test would hang waiting for + // the rest of the stream to drain. + const body = sseEvents( + deltaChunk({ role: "assistant", content: "Hello" }), + deltaChunk({ content: " world" }), + deltaChunk({}, "stop"), + ) + + const events = Array.from( + yield* LLMClient.stream(request).pipe(Stream.take(1), Stream.runCollect, Effect.provide(fixedResponse(body))), + ) + expect(events.map((event) => event.type)).toEqual(["text-delta"]) + }), + ) +}) diff --git a/packages/llm/test/provider/openai-compatible-chat.test.ts b/packages/llm/test/provider/openai-compatible-chat.test.ts new file mode 100644 index 0000000000..627e6ef4a0 --- /dev/null +++ b/packages/llm/test/provider/openai-compatible-chat.test.ts @@ -0,0 +1,237 @@ +import { describe, expect } from "bun:test" +import { Effect, Schema } from "effect" +import { HttpClientRequest } from "effect/unstable/http" +import { LLM } from "../../src" +import { LLMClient } from "../../src/route" +import * as OpenAICompatible from "../../src/providers/openai-compatible" +import * as OpenAICompatibleChat from "../../src/protocols/openai-compatible-chat" +import { it } from "../lib/effect" +import { dynamicResponse } from "../lib/http" +import { sseEvents } from "../lib/sse" + +const Json = Schema.fromJsonString(Schema.Unknown) +const decodeJson = Schema.decodeUnknownSync(Json) + +const model = OpenAICompatibleChat.model({ + id: "deepseek-chat", + provider: "deepseek", + baseURL: "https://api.deepseek.test/v1/", + apiKey: "test-key", + queryParams: { "api-version": "2026-01-01" }, +}) + +const request = LLM.request({ + id: "req_1", + model, + system: "You are concise.", + prompt: "Say hello.", + generation: { maxTokens: 20, temperature: 0 }, +}) + +const deltaChunk = (delta: object, finishReason: string | null = null) => ({ + id: "chatcmpl_fixture", + choices: [{ delta, finish_reason: finishReason }], + usage: null, +}) + +const usageChunk = (usage: object) => ({ + id: "chatcmpl_fixture", + choices: [], + usage, +}) + +const providerFamilies = [ + ["baseten", OpenAICompatible.baseten, "https://inference.baseten.co/v1"], + ["cerebras", OpenAICompatible.cerebras, "https://api.cerebras.ai/v1"], + ["deepinfra", OpenAICompatible.deepinfra, "https://api.deepinfra.com/v1/openai"], + ["deepseek", OpenAICompatible.deepseek, "https://api.deepseek.com/v1"], + ["fireworks", OpenAICompatible.fireworks, "https://api.fireworks.ai/inference/v1"], + ["togetherai", OpenAICompatible.togetherai, "https://api.together.xyz/v1"], +] as const + +describe("OpenAI-compatible Chat route", () => { + it.effect("prepares generic Chat target", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.updateRequest(request, { + tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }], + toolChoice: { type: "required" }, + }), + ) + + expect(prepared.route).toBe("openai-compatible-chat") + expect(prepared.model).toMatchObject({ + id: "deepseek-chat", + provider: "deepseek", + route: "openai-compatible-chat", + baseURL: "https://api.deepseek.test/v1/", + apiKey: "test-key", + queryParams: { "api-version": "2026-01-01" }, + }) + expect(prepared.body).toEqual({ + model: "deepseek-chat", + messages: [ + { role: "system", content: "You are concise." }, + { role: "user", content: "Say hello." }, + ], + tools: [ + { + type: "function", + function: { name: "lookup", description: "Lookup data", parameters: { type: "object" } }, + }, + ], + tool_choice: "required", + stream: true, + stream_options: { include_usage: true }, + max_tokens: 20, + temperature: 0, + }) + }), + ) + + it.effect("provides model helpers for compatible provider families", () => + Effect.gen(function* () { + expect( + providerFamilies.map(([provider, family]) => { + const model = family.model(`${provider}-model`, { apiKey: "test-key" }) + return { + id: String(model.id), + provider: String(model.provider), + route: model.route, + baseURL: model.baseURL, + apiKey: model.apiKey, + } + }), + ).toEqual( + providerFamilies.map(([provider, _, baseURL]) => ({ + id: `${provider}-model`, + provider, + route: "openai-compatible-chat", + baseURL, + apiKey: "test-key", + })), + ) + + const custom = OpenAICompatible.deepseek.model("deepseek-chat", { + apiKey: "test-key", + baseURL: "https://custom.deepseek.test/v1", + }) + expect(custom).toMatchObject({ + provider: "deepseek", + route: "openai-compatible-chat", + baseURL: "https://custom.deepseek.test/v1", + }) + }), + ) + + it.effect("matches AI SDK compatible basic request body fixture", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare(request) + + expect(prepared.body).toEqual({ + model: "deepseek-chat", + messages: [ + { role: "system", content: "You are concise." }, + { role: "user", content: "Say hello." }, + ], + stream: true, + stream_options: { include_usage: true }, + max_tokens: 20, + temperature: 0, + }) + }), + ) + + it.effect("matches AI SDK compatible tool request body fixture", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + id: "req_tool_parity", + model, + tools: [ + { + name: "lookup", + description: "Lookup data", + inputSchema: { type: "object", properties: { query: { type: "string" } }, required: ["query"] }, + }, + ], + toolChoice: "lookup", + messages: [ + LLM.user("What is the weather?"), + LLM.assistant([LLM.toolCall({ id: "call_1", name: "lookup", input: { query: "weather" } })]), + LLM.toolMessage({ id: "call_1", name: "lookup", result: { forecast: "sunny" } }), + ], + }), + ) + + expect(prepared.body).toEqual({ + model: "deepseek-chat", + messages: [ + { role: "user", content: "What is the weather?" }, + { + role: "assistant", + content: null, + tool_calls: [ + { + id: "call_1", + type: "function", + function: { name: "lookup", arguments: '{"query":"weather"}' }, + }, + ], + }, + { role: "tool", tool_call_id: "call_1", content: '{"forecast":"sunny"}' }, + ], + tools: [ + { + type: "function", + function: { + name: "lookup", + description: "Lookup data", + parameters: { type: "object", properties: { query: { type: "string" } }, required: ["query"] }, + }, + }, + ], + tool_choice: { type: "function", function: { name: "lookup" } }, + stream: true, + stream_options: { include_usage: true }, + }) + }), + ) + + it.effect("posts to the configured compatible endpoint and parses text usage", () => + Effect.gen(function* () { + const response = yield* LLMClient.generate(request).pipe( + Effect.provide( + dynamicResponse((input) => + Effect.gen(function* () { + const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie) + expect(web.url).toBe("https://api.deepseek.test/v1/chat/completions?api-version=2026-01-01") + expect(web.headers.get("authorization")).toBe("Bearer test-key") + expect(decodeJson(input.text)).toMatchObject({ + model: "deepseek-chat", + stream: true, + messages: [ + { role: "system", content: "You are concise." }, + { role: "user", content: "Say hello." }, + ], + }) + return input.respond( + sseEvents( + deltaChunk({ role: "assistant", content: "Hello" }), + deltaChunk({ content: "!" }), + deltaChunk({}, "stop"), + usageChunk({ prompt_tokens: 5, completion_tokens: 2, total_tokens: 7 }), + ), + { headers: { "content-type": "text/event-stream" } }, + ) + }), + ), + ), + ) + + expect(response.text).toBe("Hello!") + expect(response.usage).toMatchObject({ inputTokens: 5, outputTokens: 2, totalTokens: 7 }) + expect(response.events.at(-1)).toMatchObject({ type: "request-finish", reason: "stop" }) + }), + ) +}) diff --git a/packages/llm/test/provider/openai-responses.test.ts b/packages/llm/test/provider/openai-responses.test.ts new file mode 100644 index 0000000000..30add06d83 --- /dev/null +++ b/packages/llm/test/provider/openai-responses.test.ts @@ -0,0 +1,549 @@ +import { describe, expect } from "bun:test" +import { ConfigProvider, Effect, Layer, Stream } from "effect" +import { Headers, HttpClientRequest } from "effect/unstable/http" +import { LLM, LLMError } from "../../src" +import { Auth, LLMClient, RequestExecutor, WebSocketExecutor } from "../../src/route" +import * as Azure from "../../src/providers/azure" +import * as OpenAI from "../../src/providers/openai" +import * as OpenAIResponses from "../../src/protocols/openai-responses" +import * as ProviderShared from "../../src/protocols/shared" +import { it } from "../lib/effect" +import { dynamicResponse, fixedResponse } from "../lib/http" +import { sseEvents } from "../lib/sse" + +const model = OpenAIResponses.model({ + id: "gpt-4.1-mini", + baseURL: "https://api.openai.test/v1/", + headers: { authorization: "Bearer test" }, +}) + +const request = LLM.request({ + id: "req_1", + model, + system: "You are concise.", + prompt: "Say hello.", + generation: { maxTokens: 20, temperature: 0 }, +}) + +const configEnv = (env: Record) => Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env }))) + +describe("OpenAI Responses route", () => { + it.effect("prepares OpenAI Responses target", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare(request) + + expect(prepared.body).toEqual({ + model: "gpt-4.1-mini", + input: [ + { role: "system", content: "You are concise." }, + { role: "user", content: [{ type: "input_text", text: "Say hello." }] }, + ], + stream: true, + max_output_tokens: 20, + temperature: 0, + }) + }), + ) + + it.effect("prepares OpenAI Responses WebSocket target", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.updateRequest(request, { + model: OpenAI.responsesWebSocket("gpt-4.1-mini", { baseURL: "https://api.openai.test/v1/", apiKey: "test" }), + }), + ) + + expect(prepared.route).toBe("openai-responses-websocket") + expect(prepared.protocol).toBe("openai-responses") + expect(prepared.metadata).toEqual({ transport: "websocket-json" }) + expect(prepared.body).toMatchObject({ model: "gpt-4.1-mini", stream: true }) + }), + ) + + it.effect("streams OpenAI Responses over WebSocket", () => + Effect.gen(function* () { + const sent: string[] = [] + const opened: Array<{ readonly url: string; readonly authorization: string | undefined }> = [] + let closed = false + const deps = Layer.mergeAll( + Layer.succeed( + RequestExecutor.Service, + RequestExecutor.Service.of({ + execute: () => Effect.die("unexpected HTTP request"), + }), + ), + Layer.succeed( + WebSocketExecutor.Service, + WebSocketExecutor.Service.of({ + open: (input) => + Effect.succeed({ + sendText: (message) => + Effect.sync(() => { + opened.push({ url: input.url, authorization: input.headers.authorization }) + sent.push(message) + }), + messages: Stream.fromArray([ + ProviderShared.encodeJson({ type: "response.output_text.delta", item_id: "msg_1", delta: "Hi" }), + ProviderShared.encodeJson({ type: "response.completed", response: { id: "resp_ws" } }), + ]), + close: Effect.sync(() => { + closed = true + }), + }), + }), + ), + ) + const response = yield* LLMClient.generate( + LLM.request({ + model: OpenAI.responsesWebSocket("gpt-4.1-mini", { baseURL: "https://api.openai.test/v1/", apiKey: "test" }), + prompt: "Say hello.", + }), + ).pipe(Effect.provide(LLMClient.layerWithWebSocket.pipe(Layer.provide(deps)))) + + expect(response.text).toBe("Hi") + expect(opened).toEqual([{ url: "wss://api.openai.test/v1/responses", authorization: "Bearer test" }]) + expect(closed).toBe(true) + expect(sent).toHaveLength(1) + expect(JSON.parse(sent[0])).toEqual({ + type: "response.create", + model: "gpt-4.1-mini", + input: [{ role: "user", content: [{ type: "input_text", text: "Say hello." }] }], + store: false, + }) + }), + ) + + it.effect("requires WebSocket runtime for OpenAI Responses WebSocket", () => + Effect.gen(function* () { + const error = yield* LLMClient.generate( + LLM.request({ + model: OpenAI.responsesWebSocket("gpt-4.1-mini", { baseURL: "https://api.openai.test/v1/", apiKey: "test" }), + prompt: "Say hello.", + }), + ).pipe( + Effect.provide( + LLMClient.layer.pipe( + Layer.provide( + Layer.succeed( + RequestExecutor.Service, + RequestExecutor.Service.of({ + execute: () => Effect.die("unexpected HTTP request"), + }), + ), + ), + ), + ), + Effect.flip, + ) + + expect(error.message).toContain("requires WebSocketExecutor.Service") + }), + ) + + it.effect("fails immediately when WebSocket is already closed", () => + Effect.gen(function* () { + const error = yield* WebSocketExecutor.fromWebSocket( + { readyState: globalThis.WebSocket.CLOSED } as globalThis.WebSocket, + { url: "wss://api.openai.test/v1/responses", headers: Headers.empty }, + ).pipe(Effect.flip) + + expect(error.message).toContain("closed before opening") + }), + ) + + it.effect("adds native query params to the Responses URL", () => + Effect.gen(function* () { + yield* LLMClient.generate( + LLM.updateRequest(request, { + model: OpenAIResponses.model({ ...model, queryParams: { "api-version": "v1" } }), + }), + ).pipe( + Effect.provide( + dynamicResponse((input) => + Effect.gen(function* () { + const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie) + expect(web.url).toBe("https://api.openai.test/v1/responses?api-version=v1") + return input.respond(sseEvents({ type: "response.completed", response: {} }), { + headers: { "content-type": "text/event-stream" }, + }) + }), + ), + ), + ) + }), + ) + + it.effect("uses Azure api-key header for static OpenAI Responses keys", () => + Effect.gen(function* () { + yield* LLMClient.generate( + LLM.updateRequest(request, { + model: Azure.responses("gpt-4.1-mini", { + baseURL: "https://opencode-test.openai.azure.com/openai/v1/", + apiKey: "azure-key", + headers: { authorization: "Bearer stale" }, + }), + }), + ).pipe( + Effect.provide( + dynamicResponse((input) => + Effect.gen(function* () { + const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie) + expect(web.headers.get("api-key")).toBe("azure-key") + expect(web.headers.get("authorization")).toBeNull() + return input.respond(sseEvents({ type: "response.completed", response: {} }), { + headers: { "content-type": "text/event-stream" }, + }) + }), + ), + ), + ) + }), + ) + + it.effect("loads OpenAI default auth from Effect Config", () => + LLMClient.generate( + LLM.updateRequest(request, { + model: OpenAI.responses("gpt-4.1-mini", { baseURL: "https://api.openai.test/v1/" }), + }), + ).pipe( + configEnv({ OPENAI_API_KEY: "env-key" }), + Effect.provide( + dynamicResponse((input) => + Effect.gen(function* () { + const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie) + expect(web.headers.get("authorization")).toBe("Bearer env-key") + return input.respond(sseEvents({ type: "response.completed", response: {} }), { + headers: { "content-type": "text/event-stream" }, + }) + }), + ), + ), + ), + ) + + it.effect("lets explicit auth override OpenAI default API key auth", () => + LLMClient.generate( + LLM.updateRequest(request, { + model: OpenAI.responses("gpt-4.1-mini", { + baseURL: "https://api.openai.test/v1/", + auth: Auth.bearer("oauth-token"), + }), + }), + ).pipe( + Effect.provide( + dynamicResponse((input) => + Effect.gen(function* () { + const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie) + expect(web.headers.get("authorization")).toBe("Bearer oauth-token") + return input.respond(sseEvents({ type: "response.completed", response: {} }), { + headers: { "content-type": "text/event-stream" }, + }) + }), + ), + ), + ), + ) + + it.effect("prepares function call and function output input items", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + id: "req_tool_result", + model, + messages: [ + LLM.user("What is the weather?"), + LLM.assistant([LLM.toolCall({ id: "call_1", name: "lookup", input: { query: "weather" } })]), + LLM.toolMessage({ id: "call_1", name: "lookup", result: { forecast: "sunny" } }), + ], + }), + ) + + expect(prepared.body).toEqual({ + model: "gpt-4.1-mini", + input: [ + { role: "user", content: [{ type: "input_text", text: "What is the weather?" }] }, + { type: "function_call", call_id: "call_1", name: "lookup", arguments: '{"query":"weather"}' }, + { type: "function_call_output", call_id: "call_1", output: '{"forecast":"sunny"}' }, + ], + stream: true, + }) + }), + ) + + it.effect("maps OpenAI provider options to Responses options", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + model: OpenAI.model("gpt-5.2", { baseURL: "https://api.openai.test/v1/" }), + prompt: "think", + providerOptions: { + openai: { + promptCacheKey: "session_123", + reasoningEffort: "high", + reasoningSummary: "auto", + includeEncryptedReasoning: true, + }, + }, + }), + ) + + expect(prepared.body.store).toBe(false) + expect(prepared.body.prompt_cache_key).toBe("session_123") + expect(prepared.body.include).toEqual(["reasoning.encrypted_content"]) + expect(prepared.body.reasoning).toEqual({ effort: "high", summary: "auto" }) + expect(prepared.body.text).toEqual({ verbosity: "low" }) + }), + ) + + it.effect("request OpenAI provider options override model defaults", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + model: OpenAI.model("gpt-4.1-mini", { + baseURL: "https://api.openai.test/v1/", + providerOptions: { openai: { promptCacheKey: "model_cache" } }, + }), + prompt: "no cache", + providerOptions: { openai: { promptCacheKey: "request_cache" } }, + }), + ) + + expect(prepared.body.prompt_cache_key).toBe("request_cache") + }), + ) + + it.effect("parses text and usage stream fixtures", () => + Effect.gen(function* () { + const body = sseEvents( + { type: "response.output_text.delta", item_id: "msg_1", delta: "Hello" }, + { type: "response.output_text.delta", item_id: "msg_1", delta: "!" }, + { + type: "response.completed", + response: { + id: "resp_1", + service_tier: "default", + usage: { + input_tokens: 5, + output_tokens: 2, + total_tokens: 7, + input_tokens_details: { cached_tokens: 1 }, + output_tokens_details: { reasoning_tokens: 0 }, + }, + }, + }, + ) + const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body))) + + expect(response.text).toBe("Hello!") + expect(response.events).toEqual([ + { type: "text-delta", id: "msg_1", text: "Hello", providerMetadata: { openai: { itemId: "msg_1" } } }, + { type: "text-delta", id: "msg_1", text: "!", providerMetadata: { openai: { itemId: "msg_1" } } }, + { + type: "request-finish", + reason: "stop", + providerMetadata: { openai: { responseId: "resp_1", serviceTier: "default" } }, + usage: { + inputTokens: 5, + outputTokens: 2, + reasoningTokens: 0, + cacheReadInputTokens: 1, + totalTokens: 7, + native: { + input_tokens: 5, + output_tokens: 2, + total_tokens: 7, + input_tokens_details: { cached_tokens: 1 }, + output_tokens_details: { reasoning_tokens: 0 }, + }, + }, + }, + ]) + }), + ) + + it.effect("assembles streamed function call input", () => + Effect.gen(function* () { + const body = sseEvents( + { + type: "response.output_item.added", + item: { type: "function_call", id: "item_1", call_id: "call_1", name: "lookup", arguments: "" }, + }, + { type: "response.function_call_arguments.delta", item_id: "item_1", delta: '{"query"' }, + { type: "response.function_call_arguments.delta", item_id: "item_1", delta: ':"weather"}' }, + { + type: "response.output_item.done", + item: { + type: "function_call", + id: "item_1", + call_id: "call_1", + name: "lookup", + arguments: '{"query":"weather"}', + }, + }, + { type: "response.completed", response: { usage: { input_tokens: 5, output_tokens: 1 } } }, + ) + const response = yield* LLMClient.generate( + LLM.updateRequest(request, { + tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }], + }), + ).pipe(Effect.provide(fixedResponse(body))) + + expect(response.events).toEqual([ + { + type: "tool-input-delta", + id: "call_1", + name: "lookup", + text: '{"query"', + providerMetadata: { openai: { itemId: "item_1" } }, + }, + { + type: "tool-input-delta", + id: "call_1", + name: "lookup", + text: ':"weather"}', + providerMetadata: { openai: { itemId: "item_1" } }, + }, + { + type: "tool-call", + id: "call_1", + name: "lookup", + input: { query: "weather" }, + providerMetadata: { openai: { itemId: "item_1" } }, + }, + { + type: "request-finish", + reason: "tool-calls", + usage: { inputTokens: 5, outputTokens: 1, totalTokens: 6, native: { input_tokens: 5, output_tokens: 1 } }, + }, + ]) + }), + ) + + it.effect("decodes web_search_call as provider-executed tool-call + tool-result", () => + Effect.gen(function* () { + const item = { + type: "web_search_call", + id: "ws_1", + status: "completed", + action: { type: "search", query: "effect 4" }, + } + const body = sseEvents( + { type: "response.output_item.added", item }, + { type: "response.output_item.done", item }, + { type: "response.completed", response: { usage: { input_tokens: 5, output_tokens: 1 } } }, + ) + const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body))) + + const callsAndResults = response.events.filter( + (event) => event.type === "tool-call" || event.type === "tool-result", + ) + expect(callsAndResults).toEqual([ + { + type: "tool-call", + id: "ws_1", + name: "web_search", + input: { type: "search", query: "effect 4" }, + providerExecuted: true, + providerMetadata: { openai: { itemId: "ws_1" } }, + }, + { + type: "tool-result", + id: "ws_1", + name: "web_search", + result: { type: "json", value: item }, + providerExecuted: true, + providerMetadata: { openai: { itemId: "ws_1" } }, + }, + ]) + }), + ) + + it.effect("decodes code_interpreter_call as provider-executed events with code input", () => + Effect.gen(function* () { + const item = { + type: "code_interpreter_call", + id: "ci_1", + status: "completed", + code: "print(1+1)", + container_id: "cnt_xyz", + outputs: [{ type: "logs", logs: "2\n" }], + } + const body = sseEvents( + { type: "response.output_item.done", item }, + { type: "response.completed", response: { usage: { input_tokens: 5, output_tokens: 1 } } }, + ) + const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body))) + + const toolCall = response.events.find((event) => event.type === "tool-call") + expect(toolCall).toEqual({ + type: "tool-call", + id: "ci_1", + name: "code_interpreter", + input: { code: "print(1+1)", container_id: "cnt_xyz" }, + providerExecuted: true, + providerMetadata: { openai: { itemId: "ci_1" } }, + }) + const toolResult = response.events.find((event) => event.type === "tool-result") + expect(toolResult).toEqual({ + type: "tool-result", + id: "ci_1", + name: "code_interpreter", + result: { type: "json", value: item }, + providerExecuted: true, + providerMetadata: { openai: { itemId: "ci_1" } }, + }) + }), + ) + + it.effect("rejects unsupported user media content", () => + Effect.gen(function* () { + const error = yield* LLMClient.prepare( + LLM.request({ + id: "req_media", + model, + messages: [LLM.user({ type: "media", mediaType: "image/png", data: "AAECAw==" })], + }), + ).pipe(Effect.flip) + + expect(error.message).toContain("OpenAI Responses user messages only support text content for now") + }), + ) + + it.effect("emits provider-error events for mid-stream provider errors", () => + Effect.gen(function* () { + const response = yield* LLMClient.generate(request).pipe( + Effect.provide(fixedResponse(sseEvents({ type: "error", code: "rate_limit_exceeded", message: "Slow down" }))), + ) + + expect(response.events).toEqual([{ type: "provider-error", message: "Slow down" }]) + }), + ) + + it.effect("falls back to error code when no message is present", () => + Effect.gen(function* () { + const response = yield* LLMClient.generate(request).pipe( + Effect.provide(fixedResponse(sseEvents({ type: "error", code: "internal_error" }))), + ) + + expect(response.events).toEqual([{ type: "provider-error", message: "internal_error" }]) + }), + ) + + it.effect("fails HTTP provider errors before stream parsing", () => + Effect.gen(function* () { + const error = yield* LLMClient.generate(request).pipe( + Effect.provide( + fixedResponse('{"error":{"type":"invalid_request_error","message":"Bad request"}}', { + status: 400, + headers: { "content-type": "application/json" }, + }), + ), + Effect.flip, + ) + + expect(error).toBeInstanceOf(LLMError) + expect(error.reason).toMatchObject({ _tag: "InvalidRequest" }) + expect(error.message).toContain("HTTP 400") + }), + ) +}) diff --git a/packages/llm/test/provider/openrouter.test.ts b/packages/llm/test/provider/openrouter.test.ts new file mode 100644 index 0000000000..b3fb6bddc7 --- /dev/null +++ b/packages/llm/test/provider/openrouter.test.ts @@ -0,0 +1,56 @@ +import { describe, expect } from "bun:test" +import { Effect } from "effect" +import { LLM } from "../../src" +import { LLMClient } from "../../src/route" +import * as OpenRouter from "../../src/providers/openrouter" +import { it } from "../lib/effect" + +describe("OpenRouter", () => { + it.effect("prepares OpenRouter models through the OpenAI-compatible Chat route", () => + Effect.gen(function* () { + const model = OpenRouter.model("openai/gpt-4o-mini", { apiKey: "test-key" }) + + expect(model).toMatchObject({ + id: "openai/gpt-4o-mini", + provider: "openrouter", + route: "openrouter", + baseURL: "https://openrouter.ai/api/v1", + apiKey: "test-key", + }) + + const prepared = yield* LLMClient.prepare(LLM.request({ model, prompt: "Say hello." })) + + expect(prepared.route).toBe("openrouter") + expect(prepared.body).toMatchObject({ + model: "openai/gpt-4o-mini", + messages: [{ role: "user", content: "Say hello." }], + stream: true, + }) + }), + ) + + it.effect("applies OpenRouter payload options from the model helper", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + model: OpenRouter.model("anthropic/claude-3.7-sonnet:thinking", { + providerOptions: { + openrouter: { + usage: true, + reasoning: { effort: "high" }, + promptCacheKey: "session_123", + }, + }, + }), + prompt: "Think briefly.", + }), + ) + + expect(prepared.body).toMatchObject({ + usage: { include: true }, + reasoning: { effort: "high" }, + prompt_cache_key: "session_123", + }) + }), + ) +}) diff --git a/packages/llm/test/recorded-golden.ts b/packages/llm/test/recorded-golden.ts new file mode 100644 index 0000000000..6a6c8c7ac9 --- /dev/null +++ b/packages/llm/test/recorded-golden.ts @@ -0,0 +1,103 @@ +import type { HttpRecorder } from "@opencode-ai/http-recorder" +import { describe, type TestOptions } from "bun:test" +import { Effect } from "effect" +import type { ModelRef } from "../src" +import { goldenScenarioTags, runGoldenScenario, type GoldenScenarioID } from "./recorded-scenarios" +import { recordedTests } from "./recorded-test" +import { kebab } from "./recorded-utils" + +type Transport = "http" | "websocket" + +type ScenarioInput = + | GoldenScenarioID + | { + readonly id: GoldenScenarioID + readonly name?: string + readonly cassette?: string + readonly tags?: ReadonlyArray + readonly maxTokens?: number + readonly temperature?: number | false + readonly timeout?: number | TestOptions + } + +type TargetInput = { + readonly name: string + readonly model: ModelRef + readonly protocol?: string + readonly requires?: ReadonlyArray + readonly transport?: Transport + readonly prefix?: string + readonly tags?: ReadonlyArray + readonly metadata?: Record + readonly options?: HttpRecorder.RecordReplayOptions + readonly scenarios: ReadonlyArray +} + +const scenarioInput = (input: ScenarioInput) => (typeof input === "string" ? { id: input } : input) + +const scenarioTitle = (id: GoldenScenarioID) => { + if (id === "text") return "streams text" + if (id === "tool-call") return "streams tool call" + return "drives a tool loop" +} + +const defaultPrefix = (target: TargetInput) => { + if (target.prefix) return target.prefix + const transport = target.transport === "websocket" ? "-websocket" : "" + return `${target.model.provider}-${target.protocol ?? target.model.route}${transport}` +} + +const metadata = (target: TargetInput) => ({ + provider: target.model.provider, + protocol: target.protocol, + route: target.model.route, + transport: target.transport ?? "http", + model: target.model.id, + ...target.metadata, +}) + +const tags = (target: TargetInput) => [ + ...(target.transport === "websocket" ? ["transport:websocket"] : []), + ...(target.tags ?? []), +] + +const runTarget = (target: TargetInput) => { + const recorded = recordedTests({ + prefix: defaultPrefix(target), + provider: target.model.provider, + protocol: target.protocol, + requires: target.requires, + tags: tags(target), + metadata: metadata(target), + options: target.options, + }) + + describe(`${target.name} recorded`, () => { + target.scenarios.forEach((raw) => { + const input = scenarioInput(raw) + const name = input.name ?? scenarioTitle(input.id) + recorded.effect.with( + name, + { + cassette: input.cassette, + id: `${kebab(target.name)}-${input.id}`, + tags: [...goldenScenarioTags(input.id), ...(input.tags ?? [])], + }, + () => + Effect.gen(function* () { + yield* runGoldenScenario(input.id, { + id: `recorded_${kebab(target.name).replaceAll("-", "_")}_${input.id.replaceAll("-", "_")}`, + model: target.model, + maxTokens: input.maxTokens, + temperature: input.temperature, + }) + }), + input.timeout, + ) + }) + }) +} + +export const describeRecordedGoldenScenarios = (targets: ReadonlyArray) => { + targets.forEach(runTarget) +} diff --git a/packages/llm/test/recorded-runner.ts b/packages/llm/test/recorded-runner.ts new file mode 100644 index 0000000000..97d9b03f54 --- /dev/null +++ b/packages/llm/test/recorded-runner.ts @@ -0,0 +1,100 @@ +import { test, type TestOptions } from "bun:test" +import { Effect, type Layer } from "effect" +import { testEffect } from "./lib/effect" +import { cassetteName, classifiedTags, matchesSelected, missingEnv, unique } from "./recorded-utils" + +export type RecordedBody = Effect.Effect | (() => Effect.Effect) + +export type RecordedGroupOptions = { + readonly prefix: string + readonly provider?: string + readonly protocol?: string + readonly requires?: ReadonlyArray + readonly tags?: ReadonlyArray + readonly metadata?: Record +} + +export type RecordedCaseOptions = { + readonly cassette?: string + readonly id?: string + readonly provider?: string + readonly protocol?: string + readonly requires?: ReadonlyArray + readonly tags?: ReadonlyArray + readonly metadata?: Record +} + +export const recordedEffectGroup = < + R, + E, + Options extends RecordedGroupOptions, + CaseOptions extends RecordedCaseOptions, +>(input: { + readonly duplicateLabel: string + readonly options: Options + readonly cassetteExists: (cassette: string) => boolean + readonly layer: (input: { + readonly cassette: string + readonly tags: ReadonlyArray + readonly metadata: Record + readonly recording: boolean + readonly options: Options + readonly caseOptions: CaseOptions + }) => Layer.Layer +}) => { + const cassettes = new Set() + + const run = ( + name: string, + caseOptions: CaseOptions, + body: RecordedBody, + testOptions?: number | TestOptions, + ) => { + const cassette = cassetteName(input.options.prefix, name, caseOptions) + if (cassettes.has(cassette)) throw new Error(`Duplicate ${input.duplicateLabel} "${cassette}"`) + cassettes.add(cassette) + const tags = unique([ + ...classifiedTags(input.options), + ...classifiedTags({ + provider: caseOptions.provider, + protocol: caseOptions.protocol, + tags: caseOptions.tags, + }), + ]) + + if (!matchesSelected({ prefix: input.options.prefix, name, cassette, tags })) + return test.skip(name, () => {}, testOptions) + + const recording = process.env.RECORD === "true" + if (recording) { + if (missingEnv([...(input.options.requires ?? []), ...(caseOptions.requires ?? [])]).length > 0) { + return test.skip(name, () => {}, testOptions) + } + } else if (!input.cassetteExists(cassette)) { + return test.skip(name, () => {}, testOptions) + } + + return testEffect( + input.layer({ + cassette, + tags, + metadata: { ...input.options.metadata, ...caseOptions.metadata, tags }, + recording, + options: input.options, + caseOptions, + }), + ).live(name, body, testOptions) + } + + const effect = (name: string, body: RecordedBody, testOptions?: number | TestOptions) => + run(name, {} as CaseOptions, body, testOptions) + + effect.with = ( + name: string, + caseOptions: CaseOptions, + body: RecordedBody, + testOptions?: number | TestOptions, + ) => run(name, caseOptions, body, testOptions) + + return { effect } +} diff --git a/packages/llm/test/recorded-scenarios.ts b/packages/llm/test/recorded-scenarios.ts new file mode 100644 index 0000000000..3fb3e0b9a9 --- /dev/null +++ b/packages/llm/test/recorded-scenarios.ts @@ -0,0 +1,265 @@ +import { expect } from "bun:test" +import { Effect, Schema, Stream } from "effect" +import { LLM, LLMEvent, LLMResponse, type LLMRequest, type ModelRef } from "../src" +import { LLMClient } from "../src/route" +import { tool } from "../src/tool" + +export const weatherToolName = "get_weather" + +export const weatherTool = LLM.toolDefinition({ + name: weatherToolName, + description: "Get current weather for a city.", + inputSchema: { + type: "object", + properties: { city: { type: "string" } }, + required: ["city"], + additionalProperties: false, + }, +}) + +export const weatherRuntimeTool = tool({ + description: weatherTool.description, + parameters: Schema.Struct({ city: Schema.String }), + success: Schema.Struct({ temperature: Schema.Number, condition: Schema.String }), + execute: ({ city }) => + Effect.succeed( + city === "Paris" ? { temperature: 22, condition: "sunny" } : { temperature: 0, condition: "unknown" }, + ), +}) + +export const textRequest = (input: { + readonly id: string + readonly model: ModelRef + readonly prompt?: string + readonly maxTokens?: number + readonly temperature?: number | false +}) => + LLM.request({ + id: input.id, + model: input.model, + system: "You are concise.", + prompt: input.prompt ?? "Reply with exactly: Hello!", + generation: + input.temperature === false + ? { maxTokens: input.maxTokens ?? 20 } + : { maxTokens: input.maxTokens ?? 20, temperature: input.temperature ?? 0 }, + }) + +export const weatherToolRequest = (input: { + readonly id: string + readonly model: ModelRef + readonly maxTokens?: number + readonly temperature?: number | false +}) => + LLM.request({ + id: input.id, + model: input.model, + system: "Call tools exactly as requested.", + prompt: "Call get_weather with city exactly Paris.", + tools: [weatherTool], + toolChoice: LLM.toolChoice(weatherTool), + generation: + input.temperature === false + ? { maxTokens: input.maxTokens ?? 80 } + : { maxTokens: input.maxTokens ?? 80, temperature: input.temperature ?? 0 }, + }) + +export const weatherToolLoopRequest = (input: { + readonly id: string + readonly model: ModelRef + readonly system?: string + readonly maxTokens?: number + readonly temperature?: number | false +}) => + LLM.request({ + id: input.id, + model: input.model, + system: input.system ?? "Use the get_weather tool, then answer in one short sentence.", + prompt: "What is the weather in Paris?", + generation: + input.temperature === false + ? { maxTokens: input.maxTokens ?? 80 } + : { maxTokens: input.maxTokens ?? 80, temperature: input.temperature ?? 0 }, + }) + +export const goldenWeatherToolLoopRequest = (input: { + readonly id: string + readonly model: ModelRef + readonly maxTokens?: number + readonly temperature?: number | false +}) => + weatherToolLoopRequest({ + ...input, + system: "Use the get_weather tool exactly once. After the tool result, reply exactly: Paris is sunny.", + }) + +export const runWeatherToolLoop = (request: LLMRequest) => + LLMClient.stream({ + request, + tools: { [weatherToolName]: weatherRuntimeTool }, + stopWhen: LLMClient.stepCountIs(10), + }).pipe( + Stream.runCollect, + Effect.map((events) => Array.from(events)), + ) + +export const expectFinish = ( + events: ReadonlyArray, + reason: Extract["reason"], +) => expect(events.at(-1)).toMatchObject({ type: "request-finish", reason }) + +export const expectWeatherToolCall = (response: LLMResponse) => + expect(response.toolCalls).toMatchObject([ + { type: "tool-call", id: expect.any(String), name: weatherToolName, input: { city: "Paris" } }, + ]) + +export const expectWeatherToolLoop = (events: ReadonlyArray) => { + const finishes = events.filter(LLMEvent.is.requestFinish) + expect(finishes).toHaveLength(2) + expect(finishes[0]?.reason).toBe("tool-calls") + expect(finishes.at(-1)?.reason).toBe("stop") + + const toolCalls = events.filter(LLMEvent.is.toolCall) + expect(toolCalls).toHaveLength(1) + expect(toolCalls[0]).toMatchObject({ type: "tool-call", name: weatherToolName, input: { city: "Paris" } }) + + const toolResults = events.filter(LLMEvent.is.toolResult) + expect(toolResults).toHaveLength(1) + expect(toolResults[0]).toMatchObject({ + type: "tool-result", + name: weatherToolName, + result: { type: "json", value: { temperature: 22, condition: "sunny" } }, + }) + + const output = LLMResponse.text({ events }) + expect(output).toContain("Paris") + expect(output.trim().length).toBeGreaterThan(0) +} + +export const expectGoldenWeatherToolLoop = (events: ReadonlyArray) => { + expectWeatherToolLoop(events) + expect(LLMResponse.text({ events }).trim()).toMatch(/^Paris is sunny\.?$/) +} + +export type GoldenScenarioID = "text" | "tool-call" | "tool-loop" + +export interface GoldenScenarioContext { + readonly id: string + readonly model: ModelRef + readonly maxTokens?: number + readonly temperature?: number | false +} + +const generate = (request: LLMRequest) => LLMClient.generate(request) + +export const goldenScenarioTags = (id: GoldenScenarioID) => { + if (id === "text") return ["text", "golden"] + if (id === "tool-call") return ["tool", "tool-call", "golden"] + return ["tool", "tool-loop", "golden"] +} + +export const runGoldenScenario = (id: GoldenScenarioID, context: GoldenScenarioContext) => + Effect.gen(function* () { + if (id === "text") { + const response = yield* generate( + textRequest({ + id: context.id, + model: context.model, + prompt: "Reply exactly with: Hello!", + maxTokens: context.maxTokens ?? 40, + temperature: context.temperature, + }), + ) + expect(response.text.trim()).toMatch(/^Hello!?$/) + expectFinish(response.events, "stop") + return + } + + if (id === "tool-call") { + const response = yield* generate( + weatherToolRequest({ + id: context.id, + model: context.model, + maxTokens: context.maxTokens ?? 80, + temperature: context.temperature, + }), + ) + expectWeatherToolCall(response) + expectFinish(response.events, "tool-calls") + return + } + + expectGoldenWeatherToolLoop( + yield* runWeatherToolLoop( + goldenWeatherToolLoopRequest({ + id: context.id, + model: context.model, + maxTokens: context.maxTokens ?? 80, + temperature: context.temperature, + }), + ), + ) + }) + +const usageSummary = (usage: LLMResponse["usage"] | undefined) => { + if (!usage) return undefined + return Object.fromEntries( + [ + ["inputTokens", usage.inputTokens], + ["outputTokens", usage.outputTokens], + ["reasoningTokens", usage.reasoningTokens], + ["cacheReadInputTokens", usage.cacheReadInputTokens], + ["cacheWriteInputTokens", usage.cacheWriteInputTokens], + ["totalTokens", usage.totalTokens], + ].filter((entry) => entry[1] !== undefined), + ) +} + +const pushText = (summary: Array>, type: "text" | "reasoning", value: string) => { + const last = summary.at(-1) + if (last?.type === type) { + last.value = `${last.value ?? ""}${value}` + return + } + summary.push({ type, value }) +} + +export const eventSummary = (events: ReadonlyArray) => { + const summary: Array> = [] + for (const event of events) { + if (event.type === "text-delta") { + pushText(summary, "text", event.text) + continue + } + if (event.type === "reasoning-delta") { + pushText(summary, "reasoning", event.text) + continue + } + if (event.type === "tool-call") { + summary.push({ + type: "tool-call", + name: event.name, + input: event.input, + providerExecuted: event.providerExecuted, + }) + continue + } + if (event.type === "tool-result") { + summary.push({ + type: "tool-result", + name: event.name, + result: event.result, + providerExecuted: event.providerExecuted, + }) + continue + } + if (event.type === "tool-error") { + summary.push({ type: "tool-error", name: event.name, message: event.message }) + continue + } + if (event.type === "request-finish") { + summary.push({ type: "finish", reason: event.reason, usage: usageSummary(event.usage) }) + } + } + return summary.map((item) => Object.fromEntries(Object.entries(item).filter((entry) => entry[1] !== undefined))) +} diff --git a/packages/llm/test/recorded-test.ts b/packages/llm/test/recorded-test.ts new file mode 100644 index 0000000000..6514f13dad --- /dev/null +++ b/packages/llm/test/recorded-test.ts @@ -0,0 +1,76 @@ +import { NodeFileSystem } from "@effect/platform-node" +import { HttpRecorder } from "@opencode-ai/http-recorder" +import { Layer } from "effect" +import { FetchHttpClient } from "effect/unstable/http" +import * as path from "node:path" +import { fileURLToPath } from "node:url" +import { LLMClient, RequestExecutor } from "../src/route" +import type { Service as LLMClientService } from "../src/route/client" +import type { Service as RequestExecutorService } from "../src/route/executor" +import type { Service as WebSocketExecutorService } from "../src/route/transport/websocket" +import { + recordedEffectGroup, + type RecordedCaseOptions as RunnerCaseOptions, + type RecordedGroupOptions, +} from "./recorded-runner" +import { webSocketCassetteLayer } from "./recorded-websocket" + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const FIXTURES_DIR = path.resolve(__dirname, "fixtures", "recordings") + +type RecordedEnv = RequestExecutorService | WebSocketExecutorService | LLMClientService + +type RecordedTestsOptions = RecordedGroupOptions & { + readonly options?: HttpRecorder.RecordReplayOptions +} + +type RecordedCaseOptions = RunnerCaseOptions & { + readonly options?: HttpRecorder.RecordReplayOptions +} + +const mergeOptions = ( + base: HttpRecorder.RecordReplayOptions | undefined, + override: HttpRecorder.RecordReplayOptions | undefined, +) => { + if (!base) return override + if (!override) return base + return { + ...base, + ...override, + metadata: base.metadata || override.metadata ? { ...base.metadata, ...override.metadata } : undefined, + } +} + +export const recordedTests = (options: RecordedTestsOptions) => + recordedEffectGroup({ + duplicateLabel: "recorded cassette", + options, + cassetteExists: (cassette) => HttpRecorder.hasCassetteSync(cassette, { directory: FIXTURES_DIR }), + layer: ({ cassette, metadata, options, caseOptions, recording }) => { + const recorderOptions = mergeOptions(options.options, caseOptions.options) + const recorderMetadata = { + ...recorderOptions?.metadata, + ...metadata, + } + const mode = recorderOptions?.mode ?? (recording ? "record" : "replay") + const cassetteService = HttpRecorder.Cassette.layer({ directory: FIXTURES_DIR }).pipe( + Layer.provide(NodeFileSystem.layer), + ) + const requestExecutor = RequestExecutor.layer.pipe( + Layer.provide( + HttpRecorder.recordingLayer(cassette, { + ...recorderOptions, + mode, + metadata: recorderMetadata, + }).pipe(Layer.provide(FetchHttpClient.layer)), + ), + ) + const deps = Layer.mergeAll( + requestExecutor, + webSocketCassetteLayer(cassette, { metadata: recorderMetadata, mode }), + ) + return Layer.mergeAll(deps, LLMClient.layerWithWebSocket.pipe(Layer.provide(deps))).pipe( + Layer.provide(cassetteService), + ) + }, + }) diff --git a/packages/llm/test/recorded-utils.ts b/packages/llm/test/recorded-utils.ts new file mode 100644 index 0000000000..513b2f819c --- /dev/null +++ b/packages/llm/test/recorded-utils.ts @@ -0,0 +1,56 @@ +export const kebab = (value: string) => + value + .trim() + .replace(/['"]/g, "") + .replace(/[^a-zA-Z0-9]+/g, "-") + .replace(/^-|-$/g, "") + .toLowerCase() + +export const missingEnv = (names: ReadonlyArray) => names.filter((name) => !process.env[name]) + +export const envList = (name: string) => + (process.env[name] ?? "") + .split(",") + .map((item) => item.trim().toLowerCase()) + .filter((item) => item !== "") + +export const unique = (items: ReadonlyArray) => Array.from(new Set(items)) + +export const classifiedTags = (input: { + readonly prefix?: string + readonly provider?: string + readonly protocol?: string + readonly tags?: ReadonlyArray +}) => + unique([ + ...(input.prefix ? [`prefix:${input.prefix}`] : []), + ...(input.provider ? [`provider:${input.provider}`] : []), + ...(input.protocol ? [`protocol:${input.protocol}`] : []), + ...(input.tags ?? []), + ]) + +export const matchesSelected = (input: { + readonly prefix: string + readonly name: string + readonly cassette: string + readonly tags: ReadonlyArray +}) => { + const prefixes = envList("RECORDED_PREFIX") + const providers = envList("RECORDED_PROVIDER") + const requiredTags = envList("RECORDED_TAGS") + const tests = envList("RECORDED_TEST") + const tags = input.tags.map((tag) => tag.toLowerCase()) + const names = [input.name, kebab(input.name), input.cassette].map((item) => item.toLowerCase()) + + if (prefixes.length > 0 && !prefixes.includes(input.prefix.toLowerCase())) return false + if (providers.length > 0 && !providers.some((provider) => tags.includes(`provider:${provider}`))) return false + if (requiredTags.length > 0 && !requiredTags.every((tag) => tags.includes(tag))) return false + if (tests.length > 0 && !tests.some((test) => names.some((name) => name.includes(test)))) return false + return true +} + +export const cassetteName = ( + prefix: string, + name: string, + options: { readonly cassette?: string; readonly id?: string }, +) => options.cassette ?? `${prefix}/${options.id ?? kebab(name)}` diff --git a/packages/llm/test/recorded-websocket.ts b/packages/llm/test/recorded-websocket.ts new file mode 100644 index 0000000000..eeea9f1b78 --- /dev/null +++ b/packages/llm/test/recorded-websocket.ts @@ -0,0 +1,27 @@ +import { Cassette, makeWebSocketExecutor } from "@opencode-ai/http-recorder" +import { Effect, Layer } from "effect" +import { WebSocketExecutor } from "../src/route" +import type { Service as WebSocketExecutorService } from "../src/route/transport/websocket" + +const liveWebSocket = WebSocketExecutor.open +type Mode = "record" | "replay" | "passthrough" + +export const webSocketCassetteLayer = ( + cassette: string, + input: { readonly metadata?: Record; readonly mode: Mode }, +): Layer.Layer => + Layer.effect( + WebSocketExecutor.Service, + Effect.gen(function* () { + const cassetteService = yield* Cassette.Service + const executor = yield* makeWebSocketExecutor({ + name: cassette, + mode: input.mode, + metadata: input.metadata, + cassette: cassetteService, + live: { open: liveWebSocket }, + compareClientMessagesAsJson: true, + }) + return WebSocketExecutor.Service.of(executor) + }), + ) diff --git a/packages/llm/test/schema.test.ts b/packages/llm/test/schema.test.ts new file mode 100644 index 0000000000..1c9bbf1e09 --- /dev/null +++ b/packages/llm/test/schema.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, test } from "bun:test" +import { Schema } from "effect" +import { + ContentPart, + LLMEvent, + LLMRequest, + ModelID, + ModelLimits, + ModelRef, + ProviderID, +} from "../src/schema" + +const model = new ModelRef({ + id: ModelID.make("fake-model"), + provider: ProviderID.make("fake-provider"), + route: "openai-chat", + baseURL: "https://fake.local", + limits: new ModelLimits({}), +}) + +describe("llm schema", () => { + test("decodes a minimal request", () => { + const input: unknown = { + id: "req_1", + model, + system: [{ type: "text", text: "You are terse." }], + messages: [{ role: "user", content: [{ type: "text", text: "hi" }] }], + tools: [], + generation: {}, + } + + const decoded = Schema.decodeUnknownSync(LLMRequest)(input) + + expect(decoded.id).toBe("req_1") + expect(decoded.messages[0]?.content[0]?.type).toBe("text") + }) + + test("accepts custom route ids", () => { + const decoded = Schema.decodeUnknownSync(LLMRequest)({ + model: { ...model, route: "custom-route" }, + system: [], + messages: [], + tools: [], + generation: {}, + }) + + expect(decoded.model.route).toBe("custom-route") + }) + + test("rejects invalid event type", () => { + expect(() => Schema.decodeUnknownSync(LLMEvent)({ type: "bogus" })).toThrow() + }) + + test("content part tagged union exposes guards", () => { + expect(ContentPart.guards.text({ type: "text", text: "hi" })).toBe(true) + expect(ContentPart.guards.media({ type: "text", text: "hi" })).toBe(false) + }) +}) diff --git a/packages/llm/test/tool-runtime.test.ts b/packages/llm/test/tool-runtime.test.ts new file mode 100644 index 0000000000..7251dee8af --- /dev/null +++ b/packages/llm/test/tool-runtime.test.ts @@ -0,0 +1,454 @@ +import { describe, expect } from "bun:test" +import { Effect, Schema, Stream } from "effect" +import { LLM, LLMEvent, LLMRequest, LLMResponse } from "../src" +import { LLMClient } from "../src/route" +import * as AnthropicMessages from "../src/protocols/anthropic-messages" +import * as OpenAIChat from "../src/protocols/openai-chat" +import { tool, ToolFailure } from "../src/tool" +import { it } from "./lib/effect" +import * as TestToolRuntime from "./lib/tool-runtime" +import { dynamicResponse, scriptedResponses } from "./lib/http" +import { deltaChunk, finishChunk, toolCallChunk } from "./lib/openai-chunks" +import { sseEvents } from "./lib/sse" + +const model = OpenAIChat.model({ + id: "gpt-4o-mini", + baseURL: "https://api.openai.test/v1/", + headers: { authorization: "Bearer test" }, +}) +const Json = Schema.fromJsonString(Schema.Unknown) +const decodeJson = Schema.decodeUnknownSync(Json) + +const baseRequest = LLM.request({ + id: "req_1", + model, + prompt: "Use the tool.", +}) + +const get_weather = tool({ + description: "Get current weather for a city.", + parameters: Schema.Struct({ city: Schema.String }), + success: Schema.Struct({ temperature: Schema.Number, condition: Schema.String }), + execute: ({ city }) => + Effect.gen(function* () { + if (city === "FAIL") return yield* new ToolFailure({ message: `Weather lookup failed for ${city}` }) + return { temperature: 22, condition: "sunny" } + }), +}) + +const schema_only_weather = tool({ + description: "Get current weather for a city.", + parameters: Schema.Struct({ city: Schema.String }), + success: Schema.Struct({ temperature: Schema.Number, condition: Schema.String }), +}) + +describe("LLMClient tools", () => { + it.effect("uses the registered model route when adding runtime tools", () => + Effect.gen(function* () { + const layer = scriptedResponses([ + sseEvents(deltaChunk({ role: "assistant", content: "Done." }), finishChunk("stop")), + ]) + + const events = Array.from( + yield* TestToolRuntime.runTools({ request: baseRequest, tools: { get_weather } }).pipe( + Stream.runCollect, + Effect.provide(layer), + ), + ) + + expect(LLMResponse.text({ events })).toBe("Done.") + }), + ) + + it.effect("sends tool-call history and request options on the follow-up request", () => + Effect.gen(function* () { + const bodies: unknown[] = [] + const responses = [ + sseEvents(toolCallChunk("call_1", "get_weather", '{"city":"Paris"}'), finishChunk("tool_calls")), + sseEvents(deltaChunk({ role: "assistant", content: "It's sunny in Paris." }), finishChunk("stop")), + ] + const layer = dynamicResponse((input) => + Effect.sync(() => { + bodies.push(decodeJson(input.text)) + return input.respond(responses[bodies.length - 1] ?? responses[responses.length - 1], { + headers: { "content-type": "text/event-stream" }, + }) + }), + ) + + yield* TestToolRuntime.runTools({ + request: LLMRequest.update(baseRequest, { + generation: LLM.generation({ maxTokens: 50 }), + toolChoice: LLM.toolChoice("auto"), + }), + tools: { get_weather }, + }).pipe(Stream.runCollect, Effect.provide(layer)) + + const second = bodies[1] as { + readonly messages?: ReadonlyArray> + readonly tools?: ReadonlyArray + readonly tool_choice?: unknown + readonly max_tokens?: unknown + } + + expect(second.max_tokens).toBe(50) + expect(second.tool_choice).toBe("auto") + expect(second.tools).toHaveLength(1) + expect(second.messages?.map((message) => message.role)).toEqual(["user", "assistant", "tool"]) + expect(second.messages?.[1]).toMatchObject({ + role: "assistant", + content: null, + tool_calls: [{ id: "call_1", type: "function", function: { name: "get_weather" } }], + }) + expect(second.messages?.[2]).toMatchObject({ + role: "tool", + tool_call_id: "call_1", + content: '{"temperature":22,"condition":"sunny"}', + }) + }), + ) + + it.effect("dispatches a tool call, appends results, and resumes streaming", () => + Effect.gen(function* () { + const layer = scriptedResponses([ + sseEvents(toolCallChunk("call_1", "get_weather", '{"city":"Paris"}'), finishChunk("tool_calls")), + sseEvents(deltaChunk({ role: "assistant", content: "It's sunny in Paris." }), finishChunk("stop")), + ]) + + const events = Array.from( + yield* TestToolRuntime.runTools({ request: baseRequest, tools: { get_weather } }).pipe( + Stream.runCollect, + Effect.provide(layer), + ), + ) + + const result = events.find(LLMEvent.is.toolResult) + expect(result).toMatchObject({ + type: "tool-result", + id: "call_1", + name: "get_weather", + result: { type: "json", value: { temperature: 22, condition: "sunny" } }, + }) + expect(events.at(-1)?.type).toBe("request-finish") + expect(LLMResponse.text({ events })).toBe("It's sunny in Paris.") + }), + ) + + it.effect("executes tool calls for one step without looping by default", () => + Effect.gen(function* () { + const layer = scriptedResponses([ + sseEvents(toolCallChunk("call_1", "get_weather", '{"city":"Paris"}'), finishChunk("tool_calls")), + sseEvents(deltaChunk({ role: "assistant", content: "Should not run." }), finishChunk("stop")), + ]) + + const events = Array.from( + yield* LLMClient.stream({ request: baseRequest, tools: { get_weather } }).pipe( + Stream.runCollect, + Effect.provide(layer), + ), + ) + + expect(events.filter(LLMEvent.is.requestFinish)).toHaveLength(1) + expect(events.find(LLMEvent.is.toolResult)).toMatchObject({ type: "tool-result", id: "call_1" }) + }), + ) + + it.effect("can expose tool schemas without executing tool calls", () => + Effect.gen(function* () { + const layer = scriptedResponses([ + sseEvents(toolCallChunk("call_1", "get_weather", '{"city":"Paris"}'), finishChunk("tool_calls")), + ]) + + const events = Array.from( + yield* LLMClient.stream({ + request: baseRequest, + tools: { get_weather: schema_only_weather }, + toolExecution: "none", + }).pipe(Stream.runCollect, Effect.provide(layer)), + ) + + expect(events.find(LLMEvent.is.toolCall)).toMatchObject({ type: "tool-call", id: "call_1" }) + expect(events.find(LLMEvent.is.toolResult)).toBeUndefined() + }), + ) + + it.effect("preserves provider metadata when folding streamed assistant content into follow-up history", () => + Effect.gen(function* () { + const bodies: unknown[] = [] + const layer = dynamicResponse((input) => + Effect.sync(() => { + bodies.push(decodeJson(input.text)) + return input.respond( + bodies.length === 1 + ? sseEvents( + { type: "message_start", message: { usage: { input_tokens: 5 } } }, + { type: "content_block_start", index: 0, content_block: { type: "thinking", thinking: "" } }, + { type: "content_block_delta", index: 0, delta: { type: "thinking_delta", thinking: "thinking" } }, + { type: "content_block_delta", index: 0, delta: { type: "signature_delta", signature: "sig_1" } }, + { type: "content_block_stop", index: 0 }, + { + type: "content_block_start", + index: 1, + content_block: { type: "tool_use", id: "call_1", name: "get_weather" }, + }, + { + type: "content_block_delta", + index: 1, + delta: { type: "input_json_delta", partial_json: '{"city":"Paris"}' }, + }, + { type: "content_block_stop", index: 1 }, + { type: "message_delta", delta: { stop_reason: "tool_use" }, usage: { output_tokens: 5 } }, + ) + : sseEvents( + { type: "message_start", message: { usage: { input_tokens: 5 } } }, + { type: "content_block_start", index: 0, content_block: { type: "text", text: "" } }, + { type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "Done." } }, + { type: "content_block_stop", index: 0 }, + { type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 1 } }, + ), + { headers: { "content-type": "text/event-stream" } }, + ) + }), + ) + + yield* TestToolRuntime.runTools({ + request: LLM.updateRequest(baseRequest, { + model: AnthropicMessages.model({ id: "claude-sonnet-4-5", apiKey: "test" }), + }), + tools: { get_weather }, + }).pipe(Stream.runCollect, Effect.provide(layer)) + + expect(bodies[1]).toMatchObject({ + messages: [ + { role: "user" }, + { + role: "assistant", + content: [ + { type: "thinking", thinking: "thinking", signature: "sig_1" }, + { type: "tool_use", id: "call_1", name: "get_weather", input: { city: "Paris" } }, + ], + }, + { role: "user", content: [{ type: "tool_result", tool_use_id: "call_1" }] }, + ], + }) + }), + ) + + it.effect("emits tool-error for unknown tools so the model can self-correct", () => + Effect.gen(function* () { + const layer = scriptedResponses([ + sseEvents(toolCallChunk("call_1", "missing_tool", "{}"), finishChunk("tool_calls")), + sseEvents(deltaChunk({ role: "assistant", content: "Sorry." }), finishChunk("stop")), + ]) + + const events = Array.from( + yield* TestToolRuntime.runTools({ request: baseRequest, tools: { get_weather } }).pipe( + Stream.runCollect, + Effect.provide(layer), + ), + ) + + const toolError = events.find(LLMEvent.is.toolError) + expect(toolError).toMatchObject({ type: "tool-error", id: "call_1", name: "missing_tool" }) + expect(toolError?.message).toContain("Unknown tool") + expect(events.find(LLMEvent.is.toolResult)).toMatchObject({ + type: "tool-result", + id: "call_1", + name: "missing_tool", + result: { type: "error", value: "Unknown tool: missing_tool" }, + }) + }), + ) + + it.effect("emits tool-error when the LLM input fails the parameters schema", () => + Effect.gen(function* () { + const layer = scriptedResponses([ + sseEvents(toolCallChunk("call_1", "get_weather", '{"city":42}'), finishChunk("tool_calls")), + sseEvents(deltaChunk({ role: "assistant", content: "Done." }), finishChunk("stop")), + ]) + + const events = Array.from( + yield* TestToolRuntime.runTools({ request: baseRequest, tools: { get_weather } }).pipe( + Stream.runCollect, + Effect.provide(layer), + ), + ) + + const toolError = events.find(LLMEvent.is.toolError) + expect(toolError).toMatchObject({ type: "tool-error", id: "call_1", name: "get_weather" }) + expect(toolError?.message).toContain("Invalid tool input") + }), + ) + + it.effect("emits tool-error when the handler returns a ToolFailure", () => + Effect.gen(function* () { + const layer = scriptedResponses([ + sseEvents(toolCallChunk("call_1", "get_weather", '{"city":"FAIL"}'), finishChunk("tool_calls")), + sseEvents(deltaChunk({ role: "assistant", content: "Sorry." }), finishChunk("stop")), + ]) + + const events = Array.from( + yield* TestToolRuntime.runTools({ request: baseRequest, tools: { get_weather } }).pipe( + Stream.runCollect, + Effect.provide(layer), + ), + ) + + const toolError = events.find(LLMEvent.is.toolError) + expect(toolError).toMatchObject({ type: "tool-error", id: "call_1", name: "get_weather" }) + expect(toolError?.message).toBe("Weather lookup failed for FAIL") + }), + ) + + it.effect("stops when the model finishes without requesting more tools", () => + Effect.gen(function* () { + const layer = scriptedResponses([ + sseEvents(deltaChunk({ role: "assistant", content: "Done." }), finishChunk("stop")), + ]) + + const events = Array.from( + yield* TestToolRuntime.runTools({ request: baseRequest, tools: { get_weather } }).pipe( + Stream.runCollect, + Effect.provide(layer), + ), + ) + + expect(events.map((event) => event.type)).toEqual(["text-delta", "request-finish"]) + expect(LLMResponse.text({ events })).toBe("Done.") + }), + ) + + it.effect("respects maxSteps and stops the loop", () => + Effect.gen(function* () { + // Every script entry asks for another tool call. With maxSteps: 2 the + // runtime should run at most two model rounds and then exit even though + // the model still wants to keep going. + const toolCallStep = sseEvents( + toolCallChunk("call_x", "get_weather", '{"city":"Paris"}'), + finishChunk("tool_calls"), + ) + const layer = scriptedResponses([toolCallStep, toolCallStep, toolCallStep]) + + const events = Array.from( + yield* TestToolRuntime.runTools({ request: baseRequest, tools: { get_weather }, maxSteps: 2 }).pipe( + Stream.runCollect, + Effect.provide(layer), + ), + ) + + expect(events.filter(LLMEvent.is.requestFinish)).toHaveLength(2) + }), + ) + + it.effect("stops follow-up when stopWhen returns true after the first step", () => + Effect.gen(function* () { + const layer = scriptedResponses([ + sseEvents(toolCallChunk("call_1", "get_weather", '{"city":"Paris"}'), finishChunk("tool_calls")), + sseEvents(deltaChunk({ role: "assistant", content: "Should not run." }), finishChunk("stop")), + ]) + + const events = Array.from( + yield* TestToolRuntime.runTools({ + request: baseRequest, + tools: { get_weather }, + stopWhen: (state) => state.step >= 0, + }).pipe(Stream.runCollect, Effect.provide(layer)), + ) + + expect(events.filter(LLMEvent.is.requestFinish)).toHaveLength(1) + expect(events.find(LLMEvent.is.toolResult)).toMatchObject({ type: "tool-result", id: "call_1" }) + }), + ) + + it.effect("does not dispatch provider-executed tool calls", () => + Effect.gen(function* () { + let streams = 0 + const layer = dynamicResponse((input) => + Effect.sync(() => { + streams++ + return input.respond( + sseEvents( + { type: "message_start", message: { usage: { input_tokens: 5 } } }, + { + type: "content_block_start", + index: 0, + content_block: { type: "server_tool_use", id: "srvtoolu_abc", name: "web_search" }, + }, + { + type: "content_block_delta", + index: 0, + delta: { type: "input_json_delta", partial_json: '{"query":"x"}' }, + }, + { type: "content_block_stop", index: 0 }, + { + type: "content_block_start", + index: 1, + content_block: { + type: "web_search_tool_result", + tool_use_id: "srvtoolu_abc", + content: [{ type: "web_search_result", url: "https://example.com", title: "Example" }], + }, + }, + { type: "content_block_stop", index: 1 }, + { type: "content_block_start", index: 2, content_block: { type: "text", text: "" } }, + { type: "content_block_delta", index: 2, delta: { type: "text_delta", text: "Done." } }, + { type: "content_block_stop", index: 2 }, + { type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 8 } }, + ), + { headers: { "content-type": "text/event-stream" } }, + ) + }), + ) + const events = Array.from( + yield* TestToolRuntime.runTools({ + request: LLM.updateRequest(baseRequest, { + model: AnthropicMessages.model({ id: "claude-sonnet-4-5", apiKey: "test" }), + }), + tools: {}, + }).pipe(Stream.runCollect, Effect.provide(layer)), + ) + + expect(streams).toBe(1) + expect(events.find(LLMEvent.is.toolError)).toBeUndefined() + expect(events.filter(LLMEvent.is.toolCall)).toEqual([ + { + type: "tool-call", + id: "srvtoolu_abc", + name: "web_search", + input: { query: "x" }, + providerExecuted: true, + }, + ]) + expect(LLMResponse.text({ events })).toBe("Done.") + }), + ) + + it.effect("dispatches multiple tool calls in one step concurrently", () => + Effect.gen(function* () { + const layer = scriptedResponses([ + sseEvents( + deltaChunk({ + role: "assistant", + tool_calls: [ + { index: 0, id: "c1", function: { name: "get_weather", arguments: '{"city":"Paris"}' } }, + { index: 1, id: "c2", function: { name: "get_weather", arguments: '{"city":"Tokyo"}' } }, + ], + }), + finishChunk("tool_calls"), + ), + sseEvents(deltaChunk({ role: "assistant", content: "Both done." }), finishChunk("stop")), + ]) + + const events = Array.from( + yield* TestToolRuntime.runTools({ request: baseRequest, tools: { get_weather } }).pipe( + Stream.runCollect, + Effect.provide(layer), + ), + ) + + const results = events.filter(LLMEvent.is.toolResult) + expect(results).toHaveLength(2) + expect(results.map((event) => event.id).toSorted()).toEqual(["c1", "c2"]) + }), + ) +}) diff --git a/packages/llm/test/tool-stream.test.ts b/packages/llm/test/tool-stream.test.ts new file mode 100644 index 0000000000..04a0035c99 --- /dev/null +++ b/packages/llm/test/tool-stream.test.ts @@ -0,0 +1,88 @@ +import { describe, expect } from "bun:test" +import { Effect } from "effect" +import { LLMError } from "../src/schema" +import { ToolStream } from "../src/protocols/utils/tool-stream" +import { it } from "./lib/effect" + +const ADAPTER = "test-route" + +describe("ToolStream", () => { + it.effect("starts from OpenAI-style deltas and finalizes parsed input", () => + Effect.gen(function* () { + const first = ToolStream.appendOrStart( + ADAPTER, + ToolStream.empty(), + 0, + { id: "call_1", name: "lookup", text: '{"query"' }, + "missing tool", + ) + if (ToolStream.isError(first)) return yield* first + const second = ToolStream.appendOrStart(ADAPTER, first.tools, 0, { text: ':"weather"}' }, "missing tool") + if (ToolStream.isError(second)) return yield* second + const finished = yield* ToolStream.finish(ADAPTER, second.tools, 0) + + expect(first.event).toEqual({ type: "tool-input-delta", id: "call_1", name: "lookup", text: '{"query"' }) + expect(second.event).toEqual({ type: "tool-input-delta", id: "call_1", name: "lookup", text: ':"weather"}' }) + expect(finished).toEqual({ + tools: {}, + event: { type: "tool-call", id: "call_1", name: "lookup", input: { query: "weather" } }, + }) + }), + ) + + it.effect("fails appendExisting when the provider skipped the tool start", () => + Effect.gen(function* () { + const error = ToolStream.appendExisting(ADAPTER, ToolStream.empty(), 0, "{}", "missing tool") + + expect(error).toBeInstanceOf(LLMError) + if (ToolStream.isError(error)) expect(error.reason.message).toBe("missing tool") + }), + ) + + it.effect("uses final input override without losing accumulated deltas", () => + Effect.gen(function* () { + const tools = ToolStream.start(ToolStream.empty(), "item_1", { + id: "call_1", + name: "lookup", + input: '{"query":"partial"}', + }) + const finished = yield* ToolStream.finishWithInput(ADAPTER, tools, "item_1", '{"query":"final"}') + + expect(finished).toEqual({ + tools: {}, + event: { type: "tool-call", id: "call_1", name: "lookup", input: { query: "final" } }, + }) + }), + ) + + it.effect("preserves providerExecuted and clears all tools", () => + Effect.gen(function* () { + const first: ToolStream.State = ToolStream.start(ToolStream.empty(), 0, { + id: "call_1", + name: "lookup", + input: "{}", + }) + const tools = ToolStream.start(first, 1, { + id: "call_2", + name: "web_search", + input: '{"query":"docs"}', + providerExecuted: true, + }) + const finished = yield* ToolStream.finishAll(ADAPTER, tools) + + expect(finished).toEqual({ + tools: {}, + events: [ + { type: "tool-call", id: "call_1", name: "lookup", input: {} }, + { + type: "tool-call", + id: "call_2", + name: "web_search", + input: { query: "docs" }, + providerExecuted: true, + }, + ], + }) + }), + ) +}) diff --git a/packages/llm/test/tool.types.ts b/packages/llm/test/tool.types.ts new file mode 100644 index 0000000000..4ffc30c986 --- /dev/null +++ b/packages/llm/test/tool.types.ts @@ -0,0 +1,29 @@ +import { Effect, Schema } from "effect" +import { LLM } from "../src" +import * as OpenAIChat from "../src/protocols/openai-chat" +import { tool } from "../src/tool" + +const request = LLM.request({ + model: OpenAIChat.model({ id: "gpt-4o-mini", apiKey: "fixture" }), + prompt: "Use the tool.", +}) + +const executable = tool({ + description: "Get weather.", + parameters: Schema.Struct({ city: Schema.String }), + success: Schema.Struct({ forecast: Schema.String }), + execute: (input) => Effect.succeed({ forecast: input.city }), +}) + +const schemaOnly = tool({ + description: "Get weather.", + parameters: Schema.Struct({ city: Schema.String }), + success: Schema.Struct({ forecast: Schema.String }), +}) + +LLM.stream({ request, tools: { executable } }) +LLM.generate({ request, tools: { executable }, stopWhen: LLM.stepCountIs(2) }) +LLM.stream({ request, tools: { schemaOnly }, toolExecution: "none" }) + +// @ts-expect-error Handler-less tools can only be passed with toolExecution: "none". +LLM.stream({ request, tools: { schemaOnly } }) diff --git a/packages/llm/tsconfig.json b/packages/llm/tsconfig.json new file mode 100644 index 0000000000..2bc480ffbb --- /dev/null +++ b/packages/llm/tsconfig.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "@tsconfig/bun/tsconfig.json", + "compilerOptions": { + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "noUncheckedIndexedAccess": false, + "plugins": [ + { + "name": "@effect/language-service", + "transform": "@effect/language-service/transform", + "namespaceImportPackages": ["effect", "@effect/*"] + } + ] + } +} diff --git a/turbo.json b/turbo.json index 0183fabca4..6c65881b85 100644 --- a/turbo.json +++ b/turbo.json @@ -13,6 +13,10 @@ "outputs": [], "passThroughEnv": ["*"] }, + "test:ci": { + "outputs": [".artifacts/unit/junit.xml"], + "passThroughEnv": ["*"] + }, "opencode#test:ci": { "dependsOn": ["^build"], "outputs": [".artifacts/unit/junit.xml"], From ba8c920639e1cda59edcc5a559a6636991eaf629 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Fri, 8 May 2026 20:57:36 +0000 Subject: [PATCH 019/189] chore: generate --- packages/http-recorder/src/cassette.ts | 5 ++++- packages/http-recorder/src/redaction.ts | 6 +++++- packages/http-recorder/test/record-replay.test.ts | 6 ++---- packages/llm/AGENTS.md | 2 +- packages/llm/script/setup-recording-env.ts | 7 ++++++- packages/llm/src/llm.ts | 13 ++++--------- packages/llm/src/providers/anthropic.ts | 6 ++++-- packages/llm/src/providers/google.ts | 6 ++++-- packages/llm/src/route/auth.ts | 5 +++-- packages/llm/src/route/client.ts | 4 +--- packages/llm/src/route/endpoint.ts | 4 +--- packages/llm/test/endpoint.test.ts | 11 ++++------- ...eway-workers-ai-gpt-oss-20b-tools-tool-call.json | 8 +------- ...are-ai-gateway-workers-ai-llama-3-1-8b-text.json | 7 +------ ...lare-workers-ai-gpt-oss-20b-tools-tool-call.json | 8 +------- .../cloudflare-workers-ai-llama-3-1-8b-text.json | 7 +------ packages/llm/test/generate-object.test.ts | 5 ++++- packages/llm/test/provider/cloudflare.test.ts | 4 +--- packages/llm/test/schema.test.ts | 10 +--------- 19 files changed, 49 insertions(+), 75 deletions(-) diff --git a/packages/http-recorder/src/cassette.ts b/packages/http-recorder/src/cassette.ts index 23f1ba4e68..769bcc7c70 100644 --- a/packages/http-recorder/src/cassette.ts +++ b/packages/http-recorder/src/cassette.ts @@ -90,7 +90,10 @@ export const layer = (options: { readonly directory?: string } = {}) => return (yield* walk(directory)) .filter((file) => file.endsWith(".json")) .map((file) => ({ - name: path.relative(directory, file).replace(/\\/g, "/").replace(/\.json$/, ""), + name: path + .relative(directory, file) + .replace(/\\/g, "/") + .replace(/\.json$/, ""), path: file, })) .toSorted((a, b) => a.name.localeCompare(b.name)) diff --git a/packages/http-recorder/src/redaction.ts b/packages/http-recorder/src/redaction.ts index 062ea61dc7..3a8b097839 100644 --- a/packages/http-recorder/src/redaction.ts +++ b/packages/http-recorder/src/redaction.ts @@ -65,7 +65,11 @@ const redactionSet = (values: ReadonlyArray | undefined, defaults: Reado export type UrlRedactor = (url: string) => string -export const redactUrl = (raw: string, query: ReadonlyArray = DEFAULT_REDACT_QUERY, urlRedactor?: UrlRedactor) => { +export const redactUrl = ( + raw: string, + query: ReadonlyArray = DEFAULT_REDACT_QUERY, + urlRedactor?: UrlRedactor, +) => { if (!URL.canParse(raw)) return urlRedactor?.(raw) ?? raw const url = new URL(raw) if (url.username) url.username = REDACTED diff --git a/packages/http-recorder/test/record-replay.test.ts b/packages/http-recorder/test/record-replay.test.ts index 2f118a88c1..676422e6a4 100644 --- a/packages/http-recorder/test/record-replay.test.ts +++ b/packages/http-recorder/test/record-replay.test.ts @@ -64,10 +64,8 @@ describe("http-recorder", () => { test("applies custom URL redaction after built-in redaction", () => { expect( - HttpRecorder.redactUrl( - "https://example.test/accounts/real-account/path?key=secret-key", - undefined, - (url) => url.replace("/accounts/real-account/", "/accounts/{account}/"), + HttpRecorder.redactUrl("https://example.test/accounts/real-account/path?key=secret-key", undefined, (url) => + url.replace("/accounts/real-account/", "/accounts/{account}/"), ), ).toBe("https://example.test/accounts/{account}/path?key=%5BREDACTED%5D") }) diff --git a/packages/llm/AGENTS.md b/packages/llm/AGENTS.md index 61d57cf06b..b20847da3b 100644 --- a/packages/llm/AGENTS.md +++ b/packages/llm/AGENTS.md @@ -28,7 +28,7 @@ const request = LLM.request({ prompt: "Say hello.", }) -const response = yield* LLMClient.generate(request) +const response = yield * LLMClient.generate(request) ``` `LLM.request(...)` builds an `LLMRequest`. `LLMClient.generate(...)` selects a registered route by `request.model.route`, builds the provider-native body, asks the route's transport for a real `HttpClientRequest.HttpClientRequest`, sends it through `RequestExecutor.Service`, parses the provider stream into common `LLMEvent`s, and finally returns an `LLMResponse`. diff --git a/packages/llm/script/setup-recording-env.ts b/packages/llm/script/setup-recording-env.ts index 945f2b2ada..d32769b3ce 100644 --- a/packages/llm/script/setup-recording-env.ts +++ b/packages/llm/script/setup-recording-env.ts @@ -110,7 +110,12 @@ const PROVIDERS: ReadonlyArray = [ note: "Cloudflare Unified/OpenAI-compatible gateway; supports provider/model ids like workers-ai/@cf/...", vars: [ { name: "CLOUDFLARE_ACCOUNT_ID", label: "Cloudflare account ID", secret: false }, - { name: "CLOUDFLARE_GATEWAY_ID", label: "Cloudflare AI Gateway ID (defaults to default)", optional: true, secret: false }, + { + name: "CLOUDFLARE_GATEWAY_ID", + label: "Cloudflare AI Gateway ID (defaults to default)", + optional: true, + secret: false, + }, { name: "CLOUDFLARE_API_TOKEN", label: "Cloudflare AI Gateway token" }, ], validate: (env) => diff --git a/packages/llm/src/llm.ts b/packages/llm/src/llm.ts index 21d88302ed..bca78c888a 100644 --- a/packages/llm/src/llm.ts +++ b/packages/llm/src/llm.ts @@ -1,10 +1,5 @@ import { Effect, JsonSchema, Schema } from "effect" -import { - LLMClient, - modelLimits, - modelRef, - type ModelRefInput, -} from "./route/client" +import { LLMClient, modelLimits, modelRef, type ModelRefInput } from "./route/client" import { GenerationOptions, HttpOptions, @@ -196,10 +191,10 @@ const runGenerateObject = Effect.fn("LLM.generateObject")(function* ( export function generateObject>( options: GenerateObjectOptions, ): Effect.Effect>, LLMError> -export function generateObject(options: GenerateObjectDynamicOptions): Effect.Effect, LLMError> export function generateObject( - options: GenerateObjectOptions> | GenerateObjectDynamicOptions, -) { + options: GenerateObjectDynamicOptions, +): Effect.Effect, LLMError> +export function generateObject(options: GenerateObjectOptions> | GenerateObjectDynamicOptions) { if ("schema" in options) { const { schema, ...rest } = options return runGenerateObject( diff --git a/packages/llm/src/providers/anthropic.ts b/packages/llm/src/providers/anthropic.ts index a7ec7ede9f..cca12bf7c2 100644 --- a/packages/llm/src/providers/anthropic.ts +++ b/packages/llm/src/providers/anthropic.ts @@ -7,8 +7,10 @@ export const id = ProviderID.make("anthropic") export const routes = [AnthropicMessages.route] -export const model = (id: string | ModelID, options: Omit & { readonly baseURL?: string } = {}) => - AnthropicMessages.model({ ...options, id }) +export const model = ( + id: string | ModelID, + options: Omit & { readonly baseURL?: string } = {}, +) => AnthropicMessages.model({ ...options, id }) export const provider = Provider.make({ id, diff --git a/packages/llm/src/providers/google.ts b/packages/llm/src/providers/google.ts index d63439bfec..c03b9a7c25 100644 --- a/packages/llm/src/providers/google.ts +++ b/packages/llm/src/providers/google.ts @@ -7,8 +7,10 @@ export const id = ProviderID.make("google") export const routes = [Gemini.route] -export const model = (id: string | ModelID, options: Omit & { readonly baseURL?: string } = {}) => - Gemini.model({ ...options, id }) +export const model = ( + id: string | ModelID, + options: Omit & { readonly baseURL?: string } = {}, +) => Gemini.model({ ...options, id }) export const provider = Provider.make({ id, diff --git a/packages/llm/src/route/auth.ts b/packages/llm/src/route/auth.ts index 540c2845f2..b46e223363 100644 --- a/packages/llm/src/route/auth.ts +++ b/packages/llm/src/route/auth.ts @@ -168,8 +168,9 @@ export function bearerHeader( name: string, source?: string | Redacted.Redacted | Config.Config> | Credential, ) { - const render = (input: string | Redacted.Redacted | Config.Config> | Credential) => - fromCredential(credentialInput(input), (secret) => ({ [name]: `Bearer ${secret}` })) + const render = ( + input: string | Redacted.Redacted | Config.Config> | Credential, + ) => fromCredential(credentialInput(input), (secret) => ({ [name]: `Bearer ${secret}` })) if (source === undefined) return render return render(source) } diff --git a/packages/llm/src/route/client.ts b/packages/llm/src/route/client.ts index 0b9d92cecb..734eedff21 100644 --- a/packages/llm/src/route/client.ts +++ b/packages/llm/src/route/client.ts @@ -142,9 +142,7 @@ const modelWithDefaults = if (!provider) throw new Error(`Route.model(${route.id}) requires a provider`) const baseURL = mapped.baseURL ?? defaults.baseURL ?? route.defaults.baseURL if (!baseURL) - throw new Error( - `Route.model(${route.id}) requires a baseURL — supply it via input, defaults, or route defaults`, - ) + throw new Error(`Route.model(${route.id}) requires a baseURL — supply it via input, defaults, or route defaults`) const generation = mergeGenerationOptions(route.defaults.generation, defaults.generation) const providerOptions = mergeProviderOptions(route.defaults.providerOptions, defaults.providerOptions) const http = mergeHttpOptions(httpOptions(route.defaults.http), httpOptions(defaults.http)) diff --git a/packages/llm/src/route/endpoint.ts b/packages/llm/src/route/endpoint.ts index 71d551893d..361ad508e1 100644 --- a/packages/llm/src/route/endpoint.ts +++ b/packages/llm/src/route/endpoint.ts @@ -30,9 +30,7 @@ const renderPart = (part: EndpointPart, input: EndpointInput) typeof part === "function" ? part(input) : part export const render = (endpoint: Endpoint, input: EndpointInput) => { - const url = new URL( - `${ProviderShared.trimBaseUrl(input.request.model.baseURL)}${renderPart(endpoint.path, input)}`, - ) + const url = new URL(`${ProviderShared.trimBaseUrl(input.request.model.baseURL)}${renderPart(endpoint.path, input)}`) const params = input.request.model.queryParams if (params) for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value) return url diff --git a/packages/llm/test/endpoint.test.ts b/packages/llm/test/endpoint.test.ts index f708a87ea0..43d2e1c5c4 100644 --- a/packages/llm/test/endpoint.test.ts +++ b/packages/llm/test/endpoint.test.ts @@ -2,12 +2,7 @@ import { describe, expect, test } from "bun:test" import { LLM } from "../src" import { Endpoint } from "../src/route" -const request = ( - input: { - readonly baseURL: string - readonly queryParams?: Record - }, -) => +const request = (input: { readonly baseURL: string; readonly queryParams?: Record }) => LLM.request({ model: LLM.model({ id: "model-1", @@ -43,7 +38,9 @@ describe("Endpoint", () => { test("path may be a function of the validated body", () => { const url = Endpoint.render( - Endpoint.path<{ readonly modelId: string }>(({ body }) => `/model/${encodeURIComponent(body.modelId)}/converse-stream`), + Endpoint.path<{ readonly modelId: string }>( + ({ body }) => `/model/${encodeURIComponent(body.modelId)}/converse-stream`, + ), { request: request({ baseURL: "https://bedrock-runtime.us-east-1.amazonaws.com" }), body: { modelId: "us.amazon.nova-micro-v1:0" }, diff --git a/packages/llm/test/fixtures/recordings/cloudflare-ai-gateway/cloudflare-ai-gateway-workers-ai-gpt-oss-20b-tools-tool-call.json b/packages/llm/test/fixtures/recordings/cloudflare-ai-gateway/cloudflare-ai-gateway-workers-ai-gpt-oss-20b-tools-tool-call.json index 80ade53b9c..981c14f03e 100644 --- a/packages/llm/test/fixtures/recordings/cloudflare-ai-gateway/cloudflare-ai-gateway-workers-ai-gpt-oss-20b-tools-tool-call.json +++ b/packages/llm/test/fixtures/recordings/cloudflare-ai-gateway/cloudflare-ai-gateway-workers-ai-gpt-oss-20b-tools-tool-call.json @@ -7,13 +7,7 @@ "route": "cloudflare-ai-gateway", "transport": "http", "model": "workers-ai/@cf/openai/gpt-oss-20b", - "tags": [ - "prefix:cloudflare-ai-gateway", - "provider:cloudflare-ai-gateway", - "tool", - "tool-call", - "golden" - ] + "tags": ["prefix:cloudflare-ai-gateway", "provider:cloudflare-ai-gateway", "tool", "tool-call", "golden"] }, "interactions": [ { diff --git a/packages/llm/test/fixtures/recordings/cloudflare-ai-gateway/cloudflare-ai-gateway-workers-ai-llama-3-1-8b-text.json b/packages/llm/test/fixtures/recordings/cloudflare-ai-gateway/cloudflare-ai-gateway-workers-ai-llama-3-1-8b-text.json index ff535b578b..6a8eff09d9 100644 --- a/packages/llm/test/fixtures/recordings/cloudflare-ai-gateway/cloudflare-ai-gateway-workers-ai-llama-3-1-8b-text.json +++ b/packages/llm/test/fixtures/recordings/cloudflare-ai-gateway/cloudflare-ai-gateway-workers-ai-llama-3-1-8b-text.json @@ -7,12 +7,7 @@ "route": "cloudflare-ai-gateway", "transport": "http", "model": "workers-ai/@cf/meta/llama-3.1-8b-instruct", - "tags": [ - "prefix:cloudflare-ai-gateway", - "provider:cloudflare-ai-gateway", - "text", - "golden" - ] + "tags": ["prefix:cloudflare-ai-gateway", "provider:cloudflare-ai-gateway", "text", "golden"] }, "interactions": [ { diff --git a/packages/llm/test/fixtures/recordings/cloudflare-workers-ai/cloudflare-workers-ai-gpt-oss-20b-tools-tool-call.json b/packages/llm/test/fixtures/recordings/cloudflare-workers-ai/cloudflare-workers-ai-gpt-oss-20b-tools-tool-call.json index 2c973bffe1..fa22f1ddb9 100644 --- a/packages/llm/test/fixtures/recordings/cloudflare-workers-ai/cloudflare-workers-ai-gpt-oss-20b-tools-tool-call.json +++ b/packages/llm/test/fixtures/recordings/cloudflare-workers-ai/cloudflare-workers-ai-gpt-oss-20b-tools-tool-call.json @@ -7,13 +7,7 @@ "route": "cloudflare-workers-ai", "transport": "http", "model": "@cf/openai/gpt-oss-20b", - "tags": [ - "prefix:cloudflare-workers-ai", - "provider:cloudflare-workers-ai", - "tool", - "tool-call", - "golden" - ] + "tags": ["prefix:cloudflare-workers-ai", "provider:cloudflare-workers-ai", "tool", "tool-call", "golden"] }, "interactions": [ { diff --git a/packages/llm/test/fixtures/recordings/cloudflare-workers-ai/cloudflare-workers-ai-llama-3-1-8b-text.json b/packages/llm/test/fixtures/recordings/cloudflare-workers-ai/cloudflare-workers-ai-llama-3-1-8b-text.json index 4ed314e15f..52cc25f86b 100644 --- a/packages/llm/test/fixtures/recordings/cloudflare-workers-ai/cloudflare-workers-ai-llama-3-1-8b-text.json +++ b/packages/llm/test/fixtures/recordings/cloudflare-workers-ai/cloudflare-workers-ai-llama-3-1-8b-text.json @@ -7,12 +7,7 @@ "route": "cloudflare-workers-ai", "transport": "http", "model": "@cf/meta/llama-3.1-8b-instruct", - "tags": [ - "prefix:cloudflare-workers-ai", - "provider:cloudflare-workers-ai", - "text", - "golden" - ] + "tags": ["prefix:cloudflare-workers-ai", "provider:cloudflare-workers-ai", "text", "golden"] }, "interactions": [ { diff --git a/packages/llm/test/generate-object.test.ts b/packages/llm/test/generate-object.test.ts index a9e6b5bf7a..66e39f7770 100644 --- a/packages/llm/test/generate-object.test.ts +++ b/packages/llm/test/generate-object.test.ts @@ -164,7 +164,10 @@ describe("LLM.generateObject", () => { const layer = dynamicResponse((input) => Effect.sync(() => input.respond( - sseEvents(toolCallChunk("call_1", "generate_object", '{"value":"not-a-number"}'), finishChunk("tool_calls")), + sseEvents( + toolCallChunk("call_1", "generate_object", '{"value":"not-a-number"}'), + finishChunk("tool_calls"), + ), { headers: { "content-type": "text/event-stream" } }, ), ), diff --git a/packages/llm/test/provider/cloudflare.test.ts b/packages/llm/test/provider/cloudflare.test.ts index 00b69fa18c..125e79bf9e 100644 --- a/packages/llm/test/provider/cloudflare.test.ts +++ b/packages/llm/test/provider/cloudflare.test.ts @@ -181,9 +181,7 @@ describe("Cloudflare", () => { dynamicResponse((input) => Effect.gen(function* () { const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie) - expect(web.url).toBe( - "https://api.cloudflare.com/client/v4/accounts/test-account/ai/v1/chat/completions", - ) + expect(web.url).toBe("https://api.cloudflare.com/client/v4/accounts/test-account/ai/v1/chat/completions") expect(web.headers.get("authorization")).toBe("Bearer test-token") expect(decodeJson(input.text)).toMatchObject({ model: "@cf/meta/llama-3.1-8b-instruct", diff --git a/packages/llm/test/schema.test.ts b/packages/llm/test/schema.test.ts index 1c9bbf1e09..46eb85b075 100644 --- a/packages/llm/test/schema.test.ts +++ b/packages/llm/test/schema.test.ts @@ -1,14 +1,6 @@ import { describe, expect, test } from "bun:test" import { Schema } from "effect" -import { - ContentPart, - LLMEvent, - LLMRequest, - ModelID, - ModelLimits, - ModelRef, - ProviderID, -} from "../src/schema" +import { ContentPart, LLMEvent, LLMRequest, ModelID, ModelLimits, ModelRef, ProviderID } from "../src/schema" const model = new ModelRef({ id: ModelID.make("fake-model"), From f4337dff3ca510a45bb53f498ec24f584d144292 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Fri, 8 May 2026 21:11:02 +0000 Subject: [PATCH 020/189] chore: update nix node_modules hashes --- nix/hashes.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/nix/hashes.json b/nix/hashes.json index 7a6e9c8c4b..f33a18b0e3 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-UxWxALOCC/n6JNFcu/IKjC/B9bySQmcr2riWO1Doc3s=", - "aarch64-linux": "sha256-QLM8fPkPOukwOLR26zgZHhWEbfaEhmIqIJSjoQYOvfg=", - "aarch64-darwin": "sha256-GjgCPpkTzqkeiLsp2P+Awtm0K0XKTJV7v9QJoGC02YU=", - "x86_64-darwin": "sha256-pm7xhKAUBgp+zDh1KzyOlKS2TYJpSdDPnZFqFHrflSA=" + "x86_64-linux": "sha256-BjFBDG1eeMmCoXAwXgEcFpsv1nk8wTrEGlFITxiLPrE=", + "aarch64-linux": "sha256-2ebJyhgQ2jBOoJIROUj/jcUbYMNiviADUI0bMDn3nZs=", + "aarch64-darwin": "sha256-ze+AB2zbWnQ9coZcBKhzUbmBaVy5j06nwgbpEu/9Ycw=", + "x86_64-darwin": "sha256-n3QI2Rtd9DxEdpXgiPCVeUwh13bR4F5EfyWJPKuVYnk=" } } From 36f8b7e47da389696b386bb39c19ebe47ae5cc9e Mon Sep 17 00:00:00 2001 From: vimtor Date: Fri, 8 May 2026 23:17:55 +0200 Subject: [PATCH 021/189] chore: reduce alerts false positives --- infra/monitoring.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/infra/monitoring.ts b/infra/monitoring.ts index aa0624c619..d06d536c78 100644 --- a/infra/monitoring.ts +++ b/infra/monitoring.ts @@ -38,7 +38,9 @@ const modelHttpErrorsQuery = (product: "go" | "zen") => { calculatedFields: [ { name: "is_failed_http_status", - expression: `IF(AND(GTE($status, "400"), NOT(EQUALS($status, "401"))), 1, 0)`, + expression: product === "go" + ? `IF(AND(GTE($status, "400"), NOT(EQUALS($status, "401")), NOT(EQUALS($status, "429"))), 1, 0)` + : `IF(AND(GTE($status, "400"), NOT(EQUALS($status, "401"))), 1, 0)`, }, ], calculations: [ @@ -66,7 +68,7 @@ const providerHttpErrorsQuery = (product: "go" | "zen") => { }, { name: "is_failed_provider_http_status", - expression: `IF(GTE($llm.error.code, "400"), 1, 0)`, + expression: `IF(GT($llm.error.code, "400"), 1, 0)`, }, ], calculations: [ From 35deef61759966543b0c1096e8cbece269f549c9 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Fri, 8 May 2026 21:19:51 +0000 Subject: [PATCH 022/189] chore: generate --- infra/monitoring.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/infra/monitoring.ts b/infra/monitoring.ts index d06d536c78..b4ece54be1 100644 --- a/infra/monitoring.ts +++ b/infra/monitoring.ts @@ -38,9 +38,10 @@ const modelHttpErrorsQuery = (product: "go" | "zen") => { calculatedFields: [ { name: "is_failed_http_status", - expression: product === "go" - ? `IF(AND(GTE($status, "400"), NOT(EQUALS($status, "401")), NOT(EQUALS($status, "429"))), 1, 0)` - : `IF(AND(GTE($status, "400"), NOT(EQUALS($status, "401"))), 1, 0)`, + expression: + product === "go" + ? `IF(AND(GTE($status, "400"), NOT(EQUALS($status, "401")), NOT(EQUALS($status, "429"))), 1, 0)` + : `IF(AND(GTE($status, "400"), NOT(EQUALS($status, "401"))), 1, 0)`, }, ], calculations: [ From a0fc27e4246c2f3a8dd1876eb0de0aa2fcdec0c3 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Sat, 9 May 2026 01:29:13 +0200 Subject: [PATCH 023/189] flatten to keybind compatible config (#26421) --- .opencode/plugins/tui-smoke.tsx | 208 ++++---- .opencode/tui.json | 20 +- bun.lock | 30 +- package.json | 6 +- packages/opencode/specs/tui-plugins.md | 13 +- packages/opencode/specs/v2/keymappings.md | 36 -- .../opencode/src/cli/cmd/run/runtime.boot.ts | 30 +- packages/opencode/src/cli/cmd/tui/app.tsx | 41 +- .../src/cli/cmd/tui/component/dialog-mcp.tsx | 2 +- .../cli/cmd/tui/component/dialog-model.tsx | 3 - .../cmd/tui/component/dialog-session-list.tsx | 6 +- .../cli/cmd/tui/component/dialog-stash.tsx | 4 +- .../cmd/tui/component/prompt/autocomplete.tsx | 11 +- .../cli/cmd/tui/component/prompt/index.tsx | 11 +- .../src/cli/cmd/tui/config/keybind.ts | 384 ++++++++++++++ .../cmd/tui/config/legacy-keymap-transform.ts | 187 ------- .../src/cli/cmd/tui/config/tui-schema.ts | 343 +------------ .../opencode/src/cli/cmd/tui/config/tui.ts | 42 +- .../cli/cmd/tui/feature-plugins/home/tips.tsx | 2 +- .../tui/feature-plugins/system/plugins.tsx | 7 +- .../tui/feature-plugins/system/which-key.tsx | 33 +- packages/opencode/src/cli/cmd/tui/keymap.tsx | 61 ++- .../src/cli/cmd/tui/routes/session/index.tsx | 41 +- .../cli/cmd/tui/routes/session/permission.tsx | 12 +- .../cli/cmd/tui/routes/session/question.tsx | 14 +- .../src/cli/cmd/tui/ui/dialog-select.tsx | 18 +- packages/opencode/src/config/keybinds.ts | 143 ------ .../test/cli/run/runtime.boot.test.ts | 49 +- .../test/cli/tui/plugin-loader.test.ts | 39 +- .../test/cli/tui/plugin-toggle.test.ts | 20 +- packages/opencode/test/config/tui.test.ts | 200 +++----- packages/opencode/test/fixture/tui-plugin.ts | 8 +- packages/opencode/test/fixture/tui-runtime.ts | 44 +- packages/plugin/package.json | 6 +- packages/plugin/src/tui.ts | 41 +- packages/web/src/content/docs/config.mdx | 16 +- packages/web/src/content/docs/keybinds.mdx | 467 ++++++++---------- packages/web/src/content/docs/tui.mdx | 16 +- 38 files changed, 1096 insertions(+), 1518 deletions(-) delete mode 100644 packages/opencode/specs/v2/keymappings.md create mode 100644 packages/opencode/src/cli/cmd/tui/config/keybind.ts delete mode 100644 packages/opencode/src/cli/cmd/tui/config/legacy-keymap-transform.ts delete mode 100644 packages/opencode/src/config/keybinds.ts diff --git a/.opencode/plugins/tui-smoke.tsx b/.opencode/plugins/tui-smoke.tsx index fc890537ec..2d3095a57c 100644 --- a/.opencode/plugins/tui-smoke.tsx +++ b/.opencode/plugins/tui-smoke.tsx @@ -2,87 +2,62 @@ import { useTerminalDimensions, type JSX } from "@opentui/solid" import { useBindings, useKeymapSelector } from "@opentui/keymap/solid" import { RGBA, VignetteEffect, type KeyEvent, type Renderable } from "@opentui/core" -import { resolveBindingSections, type BindingSectionsConfig, type BindingValue } from "@opentui/keymap/extras" -import type { Binding } from "@opentui/keymap" +import { createBindingLookup, type BindingConfig } from "@opentui/keymap/extras" import type { TuiPlugin, TuiPluginApi, TuiPluginMeta, TuiPluginModule, TuiSlotPlugin } from "@opencode-ai/plugin/tui" const tabs = ["overview", "counter", "help"] const command = { - modal: "plugin.smoke.modal", - screen: "plugin.smoke.screen", - alert: "plugin.smoke.alert", - confirm: "plugin.smoke.confirm", - prompt: "plugin.smoke.prompt", - select: "plugin.smoke.select", - host: "plugin.smoke.host", - home: "plugin.smoke.home", - toast: "plugin.smoke.toast", - dialog_close: "plugin.smoke.dialog.close", - local_push: "plugin.smoke.local.push", - local_pop: "plugin.smoke.local.pop", - screen_home: "plugin.smoke.screen.home", - screen_left: "plugin.smoke.screen.left", - screen_right: "plugin.smoke.screen.right", - screen_up: "plugin.smoke.screen.up", - screen_down: "plugin.smoke.screen.down", - screen_modal: "plugin.smoke.screen.modal", - screen_local: "plugin.smoke.screen.local", - screen_host: "plugin.smoke.screen.host", - screen_alert: "plugin.smoke.screen.alert", - screen_confirm: "plugin.smoke.screen.confirm", - screen_prompt: "plugin.smoke.screen.prompt", - screen_select: "plugin.smoke.screen.select", - modal_accept: "plugin.smoke.modal.accept", - modal_close: "plugin.smoke.modal.close", -} as const - -const sectionNames = ["global", "dialog", "local", "screen", "modal"] as const -type SectionName = (typeof sectionNames)[number] -type SectionConfig = Record> -type ResolvedSections = Record[]> -type SmokeKeymap = { - sections?: Partial> + modal: "smoke_modal", + screen: "smoke_screen", + alert: "smoke_alert", + confirm: "smoke_confirm", + prompt: "smoke_prompt", + select: "smoke_select", + host: "smoke_host", + home: "smoke_home", + toast: "smoke_toast", + dialog_close: "smoke_dialog_close", + local_push: "smoke_local_push", + local_pop: "smoke_local_pop", + screen_home: "smoke_screen_home", + screen_left: "smoke_screen_left", + screen_right: "smoke_screen_right", + screen_up: "smoke_screen_up", + screen_down: "smoke_screen_down", + screen_modal: "smoke_screen_modal", + screen_local: "smoke_screen_local", + screen_host: "smoke_screen_host", + screen_alert: "smoke_screen_alert", + screen_confirm: "smoke_screen_confirm", + screen_prompt: "smoke_screen_prompt", + screen_select: "smoke_screen_select", + modal_accept: "smoke_modal_accept", + modal_close: "smoke_modal_close", } -type SmokeOptions = { - enabled?: boolean - label?: unknown - route?: unknown - vignette?: unknown - keymap?: SmokeKeymap -} +type SmokeBindings = BindingConfig const defaultKeymap = { - global: { - [command.modal]: "ctrl+shift+m", - [command.screen]: "ctrl+shift+o", - }, - dialog: { - [command.dialog_close]: "escape", - }, - local: { - [command.local_push]: "enter,return", - [command.local_pop]: "escape,q,backspace", - }, - screen: { - [command.screen_home]: "escape,ctrl+h", - [command.screen_left]: "left,h", - [command.screen_right]: "right,l", - [command.screen_up]: "up,k", - [command.screen_down]: "down,j", - [command.screen_modal]: "ctrl+shift+m", - [command.screen_local]: "x", - [command.screen_host]: "z", - [command.screen_alert]: "a", - [command.screen_confirm]: "c", - [command.screen_prompt]: "p", - [command.screen_select]: "s", - }, - modal: { - [command.modal_accept]: "enter,return", - [command.modal_close]: "escape", - }, -} satisfies Record + [command.modal]: "ctrl+shift+m", + [command.screen]: "ctrl+shift+o", + [command.dialog_close]: "escape", + [command.local_push]: "enter,return", + [command.local_pop]: "escape,q,backspace", + [command.screen_home]: "escape,ctrl+h", + [command.screen_left]: "left,h", + [command.screen_right]: "right,l", + [command.screen_up]: "up,k", + [command.screen_down]: "down,j", + [command.screen_modal]: "ctrl+shift+m", + [command.screen_local]: "x", + [command.screen_host]: "z", + [command.screen_alert]: "a", + [command.screen_confirm]: "c", + [command.screen_prompt]: "p", + [command.screen_select]: "s", + [command.modal_accept]: "enter,return", + [command.modal_close]: "escape", +} const pick = (value: unknown, fallback: string) => { if (typeof value !== "string") return fallback @@ -95,11 +70,14 @@ const num = (value: unknown, fallback: number) => { return value } +const record = (value: unknown): value is Record => + !!value && typeof value === "object" && !Array.isArray(value) + type Cfg = { label: string route: string vignette: number - keymap: SmokeKeymap | undefined + keybinds: SmokeBindings | undefined } type Route = { @@ -116,12 +94,12 @@ type State = { local: number } -const cfg = (options: SmokeOptions | undefined) => { +const cfg = (options: Record | undefined) => { return { label: pick(options?.label, "smoke"), route: pick(options?.route, "workspace-smoke"), vignette: Math.max(0, num(options?.vignette, 0.35)), - keymap: options?.keymap, + keybinds: record(options?.keybinds) ? (options.keybinds as SmokeBindings) : undefined, } } @@ -132,21 +110,8 @@ const names = (input: Cfg) => { } } -function createKeys(input: SmokeKeymap | undefined): { sections: ResolvedSections } { - const sections = resolveBindingSections( - { - global: { ...defaultKeymap.global, ...input?.sections?.global }, - dialog: { ...defaultKeymap.dialog, ...input?.sections?.dialog }, - local: { ...defaultKeymap.local, ...input?.sections?.local }, - screen: { ...defaultKeymap.screen, ...input?.sections?.screen }, - modal: { ...defaultKeymap.modal, ...input?.sections?.modal }, - } satisfies BindingSectionsConfig, - { sections: sectionNames }, - ).sections - - return { - sections, - } +function createKeys(input: SmokeBindings | undefined) { + return createBindingLookup({ ...defaultKeymap, ...input }) } type Keys = ReturnType @@ -376,7 +341,7 @@ const Screen = (props: { }, }, ], - bindings: props.keys.sections.dialog, + bindings: props.keys.gather("smoke.dialog", [command.dialog_close]), })) useBindings(() => ({ @@ -395,7 +360,7 @@ const Screen = (props: { }, }, ], - bindings: props.keys.sections.local, + bindings: props.keys.gather("smoke.local", [command.local_push, command.local_pop]), })) useBindings(() => ({ @@ -478,7 +443,20 @@ const Screen = (props: { }, }, ], - bindings: props.keys.sections.screen, + bindings: props.keys.gather("smoke.screen", [ + command.screen_home, + command.screen_left, + command.screen_right, + command.screen_up, + command.screen_down, + command.screen_modal, + command.screen_local, + command.screen_host, + command.screen_alert, + command.screen_confirm, + command.screen_prompt, + command.screen_select, + ]), })) const shortcuts = useKeymapSelector((keymap) => { const bindings = keymap.getCommandBindings({ @@ -687,7 +665,7 @@ const Modal = (props: { }, }, ], - bindings: props.keys.sections.modal, + bindings: props.keys.gather("smoke.modal", [command.modal_accept, command.modal_close]), })) const shortcuts = useKeymapSelector((keymap) => { const bindings = keymap.getCommandBindings({ @@ -766,25 +744,8 @@ const home = (api: TuiPluginApi, input: Cfg) => ({ }, home_prompt(ctx, value) { const skin = look(ctx.theme.current) - type Prompt = (props: { - workspaceID?: string - visible?: boolean - disabled?: boolean - onSubmit?: () => void - hint?: JSX.Element - right?: JSX.Element - showPlaceholder?: boolean - placeholders?: { - normal?: string[] - shell?: string[] - } - }) => JSX.Element - type Slot = ( - props: { name: string; mode?: unknown; children?: JSX.Element } & Record, - ) => JSX.Element | null - const ui = api.ui as TuiPluginApi["ui"] & { Prompt: Prompt; Slot: Slot } - const Prompt = ui.Prompt - const Slot = ui.Slot + const Prompt = api.ui.Prompt + const Slot = api.ui.Slot const normal = [ `[SMOKE] route check for ${input.label}`, "[SMOKE] confirm home_prompt slot override", @@ -1003,20 +964,29 @@ const reg = (api: TuiPluginApi, input: Cfg, keys: Keys) => { }, }, ], - bindings: keys.sections.global, + bindings: keys.gather("smoke.global", [ + command.modal, + command.screen, + command.alert, + command.confirm, + command.prompt, + command.select, + command.host, + command.home, + command.toast, + ]), }) } const tui: TuiPlugin = async (api, options, meta) => { - const input = options as SmokeOptions | undefined - if (input?.enabled === false) return + if (options?.enabled === false) return await api.theme.install("./smoke-theme.json") api.theme.set("smoke-theme") - const value = cfg(input) + const value = cfg(options) const route = names(value) - const keys = createKeys(value.keymap) + const keys = createKeys(value.keybinds) const fx = new VignetteEffect(value.vignette) const post = fx.apply.bind(fx) api.renderer.addPostProcessFn(post) diff --git a/.opencode/tui.json b/.opencode/tui.json index e795209d9c..b92e58dac2 100644 --- a/.opencode/tui.json +++ b/.opencode/tui.json @@ -6,20 +6,12 @@ { "enabled": false, "label": "workspace", - "keymap": { - "sections": { - "global": { - "plugin.smoke.modal": "ctrl+alt+m", - "plugin.smoke.screen": "ctrl+alt+o" - }, - "screen": { - "plugin.smoke.screen.home": "escape,ctrl+shift+h", - "plugin.smoke.screen.modal": "ctrl+alt+m" - }, - "dialog": { - "plugin.smoke.dialog.close": "escape,q" - } - } + "keybinds": { + "smoke_modal": "ctrl+alt+m", + "smoke_screen": "ctrl+alt+o", + "smoke_screen_home": "escape,ctrl+shift+h", + "smoke_screen_modal": "ctrl+alt+m", + "smoke_dialog_close": "escape,q" } } ] diff --git a/bun.lock b/bun.lock index 90be71910a..fa3924bf39 100644 --- a/bun.lock +++ b/bun.lock @@ -519,9 +519,9 @@ "typescript": "catalog:", }, "peerDependencies": { - "@opentui/core": ">=0.2.5", - "@opentui/keymap": ">=0.2.5", - "@opentui/solid": ">=0.2.5", + "@opentui/core": ">=0.2.6", + "@opentui/keymap": ">=0.2.6", + "@opentui/solid": ">=0.2.6", }, "optionalPeers": [ "@opentui/core", @@ -700,9 +700,9 @@ "@npmcli/arborist": "9.4.0", "@octokit/rest": "22.0.0", "@openauthjs/openauth": "0.0.0-20250322224806", - "@opentui/core": "0.2.5", - "@opentui/keymap": "0.2.5", - "@opentui/solid": "0.2.5", + "@opentui/core": "0.2.6", + "@opentui/keymap": "0.2.6", + "@opentui/solid": "0.2.6", "@pierre/diffs": "1.1.0-beta.18", "@playwright/test": "1.59.1", "@sentry/solid": "10.36.0", @@ -1631,23 +1631,23 @@ "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.40.0", "", {}, "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw=="], - "@opentui/core": ["@opentui/core@0.2.5", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.2.5", "@opentui/core-darwin-x64": "0.2.5", "@opentui/core-linux-arm64": "0.2.5", "@opentui/core-linux-x64": "0.2.5", "@opentui/core-win32-arm64": "0.2.5", "@opentui/core-win32-x64": "0.2.5" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-A5DNOW39S60LtOcBdWYx7fuIGsPcClzbdKz9WuLp+wgy0Bt/jPw5XX6dk3k4dCX4jmhA1nX7x7680+GXLHPL6Q=="], + "@opentui/core": ["@opentui/core@0.2.6", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.2.6", "@opentui/core-darwin-x64": "0.2.6", "@opentui/core-linux-arm64": "0.2.6", "@opentui/core-linux-x64": "0.2.6", "@opentui/core-win32-arm64": "0.2.6", "@opentui/core-win32-x64": "0.2.6" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-dBpMaWVM7wtW2/2TlGPrkPjg6gOL3MVU/5XXk+U1LDJB8L4q4NeYWVdzfAVNcEvgmuuCy/cVqdY2D4ei+e7MMg=="], - "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Jdl8TN7oxV8NTaKZsUAt0B/A4hIYiyUKwXNSe4w1OchNMlgjwF1fx/7RhgHXSvWh1Fcqi1IH5FfhsmO89Aed1A=="], + "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-hR5nsxNj+059utzenTCF0kealUlibON6fLuebFUCGM/5kJnqa+shIh0XbUDFm0+F47vqVUgZufBdUuieQZIbvQ=="], - "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-78sKg0ZvwFHzZZGJCeaSNIVi2dadDxQymHAmrK698zEgnQr4eLVVB+MxNpxJx55/z9Y+YqbfSZaobC6w6Q3y5A=="], + "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-pJ/bH4WC/mbBaakM1YdH6TVo67jhy0KPd61bCz97w0I/PJGr8fmNKvhmMt/AwyFgOQi3FYZiEKLMpGdvUcSsrQ=="], - "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-BtqbOjP64hKQaVd0ApHunt0MjkEEKTvxpaBwk7OhwVCoYakQBDZTZXUQ9zuPXvaHc9IF286z1PnJGLu0t11BAw=="], + "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-9Pnd3kOxig8ii+/IqYheOPEgferylsQA0L6tKBnHQ9jRlCJOcu0Rv65Jepueh212vevdV9DzPURJnhejG06J6g=="], - "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.5", "", { "os": "linux", "cpu": "x64" }, "sha512-c3sEXtmOd1E5R4wfWh/MejplxgApYKqzyJ0AVMTU8pU1MHRAMwD8UFDMSVQhl7rYMTuBYPWok3IoCK2u8a2A4A=="], + "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.6", "", { "os": "linux", "cpu": "x64" }, "sha512-458Mx9tBzEPzfft8cSt5ZaIpEepoxBXBOL6AUVmDTKWaZ3uouraPcEKraGAyvOTDQp2XDI3R8c/2GdaR77FaUQ=="], - "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WlgpYkgmuvMPc2mYGJSwN7c+VGAxiZvMKwZEbS+w9PMj7sJhvY+zFrOJNFpvjbAFw8vS3Kz39km4Nj7GF8JH6w=="], + "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-BDUrdrT1RCcVnQoHJmUut4y811jDBAEtc6GJFB4Gs265Be8SrTjVCus6p2fSQ7j9sZQ1OcjO+5+4NkheSZICDQ=="], - "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.5", "", { "os": "win32", "cpu": "x64" }, "sha512-4X7BHJ7Wztzj7p0E+SsN0d4goUVU7Dy2VnhnD4n65ODgVbW59iqasAvbnPLbX3ghjgKiwQ+2SD+ImCIHE6uCAA=="], + "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.6", "", { "os": "win32", "cpu": "x64" }, "sha512-SUYAzRJ9TSoD2Qt8kn6FJz6dbTrFEPVig5mScB4zFGgGQO/Bbod2/Q31vLS/IQrX+FDb67WaErD+kuMCnMPPLA=="], - "@opentui/keymap": ["@opentui/keymap@0.2.5", "", { "dependencies": { "@opentui/core": "0.2.5" }, "peerDependencies": { "@opentui/react": "0.2.5", "@opentui/solid": "0.2.5", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-/B6Gy9LLRRKhvyDV1rFX0p7BUN8NQOcXwTV8E0xb7ym1yREvVmij+hCRkXXddMme2HW9NmV0+RRHo4kJzJxkNQ=="], + "@opentui/keymap": ["@opentui/keymap@0.2.6", "", { "dependencies": { "@opentui/core": "0.2.6" }, "peerDependencies": { "@opentui/react": "0.2.6", "@opentui/solid": "0.2.6", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-+6OYuedrFCKVo4ryGFNwws++2VOmPcXU3PwpY0mP47gYQY2nvQ+etWIs2Y7r5eMIqUfxVCldkKsrzcEcA4tb/A=="], - "@opentui/solid": ["@opentui/solid@0.2.5", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.2.5", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-M8MxDYJzjtF8TvxB6Q7656GOSS+QIg89jD0jf/asfF4qeip5TQhNZ3ba+R1v2fVuIkQCyRJzTtOtMZiglzGKPQ=="], + "@opentui/solid": ["@opentui/solid@0.2.6", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.2.6", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-2y225WlOGi/fCaajkxBmLyVW8Cr+OmhowHdvrYcz5w2kBD15sKbJLIYu1G9DxceirT1uIyasGy2TGzRRcVkTDg=="], "@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="], diff --git a/package.json b/package.json index f2258ab698..27a3597553 100644 --- a/package.json +++ b/package.json @@ -35,9 +35,9 @@ "@types/cross-spawn": "6.0.6", "@octokit/rest": "22.0.0", "@hono/zod-validator": "0.4.2", - "@opentui/core": "0.2.5", - "@opentui/keymap": "0.2.5", - "@opentui/solid": "0.2.5", + "@opentui/core": "0.2.6", + "@opentui/keymap": "0.2.6", + "@opentui/solid": "0.2.6", "ulid": "3.0.1", "@kobalte/core": "0.13.11", "@types/luxon": "3.7.1", diff --git a/packages/opencode/specs/tui-plugins.md b/packages/opencode/specs/tui-plugins.md index 73927dbf83..c1a9b271c1 100644 --- a/packages/opencode/specs/tui-plugins.md +++ b/packages/opencode/specs/tui-plugins.md @@ -20,6 +20,12 @@ Example: { "$schema": "https://opencode.ai/tui.json", "theme": "smoke-theme", + "leader_timeout": 2000, + "keybinds": { + "leader": "ctrl+x", + "command_list": "ctrl+p", + "session_new": "n" + }, "plugin": ["@acme/opencode-plugin@1.2.3", ["./plugins/demo.tsx", { "label": "demo" }]], "plugin_enabled": { "acme.demo": false @@ -39,6 +45,9 @@ Example: - Internal plugins can declare `enabled: false` to be registered but inactive by default; `plugin_enabled` and runtime KV can still enable them by id. - `plugin_enabled` is merged across config layers. - Runtime enable/disable state is also stored in KV under `plugin_enabled`; that KV state overrides config on startup. +- `leader_timeout` is a top-level TUI setting. +- `keybinds` is a flat object keyed by command id; values are key binding values (`false`, `"none"`, a key string/object, a binding object, or an array of key strings/objects/binding objects). +- `keybinds.leader` sets the key used by `` shortcuts. ## Author package shape @@ -228,14 +237,14 @@ Top-level API groups exposed to `tui(api, options, meta)`: - To surface a command in the host command palette, set `namespace: "palette"` and provide metadata such as `title`, `category`, `desc`, `suggested`, `hidden`, `enabled`, `slashName`, and `slashAliases` on the command. - Use `api.keymap.dispatchCommand(name)` for user-style execution semantics and `api.keymap.runCommand(name)` only for forced programmatic execution. - Disposers returned by `api.keymap` registrations and `acquireResource(...)` are automatically cleaned up when the plugin deactivates. You do not need to add those disposers to `api.lifecycle.onDispose(...)` yourself. -- Built-in which-key shortcuts are resolved from `keymap.sections.which_key`, not plugin options. +- Built-in which-key shortcuts are resolved from flat `keybinds` command ids such as `which_key_toggle`, not plugin options. ### Keys - `api.keys` exposes host-formatted shortcut display helpers for plugin UI. - `formatSequence(parts)` formats parsed key sequence parts using the host's display policy. - `formatBindings(bindings)` formats binding lists and returns `undefined` when there is nothing to show. -- For generic config-to-bindings helpers, import `resolveBindingSections` from `@opencode-ai/plugin/tui`. +- For generic config-to-bindings helpers, import `createBindingLookup` from `@opencode-ai/plugin/tui`. ### Routes diff --git a/packages/opencode/specs/v2/keymappings.md b/packages/opencode/specs/v2/keymappings.md deleted file mode 100644 index 30a298eee4..0000000000 --- a/packages/opencode/specs/v2/keymappings.md +++ /dev/null @@ -1,36 +0,0 @@ -# Keybindings vs. Keymappings - -Make it `keymappings`, closer to neovim. Can be layered like `abc`. Commands don't define their binding, but have an id that a key can be mapped to like - -```ts -{ key: "ctrl+w", cmd: string | function, description } -``` - -_Why_ -Currently its keybindings that have an `id` like `message_redo` and then a command can use that or define it's own binding. While some keybindings are just used with `.match` in arbitrary key handlers and there is no info what the key is used for, except the binding id maybe. It also is unknown in which context/scope what binding is active, so a plugin like `which-key` is nearly impossible to get right. - -## OpenTUI Keymap Migration - -The v2 TUI uses `@opentui/keymap` as the key/cmd engine. The remaining legacy compatibility is config-only and exists to migrate users from `keybinds` to `keymap`: - -- `packages/opencode/src/config/keybinds.ts`: old `keybinds` schema, defaults, and legacy key names. -- `packages/opencode/src/cli/cmd/tui/config/legacy-keymap-transform.ts`: transforms parsed legacy `keybinds` into OpenTUI `keymap` sections. -- `packages/opencode/src/cli/cmd/tui/config/tui-migrate.ts`: migrates legacy TUI keys from `opencode.json` into `tui.json`, including `theme`, `keybinds`, and nested `tui`. -- `packages/opencode/src/cli/cmd/tui/config/tui-schema.ts`: still accepts deprecated `keybinds` via `KeybindOverride` and marks it as deprecated. This file also contains the new `keymap` config schema. -- `packages/opencode/src/cli/cmd/tui/config/tui.ts`: parses legacy `keybinds`, applies the Windows `terminal_suspend`/`input_undo` adjustment, and uses `LegacyKeymapTransform.create(...)` as the fallback when no `keymap` section is configured. -- `packages/plugin/src/tui.ts`: plugin-facing `tuiConfig` still includes `keybinds` through `PluginConfig`; this should be removed when the public plugin API no longer exposes legacy config. - -The transform must stay while users are migrating. It lets users upgrade without first rewriting their existing `keybinds` config. If `keymap` is configured, `keybinds` are ignored for keymap resolution. If `keymap` is missing, `legacy-keymap-transform.ts` turns legacy `keybinds` into the resolved `keymap` consumed by OpenTUI. - -## Removing Legacy Later - -When switching fully to the new config style, remove legacy support with these exact changes: - -- Delete `packages/opencode/src/config/keybinds.ts`. -- Delete `packages/opencode/src/cli/cmd/tui/config/legacy-keymap-transform.ts`. -- Delete `packages/opencode/src/cli/cmd/tui/config/tui-migrate.ts`. -- In `packages/opencode/src/cli/cmd/tui/config/tui-schema.ts`, remove the `ConfigKeybinds` import, remove `KeybindOverride`, and delete the deprecated `keybinds` field from `TuiInfo`. -- In `packages/opencode/src/cli/cmd/tui/config/tui.ts`, remove `migrateTuiConfig(...)`, remove `ConfigKeybinds`, remove the Windows legacy keybind adjustment, remove `LegacyKeymapTransform.create(...)`, and require/default `keymap` through the new config path instead. -- In `packages/opencode/src/cli/cmd/tui/config/tui.ts`, remove `keybinds` from `Resolved`; resolved TUI config should expose `keymap` only. -- In `packages/plugin/src/tui.ts`, remove `keybinds` from plugin-facing `TuiConfigView`. -- Remove or rewrite tests that write or assert `keybinds`, especially in `packages/opencode/test/config/tui.test.ts`, `packages/opencode/test/fixture/tui-runtime.ts`, and TUI plugin loader tests. diff --git a/packages/opencode/src/cli/cmd/run/runtime.boot.ts b/packages/opencode/src/cli/cmd/run/runtime.boot.ts index 9d4aa3658c..3ff9801c6a 100644 --- a/packages/opencode/src/cli/cmd/run/runtime.boot.ts +++ b/packages/opencode/src/cli/cmd/run/runtime.boot.ts @@ -6,7 +6,9 @@ // history ring. All are async because they read config or hit the SDK, but // none block each other. import { Context, Effect, Layer } from "effect" +import { stringifyKeyStroke } from "@opentui/keymap" import { TuiConfig } from "@/cli/cmd/tui/config/tui" +import { TuiKeybind } from "@/cli/cmd/tui/config/keybind" import { makeRuntime } from "@/effect/run-service" import { reusePendingTask } from "./runtime.shared" import { resolveSession, sessionHistory } from "./session.shared" @@ -14,7 +16,7 @@ import type { FooterKeybinds, RunDiffStyle, RunInput, RunPrompt, RunProvider } f import { pickVariant } from "./variant.shared" const DEFAULT_KEYBINDS: FooterKeybinds = { - leader: "ctrl+x", + leader: TuiKeybind.LeaderDefault, leaderTimeout: 2000, commandList: [{ key: "ctrl+p" }], variantCycle: [{ key: "ctrl+t" }], @@ -78,22 +80,28 @@ function emptySessionInfo(): SessionInfo { } } +function leaderKey(config: Config) { + const key = config.keybinds.get("leader")?.[0]?.key + if (!key) return TuiKeybind.LeaderDefault + return typeof key === "string" ? key : stringifyKeyStroke(key) +} + function footerKeybinds(config: Config | undefined): FooterKeybinds { if (!config) { return DEFAULT_KEYBINDS } return { - leader: config.keymap.leader, - leaderTimeout: config.keymap.leader_timeout, - commandList: config.keymap.get("global", "command.palette.show") ?? [], - variantCycle: config.keymap.get("global", "variant.cycle") ?? [], - interrupt: config.keymap.get("prompt", "session.interrupt") ?? [], - historyPrevious: config.keymap.get("prompt", "prompt.history.previous") ?? [], - historyNext: config.keymap.get("prompt", "prompt.history.next") ?? [], - inputClear: config.keymap.get("prompt", "prompt.clear") ?? [], - inputSubmit: config.keymap.get("input", "input.submit") ?? [], - inputNewline: config.keymap.get("input", "input.newline") ?? [], + leader: leaderKey(config), + leaderTimeout: config.leader_timeout, + commandList: config.keybinds.get("command.palette.show"), + variantCycle: config.keybinds.get("variant.cycle"), + interrupt: config.keybinds.get("session.interrupt"), + historyPrevious: config.keybinds.get("prompt.history.previous"), + historyNext: config.keybinds.get("prompt.history.next"), + inputClear: config.keybinds.get("prompt.clear"), + inputSubmit: config.keybinds.get("input.submit"), + inputNewline: config.keybinds.get("input.newline"), } } diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 275a494578..c7a2cd560f 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -70,6 +70,42 @@ import { OpencodeKeymapProvider, registerOpencodeKeymap, useBindings, useOpencod import type { EventSource } from "./context/sdk" import { DialogVariant } from "./component/dialog-variant" +const appBindingCommands = [ + "command.palette.show", + "session.list", + "session.new", + "model.list", + "model.cycle_recent", + "model.cycle_recent_reverse", + "model.cycle_favorite", + "model.cycle_favorite_reverse", + "agent.list", + "mcp.list", + "agent.cycle", + "agent.cycle.reverse", + "variant.cycle", + "variant.list", + "provider.connect", + "console.org.switch", + "opencode.status", + "theme.switch", + "theme.switch_mode", + "theme.mode.lock", + "help.show", + "docs.open", + "app.exit", + "app.debug", + "app.console", + "app.heap_snapshot", + "terminal.suspend", + "terminal.title.toggle", + "app.toggle.animations", + "app.toggle.file_context", + "app.toggle.diffwrap", + "app.toggle.paste_summary", + "app.toggle.session_directory_filter", +] as const + function rendererConfig(_config: TuiConfig.Resolved): CliRendererConfig { const mouseEnabled = !Flag.OPENCODE_DISABLE_MOUSE && (_config.mouse ?? true) @@ -215,9 +251,6 @@ export function tui(input: { function App(props: { onSnapshot?: () => Promise }) { const tuiConfig = useTuiConfig() - const { - keymap: { sections }, - } = tuiConfig const route = useRoute() const dimensions = useTerminalDimensions() const renderer = useRenderer() @@ -749,7 +782,7 @@ function App(props: { onSnapshot?: () => Promise }) { useBindings(() => ({ enabled: command.matcher, - bindings: sections.global, + bindings: tuiConfig.keybinds.gather("app", appBindingCommands), })) event.on(TuiEvent.CommandExecute.type, (evt) => { diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-mcp.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-mcp.tsx index faa26dc3a6..c577d49329 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-mcp.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-mcp.tsx @@ -46,7 +46,7 @@ export function DialogMcp() { const actions = createMemo(() => [ { - command: "dialog.action.toggle", + command: "dialog.mcp.toggle", title: "toggle", onTrigger: async (option: DialogSelectOption) => { // Prevent toggling while an operation is already in progress diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx index 068c6a1e03..09c2d64b00 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx @@ -8,13 +8,11 @@ import { createDialogProviderOptions, DialogProvider } from "./dialog-provider" import { DialogVariant } from "./dialog-variant" import * as fuzzysort from "fuzzysort" import { useConnected } from "./use-connected" -import { useTuiConfig } from "../context/tui-config" export function DialogModel(props: { providerID?: string }) { const local = useLocal() const sync = useSync() const dialog = useDialog() - const tuiConfig = useTuiConfig() const [query, setQuery] = createSignal("") const connected = useConnected() @@ -167,7 +165,6 @@ export function DialogModel(props: { providerID?: string }) { }, }, ]} - bindings={tuiConfig.keymap.sections.model} onFilter={setQuery} flat={true} skipFilter={true} diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx index 195221b88f..31c8eb555d 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx @@ -28,7 +28,7 @@ export function DialogSessionList() { const toast = useToast() const [toDelete, setToDelete] = createSignal() const [search, setSearch] = createDebouncedSignal("", 150) - const deleteHint = useCommandShortcut("dialog.action.delete") + const deleteHint = useCommandShortcut("session.delete") const [searchResults, { refetch }] = createResource( () => ({ query: search(), filter: sync.session.query() }), @@ -190,7 +190,7 @@ export function DialogSessionList() { }} actions={[ { - command: "dialog.action.delete", + command: "session.delete", title: "delete", onTrigger: async (option) => { if (toDelete() === option.value) { @@ -238,7 +238,7 @@ export function DialogSessionList() { }, }, { - command: "dialog.action.rename", + command: "session.rename", title: "rename", onTrigger: async (option) => { dialog.replace(() => ) diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-stash.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-stash.tsx index 62843c2527..2dfe2dee9c 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-stash.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-stash.tsx @@ -32,7 +32,7 @@ export function DialogStash(props: { onSelect: (entry: StashEntry) => void }) { const { theme } = useTheme() const [toDelete, setToDelete] = createSignal() - const deleteHint = useCommandShortcut("dialog.action.delete") + const deleteHint = useCommandShortcut("stash.delete") const options = createMemo(() => { const entries = stash.list() @@ -70,7 +70,7 @@ export function DialogStash(props: { onSelect: (entry: StashEntry) => void }) { }} actions={[ { - command: "dialog.action.delete", + command: "stash.delete", title: "delete", onTrigger: (option) => { if (toDelete() === option.value) { diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx index 57c890f5a2..7f390f0eb6 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx @@ -87,9 +87,6 @@ export function Autocomplete(props: { const dimensions = useTerminalDimensions() const frecency = useFrecency() const tuiConfig = useTuiConfig() - const { - keymap: { sections }, - } = tuiConfig const [store, setStore] = createStore({ index: 0, selected: 0, @@ -575,7 +572,13 @@ export function Autocomplete(props: { }, }, ], - bindings: sections.autocomplete, + bindings: tuiConfig.keybinds.gather("prompt.autocomplete", [ + "prompt.autocomplete.prev", + "prompt.autocomplete.next", + "prompt.autocomplete.hide", + "prompt.autocomplete.select", + "prompt.autocomplete.complete", + ]), })) function show(mode: "@" | "/") { diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index c6bcb89924..d3bfdfbac3 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -147,7 +147,6 @@ export function Prompt(props: PromptProps) { const project = useProject() const sync = useSync() const tuiConfig = useTuiConfig() - const keymapConfig = tuiConfig.keymap const dialog = useDialog() const toast = useToast() const status = createMemo(() => sync.data.session_status?.[props.sessionID ?? ""] ?? { type: "idle" }) @@ -630,7 +629,7 @@ export function Prompt(props: PromptProps) { useBindings(() => ({ enabled: command.matcher, - bindings: keymapConfig.pick("prompt", [ + bindings: tuiConfig.keybinds.gather("prompt.palette", [ "prompt.submit", "prompt.editor", "prompt.editor_context.clear", @@ -865,7 +864,7 @@ export function Prompt(props: PromptProps) { return { target: inputTarget, enabled: inputTarget() !== undefined && !props.disabled, - bindings: keymapConfig.pick("prompt", ["prompt.paste"]), + bindings: tuiConfig.keybinds.get("prompt.paste"), } }) @@ -873,7 +872,7 @@ export function Prompt(props: PromptProps) { return { target: inputTarget, enabled: inputTarget() !== undefined && !props.disabled && store.prompt.input !== "", - bindings: keymapConfig.pick("prompt", ["prompt.clear"]), + bindings: tuiConfig.keybinds.get("prompt.clear"), } }) @@ -957,7 +956,7 @@ export function Prompt(props: PromptProps) { }, }, ], - bindings: keymapConfig.pick("prompt", ["prompt.history.previous"]), + bindings: tuiConfig.keybinds.get("prompt.history.previous"), } }) @@ -995,7 +994,7 @@ export function Prompt(props: PromptProps) { }, }, ], - bindings: keymapConfig.pick("prompt", ["prompt.history.next"]), + bindings: tuiConfig.keybinds.get("prompt.history.next"), } }) diff --git a/packages/opencode/src/cli/cmd/tui/config/keybind.ts b/packages/opencode/src/cli/cmd/tui/config/keybind.ts new file mode 100644 index 0000000000..46a48e18e9 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/config/keybind.ts @@ -0,0 +1,384 @@ +export * as TuiKeybind from "./keybind" + +import type { KeyEvent, Renderable } from "@opentui/core" +import type { Binding } from "@opentui/keymap" +import type { BindingCommandMap, BindingConfig, BindingDefaults, BindingValue } from "@opentui/keymap/extras" +import z from "zod" + +const KeyStroke = z + .object({ + name: z.string(), + ctrl: z.boolean().optional(), + shift: z.boolean().optional(), + meta: z.boolean().optional(), + super: z.boolean().optional(), + hyper: z.boolean().optional(), + }) + .strict() + +const BindingObject = z + .object({ + key: z.union([z.string(), KeyStroke]), + event: z.enum(["press", "release"]).optional(), + preventDefault: z.boolean().optional(), + fallthrough: z.boolean().optional(), + }) + .passthrough() + +const BindingItem = z.union([z.string(), KeyStroke, BindingObject]) +export const BindingValueSchema = z.union([z.literal(false), z.literal("none"), BindingItem, z.array(BindingItem)]) + +type Definition = { + default: z.input + description: string +} + +const inputUndoDefault = process.platform === "win32" ? "ctrl+z,ctrl+-,super+z" : "ctrl+-,super+z" +export const LeaderDefault = "ctrl+x" + +const keybind = (value: Definition["default"], description: string): Definition => ({ default: value, description }) + +const Definitions = { + leader: keybind(LeaderDefault, "Leader key for keybind combinations"), + + app_exit: keybind("ctrl+c,ctrl+d,q", "Exit the application"), + app_debug: keybind("none", "Toggle debug panel"), + app_console: keybind("none", "Toggle console"), + app_heap_snapshot: keybind("none", "Write heap snapshot"), + app_toggle_animations: keybind("none", "Toggle animations"), + app_toggle_file_context: keybind("none", "Toggle file context"), + app_toggle_diffwrap: keybind("none", "Toggle diff wrapping"), + app_toggle_paste_summary: keybind("none", "Toggle paste summary"), + app_toggle_session_directory_filter: keybind("none", "Toggle session directory filtering"), + command_list: keybind("ctrl+p", "List available commands"), + help_show: keybind("none", "Open help dialog"), + docs_open: keybind("none", "Open documentation"), + + editor_open: keybind("e", "Open external editor"), + theme_list: keybind("t", "List available themes"), + theme_switch_mode: keybind("none", "Switch between light and dark theme mode"), + theme_mode_lock: keybind("none", "Lock or unlock theme mode"), + sidebar_toggle: keybind("b", "Toggle sidebar"), + scrollbar_toggle: keybind("none", "Toggle session scrollbar"), + status_view: keybind("s", "View status"), + + session_export: keybind("x", "Export session to editor"), + session_copy: keybind("none", "Copy session transcript"), + session_new: keybind("n", "Create a new session"), + session_list: keybind("l", "List all sessions"), + session_timeline: keybind("g", "Show session timeline"), + session_fork: keybind("none", "Fork session from message"), + session_rename: keybind("ctrl+r", "Rename session"), + session_delete: keybind("ctrl+d", "Delete session"), + session_share: keybind("none", "Share current session"), + session_unshare: keybind("none", "Unshare current session"), + session_interrupt: keybind("escape", "Interrupt current session"), + session_compact: keybind("c", "Compact the session"), + session_toggle_timestamps: keybind("none", "Toggle message timestamps"), + session_toggle_generic_tool_output: keybind("none", "Toggle generic tool output"), + session_child_first: keybind("down", "Go to first child session"), + session_child_cycle: keybind("right", "Go to next child session"), + session_child_cycle_reverse: keybind("left", "Go to previous child session"), + session_parent: keybind("up", "Go to parent session"), + + stash_delete: keybind("ctrl+d", "Delete stash entry"), + model_provider_list: keybind("ctrl+a", "Open provider list from model dialog"), + model_favorite_toggle: keybind("ctrl+f", "Toggle model favorite status"), + model_list: keybind("m", "List available models"), + model_cycle_recent: keybind("f2", "Next recently used model"), + model_cycle_recent_reverse: keybind("shift+f2", "Previous recently used model"), + model_cycle_favorite: keybind("none", "Next favorite model"), + model_cycle_favorite_reverse: keybind("none", "Previous favorite model"), + mcp_list: keybind("none", "List MCP servers"), + provider_connect: keybind("none", "Connect provider"), + console_org_switch: keybind("none", "Switch console organization"), + agent_list: keybind("a", "List agents"), + agent_cycle: keybind("tab", "Next agent"), + agent_cycle_reverse: keybind("shift+tab", "Previous agent"), + variant_cycle: keybind("ctrl+t", "Cycle model variants"), + variant_list: keybind("none", "List model variants"), + + messages_page_up: keybind("pageup,ctrl+alt+b", "Scroll messages up by one page"), + messages_page_down: keybind("pagedown,ctrl+alt+f", "Scroll messages down by one page"), + messages_line_up: keybind("ctrl+alt+y", "Scroll messages up by one line"), + messages_line_down: keybind("ctrl+alt+e", "Scroll messages down by one line"), + messages_half_page_up: keybind("ctrl+alt+u", "Scroll messages up by half page"), + messages_half_page_down: keybind("ctrl+alt+d", "Scroll messages down by half page"), + messages_first: keybind("ctrl+g,home", "Navigate to first message"), + messages_last: keybind("ctrl+alt+g,end", "Navigate to last message"), + messages_next: keybind("none", "Navigate to next message"), + messages_previous: keybind("none", "Navigate to previous message"), + messages_last_user: keybind("none", "Navigate to last user message"), + messages_copy: keybind("y", "Copy message"), + messages_undo: keybind("u", "Undo message"), + messages_redo: keybind("r", "Redo message"), + messages_toggle_conceal: keybind("h", "Toggle code block concealment in messages"), + tool_details: keybind("none", "Toggle tool details visibility"), + display_thinking: keybind("none", "Toggle thinking blocks visibility"), + + prompt_submit: keybind("none", "Submit prompt"), + prompt_editor_context_clear: keybind("none", "Clear editor context"), + prompt_skills: keybind("none", "Open skill selector"), + prompt_stash: keybind("none", "Stash prompt"), + prompt_stash_pop: keybind("none", "Pop stashed prompt"), + prompt_stash_list: keybind("none", "List stashed prompts"), + workspace_set: keybind("none", "Set workspace"), + + input_clear: keybind("ctrl+c", "Clear input field"), + input_paste: keybind({ key: "ctrl+v", preventDefault: false }, "Paste from clipboard"), + input_submit: keybind("return", "Submit input"), + input_newline: keybind("shift+return,ctrl+return,alt+return,ctrl+j", "Insert newline in input"), + input_move_left: keybind("left,ctrl+b", "Move cursor left in input"), + input_move_right: keybind("right,ctrl+f", "Move cursor right in input"), + input_move_up: keybind("up", "Move cursor up in input"), + input_move_down: keybind("down", "Move cursor down in input"), + input_select_left: keybind("shift+left", "Select left in input"), + input_select_right: keybind("shift+right", "Select right in input"), + input_select_up: keybind("shift+up", "Select up in input"), + input_select_down: keybind("shift+down", "Select down in input"), + input_line_home: keybind("ctrl+a", "Move to start of line in input"), + input_line_end: keybind("ctrl+e", "Move to end of line in input"), + input_select_line_home: keybind("ctrl+shift+a", "Select to start of line in input"), + input_select_line_end: keybind("ctrl+shift+e", "Select to end of line in input"), + input_visual_line_home: keybind("alt+a", "Move to start of visual line in input"), + input_visual_line_end: keybind("alt+e", "Move to end of visual line in input"), + input_select_visual_line_home: keybind("alt+shift+a", "Select to start of visual line in input"), + input_select_visual_line_end: keybind("alt+shift+e", "Select to end of visual line in input"), + input_buffer_home: keybind("home", "Move to start of buffer in input"), + input_buffer_end: keybind("end", "Move to end of buffer in input"), + input_select_buffer_home: keybind("shift+home", "Select to start of buffer in input"), + input_select_buffer_end: keybind("shift+end", "Select to end of buffer in input"), + input_delete_line: keybind("ctrl+shift+d", "Delete line in input"), + input_delete_to_line_end: keybind("ctrl+k", "Delete to end of line in input"), + input_delete_to_line_start: keybind("ctrl+u", "Delete to start of line in input"), + input_backspace: keybind("backspace,shift+backspace", "Backspace in input"), + input_delete: keybind("ctrl+d,delete,shift+delete", "Delete character in input"), + input_undo: keybind(inputUndoDefault, "Undo in input"), + input_redo: keybind("ctrl+.,super+shift+z", "Redo in input"), + input_word_forward: keybind("alt+f,alt+right,ctrl+right", "Move word forward in input"), + input_word_backward: keybind("alt+b,alt+left,ctrl+left", "Move word backward in input"), + input_select_word_forward: keybind("alt+shift+f,alt+shift+right", "Select word forward in input"), + input_select_word_backward: keybind("alt+shift+b,alt+shift+left", "Select word backward in input"), + input_delete_word_forward: keybind("alt+d,alt+delete,ctrl+delete", "Delete word forward in input"), + input_delete_word_backward: keybind("ctrl+w,ctrl+backspace,alt+backspace", "Delete word backward in input"), + input_select_all: keybind("super+a", "Select all in input"), + history_previous: keybind("up", "Previous history item"), + history_next: keybind("down", "Next history item"), + + "dialog.select.prev": keybind("up,ctrl+p", "Move to previous dialog item"), + "dialog.select.next": keybind("down,ctrl+n", "Move to next dialog item"), + "dialog.select.page_up": keybind("pageup", "Move up one page in dialog"), + "dialog.select.page_down": keybind("pagedown", "Move down one page in dialog"), + "dialog.select.home": keybind("home", "Move to first dialog item"), + "dialog.select.end": keybind("end", "Move to last dialog item"), + "dialog.select.submit": keybind("return", "Submit selected dialog item"), + "dialog.mcp.toggle": keybind("space", "Toggle MCP in MCP dialog"), + "prompt.autocomplete.prev": keybind("up,ctrl+p", "Move to previous autocomplete item"), + "prompt.autocomplete.next": keybind("down,ctrl+n", "Move to next autocomplete item"), + "prompt.autocomplete.hide": keybind("escape", "Hide autocomplete"), + "prompt.autocomplete.select": keybind("return", "Select autocomplete item"), + "prompt.autocomplete.complete": keybind("tab", "Complete autocomplete item"), + "permission.prompt.fullscreen": keybind("ctrl+f", "Toggle permission prompt fullscreen"), + "plugins.toggle": keybind("space", "Toggle plugin"), + "dialog.plugins.install": keybind("shift+i", "Install plugin from plugin dialog"), + + terminal_suspend: keybind("ctrl+z", "Suspend terminal"), + terminal_title_toggle: keybind("none", "Toggle terminal title"), + tips_toggle: keybind("h", "Toggle tips on home screen"), + plugin_manager: keybind("none", "Open plugin manager dialog"), + plugin_install: keybind("none", "Install plugin"), + + which_key_toggle: keybind("ctrl+alt+k", "Toggle which-key panel"), + which_key_layout_toggle: keybind("ctrl+alt+shift+k", "Switch which-key layout"), + which_key_pending_toggle: keybind("ctrl+alt+shift+p", "Toggle which-key pending preview"), + which_key_group_previous: keybind("ctrl+alt+left,ctrl+alt+[", "Previous which-key group"), + which_key_group_next: keybind("ctrl+alt+right,ctrl+alt+]", "Next which-key group"), + which_key_scroll_up: keybind("ctrl+alt+up,ctrl+alt+p", "Scroll which-key up"), + which_key_scroll_down: keybind("ctrl+alt+down,ctrl+alt+n", "Scroll which-key down"), + which_key_page_up: keybind("ctrl+alt+pageup", "Page which-key up"), + which_key_page_down: keybind("ctrl+alt+pagedown", "Page which-key down"), + which_key_home: keybind("ctrl+alt+home", "Jump to first which-key binding"), + which_key_end: keybind("ctrl+alt+end", "Jump to last which-key binding"), +} satisfies Record + +type KeybindName = keyof typeof Definitions & string + +const KeybindShape = Object.fromEntries( + Object.entries(Definitions).map(([name, item]) => [ + name, + BindingValueSchema.optional().default(item.default).describe(item.description), + ]), +) as Record>> + +const KeybindOverrideShape = Object.fromEntries( + Object.entries(Definitions).map(([name, item]) => [name, BindingValueSchema.optional().describe(item.description)]), +) as Record> + +export const Keybinds = z.strictObject(KeybindShape).describe("TUI keybinding configuration") +export const KeybindOverrides = z.strictObject(KeybindOverrideShape).describe("TUI keybinding overrides") +export const Descriptions = Object.fromEntries( + Object.entries(Definitions).map(([name, item]) => [name, item.description]), +) as Record +export const CommandMap = { + app_exit: "app.exit", + app_debug: "app.debug", + app_console: "app.console", + app_heap_snapshot: "app.heap_snapshot", + app_toggle_animations: "app.toggle.animations", + app_toggle_file_context: "app.toggle.file_context", + app_toggle_diffwrap: "app.toggle.diffwrap", + app_toggle_paste_summary: "app.toggle.paste_summary", + app_toggle_session_directory_filter: "app.toggle.session_directory_filter", + command_list: "command.palette.show", + help_show: "help.show", + docs_open: "docs.open", + editor_open: "prompt.editor", + theme_list: "theme.switch", + theme_switch_mode: "theme.switch_mode", + theme_mode_lock: "theme.mode.lock", + sidebar_toggle: "session.sidebar.toggle", + scrollbar_toggle: "session.toggle.scrollbar", + status_view: "opencode.status", + session_export: "session.export", + session_copy: "session.copy", + session_new: "session.new", + session_list: "session.list", + session_timeline: "session.timeline", + session_fork: "session.fork", + session_rename: "session.rename", + session_delete: "session.delete", + session_share: "session.share", + session_unshare: "session.unshare", + session_interrupt: "session.interrupt", + session_compact: "session.compact", + session_toggle_timestamps: "session.toggle.timestamps", + session_toggle_generic_tool_output: "session.toggle.generic_tool_output", + session_child_first: "session.child.first", + session_child_cycle: "session.child.next", + session_child_cycle_reverse: "session.child.previous", + session_parent: "session.parent", + stash_delete: "stash.delete", + model_provider_list: "model.dialog.provider", + model_favorite_toggle: "model.dialog.favorite", + model_list: "model.list", + model_cycle_recent: "model.cycle_recent", + model_cycle_recent_reverse: "model.cycle_recent_reverse", + model_cycle_favorite: "model.cycle_favorite", + model_cycle_favorite_reverse: "model.cycle_favorite_reverse", + mcp_list: "mcp.list", + provider_connect: "provider.connect", + console_org_switch: "console.org.switch", + agent_list: "agent.list", + agent_cycle: "agent.cycle", + agent_cycle_reverse: "agent.cycle.reverse", + variant_cycle: "variant.cycle", + variant_list: "variant.list", + messages_page_up: "session.page.up", + messages_page_down: "session.page.down", + messages_line_up: "session.line.up", + messages_line_down: "session.line.down", + messages_half_page_up: "session.half.page.up", + messages_half_page_down: "session.half.page.down", + messages_first: "session.first", + messages_last: "session.last", + messages_next: "session.message.next", + messages_previous: "session.message.previous", + messages_last_user: "session.messages_last_user", + messages_copy: "messages.copy", + messages_undo: "session.undo", + messages_redo: "session.redo", + messages_toggle_conceal: "session.toggle.conceal", + tool_details: "session.toggle.actions", + display_thinking: "session.toggle.thinking", + prompt_submit: "prompt.submit", + prompt_editor_context_clear: "prompt.editor_context.clear", + prompt_skills: "prompt.skills", + prompt_stash: "prompt.stash", + prompt_stash_pop: "prompt.stash.pop", + prompt_stash_list: "prompt.stash.list", + workspace_set: "workspace.set", + input_clear: "prompt.clear", + input_paste: "prompt.paste", + input_submit: "input.submit", + input_newline: "input.newline", + input_move_left: "input.move.left", + input_move_right: "input.move.right", + input_move_up: "input.move.up", + input_move_down: "input.move.down", + input_select_left: "input.select.left", + input_select_right: "input.select.right", + input_select_up: "input.select.up", + input_select_down: "input.select.down", + input_line_home: "input.line.home", + input_line_end: "input.line.end", + input_select_line_home: "input.select.line.home", + input_select_line_end: "input.select.line.end", + input_visual_line_home: "input.visual.line.home", + input_visual_line_end: "input.visual.line.end", + input_select_visual_line_home: "input.select.visual.line.home", + input_select_visual_line_end: "input.select.visual.line.end", + input_buffer_home: "input.buffer.home", + input_buffer_end: "input.buffer.end", + input_select_buffer_home: "input.select.buffer.home", + input_select_buffer_end: "input.select.buffer.end", + input_delete_line: "input.delete.line", + input_delete_to_line_end: "input.delete.to.line.end", + input_delete_to_line_start: "input.delete.to.line.start", + input_backspace: "input.backspace", + input_delete: "input.delete", + input_undo: "input.undo", + input_redo: "input.redo", + input_word_forward: "input.word.forward", + input_word_backward: "input.word.backward", + input_select_word_forward: "input.select.word.forward", + input_select_word_backward: "input.select.word.backward", + input_delete_word_forward: "input.delete.word.forward", + input_delete_word_backward: "input.delete.word.backward", + input_select_all: "input.select.all", + history_previous: "prompt.history.previous", + history_next: "prompt.history.next", + terminal_suspend: "terminal.suspend", + terminal_title_toggle: "terminal.title.toggle", + tips_toggle: "tips.toggle", + plugin_manager: "plugins.list", + plugin_install: "plugins.install", + which_key_toggle: "which-key.toggle", + which_key_layout_toggle: "which-key.layout.toggle", + which_key_pending_toggle: "which-key.pending.toggle", + which_key_group_previous: "which-key.group.previous", + which_key_group_next: "which-key.group.next", + which_key_scroll_up: "which-key.scroll.up", + which_key_scroll_down: "which-key.scroll.down", + which_key_page_up: "which-key.page.up", + which_key_page_down: "which-key.page.down", + which_key_home: "which-key.home", + which_key_end: "which-key.end", +} satisfies BindingCommandMap +const CommandDescriptions = Object.fromEntries( + Object.entries(Definitions).map(([name, item]) => [ + CommandMap[name as keyof typeof CommandMap] ?? name, + item.description, + ]), +) as Record + +export type Keybinds = z.output +export type KeybindOverrides = z.output +export type BindingLookupView = { + readonly bindings: readonly Binding[] + get(command: string): readonly Binding[] + has(command: string): boolean + gather(name: string, commands: readonly string[]): readonly Binding[] + pick(name: string, commands: readonly string[]): Binding[] + omit(name: string, commands: readonly string[]): Binding[] +} + +export function toBindingConfig(keybinds: Keybinds): BindingConfig { + return Object.fromEntries(Object.entries(keybinds)) as BindingConfig +} + +export function bindingDefaults(): BindingDefaults { + return ({ command, binding }) => { + if (binding.desc !== undefined) return + return { desc: CommandDescriptions[command] } + } +} diff --git a/packages/opencode/src/cli/cmd/tui/config/legacy-keymap-transform.ts b/packages/opencode/src/cli/cmd/tui/config/legacy-keymap-transform.ts deleted file mode 100644 index 4b266a4ecc..0000000000 --- a/packages/opencode/src/cli/cmd/tui/config/legacy-keymap-transform.ts +++ /dev/null @@ -1,187 +0,0 @@ -import type { KeyEvent, Renderable } from "@opentui/core" -import type { Binding } from "@opentui/keymap" -import type { BindingValue } from "@opentui/keymap/extras" -import { ConfigKeybinds } from "@/config/keybinds" -import { type KeymapConfigInput, type KeymapSection } from "./tui-schema" - -type LegacyKeybinds = Partial -type SectionsConfig = Record>> - -const inputCommands = { - input_submit: "input.submit", - input_newline: "input.newline", - input_move_left: "input.move.left", - input_move_right: "input.move.right", - input_move_up: "input.move.up", - input_move_down: "input.move.down", - input_select_left: "input.select.left", - input_select_right: "input.select.right", - input_select_up: "input.select.up", - input_select_down: "input.select.down", - input_line_home: "input.line.home", - input_line_end: "input.line.end", - input_select_line_home: "input.select.line.home", - input_select_line_end: "input.select.line.end", - input_visual_line_home: "input.visual.line.home", - input_visual_line_end: "input.visual.line.end", - input_select_visual_line_home: "input.select.visual.line.home", - input_select_visual_line_end: "input.select.visual.line.end", - input_buffer_home: "input.buffer.home", - input_buffer_end: "input.buffer.end", - input_select_buffer_home: "input.select.buffer.home", - input_select_buffer_end: "input.select.buffer.end", - input_delete_line: "input.delete.line", - input_delete_to_line_end: "input.delete.to.line.end", - input_delete_to_line_start: "input.delete.to.line.start", - input_backspace: "input.backspace", - input_delete: "input.delete", - input_undo: "input.undo", - input_redo: "input.redo", - input_word_forward: "input.word.forward", - input_word_backward: "input.word.backward", - input_select_word_forward: "input.select.word.forward", - input_select_word_backward: "input.select.word.backward", - input_delete_word_forward: "input.delete.word.forward", - input_delete_word_backward: "input.delete.word.backward", - input_select_all: "input.select.all", -} as const satisfies Partial> - -function add( - config: SectionsConfig, - section: KeymapSection, - command: string, - binding: BindingValue | undefined, -) { - if (binding === undefined) return - config[section] ??= {} - config[section][command] = binding -} - -function bindingWith(key: string | undefined, input: Omit, "key" | "cmd">) { - if (!key) return undefined - if (key === "none") return "none" - return { ...input, key } -} - -function combineBindings(...keys: (string | undefined)[]) { - const result = Array.from( - new Set( - keys.flatMap((key) => { - if (!key || key === "none") return [] - return key - .split(",") - .map((part) => part.trim()) - .filter((part) => part && part !== "none") - }), - ), - ) - if (result.length) return result.join(",") - if (keys.some((key) => key === "none")) return "none" - return undefined -} - -export function create(keybinds: LegacyKeybinds): KeymapConfigInput { - const config: SectionsConfig = {} - - add(config, "global", "command.palette.show", keybinds.command_list) - add(config, "global", "session.list", keybinds.session_list) - add(config, "global", "session.new", keybinds.session_new) - add(config, "global", "model.list", keybinds.model_list) - add(config, "global", "model.cycle_recent", keybinds.model_cycle_recent) - add(config, "global", "model.cycle_recent_reverse", keybinds.model_cycle_recent_reverse) - add(config, "global", "model.cycle_favorite", keybinds.model_cycle_favorite) - add(config, "global", "model.cycle_favorite_reverse", keybinds.model_cycle_favorite_reverse) - add(config, "global", "agent.list", keybinds.agent_list) - add(config, "global", "agent.cycle", keybinds.agent_cycle) - add(config, "global", "agent.cycle.reverse", keybinds.agent_cycle_reverse) - add(config, "global", "variant.cycle", keybinds.variant_cycle) - add(config, "global", "variant.list", keybinds.variant_list) - add(config, "prompt", "prompt.editor", keybinds.editor_open) - add(config, "global", "opencode.status", keybinds.status_view) - add(config, "global", "theme.switch", keybinds.theme_list) - add(config, "global", "app.exit", keybinds.app_exit) - add(config, "global", "terminal.suspend", keybinds.terminal_suspend) - add(config, "global", "terminal.title.toggle", keybinds.terminal_title_toggle) - - add(config, "session", "session.share", keybinds.session_share) - add(config, "session", "session.rename", keybinds.session_rename) - add(config, "session", "session.timeline", keybinds.session_timeline) - add(config, "session", "session.fork", keybinds.session_fork) - add(config, "session", "session.compact", keybinds.session_compact) - add(config, "session", "session.unshare", keybinds.session_unshare) - add(config, "session", "session.undo", keybinds.messages_undo) - add(config, "session", "session.redo", keybinds.messages_redo) - add(config, "session", "session.sidebar.toggle", keybinds.sidebar_toggle) - add(config, "session", "session.toggle.conceal", keybinds.messages_toggle_conceal) - add(config, "session", "session.toggle.thinking", keybinds.display_thinking) - add(config, "session", "session.toggle.actions", keybinds.tool_details) - add(config, "session", "session.toggle.scrollbar", keybinds.scrollbar_toggle) - add(config, "session", "session.page.up", keybinds.messages_page_up) - add(config, "session", "session.page.down", keybinds.messages_page_down) - add(config, "session", "session.line.up", keybinds.messages_line_up) - add(config, "session", "session.line.down", keybinds.messages_line_down) - add(config, "session", "session.half.page.up", keybinds.messages_half_page_up) - add(config, "session", "session.half.page.down", keybinds.messages_half_page_down) - add(config, "session", "session.first", keybinds.messages_first) - add(config, "session", "session.last", keybinds.messages_last) - add(config, "session", "session.messages_last_user", keybinds.messages_last_user) - add(config, "session", "session.message.next", keybinds.messages_next) - add(config, "session", "session.message.previous", keybinds.messages_previous) - add(config, "session", "messages.copy", keybinds.messages_copy) - add(config, "session", "session.export", keybinds.session_export) - add(config, "session", "session.child.first", keybinds.session_child_first) - add(config, "session", "session.parent", keybinds.session_parent) - add(config, "session", "session.child.next", keybinds.session_child_cycle) - add(config, "session", "session.child.previous", keybinds.session_child_cycle_reverse) - - add(config, "prompt", "session.interrupt", keybinds.session_interrupt) - add(config, "prompt", "prompt.clear", keybinds.input_clear) - add(config, "prompt", "prompt.paste", bindingWith(keybinds.input_paste, { preventDefault: false })) - add(config, "prompt", "prompt.history.previous", keybinds.history_previous) - add(config, "prompt", "prompt.history.next", keybinds.history_next) - - add(config, "autocomplete", "prompt.autocomplete.prev", keybinds["prompt.autocomplete.prev"]) - add(config, "autocomplete", "prompt.autocomplete.next", keybinds["prompt.autocomplete.next"]) - add(config, "autocomplete", "prompt.autocomplete.hide", keybinds["prompt.autocomplete.hide"]) - add(config, "autocomplete", "prompt.autocomplete.select", keybinds["prompt.autocomplete.select"]) - add(config, "autocomplete", "prompt.autocomplete.complete", keybinds["prompt.autocomplete.complete"]) - - for (const [legacy, command] of Object.entries(inputCommands) as [keyof typeof inputCommands, string][]) { - add(config, "input", command, keybinds[legacy]) - } - - add(config, "dialog_select", "dialog.select.prev", keybinds["dialog.select.prev"]) - add(config, "dialog_select", "dialog.select.next", keybinds["dialog.select.next"]) - add(config, "dialog_select", "dialog.select.page_up", keybinds["dialog.select.page_up"]) - add(config, "dialog_select", "dialog.select.page_down", keybinds["dialog.select.page_down"]) - add(config, "dialog_select", "dialog.select.home", keybinds["dialog.select.home"]) - add(config, "dialog_select", "dialog.select.end", keybinds["dialog.select.end"]) - add(config, "dialog_select", "dialog.select.submit", keybinds["dialog.select.submit"]) - add(config, "dialog_actions", "dialog.action.delete", combineBindings(keybinds.stash_delete, keybinds.session_delete)) - add(config, "dialog_actions", "dialog.action.rename", keybinds.session_rename) - add( - config, - "dialog_actions", - "dialog.action.toggle", - combineBindings(keybinds["dialog.mcp.toggle"], keybinds["plugins.toggle"]), - ) - add(config, "model", "model.dialog.provider", keybinds.model_provider_list) - add(config, "model", "model.dialog.favorite", keybinds.model_favorite_toggle) - - add(config, "permission", "permission.reject.cancel", keybinds.app_exit) - add(config, "permission", "permission.prompt.escape", keybinds.app_exit) - add(config, "permission", "permission.prompt.fullscreen", keybinds["permission.prompt.fullscreen"]) - add(config, "question", "question.reject", keybinds.app_exit) - add(config, "question", "question.edit.clear", keybinds.input_clear) - - add(config, "plugins", "plugins.list", keybinds.plugin_manager) - add(config, "plugins", "plugin.dialog.install", keybinds["dialog.plugins.install"]) - add(config, "home_tips", "tips.toggle", keybinds.tips_toggle) - - return { - ...(keybinds.leader && keybinds.leader !== "none" && { leader: keybinds.leader }), - sections: config, - } -} - -export * as LegacyKeymapTransform from "./legacy-keymap-transform" diff --git a/packages/opencode/src/cli/cmd/tui/config/tui-schema.ts b/packages/opencode/src/cli/cmd/tui/config/tui-schema.ts index 8e142dc101..d08836e1dd 100644 --- a/packages/opencode/src/cli/cmd/tui/config/tui-schema.ts +++ b/packages/opencode/src/cli/cmd/tui/config/tui-schema.ts @@ -1,339 +1,12 @@ import z from "zod" -import type { KeyEvent, Renderable } from "@opentui/core" -import type { Binding } from "@opentui/keymap" -import type { ResolvedBindingSections } from "@opentui/keymap/extras" import { ConfigPlugin } from "@/config/plugin" -import { ConfigKeybinds } from "@/config/keybinds" +import { TuiKeybind } from "./keybind" -const KeybindOverride = z - .object( - Object.fromEntries(Object.keys(ConfigKeybinds.Keybinds.shape).map((key) => [key, z.string().optional()])) as Record< - string, - z.ZodOptional - >, - ) - .strict() - -const KeyStroke = z - .object({ - name: z.string(), - ctrl: z.boolean().optional(), - shift: z.boolean().optional(), - meta: z.boolean().optional(), - super: z.boolean().optional(), - hyper: z.boolean().optional(), - }) - .strict() - -const KeymapBindingObject = z - .object({ - key: z.union([z.string(), KeyStroke]), - event: z.enum(["press", "release"]).optional(), - preventDefault: z.boolean().optional(), - fallthrough: z.boolean().optional(), - }) - .passthrough() - -const KeymapBindingItem = z.union([z.string(), KeyStroke, KeymapBindingObject]) -const KeymapBindingValue = z.union([z.literal(false), z.literal("none"), KeymapBindingItem, z.array(KeymapBindingItem)]) - -const keymapBinding = (value: z.input | (() => z.input)) => - KeymapBindingValue.prefault(value) -const keymapSection = (shape: Shape) => { - const schema = z.object(shape).strict() - return schema.prefault({} as z.input) -} -const keymapSectionInput = (shape: Shape) => - z - .object( - Object.fromEntries(Object.keys(shape).map((key) => [key, KeymapBindingValue.optional()])) as { - [Key in keyof Shape]: z.ZodOptional - }, - ) - .strict() - -const GlobalKeymapSection = { - "command.palette.show": keymapBinding("ctrl+p"), - "session.list": keymapBinding("l"), - "session.new": keymapBinding("n"), - "model.list": keymapBinding("m"), - "model.cycle_recent": keymapBinding("f2"), - "model.cycle_recent_reverse": keymapBinding("shift+f2"), - "model.cycle_favorite": keymapBinding("none"), - "model.cycle_favorite_reverse": keymapBinding("none"), - "agent.list": keymapBinding("a"), - "mcp.list": keymapBinding("none"), - "agent.cycle": keymapBinding("tab"), - "agent.cycle.reverse": keymapBinding("shift+tab"), - "variant.cycle": keymapBinding("ctrl+t"), - "variant.list": keymapBinding("none"), - "provider.connect": keymapBinding("none"), - "console.org.switch": keymapBinding("none"), - "opencode.status": keymapBinding("s"), - "theme.switch": keymapBinding("t"), - "theme.switch_mode": keymapBinding("none"), - "theme.mode.lock": keymapBinding("none"), - "help.show": keymapBinding("none"), - "docs.open": keymapBinding("none"), - "app.exit": keymapBinding("ctrl+c,ctrl+d,q"), - "app.debug": keymapBinding("none"), - "app.console": keymapBinding("none"), - "app.heap_snapshot": keymapBinding("none"), - "app.toggle.animations": keymapBinding("none"), - "app.toggle.file_context": keymapBinding("none"), - "app.toggle.diffwrap": keymapBinding("none"), - "app.toggle.paste_summary": keymapBinding("none"), - "app.toggle.session_directory_filter": keymapBinding("none"), - "terminal.suspend": keymapBinding(() => (process.platform === "win32" ? "none" : "ctrl+z")), - "terminal.title.toggle": keymapBinding("none"), -} - -const WhichKeyKeymapSection = { - "tui-which-key.toggle": keymapBinding("ctrl+alt+k"), - "tui-which-key.layout.toggle": keymapBinding("ctrl+alt+shift+k"), - "tui-which-key.pending.toggle": keymapBinding("ctrl+alt+shift+p"), - "tui-which-key.group.previous": keymapBinding("ctrl+alt+left,ctrl+alt+["), - "tui-which-key.group.next": keymapBinding("ctrl+alt+right,ctrl+alt+]"), - "tui-which-key.scroll.up": keymapBinding("ctrl+alt+up,ctrl+alt+p"), - "tui-which-key.scroll.down": keymapBinding("ctrl+alt+down,ctrl+alt+n"), - "tui-which-key.page.up": keymapBinding("ctrl+alt+pageup"), - "tui-which-key.page.down": keymapBinding("ctrl+alt+pagedown"), - "tui-which-key.home": keymapBinding("ctrl+alt+home"), - "tui-which-key.end": keymapBinding("ctrl+alt+end"), -} - -const SessionKeymapSection = { - "session.share": keymapBinding("none"), - "session.rename": keymapBinding("ctrl+r"), - "session.timeline": keymapBinding("g"), - "session.fork": keymapBinding("none"), - "session.compact": keymapBinding("c"), - "session.unshare": keymapBinding("none"), - "session.undo": keymapBinding("u"), - "session.redo": keymapBinding("r"), - "session.sidebar.toggle": keymapBinding("b"), - "session.toggle.conceal": keymapBinding("h"), - "session.toggle.timestamps": keymapBinding("none"), - "session.toggle.thinking": keymapBinding("none"), - "session.toggle.actions": keymapBinding("none"), - "session.toggle.scrollbar": keymapBinding("none"), - "session.toggle.generic_tool_output": keymapBinding("none"), - "session.page.up": keymapBinding("pageup,ctrl+alt+b"), - "session.page.down": keymapBinding("pagedown,ctrl+alt+f"), - "session.line.up": keymapBinding("ctrl+alt+y"), - "session.line.down": keymapBinding("ctrl+alt+e"), - "session.half.page.up": keymapBinding("ctrl+alt+u"), - "session.half.page.down": keymapBinding("ctrl+alt+d"), - "session.first": keymapBinding("ctrl+g,home"), - "session.last": keymapBinding("ctrl+alt+g,end"), - "session.messages_last_user": keymapBinding("none"), - "session.message.next": keymapBinding("none"), - "session.message.previous": keymapBinding("none"), - "messages.copy": keymapBinding("y"), - "session.copy": keymapBinding("none"), - "session.export": keymapBinding("x"), - "session.child.first": keymapBinding("down"), - "session.parent": keymapBinding("up"), - "session.child.next": keymapBinding("right"), - "session.child.previous": keymapBinding("left"), -} - -const PromptKeymapSection = { - "prompt.submit": keymapBinding("none"), - "prompt.editor": keymapBinding("e"), - "prompt.editor_context.clear": keymapBinding("none"), - "prompt.skills": keymapBinding("none"), - "prompt.stash": keymapBinding("none"), - "prompt.stash.pop": keymapBinding("none"), - "prompt.stash.list": keymapBinding("none"), - "workspace.set": keymapBinding("none"), - "session.interrupt": keymapBinding("escape"), - "prompt.clear": keymapBinding("ctrl+c"), - "prompt.paste": keymapBinding({ key: "ctrl+v", preventDefault: false }), - "prompt.history.previous": keymapBinding("up"), - "prompt.history.next": keymapBinding("down"), -} - -const AutocompleteKeymapSection = { - "prompt.autocomplete.prev": keymapBinding("up,ctrl+p"), - "prompt.autocomplete.next": keymapBinding("down,ctrl+n"), - "prompt.autocomplete.hide": keymapBinding("escape"), - "prompt.autocomplete.select": keymapBinding("return"), - "prompt.autocomplete.complete": keymapBinding("tab"), -} - -const InputKeymapSection = { - "input.submit": keymapBinding("return"), - "input.newline": keymapBinding("shift+return,ctrl+return,alt+return,ctrl+j"), - "input.move.left": keymapBinding("left,ctrl+b"), - "input.move.right": keymapBinding("right,ctrl+f"), - "input.move.up": keymapBinding("up"), - "input.move.down": keymapBinding("down"), - "input.select.left": keymapBinding("shift+left"), - "input.select.right": keymapBinding("shift+right"), - "input.select.up": keymapBinding("shift+up"), - "input.select.down": keymapBinding("shift+down"), - "input.line.home": keymapBinding("ctrl+a"), - "input.line.end": keymapBinding("ctrl+e"), - "input.select.line.home": keymapBinding("ctrl+shift+a"), - "input.select.line.end": keymapBinding("ctrl+shift+e"), - "input.visual.line.home": keymapBinding("alt+a"), - "input.visual.line.end": keymapBinding("alt+e"), - "input.select.visual.line.home": keymapBinding("alt+shift+a"), - "input.select.visual.line.end": keymapBinding("alt+shift+e"), - "input.buffer.home": keymapBinding("home"), - "input.buffer.end": keymapBinding("end"), - "input.select.buffer.home": keymapBinding("shift+home"), - "input.select.buffer.end": keymapBinding("shift+end"), - "input.delete.line": keymapBinding("ctrl+shift+d"), - "input.delete.to.line.end": keymapBinding("ctrl+k"), - "input.delete.to.line.start": keymapBinding("ctrl+u"), - "input.backspace": keymapBinding("backspace,shift+backspace"), - "input.delete": keymapBinding("ctrl+d,delete,shift+delete"), - "input.undo": keymapBinding(() => (process.platform === "win32" ? "ctrl+z,ctrl+-,super+z" : "ctrl+-,super+z")), - "input.redo": keymapBinding("ctrl+.,super+shift+z"), - "input.word.forward": keymapBinding("alt+f,alt+right,ctrl+right"), - "input.word.backward": keymapBinding("alt+b,alt+left,ctrl+left"), - "input.select.word.forward": keymapBinding("alt+shift+f,alt+shift+right"), - "input.select.word.backward": keymapBinding("alt+shift+b,alt+shift+left"), - "input.delete.word.forward": keymapBinding("alt+d,alt+delete,ctrl+delete"), - "input.delete.word.backward": keymapBinding("ctrl+w,ctrl+backspace,alt+backspace"), - "input.select.all": keymapBinding("super+a"), -} - -const DialogSelectKeymapSection = { - "dialog.select.prev": keymapBinding("up,ctrl+p"), - "dialog.select.next": keymapBinding("down,ctrl+n"), - "dialog.select.page_up": keymapBinding("pageup"), - "dialog.select.page_down": keymapBinding("pagedown"), - "dialog.select.home": keymapBinding("home"), - "dialog.select.end": keymapBinding("end"), - "dialog.select.submit": keymapBinding("return"), -} - -const DialogActionsKeymapSection = { - "dialog.action.toggle": keymapBinding("space"), - "dialog.action.delete": keymapBinding("ctrl+d"), - "dialog.action.rename": keymapBinding("ctrl+r"), -} - -const ModelKeymapSection = { - "model.dialog.provider": keymapBinding("ctrl+a"), - "model.dialog.favorite": keymapBinding("ctrl+f"), -} - -const PermissionKeymapSection = { - "permission.reject.cancel": keymapBinding("ctrl+c,ctrl+d,q"), - "permission.prompt.escape": keymapBinding("ctrl+c,ctrl+d,q"), - "permission.prompt.fullscreen": keymapBinding("ctrl+f"), -} - -const QuestionKeymapSection = { - "question.reject": keymapBinding("ctrl+c,ctrl+d,q"), - "question.edit.clear": keymapBinding("ctrl+c"), -} - -const PluginsKeymapSection = { - "plugins.list": keymapBinding("none"), - "plugins.install": keymapBinding("none"), - "plugin.dialog.install": keymapBinding("shift+i"), -} - -const HomeTipsKeymapSection = { - "tips.toggle": keymapBinding("h"), -} - -const KeymapSectionsShape = { - global: keymapSection(GlobalKeymapSection), - which_key: keymapSection(WhichKeyKeymapSection), - session: keymapSection(SessionKeymapSection), - prompt: keymapSection(PromptKeymapSection), - autocomplete: keymapSection(AutocompleteKeymapSection), - input: keymapSection(InputKeymapSection), - dialog_select: keymapSection(DialogSelectKeymapSection), - dialog_actions: keymapSection(DialogActionsKeymapSection), - model: keymapSection(ModelKeymapSection), - permission: keymapSection(PermissionKeymapSection), - question: keymapSection(QuestionKeymapSection), - plugins: keymapSection(PluginsKeymapSection), - home_tips: keymapSection(HomeTipsKeymapSection), -} - -const KeymapSectionsInputShape = { - global: keymapSectionInput(GlobalKeymapSection).optional(), - which_key: keymapSectionInput(WhichKeyKeymapSection).optional(), - session: keymapSectionInput(SessionKeymapSection).optional(), - prompt: keymapSectionInput(PromptKeymapSection).optional(), - autocomplete: keymapSectionInput(AutocompleteKeymapSection).optional(), - input: keymapSectionInput(InputKeymapSection).optional(), - dialog_select: keymapSectionInput(DialogSelectKeymapSection).optional(), - dialog_actions: keymapSectionInput(DialogActionsKeymapSection).optional(), - model: keymapSectionInput(ModelKeymapSection).optional(), - permission: keymapSectionInput(PermissionKeymapSection).optional(), - question: keymapSectionInput(QuestionKeymapSection).optional(), - plugins: keymapSectionInput(PluginsKeymapSection).optional(), - home_tips: keymapSectionInput(HomeTipsKeymapSection).optional(), -} - -export const KeymapSections = z.object(KeymapSectionsShape).strict().prefault({}) -export type KeymapSections = z.output -export type KeymapSection = keyof KeymapSections -export const KeymapSectionNames = Object.keys(KeymapSectionsShape) as KeymapSection[] export const KeymapLeaderTimeoutDefault = 2000 -export type KeymapInfo = { - leader: string - leader_timeout: number -} & ResolvedBindingSections - -export const KeymapSectionGroups = { - global: "Global", - which_key: "System", - session: "Session", - prompt: "Prompt", - autocomplete: "Autocomplete", - input: "Text Editing", - dialog_select: "Dialog", - dialog_actions: "Dialog", - model: "Model", - permission: "Permission", - question: "Question", - plugins: "Plugins", - home_tips: "Home", -} satisfies Record - -export function keymapBindingDefaults(input: { section: string; binding: Readonly> }) { - if (input.binding.group !== undefined) return - if (!Object.hasOwn(KeymapSectionGroups, input.section)) return - return { group: KeymapSectionGroups[input.section as KeymapSection] } -} - -export const KeymapConfig = z - .object({ - leader: z.string().prefault("ctrl+x"), - leader_timeout: z - .number() - .int() - .positive() - .prefault(KeymapLeaderTimeoutDefault) - .describe("Leader key timeout in milliseconds"), - sections: KeymapSections, - }) - .strict() - .describe("TUI keymap configuration") -export type KeymapConfig = z.output - -const KeymapSectionsInput = z.object(KeymapSectionsInputShape).strict().optional() -export const KeymapConfigInput = z - .object({ - leader: z.string().optional(), - leader_timeout: z.number().int().positive().optional().describe("Leader key timeout in milliseconds"), - sections: KeymapSectionsInput, - }) - .strict() - .describe("TUI keymap configuration") -export type KeymapConfigInput = z.output +const KeymapLeaderTimeout = z.number().int().positive().describe("Leader key timeout in milliseconds") export const TuiOptions = z.object({ + leader_timeout: KeymapLeaderTimeout.optional(), scroll_speed: z.number().min(0.001).optional().describe("TUI scroll speed"), scroll_acceleration: z .object({ @@ -352,17 +25,11 @@ export const TuiInfo = z .object({ $schema: z.string().optional(), theme: z.string().optional(), - keybinds: KeybindOverride.optional().meta({ - deprecated: true, - description: "Use keymap instead. This will be removed in opencode v2.0.", - }), - keymap: KeymapConfigInput.optional(), + keybinds: TuiKeybind.KeybindOverrides.optional(), plugin: ConfigPlugin.Spec.zod.array().optional(), plugin_enabled: z.record(z.string(), z.boolean()).optional(), }) .extend(TuiOptions.shape) .strict() -export const TuiJsonSchemaInfo = TuiInfo.extend({ - keymap: KeymapConfig.optional(), -}).strict() +export const TuiJsonSchemaInfo = TuiInfo diff --git a/packages/opencode/src/cli/cmd/tui/config/tui.ts b/packages/opencode/src/cli/cmd/tui/config/tui.ts index 429d7e5c1c..14d9918160 100644 --- a/packages/opencode/src/cli/cmd/tui/config/tui.ts +++ b/packages/opencode/src/cli/cmd/tui/config/tui.ts @@ -1,29 +1,26 @@ export * as TuiConfig from "./tui" import type z from "zod" -import type { KeyEvent, Renderable } from "@opentui/core" -import { resolveBindingSections, type BindingSectionsConfig } from "@opentui/keymap/extras" +import { createBindingLookup } from "@opentui/keymap/extras" import { mergeDeep, unique } from "remeda" import { Context, Effect, Fiber, Layer } from "effect" import { ConfigParse } from "@/config/parse" import * as ConfigPaths from "@/config/paths" import { migrateTuiConfig } from "./tui-migrate" -import { KeymapConfig, TuiInfo, TuiJsonSchemaInfo } from "./tui-schema" +import { KeymapLeaderTimeoutDefault, TuiInfo, TuiJsonSchemaInfo } from "./tui-schema" import { Flag } from "@opencode-ai/core/flag/flag" import { isRecord } from "@/util/record" import { Global } from "@opencode-ai/core/global" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { CurrentWorkingDirectory } from "./cwd" import { ConfigPlugin } from "@/config/plugin" -import { ConfigKeybinds } from "@/config/keybinds" +import { TuiKeybind } from "./keybind" import { InstallationLocal, InstallationVersion } from "@opencode-ai/core/installation/version" import { makeRuntime } from "@opencode-ai/core/effect/runtime" import { Filesystem } from "@/util/filesystem" import * as Log from "@opencode-ai/core/util/log" import { ConfigVariable } from "@/config/variable" import { Npm } from "@opencode-ai/core/npm" -import { LegacyKeymapTransform } from "./legacy-keymap-transform" -import { KeymapSectionNames, keymapBindingDefaults, type KeymapInfo, type KeymapSection } from "./tui-schema" const log = Log.create({ service: "tui.config" }) @@ -36,9 +33,9 @@ type Acc = { plugin_origins: ConfigPlugin.Origin[] } -export type Resolved = Omit & { - keybinds: ConfigKeybinds.Keybinds - keymap: KeymapInfo +export type Resolved = Omit & { + keybinds: TuiKeybind.BindingLookupView + leader_timeout: number // Internal resolved plugin list used by runtime loading. plugin_origins?: ConfigPlugin.Origin[] } @@ -186,31 +183,18 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory: keybinds.terminal_suspend = "none" keybinds.input_undo ??= unique([ "ctrl+z", - ...ConfigKeybinds.Keybinds.shape.input_undo.parse(undefined).split(","), + ...String(TuiKeybind.Keybinds.shape.input_undo.parse(undefined)).split(","), ]).join(",") } - const parsedKeybinds = ConfigKeybinds.Keybinds.parse(keybinds) - const keymapInput = acc.result.keymap ?? LegacyKeymapTransform.create(acc.result.keybinds ?? {}) - const keymapConfig = KeymapConfig.parse(keymapInput) - const keymap = { - leader: !keymapConfig.leader || keymapConfig.leader === "none" ? "ctrl+x" : keymapConfig.leader, - leader_timeout: keymapConfig.leader_timeout, - ...resolveBindingSections, KeymapSection>( - keymapConfig.sections, - { - sections: KeymapSectionNames, - bindingDefaults: keymapBindingDefaults, - }, - ), - } + const parsedKeybinds = TuiKeybind.Keybinds.parse(keybinds) const result: Resolved = { ...acc.result, - keybinds: parsedKeybinds, + keybinds: createBindingLookup(TuiKeybind.toBindingConfig(parsedKeybinds), { + commandMap: TuiKeybind.CommandMap, + bindingDefaults: TuiKeybind.bindingDefaults(), + }), + leader_timeout: acc.result.leader_timeout ?? KeymapLeaderTimeoutDefault, plugin_origins: acc.plugin_origins.length ? acc.plugin_origins : undefined, - // `keybinds` is deprecated and will be removed in opencode v2.0. Keep it - // only as the legacy fallback; once `keymap` is configured, ignore - // `keybinds` for keymap resolution. - keymap, } return { diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/home/tips.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/home/tips.tsx index beb92578fa..69071b1f7c 100644 --- a/packages/opencode/src/cli/cmd/tui/feature-plugins/home/tips.tsx +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/home/tips.tsx @@ -20,7 +20,7 @@ function View(props: { api: TuiPluginApi; hidden: boolean; show: boolean; connec }, }, ], - bindings: props.api.tuiConfig.keymap.sections.home_tips, + bindings: props.api.tuiConfig.keybinds.get("tips.toggle"), })) return ( diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/system/plugins.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/system/plugins.tsx index 34666ff88c..2cf03c4a8f 100644 --- a/packages/opencode/src/cli/cmd/tui/feature-plugins/system/plugins.tsx +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/system/plugins.tsx @@ -207,7 +207,7 @@ function View(props: { api: TuiPluginApi }) { actions={[ { title: "toggle", - command: "dialog.action.toggle", + command: "plugins.toggle", disabled: lock(), onTrigger: (item) => { setCur(item.value) @@ -216,14 +216,13 @@ function View(props: { api: TuiPluginApi }) { }, { title: "install", - command: "plugin.dialog.install", + command: "dialog.plugins.install", disabled: lock(), onTrigger: () => { showInstall(props.api) }, }, ]} - bindings={props.api.tuiConfig.keymap.pick("plugins", ["plugin.dialog.install"])} onSelect={(item) => { setCur(item.value) flip(item.value) @@ -258,7 +257,7 @@ const tui: TuiPlugin = async (api) => { }, }, ], - bindings: api.tuiConfig.keymap.omit("plugins", ["plugin.dialog.install"]), + bindings: api.tuiConfig.keybinds.gather("plugins.palette", ["plugins.list", "plugins.install"]), }) } diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/system/which-key.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/system/which-key.tsx index 2735939a00..3fcd244a2b 100644 --- a/packages/opencode/src/cli/cmd/tui/feature-plugins/system/which-key.tsx +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/system/which-key.tsx @@ -8,17 +8,17 @@ import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui" import type { InternalTuiPlugin } from "../../plugin/internal" const command = { - toggle: "tui-which-key.toggle", - toggleLayout: "tui-which-key.layout.toggle", - togglePending: "tui-which-key.pending.toggle", - groupPrevious: "tui-which-key.group.previous", - groupNext: "tui-which-key.group.next", - scrollUp: "tui-which-key.scroll.up", - scrollDown: "tui-which-key.scroll.down", - pageUp: "tui-which-key.page.up", - pageDown: "tui-which-key.page.down", - home: "tui-which-key.home", - end: "tui-which-key.end", + toggle: "which-key.toggle", + toggleLayout: "which-key.layout.toggle", + togglePending: "which-key.pending.toggle", + groupPrevious: "which-key.group.previous", + groupNext: "which-key.group.next", + scrollUp: "which-key.scroll.up", + scrollDown: "which-key.scroll.down", + pageUp: "which-key.page.up", + pageDown: "which-key.page.down", + home: "which-key.home", + end: "which-key.end", } as const const LAYER_PRIORITY = 900 @@ -112,8 +112,7 @@ function skin(api: TuiPluginApi): Skin { } function activeKeyLabel(active: ActiveKey) { - const group = text(active.bindingAttrs?.group) - if (active.continues) return group ?? text(active.tokenName) ?? UNKNOWN + if (active.continues) return text(active.tokenName) ?? text(active.display) ?? UNKNOWN return ( text(active.commandAttrs?.title) ?? text(active.bindingAttrs?.desc) ?? text(active.commandAttrs?.desc) ?? UNKNOWN ) @@ -361,7 +360,9 @@ function WhichKeyPanel(props: { }, }, ], - bindings: props.api.tuiConfig.keymap.pick("which_key", pendingMode() ? scrollCommands : panelCommands), + bindings: pendingMode() + ? props.api.tuiConfig.keybinds.gather("which-key.scroll", scrollCommands) + : props.api.tuiConfig.keybinds.gather("which-key.panel", panelCommands), })) createEffect(() => { @@ -571,7 +572,7 @@ const tui: TuiPlugin = async (api) => { }, }, ], - bindings: api.tuiConfig.keymap.pick("which_key", toggleCommands), + bindings: api.tuiConfig.keybinds.gather("which-key.toggle", toggleCommands), }) api.slots.register({ @@ -599,7 +600,7 @@ const tui: TuiPlugin = async (api) => { } const plugin: InternalTuiPlugin = { - id: "tui-which-key", + id: "which-key", enabled: false, tui, } diff --git a/packages/opencode/src/cli/cmd/tui/keymap.tsx b/packages/opencode/src/cli/cmd/tui/keymap.tsx index 0d65057d79..379fa5afdf 100644 --- a/packages/opencode/src/cli/cmd/tui/keymap.tsx +++ b/packages/opencode/src/cli/cmd/tui/keymap.tsx @@ -1,5 +1,6 @@ import { type CliRenderer } from "@opentui/core" import * as addons from "@opentui/keymap/addons/opentui" +import { stringifyKeyStroke } from "@opentui/keymap" import { formatCommandBindings as formatCommandBindingsExtra, formatKeySequence as formatKeySequenceExtra, @@ -14,6 +15,7 @@ import { import type { Accessor } from "solid-js" import type { TuiConfig } from "./config/tui" import { useTuiConfig } from "./context/tui-config" +import { TuiKeybind } from "./config/keybind" export const LEADER_TOKEN = "leader" @@ -24,10 +26,55 @@ export { reactiveMatcherFromSignal, useBindings, useKeymapSelector } export type OpenTuiKeymap = ReturnType +const inputCommands = [ + "input.move.left", + "input.move.right", + "input.move.up", + "input.move.down", + "input.select.left", + "input.select.right", + "input.select.up", + "input.select.down", + "input.line.home", + "input.line.end", + "input.select.line.home", + "input.select.line.end", + "input.visual.line.home", + "input.visual.line.end", + "input.select.visual.line.home", + "input.select.visual.line.end", + "input.buffer.home", + "input.buffer.end", + "input.select.buffer.home", + "input.select.buffer.end", + "input.delete.line", + "input.delete.to.line.end", + "input.delete.to.line.start", + "input.backspace", + "input.delete", + "input.newline", + "input.undo", + "input.redo", + "input.word.forward", + "input.word.backward", + "input.select.word.forward", + "input.select.word.backward", + "input.delete.word.forward", + "input.delete.word.backward", + "input.select.all", + "input.submit", +] as const + +function leaderDisplay(config: TuiConfig.Resolved) { + const key = config.keybinds.get(LEADER_TOKEN)?.[0]?.key + if (!key) return TuiKeybind.LeaderDefault + return typeof key === "string" ? key : stringifyKeyStroke(key) +} + function formatOptions(config: TuiConfig.Resolved) { return { tokenDisplay: { - [LEADER_TOKEN]: config.keymap.leader, + [LEADER_TOKEN]: leaderDisplay(config), }, keyNameAliases: { pageup: "pgup", @@ -55,19 +102,23 @@ export function registerOpencodeKeymap(keymap: OpenTuiKeymap, renderer: CliRende const offCommaBindings = addons.registerCommaBindings(keymap) const offBaseLayout = addons.registerBaseLayoutFallback(keymap) const offLeader = addons.registerTimedLeader(keymap, { - trigger: config.keymap.leader, + trigger: config.keybinds.get(LEADER_TOKEN), name: LEADER_TOKEN, - timeoutMs: config.keymap.leader_timeout, + timeoutMs: config.leader_timeout, }) const offEscape = addons.registerEscapeClearsPendingSequence(keymap) const offBackspace = addons.registerBackspacePopsPendingSequence(keymap) - const offInputBindings = addons.registerManagedTextareaLayer(keymap, renderer, { + const offInputCommands = addons.registerEditBufferCommands(keymap, renderer) + const offInputSuspension = addons.registerTextareaMappingSuspension(keymap, renderer) + const offInputBindings = keymap.registerLayer({ enabled: () => renderer.currentFocusedEditor !== null, - bindings: config.keymap.sections.input, + bindings: config.keybinds.gather("input", inputCommands), }) return () => { offInputBindings() + offInputSuspension() + offInputCommands() offBackspace() offEscape() offLeader() diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 40f3e4fbca..508ba49416 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -117,6 +117,42 @@ function goUpsellKeys(action: SessionRetry.Retryable["action"]) { } } +const sessionBindingCommands = [ + "session.share", + "session.rename", + "session.timeline", + "session.fork", + "session.compact", + "session.unshare", + "session.undo", + "session.redo", + "session.sidebar.toggle", + "session.toggle.conceal", + "session.toggle.timestamps", + "session.toggle.thinking", + "session.toggle.actions", + "session.toggle.scrollbar", + "session.toggle.generic_tool_output", + "session.page.up", + "session.page.down", + "session.line.up", + "session.line.down", + "session.half.page.up", + "session.half.page.down", + "session.first", + "session.last", + "session.messages_last_user", + "session.message.next", + "session.message.previous", + "messages.copy", + "session.copy", + "session.export", + "session.child.first", + "session.parent", + "session.child.next", + "session.child.previous", +] as const + const context = createContext<{ width: number sessionID: string @@ -144,9 +180,6 @@ export function Session() { const event = useEvent() const project = useProject() const tuiConfig = useTuiConfig() - const { - keymap: { sections }, - } = tuiConfig const kv = useKV() const { theme } = useTheme() const promptRef = usePromptRef() @@ -1015,7 +1048,7 @@ export function Session() { useBindings(() => ({ enabled: command.matcher, - bindings: sections.session, + bindings: tuiConfig.keybinds.gather("session", sessionBindingCommands), })) const revertInfo = createMemo(() => session()?.revert) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx index 036b56dbd5..0ccc3d7262 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx @@ -463,7 +463,6 @@ function RejectPrompt(props: { onConfirm: (message: string) => void; onCancel: ( let input: TextareaRenderable const { theme } = useTheme() const tuiConfig = useTuiConfig() - const keymapConfig = tuiConfig.keymap const dimensions = useTerminalDimensions() const narrow = createMemo(() => dimensions().width < 80) const dialog = useDialog() @@ -471,7 +470,7 @@ function RejectPrompt(props: { onConfirm: (message: string) => void; onCancel: ( enabled: dialog.stack.length === 0, commands: [ { - name: "permission.reject.cancel", + name: "app.exit", title: "Cancel permission rejection", category: "Permission", run() { @@ -481,7 +480,7 @@ function RejectPrompt(props: { onConfirm: (message: string) => void; onCancel: ( ], bindings: [ { key: "escape", desc: "Cancel permission rejection", group: "Permission", cmd: () => props.onCancel() }, - ...keymapConfig.pick("permission", ["permission.reject.cancel"]), + ...tuiConfig.keybinds.get("app.exit"), { key: "return", desc: "Confirm permission rejection", @@ -553,7 +552,6 @@ function Prompt>(props: { }) { const { theme } = useTheme() const tuiConfig = useTuiConfig() - const keymapConfig = tuiConfig.keymap const dimensions = useTerminalDimensions() const keys = Object.keys(props.options) as (keyof T)[] const [store, setStore] = createStore({ @@ -568,7 +566,7 @@ function Prompt>(props: { enabled: dialog.stack.length === 0, commands: [ { - name: "permission.prompt.escape", + name: "app.exit", title: "Reject permission", category: "Permission", run() { @@ -643,8 +641,8 @@ function Prompt>(props: { }, ] : []), - ...(props.escapeKey ? keymapConfig.pick("permission", ["permission.prompt.escape"]) : []), - ...(props.fullscreen ? keymapConfig.pick("permission", ["permission.prompt.fullscreen"]) : []), + ...(props.escapeKey ? tuiConfig.keybinds.get("app.exit") : []), + ...(props.fullscreen ? tuiConfig.keybinds.get("permission.prompt.fullscreen") : []), ], })) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/question.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/question.tsx index e37b51e0a4..e690f6f327 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/question.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/question.tsx @@ -13,10 +13,6 @@ export function QuestionPrompt(props: { request: QuestionRequest }) { const sdk = useSDK() const { theme } = useTheme() const tuiConfig = useTuiConfig() - const { - keymap: { sections }, - } = tuiConfig - const keymapConfig = tuiConfig.keymap const questions = createMemo(() => props.request.questions) const single = createMemo(() => questions().length === 1 && questions()[0]?.multiple !== true) @@ -128,7 +124,7 @@ export function QuestionPrompt(props: { request: QuestionRequest }) { enabled: store.editing && !confirm(), commands: [ { - name: "question.edit.clear", + name: "prompt.clear", title: "Clear answer edit", category: "Question", run() { @@ -150,7 +146,7 @@ export function QuestionPrompt(props: { request: QuestionRequest }) { setStore("editing", false) }, }, - ...keymapConfig.pick("question", ["question.edit.clear"]), + ...tuiConfig.keybinds.get("prompt.clear"), { key: "return", desc: "Submit answer edit", @@ -208,7 +204,7 @@ export function QuestionPrompt(props: { request: QuestionRequest }) { enabled: dialog.stack.length === 0 && !store.editing, commands: [ { - name: "question.reject", + name: "app.exit", title: "Reject question", category: "Question", run() { @@ -243,7 +239,7 @@ export function QuestionPrompt(props: { request: QuestionRequest }) { ? [ { key: "return", desc: "Submit answer", group: "Question", cmd: () => submit() }, { key: "escape", desc: "Reject question", group: "Question", cmd: () => reject() }, - ...sections.question, + ...tuiConfig.keybinds.get("app.exit"), ] : [ ...Array.from({ length: max }, (_, index) => ({ @@ -271,7 +267,7 @@ export function QuestionPrompt(props: { request: QuestionRequest }) { { key: "j", desc: "Next answer", group: "Question", cmd: () => moveTo((store.selected + 1) % total) }, { key: "return", desc: "Select answer", group: "Question", cmd: () => selectOption() }, { key: "escape", desc: "Reject question", group: "Question", cmd: () => reject() }, - ...sections.question, + ...tuiConfig.keybinds.get("app.exit"), ]), ], } diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx index afa9d50571..a791aebc30 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx @@ -65,9 +65,6 @@ export function DialogSelect(props: DialogSelectProps) { const dialog = useDialog() const { theme } = useTheme() const tuiConfig = useTuiConfig() - const { - keymap: { sections }, - } = tuiConfig const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig)) const [store, setStore] = createStore({ @@ -308,11 +305,16 @@ export function DialogSelect(props: DialogSelectProps) { })), ], bindings: [ - ...sections.dialog_select, - ...tuiConfig.keymap.pick( - "dialog_actions", - enabledActions.map((item) => item.command), - ), + ...tuiConfig.keybinds.gather("dialog.select", [ + "dialog.select.prev", + "dialog.select.next", + "dialog.select.page_up", + "dialog.select.page_down", + "dialog.select.home", + "dialog.select.end", + "dialog.select.submit", + ]), + ...enabledActions.flatMap((item) => tuiConfig.keybinds.get(item.command)), ...(props.bindings ?? []).filter((binding) => { if (typeof binding.cmd !== "string") return true return enabledActions.some((item) => item.command === binding.cmd) diff --git a/packages/opencode/src/config/keybinds.ts b/packages/opencode/src/config/keybinds.ts deleted file mode 100644 index d9a397f516..0000000000 --- a/packages/opencode/src/config/keybinds.ts +++ /dev/null @@ -1,143 +0,0 @@ -export * as ConfigKeybinds from "./keybinds" - -import { Effect, Schema } from "effect" -import type z from "zod" -import { zod } from "@/util/effect-zod" - -// Every keybind field has the same shape: an optional string with a default -// binding and a human description. `keybind()` keeps the declaration list -// below dense and readable. -const keybind = (value: string, description: string) => - Schema.String.pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed(value))).annotate({ description }) - -// Windows prepends ctrl+z to the undo binding because `terminal_suspend` -// cannot consume ctrl+z on native Windows terminals (no POSIX suspend). -const inputUndoDefault = process.platform === "win32" ? "ctrl+z,ctrl+-,super+z" : "ctrl+-,super+z" - -const KeybindsSchema = Schema.Struct({ - leader: keybind("ctrl+x", "Leader key for keybind combinations"), - app_exit: keybind("ctrl+c,ctrl+d,q", "Exit the application"), - editor_open: keybind("e", "Open external editor"), - theme_list: keybind("t", "List available themes"), - sidebar_toggle: keybind("b", "Toggle sidebar"), - scrollbar_toggle: keybind("none", "Toggle session scrollbar"), - status_view: keybind("s", "View status"), - session_export: keybind("x", "Export session to editor"), - session_new: keybind("n", "Create a new session"), - session_list: keybind("l", "List all sessions"), - session_timeline: keybind("g", "Show session timeline"), - session_fork: keybind("none", "Fork session from message"), - session_rename: keybind("ctrl+r", "Rename session"), - session_delete: keybind("ctrl+d", "Delete session"), - stash_delete: keybind("ctrl+d", "Delete stash entry"), - model_provider_list: keybind("ctrl+a", "Open provider list from model dialog"), - model_favorite_toggle: keybind("ctrl+f", "Toggle model favorite status"), - session_share: keybind("none", "Share current session"), - session_unshare: keybind("none", "Unshare current session"), - session_interrupt: keybind("escape", "Interrupt current session"), - session_compact: keybind("c", "Compact the session"), - messages_page_up: keybind("pageup,ctrl+alt+b", "Scroll messages up by one page"), - messages_page_down: keybind("pagedown,ctrl+alt+f", "Scroll messages down by one page"), - messages_line_up: keybind("ctrl+alt+y", "Scroll messages up by one line"), - messages_line_down: keybind("ctrl+alt+e", "Scroll messages down by one line"), - messages_half_page_up: keybind("ctrl+alt+u", "Scroll messages up by half page"), - messages_half_page_down: keybind("ctrl+alt+d", "Scroll messages down by half page"), - messages_first: keybind("ctrl+g,home", "Navigate to first message"), - messages_last: keybind("ctrl+alt+g,end", "Navigate to last message"), - messages_next: keybind("none", "Navigate to next message"), - messages_previous: keybind("none", "Navigate to previous message"), - messages_last_user: keybind("none", "Navigate to last user message"), - messages_copy: keybind("y", "Copy message"), - messages_undo: keybind("u", "Undo message"), - messages_redo: keybind("r", "Redo message"), - messages_toggle_conceal: keybind("h", "Toggle code block concealment in messages"), - tool_details: keybind("none", "Toggle tool details visibility"), - model_list: keybind("m", "List available models"), - model_cycle_recent: keybind("f2", "Next recently used model"), - model_cycle_recent_reverse: keybind("shift+f2", "Previous recently used model"), - model_cycle_favorite: keybind("none", "Next favorite model"), - model_cycle_favorite_reverse: keybind("none", "Previous favorite model"), - command_list: keybind("ctrl+p", "List available commands"), - "dialog.select.prev": keybind("up,ctrl+p", "Move to previous dialog item"), - "dialog.select.next": keybind("down,ctrl+n", "Move to next dialog item"), - "dialog.select.page_up": keybind("pageup", "Move up one page in dialog"), - "dialog.select.page_down": keybind("pagedown", "Move down one page in dialog"), - "dialog.select.home": keybind("home", "Move to first dialog item"), - "dialog.select.end": keybind("end", "Move to last dialog item"), - "dialog.select.submit": keybind("return", "Submit selected dialog item"), - "dialog.mcp.toggle": keybind("space", "Toggle MCP in MCP dialog"), - "prompt.autocomplete.prev": keybind("up,ctrl+p", "Move to previous autocomplete item"), - "prompt.autocomplete.next": keybind("down,ctrl+n", "Move to next autocomplete item"), - "prompt.autocomplete.hide": keybind("escape", "Hide autocomplete"), - "prompt.autocomplete.select": keybind("return", "Select autocomplete item"), - "prompt.autocomplete.complete": keybind("tab", "Complete autocomplete item"), - "permission.prompt.fullscreen": keybind("ctrl+f", "Toggle permission prompt fullscreen"), - "plugins.toggle": keybind("space", "Toggle plugin"), - "dialog.plugins.install": keybind("shift+i", "Install plugin from plugin dialog"), - agent_list: keybind("a", "List agents"), - agent_cycle: keybind("tab", "Next agent"), - agent_cycle_reverse: keybind("shift+tab", "Previous agent"), - variant_cycle: keybind("ctrl+t", "Cycle model variants"), - variant_list: keybind("none", "List model variants"), - input_clear: keybind("ctrl+c", "Clear input field"), - input_paste: keybind("ctrl+v", "Paste from clipboard"), - input_submit: keybind("return", "Submit input"), - input_newline: keybind("shift+return,ctrl+return,alt+return,ctrl+j", "Insert newline in input"), - input_move_left: keybind("left,ctrl+b", "Move cursor left in input"), - input_move_right: keybind("right,ctrl+f", "Move cursor right in input"), - input_move_up: keybind("up", "Move cursor up in input"), - input_move_down: keybind("down", "Move cursor down in input"), - input_select_left: keybind("shift+left", "Select left in input"), - input_select_right: keybind("shift+right", "Select right in input"), - input_select_up: keybind("shift+up", "Select up in input"), - input_select_down: keybind("shift+down", "Select down in input"), - input_line_home: keybind("ctrl+a", "Move to start of line in input"), - input_line_end: keybind("ctrl+e", "Move to end of line in input"), - input_select_line_home: keybind("ctrl+shift+a", "Select to start of line in input"), - input_select_line_end: keybind("ctrl+shift+e", "Select to end of line in input"), - input_visual_line_home: keybind("alt+a", "Move to start of visual line in input"), - input_visual_line_end: keybind("alt+e", "Move to end of visual line in input"), - input_select_visual_line_home: keybind("alt+shift+a", "Select to start of visual line in input"), - input_select_visual_line_end: keybind("alt+shift+e", "Select to end of visual line in input"), - input_buffer_home: keybind("home", "Move to start of buffer in input"), - input_buffer_end: keybind("end", "Move to end of buffer in input"), - input_select_buffer_home: keybind("shift+home", "Select to start of buffer in input"), - input_select_buffer_end: keybind("shift+end", "Select to end of buffer in input"), - input_delete_line: keybind("ctrl+shift+d", "Delete line in input"), - input_delete_to_line_end: keybind("ctrl+k", "Delete to end of line in input"), - input_delete_to_line_start: keybind("ctrl+u", "Delete to start of line in input"), - input_backspace: keybind("backspace,shift+backspace", "Backspace in input"), - input_delete: keybind("ctrl+d,delete,shift+delete", "Delete character in input"), - input_undo: keybind(inputUndoDefault, "Undo in input"), - input_redo: keybind("ctrl+.,super+shift+z", "Redo in input"), - input_word_forward: keybind("alt+f,alt+right,ctrl+right", "Move word forward in input"), - input_word_backward: keybind("alt+b,alt+left,ctrl+left", "Move word backward in input"), - input_select_word_forward: keybind("alt+shift+f,alt+shift+right", "Select word forward in input"), - input_select_word_backward: keybind("alt+shift+b,alt+shift+left", "Select word backward in input"), - input_delete_word_forward: keybind("alt+d,alt+delete,ctrl+delete", "Delete word forward in input"), - input_delete_word_backward: keybind("ctrl+w,ctrl+backspace,alt+backspace", "Delete word backward in input"), - input_select_all: keybind("super+a", "Select all in input"), - history_previous: keybind("up", "Previous history item"), - history_next: keybind("down", "Next history item"), - session_child_first: keybind("down", "Go to first child session"), - session_child_cycle: keybind("right", "Go to next child session"), - session_child_cycle_reverse: keybind("left", "Go to previous child session"), - session_parent: keybind("up", "Go to parent session"), - // `terminal_suspend` was formerly `.default("ctrl+z").transform((v) => win32 ? "none" : v)`, - // but `tui.ts` already forces the binding to "none" on win32 before calling - // `Keybinds.parse(...)`, so the schema-level transform was redundant. - terminal_suspend: keybind("ctrl+z", "Suspend terminal"), - terminal_title_toggle: keybind("none", "Toggle terminal title"), - tips_toggle: keybind("h", "Toggle tips on home screen"), - plugin_manager: keybind("none", "Open plugin manager dialog"), - display_thinking: keybind("none", "Toggle thinking blocks visibility"), -}).annotate({ identifier: "KeybindsConfig" }) - -export type Keybinds = Schema.Schema.Type - -// Consumers access `Keybinds.shape` and `Keybinds.shape.X.parse(undefined)`, -// which requires the runtime type to be a ZodObject, not just ZodType. Every -// field is `string().optional().default(...)` at runtime, so widen to that. -export const Keybinds = zod(KeybindsSchema) as unknown as z.ZodObject< - Record>> -> diff --git a/packages/opencode/test/cli/run/runtime.boot.test.ts b/packages/opencode/test/cli/run/runtime.boot.test.ts index c0d66bf75d..e2569b0ac6 100644 --- a/packages/opencode/test/cli/run/runtime.boot.test.ts +++ b/packages/opencode/test/cli/run/runtime.boot.test.ts @@ -1,12 +1,11 @@ import { afterEach, describe, expect, mock, spyOn, test } from "bun:test" import type { KeyEvent, Renderable } from "@opentui/core" import type { Binding } from "@opentui/keymap" -import { resolveBindingSections, type BindingSectionsConfig } from "@opentui/keymap/extras" +import { createBindingLookup } from "@opentui/keymap/extras" import { OpencodeClient, type Provider } from "@opencode-ai/sdk/v2" import { TuiConfig, type Resolved } from "@/cli/cmd/tui/config/tui" import { formatBindings } from "@/cli/cmd/run/keymap.shared" -import { KeymapSectionNames, keymapBindingDefaults, type KeymapSection } from "@/cli/cmd/tui/config/tui-schema" -import { ConfigKeybinds } from "@/config/keybinds" +import { TuiKeybind } from "@/cli/cmd/tui/config/keybind" import { resolveDiffStyle, resolveFooterKeybinds, resolveModelInfo } from "@/cli/cmd/run/runtime.boot" type RunBinding = Binding @@ -82,34 +81,24 @@ function config(input?: { }> }): Resolved { const bind = input?.bindings - const sections = { - global: Object.fromEntries([ - ...(bind?.commandList ? [["command.palette.show", bind.commandList] as const] : []), - ...(bind?.variantCycle ? [["variant.cycle", bind.variantCycle] as const] : []), - ]), - prompt: Object.fromEntries([ - ...(bind?.interrupt ? [["session.interrupt", bind.interrupt] as const] : []), - ...(bind?.historyPrevious ? [["prompt.history.previous", bind.historyPrevious] as const] : []), - ...(bind?.historyNext ? [["prompt.history.next", bind.historyNext] as const] : []), - ...(bind?.inputClear ? [["prompt.clear", bind.inputClear] as const] : []), - ]), - input: Object.fromEntries([ - ...(bind?.inputSubmit ? [["input.submit", bind.inputSubmit] as const] : []), - ...(bind?.inputNewline ? [["input.newline", bind.inputNewline] as const] : []), - ]), - } satisfies BindingSectionsConfig - + const keybinds = TuiKeybind.Keybinds.parse({ + ...(input?.leader && { leader: input.leader }), + ...(bind?.commandList && { command_list: bind.commandList }), + ...(bind?.variantCycle && { variant_cycle: bind.variantCycle }), + ...(bind?.interrupt && { session_interrupt: bind.interrupt }), + ...(bind?.historyPrevious && { history_previous: bind.historyPrevious }), + ...(bind?.historyNext && { history_next: bind.historyNext }), + ...(bind?.inputClear && { input_clear: bind.inputClear }), + ...(bind?.inputSubmit && { input_submit: bind.inputSubmit }), + ...(bind?.inputNewline && { input_newline: bind.inputNewline }), + }) return { diff_style: input?.diff_style, - keybinds: ConfigKeybinds.Keybinds.parse({}), - keymap: { - leader: input?.leader ?? "ctrl+x", - leader_timeout: input?.leaderTimeout ?? 2000, - ...resolveBindingSections(sections, { - sections: KeymapSectionNames, - bindingDefaults: keymapBindingDefaults, - }), - }, + keybinds: createBindingLookup(TuiKeybind.toBindingConfig(keybinds), { + commandMap: TuiKeybind.CommandMap, + bindingDefaults: TuiKeybind.bindingDefaults(), + }), + leader_timeout: input?.leaderTimeout ?? 2000, } } @@ -118,7 +107,7 @@ describe("run runtime boot", () => { mock.restore() }) - test("reads footer keybinds from resolved keymap config", async () => { + test("reads footer keybinds from resolved keybind config", async () => { spyOn(TuiConfig, "get").mockResolvedValue( config({ leader: "ctrl+g", diff --git a/packages/opencode/test/cli/tui/plugin-loader.test.ts b/packages/opencode/test/cli/tui/plugin-loader.test.ts index 1702101233..d62bc19bfe 100644 --- a/packages/opencode/test/cli/tui/plugin-loader.test.ts +++ b/packages/opencode/test/cli/tui/plugin-loader.test.ts @@ -81,7 +81,7 @@ async function load(): Promise { await Bun.write( localPluginPath, - `import { resolveBindingSections } from "@opentui/keymap/extras" + `import { createBindingLookup } from "@opentui/keymap/extras" import { useBindings } from "@opentui/keymap/solid" export const ignored = async (_input, options) => { @@ -97,20 +97,18 @@ export default { const cfg_diff = api.tuiConfig.diff_style const cfg_speed = api.tuiConfig.scroll_speed const cfg_accel = api.tuiConfig.scroll_acceleration?.enabled - const cfg_submit = api.tuiConfig.keybinds?.input_submit const has_keys = typeof api.keys.formatBindings === "function" - const keymap = resolveBindingSections(options.keymap?.sections ?? { - main: { - "plugin.loader.local": "ctrl+shift+m", - "plugin.loader.close": "escape", - }, - }, { sections: ["main"] }).sections - const key_modal = keymap.main.find((item) => item.cmd === "plugin.loader.local")?.key - const key_close = keymap.main.find((item) => item.cmd === "plugin.loader.close")?.key + const keybinds = createBindingLookup(options.keybinds ?? { + "plugin.loader.local": "ctrl+shift+m", + "plugin.loader.close": "escape", + }) + const bindings = keybinds.gather("plugin.loader", ["plugin.loader.local", "plugin.loader.close"]) + const key_modal = bindings.find((item) => item.cmd === "plugin.loader.local")?.key + const key_close = bindings.find((item) => item.cmd === "plugin.loader.close")?.key const key_unknown = "ctrl+k" const off = api.keymap.registerLayer({ commands: [{ name: "plugin.loader.local", run() {} }, { name: "plugin.loader.close", run() {} }], - bindings: keymap.main, + bindings, }) off() const kv_before = api.kv.get(options.kv_key, "missing") @@ -153,7 +151,7 @@ export default { key_unknown, has_keys, has_keymap: typeof api.keymap.registerLayer === "function", - has_resolve_binding_sections: typeof resolveBindingSections === "function", + has_create_binding_lookup: typeof createBindingLookup === "function", has_keymap_solid: typeof useBindings === "function", kv_before, kv_after, @@ -176,7 +174,6 @@ export default { cfg_diff, cfg_speed, cfg_accel, - cfg_submit, }), ) }, @@ -356,13 +353,9 @@ export default { theme_name: tmp.extra.localThemeName, kv_key: "plugin_state_key", session_id: "ses_test", - keymap: { - sections: { - main: { - "plugin.loader.local": "ctrl+alt+m", - "plugin.loader.close": "q", - }, - }, + keybinds: { + "plugin.loader.local": "ctrl+alt+m", + "plugin.loader.close": "q", }, } const invalidOpts = { @@ -408,9 +401,6 @@ export default { diff_style: "stacked", scroll_speed: 1.5, scroll_acceleration: { enabled: true }, - keybinds: { - input_submit: "ctrl+enter", - }, }, state: { session: { @@ -670,7 +660,7 @@ describe("tui.plugin.loader", () => { expect(data.local.key_unknown).toBe("ctrl+k") expect(data.local.has_keys).toBe(true) expect(data.local.has_keymap).toBe(true) - expect(data.local.has_resolve_binding_sections).toBe(true) + expect(data.local.has_create_binding_lookup).toBe(true) expect(data.local.has_keymap_solid).toBe(true) expect(data.local.kv_before).toBe("missing") expect(data.local.kv_after).toBe("stored") @@ -693,7 +683,6 @@ describe("tui.plugin.loader", () => { expect(data.local.cfg_diff).toBe("stacked") expect(data.local.cfg_speed).toBe(1.5) expect(data.local.cfg_accel).toBe(true) - expect(data.local.cfg_submit).toBe("ctrl+enter") }) test("installs themes in the correct scope and remains resilient", () => { diff --git a/packages/opencode/test/cli/tui/plugin-toggle.test.ts b/packages/opencode/test/cli/tui/plugin-toggle.test.ts index 0f3f663c02..a3ee744bff 100644 --- a/packages/opencode/test/cli/tui/plugin-toggle.test.ts +++ b/packages/opencode/test/cli/tui/plugin-toggle.test.ts @@ -171,26 +171,26 @@ test("loads disabled-by-default internal plugin inactive and activates on demand enabled: true, active: true, }) - expect(TuiPluginRuntime.list().find((item) => item.id === "tui-which-key")).toEqual({ - id: "tui-which-key", + expect(TuiPluginRuntime.list().find((item) => item.id === "which-key")).toEqual({ + id: "which-key", source: "internal", - spec: "tui-which-key", - target: "tui-which-key", + spec: "which-key", + target: "which-key", enabled: false, active: false, }) - await expect(TuiPluginRuntime.activatePlugin("tui-which-key")).resolves.toBe(true) - expect(TuiPluginRuntime.list().find((item) => item.id === "tui-which-key")).toEqual({ - id: "tui-which-key", + await expect(TuiPluginRuntime.activatePlugin("which-key")).resolves.toBe(true) + expect(TuiPluginRuntime.list().find((item) => item.id === "which-key")).toEqual({ + id: "which-key", source: "internal", - spec: "tui-which-key", - target: "tui-which-key", + spec: "which-key", + target: "which-key", enabled: true, active: true, }) expect(api.kv.get("plugin_enabled", {})).toEqual({ - "tui-which-key": true, + "which-key": true, }) } finally { await TuiPluginRuntime.dispose() diff --git a/packages/opencode/test/config/tui.test.ts b/packages/opencode/test/config/tui.test.ts index 4ad942b251..db04568573 100644 --- a/packages/opencode/test/config/tui.test.ts +++ b/packages/opencode/test/config/tui.test.ts @@ -163,7 +163,7 @@ test("migrates tui-specific keys from opencode.json when tui.json does not exist const config = await getTuiConfig(tmp.path) expect(config.theme).toBe("migrated-theme") expect(config.scroll_speed).toBe(5) - expect(config.keybinds?.app_exit).toBe("ctrl+q") + expect(config.keybinds.get("app.exit")?.[0]?.key).toBe("ctrl+q") const text = await Filesystem.readText(path.join(tmp.path, "tui.json")) expect(JSON.parse(text)).toMatchObject({ theme: "migrated-theme", @@ -398,83 +398,64 @@ test("merges keybind overrides across precedence layers", async () => { }, }) const config = await getTuiConfig(tmp.path) - expect(config.keybinds?.app_exit).toBe("ctrl+q") - expect(config.keybinds?.theme_list).toBe("ctrl+k") + expect(config.keybinds.get("app.exit")?.[0]?.key).toBe("ctrl+q") + expect(config.keybinds.get("theme.switch")?.[0]?.key).toBe("ctrl+k") }) -test("resolves semantic keymap sections", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await Bun.write( - path.join(dir, "tui.json"), - JSON.stringify({ - keybinds: { command_list: "ctrl+z" }, - keymap: { - sections: { - global: { "command.palette.show": "alt+p" }, - which_key: { "tui-which-key.toggle": "alt+k" }, - prompt: { "prompt.editor": "ctrl+e" }, - autocomplete: { "prompt.autocomplete.next": "ctrl+j" }, - dialog_actions: { "dialog.action.toggle": "ctrl+t" }, - model: { "model.dialog.favorite": "ctrl+f" }, - plugins: { "plugin.dialog.install": "shift+i" }, - }, - }, - }), - ) - }, - }) - - const config = await getTuiConfig(tmp.path) - expect(config.keymap.sections.global.find((binding) => binding.cmd === "command.palette.show")?.key).toBe("alt+p") - expect(config.keymap.sections.global.find((binding) => binding.cmd === "session.new")?.key).toBe("n") - expect(config.keymap.sections.which_key.find((binding) => binding.cmd === "tui-which-key.toggle")?.key).toBe("alt+k") - expect(config.keymap.sections.which_key.find((binding) => binding.cmd === "tui-which-key.layout.toggle")?.key).toBe( - "ctrl+alt+shift+k", - ) - expect(config.keymap.sections.which_key.find((binding) => binding.cmd === "tui-which-key.pending.toggle")?.key).toBe( - "ctrl+alt+shift+p", - ) - expect(config.keymap.sections.which_key.find((binding) => binding.cmd === "tui-which-key.group.next")?.key).toBe( - "ctrl+alt+right,ctrl+alt+]", - ) - expect( - ( - config.keymap.sections.which_key.find((binding) => binding.cmd === "tui-which-key.toggle") as - | { group?: unknown } - | undefined - )?.group, - ).toBe("System") - expect(config.keymap.sections.prompt.find((binding) => binding.cmd === "prompt.editor")?.key).toBe("ctrl+e") - expect(config.keymap.sections.autocomplete.find((binding) => binding.cmd === "prompt.autocomplete.next")?.key).toBe( - "ctrl+j", - ) - expect(config.keymap.sections.dialog_actions.find((binding) => binding.cmd === "dialog.action.toggle")?.key).toBe( - "ctrl+t", - ) - expect(config.keymap.sections.model.find((binding) => binding.cmd === "model.dialog.favorite")?.key).toBe("ctrl+f") - expect(config.keymap.sections.plugins.find((binding) => binding.cmd === "plugin.dialog.install")?.key).toBe("shift+i") - expect(config.keymap.pick("plugins", ["plugin.dialog.install"]).map((binding) => binding.cmd)).toEqual([ - "plugin.dialog.install", - ]) - expect((config.keymap.pick("plugins", ["plugin.dialog.install"])[0] as { group?: unknown } | undefined)?.group).toBe( - "Plugins", - ) - expect(config.keymap.omit("plugins", ["plugin.dialog.install"]).map((binding) => binding.cmd)).toEqual([]) -}) - -test("legacy keybinds transform into semantic keymap sections", async () => { +test("resolves keybind lookup from canonical keybinds", async () => { await using tmp = await tmpdir({ init: async (dir) => { await Bun.write( path.join(dir, "tui.json"), JSON.stringify({ keybinds: { + leader: { key: { name: "g", ctrl: true } }, command_list: "alt+p", + which_key_toggle: "alt+k", editor_open: "ctrl+e", "prompt.autocomplete.next": "ctrl+j", "dialog.mcp.toggle": "ctrl+t", + model_favorite_toggle: "ctrl+f", "dialog.plugins.install": "shift+i", + }, + leader_timeout: 1234, + }), + ) + }, + }) + + const config = await getTuiConfig(tmp.path) + expect(config.keybinds.get("leader")?.[0]?.key).toEqual({ name: "g", ctrl: true }) + expect(config.leader_timeout).toBe(1234) + expect(config.keybinds.get("command.palette.show")?.[0]?.key).toBe("alt+p") + expect(config.keybinds.get("session.new")?.[0]?.key).toBe("n") + expect(config.keybinds.get("which-key.toggle")?.[0]?.key).toBe("alt+k") + expect(config.keybinds.get("which-key.layout.toggle")?.[0]?.key).toBe("ctrl+alt+shift+k") + expect(config.keybinds.get("which-key.pending.toggle")?.[0]?.key).toBe("ctrl+alt+shift+p") + expect(config.keybinds.get("which-key.group.next")?.[0]?.key).toBe("ctrl+alt+right,ctrl+alt+]") + expect((config.keybinds.get("which-key.toggle")?.[0] as { desc?: unknown } | undefined)?.desc).toBe( + "Toggle which-key panel", + ) + expect(config.keybinds.get("prompt.editor")?.[0]?.key).toBe("ctrl+e") + expect(config.keybinds.get("prompt.autocomplete.next")?.[0]?.key).toBe("ctrl+j") + expect(config.keybinds.get("dialog.mcp.toggle")?.[0]?.key).toBe("ctrl+t") + expect(config.keybinds.get("model.dialog.favorite")?.[0]?.key).toBe("ctrl+f") + expect(config.keybinds.get("dialog.plugins.install")?.[0]?.key).toBe("shift+i") + expect(config.keybinds.gather("plugins.dialog", ["dialog.plugins.install"]).map((binding) => binding.cmd)).toEqual([ + "dialog.plugins.install", + ]) +}) + +test("keybinds accept OpenTUI binding specs", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "tui.json"), + JSON.stringify({ + keybinds: { + command_list: [{ key: "alt+p", preventDefault: false }], + editor_open: { key: { name: "e", ctrl: true }, group: "Explicit" }, + "prompt.autocomplete.next": false, plugin_manager: "ctrl+shift+p", }, }), @@ -483,52 +464,23 @@ test("legacy keybinds transform into semantic keymap sections", async () => { }) const config = await getTuiConfig(tmp.path) - expect(Object.keys(config.keymap.sections)).toEqual([ - "global", - "which_key", - "session", - "prompt", - "autocomplete", - "input", - "dialog_select", - "dialog_actions", - "model", - "permission", - "question", - "plugins", - "home_tips", - ]) - expect(config.keymap.sections.global.find((binding) => binding.cmd === "command.palette.show")?.key).toBe("alt+p") - expect(config.keymap.sections.which_key.find((binding) => binding.cmd === "tui-which-key.toggle")?.key).toBe( - "ctrl+alt+k", - ) - expect(config.keymap.sections.prompt.find((binding) => binding.cmd === "prompt.editor")?.key).toBe("ctrl+e") - expect(config.keymap.sections.autocomplete.find((binding) => binding.cmd === "prompt.autocomplete.next")?.key).toBe( - "ctrl+j", - ) - expect(config.keymap.sections.dialog_actions.find((binding) => binding.cmd === "dialog.action.toggle")?.key).toBe( - "ctrl+t", - ) - expect(config.keymap.sections.model.find((binding) => binding.cmd === "model.dialog.provider")?.key).toBe("ctrl+a") - expect(config.keymap.sections.model.find((binding) => binding.cmd === "model.dialog.favorite")?.key).toBe("ctrl+f") - expect(config.keymap.sections.plugins.find((binding) => binding.cmd === "plugin.dialog.install")?.key).toBe("shift+i") - expect(config.keymap.sections.plugins.find((binding) => binding.cmd === "plugins.list")?.key).toBe("ctrl+shift+p") - expect(config.keymap.pick("plugins", ["plugin.dialog.install"]).map((binding) => binding.cmd)).toEqual([ - "plugin.dialog.install", - ]) - expect((config.keymap.omit("plugins", ["plugin.dialog.install"])[0] as { group?: unknown } | undefined)?.group).toBe( - "Plugins", - ) - expect(config.keymap.omit("plugins", ["plugin.dialog.install"]).map((binding) => binding.cmd)).toEqual([ - "plugins.list", + expect(config.keybinds.get("command.palette.show")).toEqual([ + { key: "alt+p", cmd: "command.palette.show", preventDefault: false, desc: "List available commands" }, ]) + expect(config.keybinds.get("prompt.editor")?.[0]).toMatchObject({ + key: { name: "e", ctrl: true }, + cmd: "prompt.editor", + group: "Explicit", + }) + expect(config.keybinds.get("prompt.autocomplete.next")).toEqual([]) + expect(config.keybinds.get("plugins.list")?.[0]?.key).toBe("ctrl+shift+p") }) wintest("defaults Ctrl+Z to input undo on Windows", async () => { await using tmp = await tmpdir() const config = await getTuiConfig(tmp.path) - expect(config.keybinds?.terminal_suspend).toBe("none") - expect(config.keybinds?.input_undo).toBe("ctrl+z,ctrl+-,super+z") + expect(config.keybinds.get("terminal.suspend")).toEqual([]) + expect(config.keybinds.get("input.undo")?.[0]?.key).toBe("ctrl+z,ctrl+-,super+z") }) wintest("keeps explicit input undo overrides on Windows", async () => { @@ -538,8 +490,8 @@ wintest("keeps explicit input undo overrides on Windows", async () => { }, }) const config = await getTuiConfig(tmp.path) - expect(config.keybinds?.terminal_suspend).toBe("none") - expect(config.keybinds?.input_undo).toBe("ctrl+y") + expect(config.keybinds.get("terminal.suspend")).toEqual([]) + expect(config.keybinds.get("input.undo")?.[0]?.key).toBe("ctrl+y") }) wintest("ignores terminal suspend bindings on Windows", async () => { @@ -550,33 +502,29 @@ wintest("ignores terminal suspend bindings on Windows", async () => { }) const config = await getTuiConfig(tmp.path) - expect(config.keybinds?.terminal_suspend).toBe("none") - expect(config.keybinds?.input_undo).toBe("ctrl+z,ctrl+-,super+z") + expect(config.keybinds.get("terminal.suspend")).toEqual([]) + expect(config.keybinds.get("input.undo")?.[0]?.key).toBe("ctrl+z,ctrl+-,super+z") }) -test("applies Windows keymap defaults", async () => { +test("applies Windows keybind defaults", async () => { await withPlatform("win32", async () => { await using tmp = await tmpdir() const config = await getTuiConfig(tmp.path) - expect(config.keymap.sections.global.find((binding) => binding.cmd === "terminal.suspend")).toBeUndefined() - expect(config.keymap.sections.input.find((binding) => binding.cmd === "input.undo")?.key).toBe( - "ctrl+z,ctrl+-,super+z", - ) + expect(config.keybinds.get("terminal.suspend")).toEqual([]) + expect(config.keybinds.get("input.undo")?.[0]?.key).toBe("ctrl+z,ctrl+-,super+z") }) }) -test("keeps explicit configured keymap terminal suspend binding on Windows", async () => { +test("ignores explicit keybind terminal suspend binding on Windows", async () => { await withPlatform("win32", async () => { await using tmp = await tmpdir({ init: async (dir) => { await Bun.write( path.join(dir, "tui.json"), JSON.stringify({ - keymap: { - sections: { - global: { "terminal.suspend": "alt+z" }, - }, + keybinds: { + terminal_suspend: "alt+z", }, }), ) @@ -584,21 +532,19 @@ test("keeps explicit configured keymap terminal suspend binding on Windows", asy }) const config = await getTuiConfig(tmp.path) - expect(config.keymap.sections.global.find((binding) => binding.cmd === "terminal.suspend")?.key).toBe("alt+z") + expect(config.keybinds.get("terminal.suspend")).toEqual([]) }) }) -test("keeps explicit configured keymap input undo on Windows", async () => { +test("keeps explicit configured keybind input undo on Windows", async () => { await withPlatform("win32", async () => { await using tmp = await tmpdir({ init: async (dir) => { await Bun.write( path.join(dir, "tui.json"), JSON.stringify({ - keymap: { - sections: { - input: { "input.undo": "ctrl+y" }, - }, + keybinds: { + input_undo: "ctrl+y", }, }), ) @@ -606,7 +552,7 @@ test("keeps explicit configured keymap input undo on Windows", async () => { }) const config = await getTuiConfig(tmp.path) - expect(config.keymap.sections.input.find((binding) => binding.cmd === "input.undo")?.key).toBe("ctrl+y") + expect(config.keybinds.get("input.undo")?.[0]?.key).toBe("ctrl+y") }) }) @@ -655,7 +601,7 @@ test("applies env and file substitutions in tui.json", async () => { }) const config = await getTuiConfig(tmp.path) expect(config.theme).toBe("env-theme") - expect(config.keybinds?.app_exit).toBe("ctrl+q") + expect(config.keybinds.get("app.exit")?.[0]?.key).toBe("ctrl+q") } finally { if (original === undefined) delete process.env.TUI_THEME_TEST else process.env.TUI_THEME_TEST = original diff --git a/packages/opencode/test/fixture/tui-plugin.ts b/packages/opencode/test/fixture/tui-plugin.ts index a4a5aaad60..62a3ae6e6b 100644 --- a/packages/opencode/test/fixture/tui-plugin.ts +++ b/packages/opencode/test/fixture/tui-plugin.ts @@ -1,9 +1,7 @@ import { createOpencodeClient } from "@opencode-ai/sdk/v2" import { RGBA, type CliRenderer } from "@opentui/core" import type { HostPluginApi } from "../../src/cli/cmd/tui/plugin/slots" -import { LegacyKeymapTransform } from "../../src/cli/cmd/tui/config/legacy-keymap-transform" -import { ConfigKeybinds } from "../../src/config/keybinds" -import { createTuiResolvedKeymap } from "./tui-runtime" +import { createTuiResolvedConfig } from "./tui-runtime" type Count = { event_add: number @@ -112,11 +110,9 @@ type Opts = { } function tuiConfig(input?: Partial): HostPluginApi["tuiConfig"] { - const keybinds = ConfigKeybinds.Keybinds.parse(input?.keybinds ?? {}) return { + ...createTuiResolvedConfig(), ...input, - keybinds, - keymap: input?.keymap ?? createTuiResolvedKeymap(LegacyKeymapTransform.create(input?.keybinds ?? {})), } } diff --git a/packages/opencode/test/fixture/tui-runtime.ts b/packages/opencode/test/fixture/tui-runtime.ts index d1e4c744b0..64537b6c50 100644 --- a/packages/opencode/test/fixture/tui-runtime.ts +++ b/packages/opencode/test/fixture/tui-runtime.ts @@ -1,45 +1,29 @@ import { spyOn } from "bun:test" import path from "path" -import type { KeyEvent, Renderable } from "@opentui/core" -import { resolveBindingSections, type BindingSectionsConfig } from "@opentui/keymap/extras" +import { createBindingLookup } from "@opentui/keymap/extras" import { TuiConfig } from "../../src/cli/cmd/tui/config/tui" -import { LegacyKeymapTransform } from "../../src/cli/cmd/tui/config/legacy-keymap-transform" -import { ConfigKeybinds } from "../../src/config/keybinds" -import { - KeymapConfig, - KeymapSectionNames, - keymapBindingDefaults, - type KeymapConfigInput, - type KeymapSection, -} from "../../src/cli/cmd/tui/config/tui-schema" +import { TuiKeybind } from "../../src/cli/cmd/tui/config/keybind" type PluginSpec = string | [string, Record] -type ResolvedInput = Omit & { - keybinds?: TuiConfig.Resolved["keybinds"] - keymap?: TuiConfig.Resolved["keymap"] +type ResolvedInput = Omit & { + keybinds?: Partial + leader_timeout?: number } -export function createTuiResolvedKeymap(input: KeymapConfigInput): TuiConfig.Resolved["keymap"] { - const config = KeymapConfig.parse(input) - return { - leader: !config.leader || config.leader === "none" ? "ctrl+x" : config.leader, - leader_timeout: config.leader_timeout, - ...resolveBindingSections, KeymapSection>( - config.sections, - { - sections: KeymapSectionNames, - bindingDefaults: keymapBindingDefaults, - }, - ), - } +export function createTuiResolvedKeybinds(input: Partial = {}): TuiConfig.Resolved["keybinds"] { + const keybinds = TuiKeybind.Keybinds.parse(input) + return createBindingLookup(TuiKeybind.toBindingConfig(keybinds), { + commandMap: TuiKeybind.CommandMap, + bindingDefaults: TuiKeybind.bindingDefaults(), + }) } export function createTuiResolvedConfig(input: ResolvedInput = {}): TuiConfig.Resolved { - const keybinds = input.keybinds ?? ConfigKeybinds.Keybinds.parse({}) + const keybinds = TuiKeybind.Keybinds.parse(input.keybinds ?? {}) return { ...input, - keybinds, - keymap: input.keymap ?? createTuiResolvedKeymap(LegacyKeymapTransform.create(input.keybinds ?? {})), + keybinds: createTuiResolvedKeybinds(keybinds), + leader_timeout: input.leader_timeout ?? 2000, } } diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 0c7a694834..91afa3c883 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -22,9 +22,9 @@ "zod": "catalog:" }, "peerDependencies": { - "@opentui/core": ">=0.2.5", - "@opentui/keymap": ">=0.2.5", - "@opentui/solid": ">=0.2.5" + "@opentui/core": ">=0.2.6", + "@opentui/keymap": ">=0.2.6", + "@opentui/solid": ">=0.2.6" }, "peerDependenciesMeta": { "@opentui/core": { diff --git a/packages/plugin/src/tui.ts b/packages/plugin/src/tui.ts index b42bfdaf1f..13bc17f66b 100644 --- a/packages/plugin/src/tui.ts +++ b/packages/plugin/src/tui.ts @@ -18,8 +18,9 @@ import type { import type { CliRenderer, KeyEvent, RGBA, Renderable, SlotMode } from "@opentui/core" import type { Binding, Keymap } from "@opentui/keymap" import { - resolveBindingSections as resolveKeymapBindingSections, - type BindingSectionsConfig, + createBindingLookup as createKeymapBindingLookup, + type BindingConfig, + type CreateBindingLookupOptions, type KeySequenceFormatPart, type SequenceBindingLike, } from "@opentui/keymap/extras" @@ -31,22 +32,21 @@ export { stringifyKeySequence, stringifyKeyStroke } from "@opentui/keymap" export type { Binding, KeyLike, KeySequencePart, KeyStringifyInput, StringifyOptions } from "@opentui/keymap" export { formatCommandBindings, formatKeySequence } from "@opentui/keymap/extras" export type { - BindingSectionsConfig, + BindingConfig, + BindingLookup, BindingValue, + CreateBindingLookupOptions, FormatCommandBindingsOptions, FormatKeySequenceOptions, KeySequenceFormatPart, SequenceBindingLike, } from "@opentui/keymap/extras" -export function resolveBindingSections
( - config: BindingSectionsConfig | undefined, - options: { sections: readonly Section[] }, +export function createBindingLookup( + config: BindingConfig | undefined, + options?: CreateBindingLookupOptions, ) { - return resolveKeymapBindingSections, Section>( - config ?? {}, - options, - ) + return createKeymapBindingLookup(config ?? {}, options) } export type TuiRouteCurrent = @@ -286,17 +286,20 @@ export type TuiState = { mcp: () => ReadonlyArray } -type TuiConfigView = Pick & +type TuiBindingLookupView = { + readonly bindings: ReadonlyArray> + get: (command: string) => ReadonlyArray> + has: (command: string) => boolean + gather: (name: string, commands: readonly string[]) => ReadonlyArray> + pick: (name: string, commands: readonly string[]) => Binding[] + omit: (name: string, commands: readonly string[]) => Binding[] +} + +type TuiConfigView = Pick & NonNullable & { + leader_timeout: number plugin_enabled?: Record - keymap: { - leader: string - leader_timeout: number - sections: Record>> - get: (section: string, cmd: string) => ReadonlyArray> | undefined - pick: (section: string, commands: readonly string[]) => Binding[] - omit: (section: string, commands: readonly string[]) => Binding[] - } + keybinds: TuiBindingLookupView } export type TuiApp = { diff --git a/packages/web/src/content/docs/config.mdx b/packages/web/src/content/docs/config.mdx index 39c9974c56..ec96069c70 100644 --- a/packages/web/src/content/docs/config.mdx +++ b/packages/web/src/content/docs/config.mdx @@ -525,26 +525,20 @@ You can also define commands using markdown files in `~/.config/opencode/command --- -### Keymap +### Keybinds -Customize TUI keyboard shortcuts in `tui.json` with `keymap`. +Customize TUI keyboard shortcuts in `tui.json` with `keybinds`. ```json title="tui.json" { "$schema": "https://opencode.ai/tui.json", - "keymap": { - "sections": { - "global": { - "command.palette.show": "ctrl+p" - } - } + "keybinds": { + "command_list": "ctrl+p" } } ``` -`keymap` is merged with built-in defaults, so you only need to configure the shortcuts you want to change. - -The older `keybinds` field is deprecated and only applies when `keymap` is not present. +`keybinds` is merged with built-in defaults, so you only need to configure the shortcuts you want to change. [Learn more here](/docs/keybinds). diff --git a/packages/web/src/content/docs/keybinds.mdx b/packages/web/src/content/docs/keybinds.mdx index 599945428e..f083bb40b0 100644 --- a/packages/web/src/content/docs/keybinds.mdx +++ b/packages/web/src/content/docs/keybinds.mdx @@ -1,100 +1,218 @@ --- title: Keybinds -description: Customize your keyboard shortcuts. +description: Customize your keybinds. --- -OpenCode customizes TUI keyboard shortcuts with `keymap` in `tui.json`. - -The older `keybinds` field is still accepted as a migration fallback, but it is deprecated and will be removed in OpenCode v2.0. If `keymap` is present, OpenCode ignores `keybinds` for shortcut resolution. - -`keymap` is merged with built-in defaults, so you only need to configure the shortcuts you want to change. - ---- - -## Leader key - -OpenCode uses a `leader` key for many shortcuts. This avoids conflicts in your terminal. - -By default, `ctrl+x` is the leader key and leader shortcuts require you to first press the leader key and then the shortcut. For example, to start a new session you first press `ctrl+x` and then press `n`. - -You do not need to use a leader key, but we recommend doing so. - ---- - -## Minimal example +OpenCode has a list of keybinds that you can customize through `tui.json`. ```json title="tui.json" { "$schema": "https://opencode.ai/tui.json", - "keymap": { + "leader_timeout": 2000, + "keybinds": { "leader": "ctrl+x", - "leader_timeout": 2000, - "sections": { - "global": { - "command.palette.show": "ctrl+p", - "session.new": "n", - "session.list": "l" - }, - "session": { - "session.compact": "c", - "session.undo": "u", - "session.redo": "r" - }, - "input": { - "input.submit": "return", - "input.newline": ["shift+return", "ctrl+return", "alt+return", "ctrl+j"] - } - } + "app_exit": "ctrl+c,ctrl+d,q", + "app_debug": "none", + "app_console": "none", + "app_heap_snapshot": "none", + "app_toggle_animations": "none", + "app_toggle_file_context": "none", + "app_toggle_diffwrap": "none", + "app_toggle_paste_summary": "none", + "app_toggle_session_directory_filter": "none", + "command_list": "ctrl+p", + "help_show": "none", + "docs_open": "none", + + "editor_open": "e", + "theme_list": "t", + "theme_switch_mode": "none", + "theme_mode_lock": "none", + "sidebar_toggle": "b", + "scrollbar_toggle": "none", + "status_view": "s", + + "session_export": "x", + "session_copy": "none", + "session_new": "n", + "session_list": "l", + "session_timeline": "g", + "session_fork": "none", + "session_rename": "ctrl+r", + "session_delete": "ctrl+d", + "session_share": "none", + "session_unshare": "none", + "session_interrupt": "escape", + "session_compact": "c", + "session_toggle_timestamps": "none", + "session_toggle_generic_tool_output": "none", + "session_child_first": "down", + "session_child_cycle": "right", + "session_child_cycle_reverse": "left", + "session_parent": "up", + + "stash_delete": "ctrl+d", + "model_provider_list": "ctrl+a", + "model_favorite_toggle": "ctrl+f", + "model_list": "m", + "model_cycle_recent": "f2", + "model_cycle_recent_reverse": "shift+f2", + "model_cycle_favorite": "none", + "model_cycle_favorite_reverse": "none", + "mcp_list": "none", + "provider_connect": "none", + "console_org_switch": "none", + "agent_list": "a", + "agent_cycle": "tab", + "agent_cycle_reverse": "shift+tab", + "variant_cycle": "ctrl+t", + "variant_list": "none", + + "messages_page_up": "pageup,ctrl+alt+b", + "messages_page_down": "pagedown,ctrl+alt+f", + "messages_line_up": "ctrl+alt+y", + "messages_line_down": "ctrl+alt+e", + "messages_half_page_up": "ctrl+alt+u", + "messages_half_page_down": "ctrl+alt+d", + "messages_first": "ctrl+g,home", + "messages_last": "ctrl+alt+g,end", + "messages_next": "none", + "messages_previous": "none", + "messages_last_user": "none", + "messages_copy": "y", + "messages_undo": "u", + "messages_redo": "r", + "messages_toggle_conceal": "h", + "tool_details": "none", + "display_thinking": "none", + + "prompt_submit": "none", + "prompt_editor_context_clear": "none", + "prompt_skills": "none", + "prompt_stash": "none", + "prompt_stash_pop": "none", + "prompt_stash_list": "none", + "workspace_set": "none", + + "input_clear": "ctrl+c", + "input_paste": { + "key": "ctrl+v", + "preventDefault": false + }, + "input_submit": "return", + "input_newline": "shift+return,ctrl+return,alt+return,ctrl+j", + "input_move_left": "left,ctrl+b", + "input_move_right": "right,ctrl+f", + "input_move_up": "up", + "input_move_down": "down", + "input_select_left": "shift+left", + "input_select_right": "shift+right", + "input_select_up": "shift+up", + "input_select_down": "shift+down", + "input_line_home": "ctrl+a", + "input_line_end": "ctrl+e", + "input_select_line_home": "ctrl+shift+a", + "input_select_line_end": "ctrl+shift+e", + "input_visual_line_home": "alt+a", + "input_visual_line_end": "alt+e", + "input_select_visual_line_home": "alt+shift+a", + "input_select_visual_line_end": "alt+shift+e", + "input_buffer_home": "home", + "input_buffer_end": "end", + "input_select_buffer_home": "shift+home", + "input_select_buffer_end": "shift+end", + "input_delete_line": "ctrl+shift+d", + "input_delete_to_line_end": "ctrl+k", + "input_delete_to_line_start": "ctrl+u", + "input_backspace": "backspace,shift+backspace", + "input_delete": "ctrl+d,delete,shift+delete", + "input_undo": "ctrl+-,super+z", + "input_redo": "ctrl+.,super+shift+z", + "input_word_forward": "alt+f,alt+right,ctrl+right", + "input_word_backward": "alt+b,alt+left,ctrl+left", + "input_select_word_forward": "alt+shift+f,alt+shift+right", + "input_select_word_backward": "alt+shift+b,alt+shift+left", + "input_delete_word_forward": "alt+d,alt+delete,ctrl+delete", + "input_delete_word_backward": "ctrl+w,ctrl+backspace,alt+backspace", + "input_select_all": "super+a", + "history_previous": "up", + "history_next": "down", + + "dialog.select.prev": "up,ctrl+p", + "dialog.select.next": "down,ctrl+n", + "dialog.select.page_up": "pageup", + "dialog.select.page_down": "pagedown", + "dialog.select.home": "home", + "dialog.select.end": "end", + "dialog.select.submit": "return", + "dialog.mcp.toggle": "space", + "prompt.autocomplete.prev": "up,ctrl+p", + "prompt.autocomplete.next": "down,ctrl+n", + "prompt.autocomplete.hide": "escape", + "prompt.autocomplete.select": "return", + "prompt.autocomplete.complete": "tab", + "permission.prompt.fullscreen": "ctrl+f", + "plugins.toggle": "space", + "dialog.plugins.install": "shift+i", + + "terminal_suspend": "ctrl+z", + "terminal_title_toggle": "none", + "tips_toggle": "h", + "plugin_manager": "none", + "plugin_install": "none", + + "which_key_toggle": "ctrl+alt+k", + "which_key_layout_toggle": "ctrl+alt+shift+k", + "which_key_pending_toggle": "ctrl+alt+shift+p", + "which_key_group_previous": "ctrl+alt+left,ctrl+alt+[", + "which_key_group_next": "ctrl+alt+right,ctrl+alt+]", + "which_key_scroll_up": "ctrl+alt+up,ctrl+alt+p", + "which_key_scroll_down": "ctrl+alt+down,ctrl+alt+n", + "which_key_page_up": "ctrl+alt+pageup", + "which_key_page_down": "ctrl+alt+pagedown", + "which_key_home": "ctrl+alt+home", + "which_key_end": "ctrl+alt+end" } } ``` ---- +:::note +On Windows, the defaults for `input_undo` and `terminal_suspend` are different: -## Keymap structure - -`keymap.sections` is grouped by semantic area. Each section contains command names and the key sequence that triggers them. - -| Field | Description | -| ---------------- | --------------------------------------------------------------------------------------------------- | -| `leader` | The key used by `` sequences. Defaults to `ctrl+x`. | -| `leader_timeout` | How long OpenCode waits for the next key after the leader key, in milliseconds. Defaults to `2000`. | -| `sections` | A map of TUI areas to command bindings. | +- `input_undo` defaults to `ctrl+z,ctrl+-,super+z` when it is not explicitly configured. The `ctrl+z` binding is added because Windows terminals do not support POSIX suspend. +- `terminal_suspend` is forced to `none` because native Windows terminals do not support POSIX suspend. + ::: --- -## Binding values +## Leader Key -A string can contain one shortcut or multiple comma-separated shortcuts. You can also use an array for multiple shortcuts, or `"none"`/`false` to disable a command. +OpenCode uses a `leader` key for many keybinds. This avoids conflicts in your terminal. -```json title="tui.json" -{ - "$schema": "https://opencode.ai/tui.json", - "keymap": { - "sections": { - "session": { - "session.compact": "none", - "session.export": "x,ctrl+shift+x", - "session.copy": ["y", "ctrl+shift+c"] - } - } - } -} -``` +By default, `ctrl+x` is the leader key and many actions require you to first press the leader key and then the shortcut. For example, to start a new session you first press `ctrl+x` and then press `n`. + +You don't need to use a leader key for your keybinds but we recommend doing so. + +Some navigation keybinds intentionally do not use the leader key by default. For subagent sessions, the defaults are `session_child_first` = `down`, `session_child_cycle` = `right`, `session_child_cycle_reverse` = `left`, and `session_parent` = `up`. + +`leader_timeout` controls how long OpenCode waits for the next key after the leader key. It defaults to `2000` milliseconds. + +--- + +## Binding Values + +A string can contain one shortcut or multiple comma-separated shortcuts. You can also use an array for multiple shortcuts. For advanced cases, use an object with `key`, `event`, `preventDefault`, or `fallthrough`. ```json title="tui.json" { "$schema": "https://opencode.ai/tui.json", - "keymap": { - "sections": { - "prompt": { - "prompt.paste": { - "key": "ctrl+v", - "preventDefault": false - } - } + "keybinds": { + "messages_copy": ["y", "ctrl+shift+c"], + "input_paste": { + "key": "ctrl+v", + "preventDefault": false } } } @@ -102,219 +220,22 @@ For advanced cases, use an object with `key`, `event`, `preventDefault`, or `fal --- -## Complete keymap reference +## Disable Keybind -This example lists the built-in sections, command names, and default fallback bindings. Commands set to `"none"` are available to bind but disabled by default. - -```json title="tui.json" -{ - "$schema": "https://opencode.ai/tui.json", - "keymap": { - "leader": "ctrl+x", - "leader_timeout": 2000, - "sections": { - "global": { - "command.palette.show": "ctrl+p", - "session.list": "l", - "session.new": "n", - "model.list": "m", - "model.cycle_recent": "f2", - "model.cycle_recent_reverse": "shift+f2", - "model.cycle_favorite": "none", - "model.cycle_favorite_reverse": "none", - "agent.list": "a", - "mcp.list": "none", - "agent.cycle": "tab", - "agent.cycle.reverse": "shift+tab", - "variant.cycle": "ctrl+t", - "variant.list": "none", - "provider.connect": "none", - "console.org.switch": "none", - "opencode.status": "s", - "theme.switch": "t", - "theme.switch_mode": "none", - "theme.mode.lock": "none", - "help.show": "none", - "docs.open": "none", - "app.exit": "ctrl+c,ctrl+d,q", - "app.debug": "none", - "app.console": "none", - "app.heap_snapshot": "none", - "app.toggle.animations": "none", - "app.toggle.file_context": "none", - "app.toggle.diffwrap": "none", - "app.toggle.paste_summary": "none", - "app.toggle.session_directory_filter": "none", - "terminal.suspend": "ctrl+z", - "terminal.title.toggle": "none" - }, - "session": { - "session.share": "none", - "session.rename": "ctrl+r", - "session.timeline": "g", - "session.fork": "none", - "session.compact": "c", - "session.unshare": "none", - "session.undo": "u", - "session.redo": "r", - "session.sidebar.toggle": "b", - "session.toggle.conceal": "h", - "session.toggle.timestamps": "none", - "session.toggle.thinking": "none", - "session.toggle.actions": "none", - "session.toggle.scrollbar": "none", - "session.toggle.generic_tool_output": "none", - "session.page.up": "pageup,ctrl+alt+b", - "session.page.down": "pagedown,ctrl+alt+f", - "session.line.up": "ctrl+alt+y", - "session.line.down": "ctrl+alt+e", - "session.half.page.up": "ctrl+alt+u", - "session.half.page.down": "ctrl+alt+d", - "session.first": "ctrl+g,home", - "session.last": "ctrl+alt+g,end", - "session.messages_last_user": "none", - "session.message.next": "none", - "session.message.previous": "none", - "messages.copy": "y", - "session.copy": "none", - "session.export": "x", - "session.child.first": "down", - "session.parent": "up", - "session.child.next": "right", - "session.child.previous": "left" - }, - "prompt": { - "prompt.submit": "none", - "prompt.editor": "e", - "prompt.editor_context.clear": "none", - "prompt.skills": "none", - "prompt.stash": "none", - "prompt.stash.pop": "none", - "prompt.stash.list": "none", - "workspace.set": "none", - "session.interrupt": "escape", - "prompt.clear": "ctrl+c", - "prompt.paste": { - "key": "ctrl+v", - "preventDefault": false - }, - "prompt.history.previous": "up", - "prompt.history.next": "down" - }, - "autocomplete": { - "prompt.autocomplete.prev": "up,ctrl+p", - "prompt.autocomplete.next": "down,ctrl+n", - "prompt.autocomplete.hide": "escape", - "prompt.autocomplete.select": "return", - "prompt.autocomplete.complete": "tab" - }, - "input": { - "input.submit": "return", - "input.newline": "shift+return,ctrl+return,alt+return,ctrl+j", - "input.move.left": "left,ctrl+b", - "input.move.right": "right,ctrl+f", - "input.move.up": "up", - "input.move.down": "down", - "input.select.left": "shift+left", - "input.select.right": "shift+right", - "input.select.up": "shift+up", - "input.select.down": "shift+down", - "input.line.home": "ctrl+a", - "input.line.end": "ctrl+e", - "input.select.line.home": "ctrl+shift+a", - "input.select.line.end": "ctrl+shift+e", - "input.visual.line.home": "alt+a", - "input.visual.line.end": "alt+e", - "input.select.visual.line.home": "alt+shift+a", - "input.select.visual.line.end": "alt+shift+e", - "input.buffer.home": "home", - "input.buffer.end": "end", - "input.select.buffer.home": "shift+home", - "input.select.buffer.end": "shift+end", - "input.delete.line": "ctrl+shift+d", - "input.delete.to.line.end": "ctrl+k", - "input.delete.to.line.start": "ctrl+u", - "input.backspace": "backspace,shift+backspace", - "input.delete": "ctrl+d,delete,shift+delete", - "input.undo": "ctrl+-,super+z", - "input.redo": "ctrl+.,super+shift+z", - "input.word.forward": "alt+f,alt+right,ctrl+right", - "input.word.backward": "alt+b,alt+left,ctrl+left", - "input.select.word.forward": "alt+shift+f,alt+shift+right", - "input.select.word.backward": "alt+shift+b,alt+shift+left", - "input.delete.word.forward": "alt+d,alt+delete,ctrl+delete", - "input.delete.word.backward": "ctrl+w,ctrl+backspace,alt+backspace", - "input.select.all": "super+a" - }, - "dialog_select": { - "dialog.select.prev": "up,ctrl+p", - "dialog.select.next": "down,ctrl+n", - "dialog.select.page_up": "pageup", - "dialog.select.page_down": "pagedown", - "dialog.select.home": "home", - "dialog.select.end": "end", - "dialog.select.submit": "return" - }, - "dialog_actions": { - "dialog.action.toggle": "space", - "dialog.action.delete": "ctrl+d", - "dialog.action.rename": "ctrl+r" - }, - "model": { - "model.dialog.provider": "ctrl+a", - "model.dialog.favorite": "ctrl+f" - }, - "permission": { - "permission.reject.cancel": "ctrl+c,ctrl+d,q", - "permission.prompt.escape": "ctrl+c,ctrl+d,q", - "permission.prompt.fullscreen": "ctrl+f" - }, - "question": { - "question.reject": "ctrl+c,ctrl+d,q", - "question.edit.clear": "ctrl+c" - }, - "plugins": { - "plugins.list": "none", - "plugins.install": "none", - "plugin.dialog.install": "shift+i" - }, - "home_tips": { - "tips.toggle": "h" - } - } - } -} -``` - ---- - -## Legacy keybinds - -`keybinds` is deprecated. It is kept so existing configs continue to work while users migrate to `keymap`. - -Only use `keybinds` when `keymap` is not present. If both fields are set, `keymap` wins and `keybinds` are ignored for shortcut resolution. +You can disable a keybind by adding the key to `tui.json` with a value of `"none"` or `false`. ```json title="tui.json" { "$schema": "https://opencode.ai/tui.json", "keybinds": { - "command_list": "ctrl+p", - "session_new": "n", - "session_compact": "c" + "session_compact": "none" } } ``` -:::note -On native Windows, the defaults for undo and terminal suspend are different for both `keymap` and legacy `keybinds`: - -- `input.undo` defaults to `ctrl+z,ctrl+-,super+z` when it is not explicitly configured (the `ctrl+z` binding is added because Windows terminals do not support POSIX suspend). -- `terminal.suspend` is disabled because native Windows terminals do not support POSIX suspend. - ::: - --- -## Desktop prompt shortcuts +## Desktop Prompt Shortcuts The OpenCode desktop app prompt input supports common Readline/Emacs-style shortcuts for editing text. These are built-in and currently not configurable via `opencode.json`. diff --git a/packages/web/src/content/docs/tui.mdx b/packages/web/src/content/docs/tui.mdx index 99e9aa752b..72d9658d16 100644 --- a/packages/web/src/content/docs/tui.mdx +++ b/packages/web/src/content/docs/tui.mdx @@ -353,14 +353,10 @@ You can customize TUI behavior through `tui.json` (or `tui.jsonc`). { "$schema": "https://opencode.ai/tui.json", "theme": "opencode", - "keymap": { + "leader_timeout": 2000, + "keybinds": { "leader": "ctrl+x", - "leader_timeout": 2000, - "sections": { - "global": { - "command.palette.show": "ctrl+p" - } - } + "command_list": "ctrl+p" }, "scroll_speed": 3, "scroll_acceleration": { @@ -373,13 +369,13 @@ You can customize TUI behavior through `tui.json` (or `tui.jsonc`). This is separate from `opencode.json`, which configures server/runtime behavior. -`keymap` is merged with built-in defaults, so you only need to configure the shortcuts you want to change. +`keybinds` is merged with built-in defaults, so you only need to configure the shortcuts you want to change. ### Options - `theme` - Sets your UI theme. [Learn more](/docs/themes). -- `keymap` - Customizes keyboard shortcuts. [Learn more](/docs/keybinds). -- `keybinds` - Deprecated legacy shortcut config. This only applies when `keymap` is not present. +- `keybinds` - Customizes keyboard shortcuts. [Learn more](/docs/keybinds). +- `leader_timeout` - Controls how long OpenCode waits after the leader key. Defaults to `2000`. - `scroll_acceleration.enabled` - Enable macOS-style scroll acceleration for smooth, natural scrolling. When enabled, scroll speed increases with rapid scrolling gestures and stays precise for slower movements. **This setting takes precedence over `scroll_speed` and overrides it when enabled.** - `scroll_speed` - Controls how fast the TUI scrolls when using scroll commands (minimum: `0.001`, supports decimal values). Defaults to `3`. **Note: This is ignored if `scroll_acceleration.enabled` is set to `true`.** - `diff_style` - Controls diff rendering. `"auto"` adapts to terminal width, `"stacked"` always shows a single-column layout. From 8694da9309673ff8dcccdee5b3d5aa0188a054fa Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Fri, 8 May 2026 23:46:19 +0000 Subject: [PATCH 024/189] chore: update nix node_modules hashes --- nix/hashes.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/nix/hashes.json b/nix/hashes.json index f33a18b0e3..55d4fd038c 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-BjFBDG1eeMmCoXAwXgEcFpsv1nk8wTrEGlFITxiLPrE=", - "aarch64-linux": "sha256-2ebJyhgQ2jBOoJIROUj/jcUbYMNiviADUI0bMDn3nZs=", - "aarch64-darwin": "sha256-ze+AB2zbWnQ9coZcBKhzUbmBaVy5j06nwgbpEu/9Ycw=", - "x86_64-darwin": "sha256-n3QI2Rtd9DxEdpXgiPCVeUwh13bR4F5EfyWJPKuVYnk=" + "x86_64-linux": "sha256-ZqTxXSIWZmrNpdIQICbesrMpoL24blahhQzAGTzupAc=", + "aarch64-linux": "sha256-YdZAkfJXBCnqPcYS7VUbQqT9AkYnyu/ONVG5v78Lmdw=", + "aarch64-darwin": "sha256-3Fdu1le89F53UHuaiHanHP0CB4F72fK9p1d9PC47Wsg=", + "x86_64-darwin": "sha256-Tc4WppnKZPzYn/ICIv1vtx+NiNqAHAzd/ogTRUCQK18=" } } From d19f7bc77c9e27c6162baf879c6b4d837b00b331 Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Sat, 9 May 2026 10:24:57 +1000 Subject: [PATCH 025/189] fix(web): normalize shell output carriage returns (#26426) --- packages/ui/src/components/message-part.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index d9771671a6..137f689756 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -1810,7 +1810,7 @@ ToolRegistry.register({ const sawPending = pending() const text = createMemo(() => { const cmd = props.input.command ?? props.metadata.command ?? "" - const out = stripAnsi(props.output || props.metadata.output || "") + const out = stripAnsi(props.output || props.metadata.output || "").replace(/\r\n?/g, "\n") return `$ ${cmd}${out ? "\n\n" + out : ""}` }) const [copied, setCopied] = createSignal(false) From 21d055be19cd858fada71ddf96a9f16ccd4b10d0 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 8 May 2026 20:25:53 -0400 Subject: [PATCH 026/189] fix(workspace): claim detached sessions to source project (#26413) --- .../opencode/src/control-plane/workspace.ts | 3 +- .../test/control-plane/workspace.test.ts | 35 +++++++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/control-plane/workspace.ts b/packages/opencode/src/control-plane/workspace.ts index 03640576d6..b30536ec02 100644 --- a/packages/opencode/src/control-plane/workspace.ts +++ b/packages/opencode/src/control-plane/workspace.ts @@ -5,7 +5,6 @@ import { asc } from "drizzle-orm" import { eq } from "drizzle-orm" import { inArray } from "drizzle-orm" import { Project } from "@/project/project" -import { Instance } from "@/project/instance" import { BusEvent } from "@/bus/bus-event" import { GlobalBus } from "@/bus/global" import { Auth } from "@/auth" @@ -646,7 +645,7 @@ export const layer = Layer.effect( // "claim" this session so any future events coming from // the old workspace are ignored - SyncEvent.claim(input.sessionID, input.workspaceID ?? Instance.project.id) + SyncEvent.claim(input.sessionID, input.workspaceID ?? previous.projectID) } } diff --git a/packages/opencode/test/control-plane/workspace.test.ts b/packages/opencode/test/control-plane/workspace.test.ts index e3de9cae71..8333d9573f 100644 --- a/packages/opencode/test/control-plane/workspace.test.ts +++ b/packages/opencode/test/control-plane/workspace.test.ts @@ -848,6 +848,41 @@ describe("workspace CRUD", () => { }) }) + test("sessionWarp detaches to the source project when invoked from a workspace instance", async () => { + await withInstance(async () => { + const projectID = Instance.project.id + await using workspaceTmp = await tmpdir({ git: true }) + const previousType = unique("warp-detach-workspace-instance") + const previous = workspaceInfo(projectID, previousType) + insertWorkspace(previous) + registerAdapter(projectID, previousType, localAdapter(workspaceTmp.path, { createDir: false }).adapter) + const session = await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({}))) + attachSessionToWorkspace(session.id, previous.id) + + const workspaceProjectID = await WithInstance.provide({ + directory: workspaceTmp.path, + fn: async () => { + const id = Instance.project.id + expect(id).not.toBe(projectID) + await warpWorkspaceSession({ workspaceID: null, sessionID: session.id }) + return id + }, + }) + + expect( + Database.use((db) => + db + .select({ workspaceID: SessionTable.workspace_id }) + .from(SessionTable) + .where(eq(SessionTable.id, session.id)) + .get(), + )?.workspaceID, + ).toBeNull() + expect(sessionSequenceOwner(session.id)).toBe(projectID) + expect(sessionSequenceOwner(session.id)).not.toBe(workspaceProjectID) + }) + }) + it.live("sessionWarp syncs previous remote history, replays it, steals, and claims the sequence", () => { const calls: FetchCall[] = [] let historySessionID: SessionID | undefined From 0745162eab79311c44d45f48248f40dfa937a9c6 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 8 May 2026 20:50:01 -0400 Subject: [PATCH 027/189] test(server): harden HttpApi exercise coverage (#26425) --- .github/workflows/test.yml | 5 + packages/opencode/package.json | 1 + .../test/server/httpapi-exercise/backend.ts | 55 ++- .../test/server/httpapi-exercise/dsl.ts | 32 +- .../test/server/httpapi-exercise/index.ts | 369 +++++++++++------- .../test/server/httpapi-exercise/report.ts | 3 +- .../test/server/httpapi-exercise/routing.ts | 52 +++ .../test/server/httpapi-exercise/runner.ts | 98 ++++- .../test/server/httpapi-exercise/types.ts | 10 +- 9 files changed, 451 insertions(+), 174 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 69a3a1a2d1..f226d3483a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -68,6 +68,11 @@ jobs: env: OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }} + - name: Run HttpApi exerciser gates + if: runner.os == 'Linux' + working-directory: packages/opencode + run: bun run test:httpapi + - name: Publish unit reports if: always() uses: mikepenz/action-junit-report@v6 diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 06c1ac7371..04a9cf0271 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -9,6 +9,7 @@ "typecheck": "tsgo --noEmit", "test": "bun test --timeout 30000", "test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml", + "test:httpapi": "bun run script/httpapi-exercise.ts --mode coverage --fail-on-missing --fail-on-skip && bun run script/httpapi-exercise.ts --mode auth --fail-on-missing --fail-on-skip", "build": "bun run script/build.ts", "fix-node-pty": "bun run script/fix-node-pty.ts", "dev": "bun run --conditions=browser ./src/index.ts", diff --git a/packages/opencode/test/server/httpapi-exercise/backend.ts b/packages/opencode/test/server/httpapi-exercise/backend.ts index c393383e03..f66d15a3ee 100644 --- a/packages/opencode/test/server/httpapi-exercise/backend.ts +++ b/packages/opencode/test/server/httpapi-exercise/backend.ts @@ -23,13 +23,31 @@ export function call( ) } -export function callAuthProbe(backend: Backend, scenario: ActiveScenario) { - return Effect.promise(async () => - capture( - await app(await runtime(), backend, { auth: { password: "secret" } }).request(toAuthProbeRequest(scenario)), - scenario.capture, - ), - ) +export function callAuthProbe( + backend: Backend, + scenario: ActiveScenario, + credentials: "missing" | "valid" = "missing", +) { + return Effect.promise(async () => { + const controller = new AbortController() + return Promise.race([ + Promise.resolve( + app(await runtime(), backend, { auth: { password: "secret" } }).request( + toAuthProbeRequest(scenario, credentials, controller.signal), + ), + ).then((response) => capture(response, scenario.capture)), + Bun.sleep(1_000).then(() => { + controller.abort("auth probe timed out") + return { + status: 0, + contentType: "", + text: "auth probe timed out", + body: undefined, + timedOut: true, + } + }), + ]) + }) } const appCache: Partial> = {} @@ -78,14 +96,28 @@ function toRequest(scenario: ActiveScenario, ctx: SeededContext) { }) } -function toAuthProbeRequest(scenario: ActiveScenario) { - return new Request(new URL(authProbePath(scenario.path), "http://localhost"), { +function toAuthProbeRequest(scenario: ActiveScenario, credentials: "missing" | "valid", signal: AbortSignal) { + const spec = scenario.authProbe ?? { + path: authProbePath(scenario.path), + body: scenario.method === "GET" ? undefined : {}, + } + const headers = { + ...(spec.body === undefined ? {} : { "content-type": "application/json" }), + ...spec.headers, + ...(credentials === "valid" ? { authorization: basic("opencode", "secret") } : {}), + } + return new Request(new URL(spec.path, "http://localhost"), { method: scenario.method, - headers: scenario.method === "GET" ? undefined : { "content-type": "application/json" }, - body: scenario.method === "GET" ? undefined : JSON.stringify({}), + headers, + body: spec.body === undefined ? undefined : JSON.stringify(spec.body), + signal, }) } +function basic(username: string, password: string) { + return `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}` +} + function authProbePath(path: string) { return path .replace(/\{([^}]+)\}/g, (_match, key: string) => `auth_${key}`) @@ -99,6 +131,7 @@ async function capture(response: Response, mode: CaptureMode): Promise { private readonly state: BuilderState - constructor(method: Method, path: string, name: string) { + constructor(method: Method, path: string, name: string, auth: AuthPolicy) { this.state = { method, path, @@ -25,10 +26,11 @@ class ScenarioBuilder { // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- The unseeded builder state is intentionally undefined until `.seeded(...)` narrows it. seed: () => Effect.succeed(undefined as S), request: (ctx) => ({ path, headers: ctx.headers() }), + authProbe: undefined, capture: "full", mutates: false, reset: true, - auth: "protected", + auth, } } @@ -48,6 +50,10 @@ class ScenarioBuilder { return this.clone({ request }) } + probe(authProbe: RequestSpec) { + return this.clone({ authProbe }) + } + mutating() { return this.clone({ mutates: true }) } @@ -124,7 +130,7 @@ class ScenarioBuilder { } private clone(next: Partial>) { - const builder = new ScenarioBuilder(this.state.method, this.state.path, this.state.name) + const builder = new ScenarioBuilder(this.state.method, this.state.path, this.state.name, this.state.auth) Object.assign(builder.state, this.state, next) return builder } @@ -134,7 +140,7 @@ class ScenarioBuilder { * for `.at(...)` and assertions, giving stateful route tests type-safe setup. */ seeded(seed: (ctx: ScenarioContext) => Effect.Effect) { - const builder = new ScenarioBuilder(this.state.method, this.state.path, this.state.name) + const builder = new ScenarioBuilder(this.state.method, this.state.path, this.state.name, this.state.auth) Object.assign(builder.state, this.state, { seed }) return builder } @@ -151,6 +157,7 @@ class ScenarioBuilder { name: state.name, project: state.project, seed: state.seed, + authProbe: state.authProbe, // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- `.seeded(...)` preserves the paired request/state type inside the builder. request: (ctx, seeded) => state.request({ ...ctx, state: seeded as S }), // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- `.seeded(...)` preserves the paired assertion/state type inside the builder. @@ -164,12 +171,19 @@ class ScenarioBuilder { } } +const routes = (auth: AuthPolicy) => ({ + get: (path: string, name: string) => new ScenarioBuilder("GET", path, name, auth), + post: (path: string, name: string) => new ScenarioBuilder("POST", path, name, auth), + put: (path: string, name: string) => new ScenarioBuilder("PUT", path, name, auth), + patch: (path: string, name: string) => new ScenarioBuilder("PATCH", path, name, auth), + delete: (path: string, name: string) => new ScenarioBuilder("DELETE", path, name, auth), +}) + export const http = { - get: (path: string, name: string) => new ScenarioBuilder("GET", path, name), - post: (path: string, name: string) => new ScenarioBuilder("POST", path, name), - put: (path: string, name: string) => new ScenarioBuilder("PUT", path, name), - patch: (path: string, name: string) => new ScenarioBuilder("PATCH", path, name), - delete: (path: string, name: string) => new ScenarioBuilder("DELETE", path, name), + protected: routes("protected"), + public: routes("public"), + publicBypass: routes("public-bypass"), + ticketBypass: routes("ticket-bypass"), } export const pending = (method: Method, path: string, name: string, reason: string): TodoScenario => ({ diff --git a/packages/opencode/test/server/httpapi-exercise/index.ts b/packages/opencode/test/server/httpapi-exercise/index.ts index 32d9af464b..bc876d9f07 100644 --- a/packages/opencode/test/server/httpapi-exercise/index.ts +++ b/packages/opencode/test/server/httpapi-exercise/index.ts @@ -11,7 +11,7 @@ * so this must never point at a developer's real session database. * * DSL shape: - * - `http.get/post/...` starts a scenario for one OpenAPI route key. + * - `http.protected.get/post/...` starts a scenario for one OpenAPI route key. * - `.seeded(...)` creates typed per-scenario state using Effect helpers on `ctx`. * - `.at(...)` builds the request from that typed state. * - `.json(...)` / `.jsonEffect(...)` assert response shape and optional side effects. @@ -32,7 +32,7 @@ import { exerciseGlobalRoot, } from "./environment" import { color, printHeader, printResults } from "./report" -import { coverageResult, matches, parseOptions, routeKey, routeKeys } from "./routing" +import { coverageResult, parseOptions, routeKey, routeKeys, selectedScenarios } from "./routing" import { runScenario } from "./runner" import { runtime } from "./runtime" import { type Scenario } from "./types" @@ -40,14 +40,14 @@ import { type Scenario } from "./types" void (await import("@opencode-ai/core/util/log")).init({ print: false }) const scenarios: Scenario[] = [ - http + http.protected .get("/global/health", "global.health") .global() .json(200, (body) => { object(body) check(body.healthy === true, "server should report healthy") }), - http + http.protected .get("/global/event", "global.event") .global() .stream() @@ -60,8 +60,8 @@ const scenarios: Scenario[] = [ }), "status", ), - http.get("/global/config", "global.config.get").global().json(), - http + http.protected.get("/global/config", "global.config.get").global().json(), + http.protected .patch("/global/config", "global.config.update") .global() .seeded(() => @@ -86,7 +86,7 @@ const scenarios: Scenario[] = [ }), "status", ), - http + http.protected .post("/global/dispose", "global.dispose") .global() .mutating() @@ -97,23 +97,37 @@ const scenarios: Scenario[] = [ }, "status", ), - http.get("/path", "path.get").json(200, (body, ctx) => { + http.protected.get("/path", "path.get").json(200, (body, ctx) => { object(body) check(body.directory === ctx.directory, "directory should resolve from x-opencode-directory") check(body.worktree === ctx.directory, "worktree should resolve from x-opencode-directory") }), - http.get("/vcs", "vcs.get").json(), - http + http.protected.get("/vcs", "vcs.get").json(), + http.protected.get("/vcs/status", "vcs.status").json(200, array), + http.protected .get("/vcs/diff", "vcs.diff") .at((ctx) => ({ path: "/vcs/diff?mode=git", headers: ctx.headers() })) .json(200, array), - http.get("/command", "command.list").json(200, array, "status"), - http.get("/agent", "app.agents").json(200, array, "status"), - http.get("/skill", "app.skills").json(200, array, "status"), - http.get("/lsp", "lsp.status").json(200, array), - http.get("/formatter", "formatter.status").json(200, array), - http.get("/config", "config.get").json(200, undefined, "status"), - http + http.protected.get("/vcs/diff/raw", "vcs.diff.raw").status( + 200, + (_ctx, result) => + Effect.sync(() => { + check(typeof result.text === "string", "raw VCS diff should return text") + }), + "status", + ), + http.protected + .post("/vcs/apply", "vcs.apply") + .inProject({ git: false }) + .at((ctx) => ({ path: "/vcs/apply", headers: ctx.headers(), body: { patch: "" } })) + .status(400, undefined, "status"), + http.protected.get("/command", "command.list").json(200, array, "status"), + http.protected.get("/agent", "app.agents").json(200, array, "status"), + http.protected.get("/skill", "app.skills").json(200, array, "status"), + http.protected.get("/lsp", "lsp.status").json(200, array), + http.protected.get("/formatter", "formatter.status").json(200, array), + http.protected.get("/config", "config.get").json(200, undefined, "status"), + http.protected .patch("/config", "config.update") .mutating() .at((ctx) => ({ path: "/config", headers: ctx.headers(), body: { username: "httpapi-local" } })) @@ -125,13 +139,13 @@ const scenarios: Scenario[] = [ }, "status", ), - http + http.protected .patch("/config", "config.update.invalid") .at((ctx) => ({ path: "/config", headers: ctx.headers(), body: { username: 1 } })) .status(400), - http.get("/config/providers", "config.providers").json(), - http.get("/project", "project.list").json(200, array, "status"), - http.get("/project/current", "project.current").json( + http.protected.get("/config/providers", "config.providers").json(), + http.protected.get("/project", "project.list").json(200, array, "status"), + http.protected.get("/project/current", "project.current").json( 200, (body, ctx) => { object(body) @@ -139,7 +153,7 @@ const scenarios: Scenario[] = [ }, "status", ), - http + http.protected .patch("/project/{projectID}", "project.update") .mutating() .seeded((ctx) => ctx.project()) @@ -160,7 +174,7 @@ const scenarios: Scenario[] = [ }, "status", ), - http + http.protected .post("/project/git/init", "project.initGit") .mutating() .inProject({ git: false }) @@ -173,9 +187,9 @@ const scenarios: Scenario[] = [ }, "status", ), - http.get("/provider", "provider.list").json(), - http.get("/provider/auth", "provider.auth").json(), - http + http.protected.get("/provider", "provider.list").json(), + http.protected.get("/provider/auth", "provider.auth").json(), + http.protected .post("/provider/{providerID}/oauth/authorize", "provider.oauth.authorize") .at((ctx) => ({ path: route("/provider/{providerID}/oauth/authorize", { providerID: "httpapi" }), @@ -183,7 +197,7 @@ const scenarios: Scenario[] = [ body: { method: "bad" }, })) .status(400), - http + http.protected .post("/provider/{providerID}/oauth/callback", "provider.oauth.callback") .at((ctx) => ({ path: route("/provider/{providerID}/oauth/callback", { providerID: "httpapi" }), @@ -191,8 +205,8 @@ const scenarios: Scenario[] = [ body: { method: "bad" }, })) .status(400), - http.get("/permission", "permission.list").json(200, array), - http + http.protected.get("/permission", "permission.list").json(200, array), + http.protected .post("/permission/{requestID}/reply", "permission.reply.invalid") .at((ctx) => ({ path: route("/permission/{requestID}/reply", { requestID: "per_httpapi" }), @@ -200,7 +214,7 @@ const scenarios: Scenario[] = [ body: { reply: "bad" }, })) .status(400), - http + http.protected .post("/permission/{requestID}/reply", "permission.reply") .at((ctx) => ({ path: route("/permission/{requestID}/reply", { requestID: "per_httpapi" }), @@ -210,8 +224,8 @@ const scenarios: Scenario[] = [ .json(200, (body) => { check(body === true, "permission reply should return true even when request is no longer pending") }), - http.get("/question", "question.list").json(200, array), - http + http.protected.get("/question", "question.list").json(200, array), + http.protected .post("/question/{requestID}/reply", "question.reply.invalid") .at((ctx) => ({ path: route("/question/{requestID}/reply", { requestID: "que_httpapi_reply" }), @@ -219,7 +233,7 @@ const scenarios: Scenario[] = [ body: { answers: "Yes" }, })) .status(400), - http + http.protected .post("/question/{requestID}/reply", "question.reply") .at((ctx) => ({ path: route("/question/{requestID}/reply", { requestID: "que_httpapi_reply" }), @@ -229,7 +243,7 @@ const scenarios: Scenario[] = [ .json(200, (body) => { check(body === true, "question reply should return true even when request is no longer pending") }), - http + http.protected .post("/question/{requestID}/reject", "question.reject") .at((ctx) => ({ path: route("/question/{requestID}/reject", { requestID: "que_httpapi_reject" }), @@ -238,12 +252,12 @@ const scenarios: Scenario[] = [ .json(200, (body) => { check(body === true, "question reject should return true even when request is no longer pending") }), - http + http.protected .get("/file", "file.list") .seeded((ctx) => ctx.file("hello.txt", "hello\n")) .at((ctx) => ({ path: `/file?${new URLSearchParams({ path: "." })}`, headers: ctx.headers() })) .json(200, array), - http + http.protected .get("/file/content", "file.read") .seeded((ctx) => ctx.file("hello.txt", "hello\n")) .at((ctx) => ({ path: `/file/content?${new URLSearchParams({ path: "hello.txt" })}`, headers: ctx.headers() })) @@ -251,20 +265,20 @@ const scenarios: Scenario[] = [ object(body) check(body.content === "hello", `content should match seeded file: ${JSON.stringify(body)}`) }), - http + http.protected .get("/file/content", "file.read.missing") .at((ctx) => ({ path: `/file/content?${new URLSearchParams({ path: "missing.txt" })}`, headers: ctx.headers() })) .json(200, (body) => { object(body) check(body.type === "text" && body.content === "", "missing file content should return an empty text result") }), - http.get("/file/status", "file.status").json(200, array), - http + http.protected.get("/file/status", "file.status").json(200, array), + http.protected .get("/find", "find.text") .seeded((ctx) => ctx.file("hello.txt", "hello\n")) .at((ctx) => ({ path: `/find?${new URLSearchParams({ pattern: "hello" })}`, headers: ctx.headers() })) .json(200, array), - http + http.protected .get("/find/file", "find.files") .seeded((ctx) => ctx.file("hello.txt", "hello\n")) .at((ctx) => ({ @@ -272,12 +286,12 @@ const scenarios: Scenario[] = [ headers: ctx.headers(), })) .json(200, array), - http + http.protected .get("/find/symbol", "find.symbols") .seeded((ctx) => ctx.file("hello.ts", "export const hello = 1\n")) .at((ctx) => ({ path: `/find/symbol?${new URLSearchParams({ query: "hello" })}`, headers: ctx.headers() })) .json(200, array), - http + http.protected .get("/event", "event.stream") .stream() .status( @@ -289,8 +303,8 @@ const scenarios: Scenario[] = [ }), "status", ), - http.get("/mcp", "mcp.status").json(), - http + http.protected.get("/mcp", "mcp.status").json(), + http.protected .post("/mcp", "mcp.add") .mutating() .at((ctx) => ({ @@ -307,7 +321,7 @@ const scenarios: Scenario[] = [ }, "status", ), - http + http.protected .post("/mcp", "mcp.add.invalid") .at((ctx) => ({ path: "/mcp", @@ -315,7 +329,7 @@ const scenarios: Scenario[] = [ body: { name: "httpapi-invalid", config: { type: "invalid" } }, })) .status(400), - http + http.protected .post("/mcp/{name}/auth", "mcp.auth.start") .at((ctx) => ({ path: route("/mcp/{name}/auth", { name: "httpapi-missing" }), headers: ctx.headers() })) .json( @@ -326,7 +340,7 @@ const scenarios: Scenario[] = [ }, "status", ), - http + http.protected .delete("/mcp/{name}/auth", "mcp.auth.remove") .mutating() .at((ctx) => ({ path: route("/mcp/{name}/auth", { name: "httpapi-missing" }), headers: ctx.headers() })) @@ -334,7 +348,7 @@ const scenarios: Scenario[] = [ object(body) check(body.success === true, "MCP auth removal should return success") }), - http + http.protected .post("/mcp/{name}/auth/authenticate", "mcp.auth.authenticate") .at((ctx) => ({ path: route("/mcp/{name}/auth/authenticate", { name: "httpapi-missing" }), @@ -348,7 +362,7 @@ const scenarios: Scenario[] = [ }, "status", ), - http + http.protected .post("/mcp/{name}/auth/callback", "mcp.auth.callback") .at((ctx) => ({ path: route("/mcp/{name}/auth/callback", { name: "httpapi-missing" }), @@ -356,23 +370,23 @@ const scenarios: Scenario[] = [ body: { code: 1 }, })) .status(400), - http + http.protected .post("/mcp/{name}/connect", "mcp.connect") .mutating() .at((ctx) => ({ path: route("/mcp/{name}/connect", { name: "httpapi-missing" }), headers: ctx.headers() })) .json(200, (body) => { check(body === true, "missing MCP connect should remain a no-op success") }), - http + http.protected .post("/mcp/{name}/disconnect", "mcp.disconnect") .mutating() .at((ctx) => ({ path: route("/mcp/{name}/disconnect", { name: "httpapi-missing" }), headers: ctx.headers() })) .json(200, (body) => { check(body === true, "missing MCP disconnect should remain a no-op success") }), - http.get("/pty/shells", "pty.shells").json(200, array), - http.get("/pty", "pty.list").json(200, array), - http + http.protected.get("/pty/shells", "pty.shells").json(200, array), + http.protected.get("/pty", "pty.list").json(200, array), + http.protected .post("/pty", "pty.create") .mutating() .at((ctx) => ({ path: "/pty", headers: ctx.headers(), body: controlledPtyInput("HTTP API PTY") })) @@ -386,15 +400,22 @@ const scenarios: Scenario[] = [ }, "status", ), - http + http.protected .post("/pty", "pty.create.invalid") .at((ctx) => ({ path: "/pty", headers: ctx.headers(), body: { command: 1 } })) .status(400), - http + http.protected + .post("/pty/{ptyID}/connect-token", "pty.connectToken.invalid") + .at((ctx) => ({ + path: route("/pty/{ptyID}/connect-token", { ptyID: "pty_httpapi_missing" }), + headers: ctx.headers(), + })) + .status(403, undefined, "status"), + http.protected .get("/pty/{ptyID}", "pty.get") .at((ctx) => ({ path: route("/pty/{ptyID}", { ptyID: "pty_httpapi_missing" }), headers: ctx.headers() })) .status(404), - http + http.protected .put("/pty/{ptyID}", "pty.update") .mutating() .at((ctx) => ({ @@ -403,20 +424,20 @@ const scenarios: Scenario[] = [ body: { size: { rows: 0, cols: 0 } }, })) .status(400), - http + http.protected .delete("/pty/{ptyID}", "pty.remove") .mutating() .at((ctx) => ({ path: route("/pty/{ptyID}", { ptyID: "pty_httpapi_missing" }), headers: ctx.headers() })) .json(200, (body) => { check(body === true, "PTY remove should return true") }), - http + http.protected .get("/pty/{ptyID}/connect", "pty.connect") .at((ctx) => ({ path: route("/pty/{ptyID}/connect", { ptyID: "pty_httpapi_missing" }), headers: ctx.headers() })) .status(404, undefined, "none"), - http.get("/experimental/console", "experimental.console.get").json(), - http.get("/experimental/console/orgs", "experimental.console.listOrgs").json(), - http + http.protected.get("/experimental/console", "experimental.console.get").json(), + http.protected.get("/experimental/console/orgs", "experimental.console.listOrgs").json(), + http.protected .post("/experimental/console/switch", "experimental.console.switchOrg") .at((ctx) => ({ path: "/experimental/console/switch", @@ -424,14 +445,17 @@ const scenarios: Scenario[] = [ body: { accountID: "httpapi-account", orgID: "httpapi-org" }, })) .status(400, undefined, "none"), - http.get("/experimental/workspace/adapter", "experimental.workspace.adapter.list").json(200, array), - http.get("/experimental/workspace", "experimental.workspace.list").json(200, array), - http.get("/experimental/workspace/status", "experimental.workspace.status").json(200, array), - http + http.protected.get("/experimental/workspace/adapter", "experimental.workspace.adapter.list").json(200, array), + http.protected.get("/experimental/workspace", "experimental.workspace.list").json(200, array), + http.protected.get("/experimental/workspace/status", "experimental.workspace.status").json(200, array), + http.protected .post("/experimental/workspace", "experimental.workspace.create") .at((ctx) => ({ path: "/experimental/workspace", headers: ctx.headers(), body: {} })) .status(400), - http + http.protected + .post("/experimental/workspace/sync-list", "experimental.workspace.syncList") + .status(204, undefined, "status"), + http.protected .delete("/experimental/workspace/{id}", "experimental.workspace.remove") .mutating() .at((ctx) => ({ @@ -439,7 +463,7 @@ const scenarios: Scenario[] = [ headers: ctx.headers(), })) .status(200), - http + http.protected .post("/experimental/workspace/warp", "experimental.workspace.warp") .at((ctx) => ({ path: "/experimental/workspace/warp", @@ -447,16 +471,16 @@ const scenarios: Scenario[] = [ body: {}, })) .status(400), - http + http.protected .get("/experimental/tool", "tool.list") .at((ctx) => ({ path: `/experimental/tool?${new URLSearchParams({ provider: "opencode", model: "test" })}`, headers: ctx.headers(), })) .json(200, array, "status"), - http.get("/experimental/tool/ids", "tool.ids").json(200, array), - http.get("/experimental/worktree", "worktree.list").json(200, array), - http + http.protected.get("/experimental/tool/ids", "tool.ids").json(200, array), + http.protected.get("/experimental/worktree", "worktree.list").json(200, array), + http.protected .post("/experimental/worktree", "worktree.create") .mutating() .at((ctx) => ({ path: "/experimental/worktree", headers: ctx.headers(), body: { name: "api-dsl" } })) @@ -470,11 +494,11 @@ const scenarios: Scenario[] = [ }), "status", ), - http + http.protected .post("/experimental/worktree", "worktree.create.invalid") .at((ctx) => ({ path: "/experimental/worktree", headers: ctx.headers(), body: { name: 1 } })) .status(400), - http + http.protected .delete("/experimental/worktree", "worktree.remove") .mutating() .seeded((ctx) => ctx.worktree({ name: "api-remove" })) @@ -482,7 +506,7 @@ const scenarios: Scenario[] = [ .json(200, (body) => { check(body === true, "worktree remove should return true") }), - http + http.protected .post("/experimental/worktree/reset", "worktree.reset") .mutating() .seeded((ctx) => ctx.worktree({ name: "api-reset" })) @@ -497,37 +521,41 @@ const scenarios: Scenario[] = [ yield* ctx.worktreeRemove(ctx.state.directory) }), ), - http.get("/experimental/session", "experimental.session.list").json(200, array), - http.get("/experimental/resource", "experimental.resource.list").json(), - http + http.protected.get("/experimental/session", "experimental.session.list").json(200, array), + http.protected.get("/experimental/resource", "experimental.resource.list").json(), + http.protected .post("/sync/history", "sync.history.list") .at((ctx) => ({ path: "/sync/history", headers: ctx.headers(), body: {} })) .json(200, array), - http + http.protected .post("/sync/replay", "sync.replay") .at((ctx) => ({ path: "/sync/replay", headers: ctx.headers(), body: { directory: ctx.directory, events: [] } })) .status(400), - http + http.protected + .post("/sync/steal", "sync.steal.invalid") + .at((ctx) => ({ path: "/sync/steal", headers: ctx.headers(), body: {} })) + .status(400, undefined, "status"), + http.protected .post("/sync/start", "sync.start") .mutating() .preserveDatabase() .json(200, (body) => { check(body === true, "sync start should return true when no workspace sessions exist") }), - http + http.protected .post("/instance/dispose", "instance.dispose") .mutating() .json(200, (body) => { check(body === true, "instance dispose should return true") }), - http + http.protected .post("/log", "app.log") .global() .at(() => ({ path: "/log", body: { service: "httpapi-exercise", level: "info", message: "route coverage" } })) .json(200, (body) => { check(body === true, "log route should return true") }), - http + http.protected .put("/auth/{providerID}", "auth.set") .global() .at(() => ({ path: route("/auth/{providerID}", { providerID: "test" }), body: { type: "api", key: "test-key" } })) @@ -539,7 +567,7 @@ const scenarios: Scenario[] = [ check(isRecord(auth.test) && auth.test.key === "test-key", "auth set should write isolated auth file") }), ), - http + http.protected .delete("/auth/{providerID}", "auth.remove") .global() .seeded(() => @@ -559,7 +587,63 @@ const scenarios: Scenario[] = [ check(auth.test === undefined, "auth remove should delete provider from isolated auth file") }), ), - http + http.protected + .get("/api/session", "v2.session.list") + .at((ctx) => ({ path: "/api/session?roots=true", headers: ctx.headers() })) + .json( + 200, + (body) => { + object(body) + array(body.items) + object(body.cursor) + }, + "none", + ), + http.protected + .get("/api/session/{sessionID}/context", "v2.session.context") + .at((ctx) => ({ + path: route("/api/session/{sessionID}/context", { sessionID: "ses_httpapi_missing" }), + headers: ctx.headers(), + })) + .json(200, array, "none"), + http.protected + .get("/api/session/{sessionID}/message", "v2.session.messages") + .at((ctx) => ({ + path: route("/api/session/{sessionID}/message", { sessionID: "ses_httpapi_missing" }), + headers: ctx.headers(), + })) + .json( + 200, + (body) => { + object(body) + array(body.items) + object(body.cursor) + }, + "none", + ), + http.protected + .post("/api/session/{sessionID}/prompt", "v2.session.prompt.invalid") + .at((ctx) => ({ + path: route("/api/session/{sessionID}/prompt", { sessionID: "ses_httpapi_missing" }), + headers: ctx.headers(), + body: {}, + })) + .status(400, undefined, "none"), + http.protected + .post("/api/session/{sessionID}/compact", "v2.session.compact") + .at((ctx) => ({ + path: route("/api/session/{sessionID}/compact", { sessionID: "ses_httpapi_missing" }), + headers: ctx.headers(), + })) + .status(204, undefined, "none"), + http.protected + .post("/api/session/{sessionID}/wait", "v2.session.wait") + .at((ctx) => ({ + path: route("/api/session/{sessionID}/wait", { sessionID: "ses_httpapi_missing" }), + headers: ctx.headers(), + })) + .status(204, undefined, "none"), + http.protected .get("/session", "session.list") .seeded((ctx) => ctx.session({ title: "List me" })) .at((ctx) => ({ path: "/session?roots=true", headers: ctx.headers() })) @@ -570,11 +654,11 @@ const scenarios: Scenario[] = [ "seeded session should be listed", ) }), - http + http.protected .get("/session/status", "session.status") .seeded((ctx) => ctx.session({ title: "Status session" })) .json(200, object), - http + http.protected .post("/session", "session.create") .mutating() .at((ctx) => ({ path: "/session", headers: ctx.headers(), body: { title: "Created session" } })) @@ -587,7 +671,7 @@ const scenarios: Scenario[] = [ }, "status", ), - http + http.protected .get("/session/{sessionID}", "session.get") .seeded((ctx) => ctx.session({ title: "Get me" })) .at((ctx) => ({ path: route("/session/{sessionID}", { sessionID: ctx.state.id }), headers: ctx.headers() })) @@ -596,14 +680,14 @@ const scenarios: Scenario[] = [ check(body.id === ctx.state.id, "should return requested session") check(body.title === "Get me", "should preserve seeded title") }), - http + http.protected .get("/session/{sessionID}", "session.get.missing") .at((ctx) => ({ path: route("/session/{sessionID}", { sessionID: "ses_httpapi_missing" }), headers: ctx.headers(), })) .status(404), - http + http.protected .patch("/session/{sessionID}", "session.update") .mutating() .seeded((ctx) => ctx.session({ title: "Before rename" })) @@ -620,7 +704,7 @@ const scenarios: Scenario[] = [ }, "status", ), - http + http.protected .patch("/session/{sessionID}", "session.update.invalid") .mutating() .at((ctx) => ({ @@ -629,7 +713,7 @@ const scenarios: Scenario[] = [ body: { title: 1 }, })) .status(400), - http + http.protected .delete("/session/{sessionID}", "session.delete") .mutating() .seeded((ctx) => ctx.session({ title: "Delete me" })) @@ -640,7 +724,7 @@ const scenarios: Scenario[] = [ check((yield* ctx.sessionGet(ctx.state.id)) === undefined, "deleted session should not remain in storage") }), ), - http + http.protected .get("/session/{sessionID}/children", "session.children") .seeded((ctx) => Effect.gen(function* () { @@ -660,7 +744,7 @@ const scenarios: Scenario[] = [ "children should include seeded child", ) }), - http + http.protected .get("/session/{sessionID}/todo", "session.todo") .seeded((ctx) => Effect.gen(function* () { @@ -677,12 +761,12 @@ const scenarios: Scenario[] = [ .json(200, (body, ctx) => { check(stable(body) === stable(ctx.state.todos), "todos should match seeded state") }), - http + http.protected .get("/session/{sessionID}/diff", "session.diff") .seeded((ctx) => ctx.session({ title: "Diff session" })) .at((ctx) => ({ path: route("/session/{sessionID}/diff", { sessionID: ctx.state.id }), headers: ctx.headers() })) .json(200, array), - http + http.protected .get("/session/{sessionID}/message", "session.messages") .seeded((ctx) => ctx.session({ title: "Messages session" })) .at((ctx) => ({ path: route("/session/{sessionID}/message", { sessionID: ctx.state.id }), headers: ctx.headers() })) @@ -690,7 +774,7 @@ const scenarios: Scenario[] = [ array(body) check(body.length === 0, "new session should have no messages") }), - http + http.protected .get("/session/{sessionID}/message/{messageID}", "session.message") .seeded((ctx) => Effect.gen(function* () { @@ -714,7 +798,7 @@ const scenarios: Scenario[] = [ "message should include seeded part", ) }), - http + http.protected .patch("/session/{sessionID}/message/{messageID}/part/{partID}", "part.update") .mutating() .seeded((ctx) => @@ -741,7 +825,7 @@ const scenarios: Scenario[] = [ }, "status", ), - http + http.protected .delete("/session/{sessionID}/message/{messageID}/part/{partID}", "part.delete") .mutating() .seeded((ctx) => @@ -766,7 +850,7 @@ const scenarios: Scenario[] = [ check(messages[0]?.parts.length === 0, "deleted part should not remain on message") }), ), - http + http.protected .delete("/session/{sessionID}/message/{messageID}", "session.deleteMessage") .mutating() .seeded((ctx) => @@ -789,7 +873,7 @@ const scenarios: Scenario[] = [ check((yield* ctx.messages(ctx.state.session.id)).length === 0, "deleted message should not remain") }), ), - http + http.protected .post("/session/{sessionID}/fork", "session.fork") .mutating() .seeded((ctx) => ctx.session({ title: "Fork source" })) @@ -806,7 +890,7 @@ const scenarios: Scenario[] = [ }, "status", ), - http + http.protected .post("/session/{sessionID}/abort", "session.abort") .mutating() .seeded((ctx) => ctx.session({ title: "Abort session" })) @@ -814,7 +898,7 @@ const scenarios: Scenario[] = [ .json(200, (body) => { check(body === true, "abort should return true") }), - http + http.protected .post("/session/{sessionID}/abort", "session.abort.missing") .at((ctx) => ({ path: route("/session/{sessionID}/abort", { sessionID: "ses_httpapi_missing" }), @@ -823,7 +907,7 @@ const scenarios: Scenario[] = [ .json(200, (body) => { check(body === true, "missing session abort should remain a no-op success") }), - http + http.protected .post("/session/{sessionID}/init", "session.init") .preserveDatabase() .withLlm() @@ -847,7 +931,7 @@ const scenarios: Scenario[] = [ yield* ctx.llmWait(1) }), ), - http + http.protected .post("/session/{sessionID}/message", "session.prompt") .preserveDatabase() .withLlm() @@ -882,7 +966,7 @@ const scenarios: Scenario[] = [ }), "status", ), - http + http.protected .post("/session/{sessionID}/prompt_async", "session.prompt_async") .preserveDatabase() .withLlm() @@ -908,7 +992,7 @@ const scenarios: Scenario[] = [ yield* ctx.llmWait(1) }), ), - http + http.protected .post("/session/{sessionID}/command", "session.command") .preserveDatabase() .withLlm() @@ -935,7 +1019,7 @@ const scenarios: Scenario[] = [ }), "status", ), - http + http.protected .post("/session/{sessionID}/shell", "session.shell") .preserveDatabase() .mutating() @@ -957,7 +1041,7 @@ const scenarios: Scenario[] = [ }, "status", ), - http + http.protected .post("/session/{sessionID}/summarize", "session.summarize") .preserveDatabase() .withLlm() @@ -1018,7 +1102,7 @@ const scenarios: Scenario[] = [ }), "status", ), - http + http.protected .post("/session/{sessionID}/revert", "session.revert") .mutating() .seeded((ctx) => @@ -1045,7 +1129,7 @@ const scenarios: Scenario[] = [ }, "status", ), - http + http.protected .post("/session/{sessionID}/unrevert", "session.unrevert") .mutating() .seeded((ctx) => ctx.session({ title: "Unrevert session" })) @@ -1061,7 +1145,7 @@ const scenarios: Scenario[] = [ }, "status", ), - http + http.protected .post("/session/{sessionID}/permissions/{permissionID}", "permission.respond") .seeded((ctx) => ctx.session({ title: "Deprecated permission session" })) .at((ctx) => ({ @@ -1075,7 +1159,7 @@ const scenarios: Scenario[] = [ .json(200, (body) => { check(body === true, "deprecated permission response should return true") }), - http + http.protected .post("/session/{sessionID}/share", "session.share") .mutating() .seeded((ctx) => ctx.session({ title: "Share session" })) @@ -1088,7 +1172,7 @@ const scenarios: Scenario[] = [ }, "status", ), - http + http.protected .delete("/session/{sessionID}/share", "session.unshare") .mutating() .seeded((ctx) => ctx.session({ title: "Unshare session" })) @@ -1101,25 +1185,25 @@ const scenarios: Scenario[] = [ }, "status", ), - http + http.protected .post("/tui/append-prompt", "tui.appendPrompt") .at((ctx) => ({ path: "/tui/append-prompt", headers: ctx.headers(), body: { text: "hello" } })) .json(200, boolean, "status"), - http + http.protected .post("/tui/select-session", "tui.selectSession.invalid") .at((ctx) => ({ path: "/tui/select-session", headers: ctx.headers(), body: { sessionID: "invalid" } })) .status(400), - http.post("/tui/open-help", "tui.openHelp").json(200, boolean, "status"), - http.post("/tui/open-sessions", "tui.openSessions").json(200, boolean, "status"), - http.post("/tui/open-themes", "tui.openThemes").json(200, boolean, "status"), - http.post("/tui/open-models", "tui.openModels").json(200, boolean, "status"), - http.post("/tui/submit-prompt", "tui.submitPrompt").json(200, boolean, "status"), - http.post("/tui/clear-prompt", "tui.clearPrompt").json(200, boolean, "status"), - http + http.protected.post("/tui/open-help", "tui.openHelp").json(200, boolean, "status"), + http.protected.post("/tui/open-sessions", "tui.openSessions").json(200, boolean, "status"), + http.protected.post("/tui/open-themes", "tui.openThemes").json(200, boolean, "status"), + http.protected.post("/tui/open-models", "tui.openModels").json(200, boolean, "status"), + http.protected.post("/tui/submit-prompt", "tui.submitPrompt").json(200, boolean, "status"), + http.protected.post("/tui/clear-prompt", "tui.clearPrompt").json(200, boolean, "status"), + http.protected .post("/tui/execute-command", "tui.executeCommand") .at((ctx) => ({ path: "/tui/execute-command", headers: ctx.headers(), body: { command: "agent_cycle" } })) .json(200, boolean, "status"), - http + http.protected .post("/tui/show-toast", "tui.showToast") .at((ctx) => ({ path: "/tui/show-toast", @@ -1127,7 +1211,7 @@ const scenarios: Scenario[] = [ body: { title: "Exercise", message: "covered", variant: "info", duration: 1000 }, })) .json(200, boolean, "status"), - http + http.protected .post("/tui/publish", "tui.publish") .at((ctx) => ({ path: "/tui/publish", @@ -1135,16 +1219,16 @@ const scenarios: Scenario[] = [ body: { type: "tui.prompt.append", properties: { text: "published" } }, })) .json(200, boolean, "status"), - http + http.protected .post("/tui/select-session", "tui.selectSession") .seeded((ctx) => ctx.session({ title: "TUI select" })) .at((ctx) => ({ path: "/tui/select-session", headers: ctx.headers(), body: { sessionID: ctx.state.id } })) .json(200, boolean, "status"), - http + http.protected .post("/tui/control/response", "tui.control.response") .at((ctx) => ({ path: "/tui/control/response", headers: ctx.headers(), body: { ok: true } })) .json(200, boolean, "status"), - http + http.protected .get("/tui/control/next", "tui.control.next") .mutating() .seeded((ctx) => ctx.tuiRequest({ path: "/tui/exercise", body: { text: "queued" } })) @@ -1158,23 +1242,38 @@ const scenarios: Scenario[] = [ }, "status", ), - http + http.protected .post("/global/upgrade", "global.upgrade") .global() + .probe({ path: "/global/upgrade", body: { target: 1 } }) .at(() => ({ path: "/global/upgrade", body: { target: 1 } })) .status(400), ] +const llmScenarios = new Set([ + "session.init", + "session.prompt", + "session.prompt_async", + "session.command", + "session.summarize", +]) + const main = Effect.gen(function* () { yield* Effect.addFinalizer(() => cleanupExercisePaths) const options = parseOptions(Bun.argv.slice(2)) const modules = yield* Effect.promise(() => runtime()) const effectRoutes = routeKeys(OpenApi.fromApi(modules.PublicApi)) const honoRoutes = routeKeys(yield* Effect.promise(() => modules.Server.openapiHono())) - const selected = scenarios.filter((scenario) => matches(options, scenario)) + const selected = selectedScenarios(options, scenarios) const missing = effectRoutes.filter((route) => !scenarios.some((scenario) => route === routeKey(scenario))) const extra = scenarios.filter((scenario) => !effectRoutes.includes(routeKey(scenario))) + for (const scenario of scenarios) { + if (scenario.kind === "active" && llmScenarios.has(scenario.name) && !scenario.project?.llm) { + return yield* Effect.fail(new Error(`${scenario.name} must use TestLLMServer via .withLlm()`)) + } + } + printHeader(options, effectRoutes, honoRoutes, selected, missing, extra, { database: exerciseDatabasePath, global: exerciseGlobalRoot, @@ -1183,7 +1282,15 @@ const main = Effect.gen(function* () { const results = options.mode === "coverage" ? selected.map(coverageResult) - : yield* Effect.forEach(selected, runScenario(options), { concurrency: 1 }) + : yield* Effect.forEach( + selected, + (scenario) => + Effect.gen(function* () { + if (options.progress) console.log(`${color.dim}RUN ${routeKey(scenario)} ${scenario.name}${color.reset}`) + return yield* runScenario(options)(scenario) + }), + { concurrency: 1 }, + ) printResults(results, missing, extra) if (results.some((result) => result.status === "fail")) diff --git a/packages/opencode/test/server/httpapi-exercise/report.ts b/packages/opencode/test/server/httpapi-exercise/report.ts index 9ed764f4db..cf674f1db9 100644 --- a/packages/opencode/test/server/httpapi-exercise/report.ts +++ b/packages/opencode/test/server/httpapi-exercise/report.ts @@ -1,3 +1,4 @@ +import { Duration } from "effect" import { indent, pad } from "./assertions" import type { Options, Result, Scenario } from "./types" @@ -23,7 +24,7 @@ export function printHeader( console.log(`${color.dim}db=${paths.database}${color.reset}`) console.log(`${color.dim}global=${paths.global}${color.reset}`) console.log( - `${color.dim}mode=${options.mode} selected=${selected.length} effectRoutes=${effectRoutes.length} missing=${missing.length} extra=${extra.length} onlyEffect=${effectRoutes.filter((route) => !honoRoutes.includes(route)).length} onlyHono=${honoRoutes.filter((route) => !effectRoutes.includes(route)).length}${color.reset}`, + `${color.dim}mode=${options.mode} selected=${selected.length} scenarioTimeout=${Duration.format(options.scenarioTimeout)} effectRoutes=${effectRoutes.length} missing=${missing.length} extra=${extra.length} onlyEffect=${effectRoutes.filter((route) => !honoRoutes.includes(route)).length} onlyHono=${honoRoutes.filter((route) => !effectRoutes.includes(route)).length}${color.reset}`, ) console.log("") } diff --git a/packages/opencode/test/server/httpapi-exercise/routing.ts b/packages/opencode/test/server/httpapi-exercise/routing.ts index 39bda11209..b2ca3eb5af 100644 --- a/packages/opencode/test/server/httpapi-exercise/routing.ts +++ b/packages/opencode/test/server/httpapi-exercise/routing.ts @@ -1,5 +1,27 @@ +import { Duration } from "effect" import { OpenApiMethods, type OpenApiSpec, type Options, type Result, type Scenario } from "./types" +type ScenarioTimeout = `${number} ${Duration.Unit}` + +const durationUnits = new Set([ + "nano", + "nanos", + "micro", + "micros", + "milli", + "millis", + "second", + "seconds", + "minute", + "minutes", + "hour", + "hours", + "day", + "days", + "week", + "weeks", +]) + export function routeKeys(spec: OpenApiSpec) { return Object.entries(spec.paths ?? {}) .flatMap(([path, item]) => @@ -24,8 +46,13 @@ export function parseOptions(args: string[]): Options { return { mode, include: option(args, "--include"), + startAt: option(args, "--start-at"), + stopAt: option(args, "--stop-at"), failOnMissing: args.includes("--fail-on-missing"), failOnSkip: args.includes("--fail-on-skip"), + scenarioTimeout: parseScenarioTimeout(option(args, "--scenario-timeout") ?? "30 seconds"), + progress: args.includes("--progress"), + trace: args.includes("--trace"), } } @@ -38,8 +65,33 @@ export function matches(options: Options, scenario: Scenario) { ) } +export function selectedScenarios(options: Options, scenarios: Scenario[]) { + const included = scenarios.filter((scenario) => matches(options, scenario)) + const start = options.startAt ? included.findIndex((scenario) => matchesName(options.startAt!, scenario)) : 0 + const end = options.stopAt + ? included.findIndex((scenario) => matchesName(options.stopAt!, scenario)) + : included.length - 1 + if (start === -1) throw new Error(`--start-at matched no scenario: ${options.startAt}`) + if (end === -1) throw new Error(`--stop-at matched no scenario: ${options.stopAt}`) + return included.slice(start, end + 1) +} + +function matchesName(value: string, scenario: Scenario) { + return scenario.name.includes(value) || scenario.path.includes(value) || scenario.method.includes(value.toUpperCase()) +} + function option(args: string[], name: string) { const index = args.indexOf(name) if (index === -1) return undefined return args[index + 1] } + +function parseScenarioTimeout(input: string) { + if (!isScenarioTimeout(input)) throw new Error(`invalid --scenario-timeout ${input}`) + return Duration.fromInputUnsafe(input) +} + +function isScenarioTimeout(input: string): input is ScenarioTimeout { + const [amount, unit, extra] = input.trim().split(/\s+/) + return extra === undefined && amount !== undefined && Number.isFinite(Number(amount)) && durationUnits.has(unit ?? "") +} diff --git a/packages/opencode/test/server/httpapi-exercise/runner.ts b/packages/opencode/test/server/httpapi-exercise/runner.ts index 18ef991807..c735cc125c 100644 --- a/packages/opencode/test/server/httpapi-exercise/runner.ts +++ b/packages/opencode/test/server/httpapi-exercise/runner.ts @@ -1,5 +1,5 @@ import { Flag } from "@opencode-ai/core/flag/flag" -import { Cause, Effect } from "effect" +import { Cause, Duration, Effect } from "effect" import { TestLLMServer } from "../../lib/llm-server" import type { Config } from "../../../src/config/config" import { ModelID, ProviderID } from "../../../src/provider/schema" @@ -24,6 +24,10 @@ export function runScenario(options: Options) { return (scenario: Scenario) => { if (scenario.kind === "todo") return Effect.succeed({ status: "skip", scenario } as Result) return runActive(options, scenario).pipe( + Effect.timeoutOrElse({ + duration: options.scenarioTimeout, + orElse: () => Effect.die(new Error(`scenario timed out after ${Duration.format(options.scenarioTimeout)}`)), + }), Effect.as({ status: "pass", scenario } as Result), Effect.catchCause((cause) => Effect.succeed({ status: "fail" as const, scenario, message: Cause.pretty(cause) })), Effect.scoped, @@ -36,20 +40,32 @@ function runActive(options: Options, scenario: ActiveScenario) { if (options.mode === "parity" && scenario.mutates && scenario.compare !== "none") { return Effect.gen(function* () { - const effect = yield* runBackend("effect", scenario) - const legacy = yield* runBackend("legacy", scenario) + const effect = yield* runBackend(options, "effect", scenario) + const legacy = yield* runBackend(options, "legacy", scenario) + yield* trace(options, scenario, "compare start") yield* compare(scenario, effect, legacy) + yield* trace(options, scenario, "compare done") }) } - return withContext(scenario, (ctx) => + return withContext(options, scenario, "shared", (ctx) => Effect.gen(function* () { + yield* trace(options, scenario, "effect request start") const effect = yield* call("effect", scenario, ctx) + yield* trace(options, scenario, `effect response ${effect.status}`) + yield* trace(options, scenario, "effect expect start") yield* scenario.expect(ctx, ctx.state, effect) + yield* trace(options, scenario, "effect expect done") if (options.mode === "parity" && scenario.compare !== "none") { + yield* trace(options, scenario, "legacy request start") const legacy = yield* call("legacy", scenario, ctx) + yield* trace(options, scenario, `legacy response ${legacy.status}`) + yield* trace(options, scenario, "legacy expect start") yield* scenario.expect(ctx, ctx.state, legacy) + yield* trace(options, scenario, "legacy expect done") + yield* trace(options, scenario, "compare start") yield* compare(scenario, effect, legacy) + yield* trace(options, scenario, "compare done") } }), ) @@ -57,61 +73,89 @@ function runActive(options: Options, scenario: ActiveScenario) { function runAuth(scenario: ActiveScenario) { return Effect.gen(function* () { - const effect = yield* callAuthProbe("effect", scenario) - const legacy = yield* callAuthProbe("legacy", scenario) + const effect = yield* callAuthProbe("effect", scenario, "missing") + const legacy = yield* callAuthProbe("legacy", scenario, "missing") if (scenario.auth === "protected") { if (effect.status !== 401) throw new Error(`effect auth expected 401, got ${effect.status}`) if (legacy.status !== 401) throw new Error(`legacy auth expected 401, got ${legacy.status}`) + const effectAuthed = yield* callAuthProbe("effect", scenario, "valid") + const legacyAuthed = yield* callAuthProbe("legacy", scenario, "valid") + if (effectAuthed.status === 401) throw new Error("effect auth rejected valid credentials") + if (legacyAuthed.status === 401) throw new Error("legacy auth rejected valid credentials") return } if (effect.status === 401) throw new Error("effect auth expected public access, got 401") if (legacy.status === 401) throw new Error("legacy auth expected public access, got 401") + if (effect.timedOut) throw new Error("effect auth expected public access, probe timed out") + if (legacy.timedOut) throw new Error("legacy auth expected public access, probe timed out") }) } -function runBackend(backend: "effect" | "legacy", scenario: ActiveScenario) { - return withContext(scenario, (ctx) => +function runBackend(options: Options, backend: "effect" | "legacy", scenario: ActiveScenario) { + return withContext(options, scenario, backend, (ctx) => Effect.gen(function* () { + yield* trace(options, scenario, `${backend} request start`) const result = yield* call(backend, scenario, ctx) + yield* trace(options, scenario, `${backend} response ${result.status}`) + yield* trace(options, scenario, `${backend} expect start`) yield* scenario.expect(ctx, ctx.state, result) + yield* trace(options, scenario, `${backend} expect done`) return result }), ) } -function withContext(scenario: ActiveScenario, use: (ctx: SeededContext) => Effect.Effect) { +function withContext( + options: Options, + scenario: ActiveScenario, + label: string, + use: (ctx: SeededContext) => Effect.Effect, +) { return Effect.acquireRelease( Effect.gen(function* () { + yield* trace(options, scenario, `${label} context acquire start`) const llm = scenario.project?.llm ? yield* TestLLMServer : undefined const project = scenario.project const dir = project ? yield* Effect.promise(async () => (await runtime()).tmpdir(projectOptions(project, llm?.url))) : undefined + yield* trace(options, scenario, `${label} context acquire done`) return { dir, llm } }), (ctx) => - Effect.promise(async () => { - await ctx.dir?.[Symbol.asyncDispose]() - }).pipe(Effect.ignore), + Effect.gen(function* () { + yield* trace(options, scenario, `${label} tmpdir cleanup start`) + yield* Effect.promise(async () => { + await ctx.dir?.[Symbol.asyncDispose]() + }).pipe(Effect.ignore) + yield* trace(options, scenario, `${label} tmpdir cleanup done`) + }), ).pipe( Effect.flatMap((context) => Effect.gen(function* () { + yield* trace(options, scenario, `${label} runtime start`) const modules = yield* Effect.promise(() => runtime()) + yield* trace(options, scenario, `${label} runtime done`) const path = context.dir?.path const instance = path - ? yield* modules.InstanceStore.Service.use((store) => store.load({ directory: path })).pipe( - Effect.provide(modules.AppLayer), - Effect.catchCause((cause) => - Effect.sleep("100 millis").pipe( - Effect.andThen( - modules.InstanceStore.Service.use((store) => store.load({ directory: path })).pipe( - Effect.provide(modules.AppLayer), + ? yield* trace(options, scenario, `${label} instance load start`).pipe( + Effect.andThen( + modules.InstanceStore.Service.use((store) => store.load({ directory: path })).pipe( + Effect.provide(modules.AppLayer), + Effect.catchCause((cause) => + Effect.sleep("100 millis").pipe( + Effect.andThen( + modules.InstanceStore.Service.use((store) => store.load({ directory: path })).pipe( + Effect.provide(modules.AppLayer), + ), + ), + Effect.catchCause(() => Effect.failCause(cause)), ), ), - Effect.catchCause(() => Effect.failCause(cause)), ), ), + Effect.tap(() => trace(options, scenario, `${label} instance load done`)), ) : undefined const run = (effect: Effect.Effect) => @@ -184,14 +228,26 @@ function withContext(scenario: ActiveScenario, use: (ctx: SeededContext Effect.suspend(() => llm().wait(count)), tuiRequest: (request) => Effect.sync(() => modules.Tui.submitTuiRequest(request)), } + yield* trace(options, scenario, `${label} seed start`) const state = yield* scenario.seed(base) - return yield* use({ ...base, state }) + yield* trace(options, scenario, `${label} seed done`) + yield* trace(options, scenario, `${label} use start`) + const result = yield* use({ ...base, state }) + yield* trace(options, scenario, `${label} use done`) + return result }).pipe(Effect.ensuring(context.llm ? context.llm.reset : Effect.void)), ), Effect.ensuring(scenario.reset ? resetState : Effect.void), ) } +function trace(options: Options, scenario: ActiveScenario, phase: string) { + return Effect.sync(() => { + if (!options.trace) return + console.log(`[trace] ${scenario.name}: ${phase}`) + }) +} + function projectOptions( project: ProjectOptions, llmUrl: string | undefined, diff --git a/packages/opencode/test/server/httpapi-exercise/types.ts b/packages/opencode/test/server/httpapi-exercise/types.ts index c725739b4e..2cdc822541 100644 --- a/packages/opencode/test/server/httpapi-exercise/types.ts +++ b/packages/opencode/test/server/httpapi-exercise/types.ts @@ -1,4 +1,4 @@ -import type { Effect } from "effect" +import type { Duration, Effect } from "effect" import type { Config } from "../../../src/config/config" import type { Project } from "../../../src/project/project" import type { Worktree } from "../../../src/worktree" @@ -22,8 +22,13 @@ export type JsonObject = Record export type Options = { mode: Mode include: string | undefined + startAt: string | undefined + stopAt: string | undefined failOnMissing: boolean failOnSkip: boolean + scenarioTimeout: Duration.Duration + progress: boolean + trace: boolean } export type RequestSpec = { @@ -37,6 +42,7 @@ export type CallResult = { contentType: string body: unknown text: string + timedOut: boolean } export type BackendApp = { @@ -75,6 +81,7 @@ export type ActiveScenario = { project: ProjectOptions | undefined seed: (ctx: ScenarioContext) => Effect.Effect request: (ctx: ScenarioContext, state: unknown) => RequestSpec + authProbe: RequestSpec | undefined expect: (ctx: ScenarioContext, state: unknown, result: CallResult) => Effect.Effect compare: Comparison capture: CaptureMode @@ -90,6 +97,7 @@ export type BuilderState = { project: ProjectOptions | undefined seed: (ctx: ScenarioContext) => Effect.Effect request: (ctx: SeededContext) => RequestSpec + authProbe: RequestSpec | undefined capture: CaptureMode mutates: boolean reset: boolean From 9c05d4e2fd45b60479eb2eb3873a2fe343198b04 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 8 May 2026 22:20:50 -0400 Subject: [PATCH 028/189] fix(server): serve HttpApi OpenAPI document (#26438) --- .../server/routes/instance/httpapi/server.ts | 18 +++++++++++++++--- .../test/server/httpapi-instance.test.ts | 17 +++++++++++++++++ 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index f60c4e6381..17efd053b9 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -1,6 +1,6 @@ import { Context, Effect, Layer } from "effect" -import { HttpApiBuilder } from "effect/unstable/httpapi" -import { FetchHttpClient, HttpClient, HttpMiddleware, HttpRouter, HttpServer } from "effect/unstable/http" +import { HttpApiBuilder, OpenApi } from "effect/unstable/httpapi" +import { FetchHttpClient, HttpClient, HttpMiddleware, HttpRouter, HttpServer, HttpServerResponse } from "effect/unstable/http" import * as Socket from "effect/unstable/socket/Socket" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Account } from "@/account/account" @@ -49,6 +49,7 @@ import { CorsConfig, isAllowedCorsOrigin, type CorsOptions } from "@/server/cors import { serveUIEffect } from "@/server/shared/ui" import { ServerAuth } from "@/server/auth" import { InstanceHttpApi, RootHttpApi } from "./api" +import { PublicApi } from "./public" import { authorizationLayer, authorizationRouterMiddleware } from "./middleware/authorization" import { EventApi, eventHandlers } from "./event" import { configHandlers } from "./handlers/config" @@ -144,6 +145,17 @@ const instanceRoutes = Layer.mergeAll(rawInstanceRoutes, instanceApiRoutes).pipe ]), ) +const openApiDocument = OpenApi.fromApi(PublicApi) +const openApiDocumentJson = JSON.stringify(openApiDocument) + +const docRoute = HttpRouter.use((router) => + router.add( + "GET", + "/doc", + () => Effect.succeed(HttpServerResponse.text(openApiDocumentJson, { headers: { "content-type": "application/json" } })), + ), +).pipe(Layer.provide(authOnlyRouterLayer)) + const uiRoute = HttpRouter.use((router) => Effect.gen(function* () { const fs = yield* AppFileSystem.Service @@ -153,7 +165,7 @@ const uiRoute = HttpRouter.use((router) => ).pipe(Layer.provide(authOnlyRouterLayer)) export function createRoutes(corsOptions?: CorsOptions) { - return Layer.mergeAll(rootApiRoutes, eventApiRoutes, instanceRoutes, uiRoute).pipe( + return Layer.mergeAll(rootApiRoutes, eventApiRoutes, instanceRoutes, docRoute, uiRoute).pipe( Layer.provide([ errorLayer, cors(corsOptions), diff --git a/packages/opencode/test/server/httpapi-instance.test.ts b/packages/opencode/test/server/httpapi-instance.test.ts index 61b1af6135..8adb21e463 100644 --- a/packages/opencode/test/server/httpapi-instance.test.ts +++ b/packages/opencode/test/server/httpapi-instance.test.ts @@ -48,6 +48,23 @@ const it = testEffect(Layer.mergeAll(testStateLayer, httpApiServerLayer)) const directoryHeader = (dir: string) => HttpClientRequest.setHeader("x-opencode-directory", dir) describe("instance HttpApi", () => { + it.live("serves the OpenAPI document", () => + Effect.gen(function* () { + const response = yield* HttpClient.get("/doc") + + expect(response.status).toBe(200) + expect(response.headers["content-type"]).toContain("application/json") + expect(yield* response.json).toMatchObject({ + openapi: expect.any(String), + info: expect.any(Object), + paths: expect.objectContaining({ + "/global/health": expect.any(Object), + "/session": expect.any(Object), + }), + }) + }), + ) + it.live("serves path and VCS read endpoints", () => Effect.gen(function* () { const dir = yield* tmpdirScoped({ git: true }) From 2a487ea990621be6879e6cd11f30dbf4725e0bc5 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sat, 9 May 2026 02:21:54 +0000 Subject: [PATCH 029/189] chore: generate --- .../src/server/routes/instance/httpapi/server.ts | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index 17efd053b9..783a01ae63 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -1,6 +1,13 @@ import { Context, Effect, Layer } from "effect" import { HttpApiBuilder, OpenApi } from "effect/unstable/httpapi" -import { FetchHttpClient, HttpClient, HttpMiddleware, HttpRouter, HttpServer, HttpServerResponse } from "effect/unstable/http" +import { + FetchHttpClient, + HttpClient, + HttpMiddleware, + HttpRouter, + HttpServer, + HttpServerResponse, +} from "effect/unstable/http" import * as Socket from "effect/unstable/socket/Socket" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Account } from "@/account/account" @@ -149,10 +156,8 @@ const openApiDocument = OpenApi.fromApi(PublicApi) const openApiDocumentJson = JSON.stringify(openApiDocument) const docRoute = HttpRouter.use((router) => - router.add( - "GET", - "/doc", - () => Effect.succeed(HttpServerResponse.text(openApiDocumentJson, { headers: { "content-type": "application/json" } })), + router.add("GET", "/doc", () => + Effect.succeed(HttpServerResponse.text(openApiDocumentJson, { headers: { "content-type": "application/json" } })), ), ).pipe(Layer.provide(authOnlyRouterLayer)) From 11c33d52a59e84993c96281f1495996650b1590c Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 8 May 2026 22:23:21 -0400 Subject: [PATCH 030/189] test(server): cover REST API project skills (#26451) --- .../opencode/test/server/httpapi-sdk.test.ts | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/packages/opencode/test/server/httpapi-sdk.test.ts b/packages/opencode/test/server/httpapi-sdk.test.ts index 6d2df45078..493f890838 100644 --- a/packages/opencode/test/server/httpapi-sdk.test.ts +++ b/packages/opencode/test/server/httpapi-sdk.test.ts @@ -231,6 +231,24 @@ function withFakeLlm(backend: Backend, run: (input: LlmProjectFixture) }).pipe(Effect.provide(TestLLMServer.layer)) } +function withFakeLlmProject( + backend: Backend, + options: { setup?: (dir: string) => Effect.Effect }, + run: (input: LlmProjectFixture) => Effect.Effect, +) { + return Effect.gen(function* () { + const llm = yield* TestLLMServer + return yield* withProject( + backend, + { + config: providerConfig(llm.url), + setup: options.setup, + }, + (input) => run({ ...input, llm }), + ) + }).pipe(Effect.provide(TestLLMServer.layer)) +} + function writeStandardFiles(dir: string) { return Effect.all([ call(() => Bun.write(path.join(dir, "hello.txt"), "hello")), @@ -238,6 +256,21 @@ function writeStandardFiles(dir: string) { ]).pipe(Effect.asVoid) } +function writeProjectSkill(dir: string) { + return call(() => + Bun.write( + path.join(dir, ".opencode", "skills", "project-rest-skill", "SKILL.md"), + `--- +name: project-rest-skill +description: A project skill visible to REST API prompts. +--- + +# Project REST Skill +`, + ), + ).pipe(Effect.asVoid) +} + function seedMessage(directory: string, sessionID: string) { const id = SessionID.make(sessionID) return call( @@ -647,6 +680,36 @@ describe("HttpApi SDK", () => { ), ) + httpapi( + "includes project skills in REST API async prompt context", + withFakeLlmProject("httpapi", { setup: writeProjectSkill }, ({ sdk, llm }) => + Effect.gen(function* () { + yield* llm.text("skill context ok", { usage: { input: 11, output: 7 } }) + const session = yield* capture(() => + sdk.session.create({ + title: "project skill prompt", + permission: [{ permission: "*", pattern: "*", action: "allow" }], + }), + ) + const sessionID = String(record(session.data).id) + const prompt = yield* capture(() => + sdk.session.promptAsync({ + sessionID, + agent: "build", + model: { providerID: "test", modelID: "test-model" }, + parts: [{ type: "text", text: "hello skill context" }], + }), + ) + yield* llm.wait(1) + const inputs = yield* llm.inputs + + expect(session.status).toBe(200) + expect(prompt.status).toBe(204) + expect(JSON.stringify(inputs[0])).toContain("project-rest-skill") + }), + ), + ) + parity("matches generated SDK TUI validation and command routes across backends", (backend) => withStandardProject(backend, ({ sdk }) => Effect.gen(function* () { From cc68afb2deeb6bf1db37f4c0a47900ce1feeb368 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 8 May 2026 22:35:32 -0400 Subject: [PATCH 031/189] test(server): lock fixed workspace routing context (#26454) --- .../server/httpapi-instance-context.test.ts | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/packages/opencode/test/server/httpapi-instance-context.test.ts b/packages/opencode/test/server/httpapi-instance-context.test.ts index 5e00d77708..0e5c752371 100644 --- a/packages/opencode/test/server/httpapi-instance-context.test.ts +++ b/packages/opencode/test/server/httpapi-instance-context.test.ts @@ -7,6 +7,7 @@ import * as Socket from "effect/unstable/socket/Socket" import { mkdir } from "node:fs/promises" import path from "node:path" import { registerAdapter } from "../../src/control-plane/adapters" +import { WorkspaceID } from "../../src/control-plane/schema" import type { WorkspaceAdapter } from "../../src/control-plane/types" import { Workspace } from "../../src/control-plane/workspace" import { InstanceRef, WorkspaceRef } from "../../src/effect/instance-ref" @@ -203,6 +204,40 @@ describe("HttpApi instance context middleware", () => { }), ) + it.live("uses configured workspace id instead of routing to requested workspaces", () => + Effect.gen(function* () { + const originalWorkspaceID = Flag.OPENCODE_WORKSPACE_ID + const fixedWorkspaceID = WorkspaceID.ascending() + Flag.OPENCODE_WORKSPACE_ID = fixedWorkspaceID + yield* Effect.addFinalizer(() => + Effect.sync(() => { + Flag.OPENCODE_WORKSPACE_ID = originalWorkspaceID + }), + ) + + const dir = yield* tmpdirScoped({ git: true }) + const project = yield* Project.use.fromDirectory(dir) + const workspaceDir = path.join(dir, ".workspace-local") + const workspace = yield* createLocalWorkspace({ + projectID: project.project.id, + type: "instance-context-fixed-workspace-ref", + directory: workspaceDir, + }) + yield* serveProbe() + + const response = yield* HttpClientRequest.get(`/probe?workspace=${workspace.id}`).pipe( + HttpClientRequest.setHeader("x-opencode-directory", dir), + HttpClient.execute, + ) + + expect(response.status).toBe(200) + expect(yield* response.json).toMatchObject({ + directory: dir, + workspaceID: fixedWorkspaceID, + }) + }), + ) + it.live("preserves selected workspace id on instance disposal events", () => Effect.gen(function* () { const dir = yield* tmpdirScoped({ git: true }) From 9b7b6cb30f6aca1836c6a89e5eee6f7b1f6d4636 Mon Sep 17 00:00:00 2001 From: James Long Date: Fri, 8 May 2026 22:45:44 -0400 Subject: [PATCH 032/189] feat(core): be smarter about generating a worktree name (#26368) --- packages/opencode/src/worktree/index.ts | 4 ++- .../opencode/test/project/worktree.test.ts | 29 +++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/worktree/index.ts b/packages/opencode/src/worktree/index.ts index 088dc9eb35..27d2e41cd6 100644 --- a/packages/opencode/src/worktree/index.ts +++ b/packages/opencode/src/worktree/index.ts @@ -361,13 +361,15 @@ export const layer: Layer.Layer< } const primary = yield* canonical(ctx.worktree) + const primaryName = pathSvc.basename(primary).toLowerCase() return yield* Effect.forEach(parseWorktreeList(result.text), (entry) => Effect.gen(function* () { if (!entry.path) return undefined const directory = yield* canonical(entry.path) if (directory === primary) return undefined + const name = pathSvc.basename(directory).toLowerCase() return { - name: pathSvc.basename(directory), + name: name === primaryName ? pathSvc.basename(pathSvc.dirname(directory)) : name, directory, ...(entry.branch ? { branch: entry.branch.replace(/^refs\/heads\//, "") } : {}), } diff --git a/packages/opencode/test/project/worktree.test.ts b/packages/opencode/test/project/worktree.test.ts index b191a3c952..4f0ead54e4 100644 --- a/packages/opencode/test/project/worktree.test.ts +++ b/packages/opencode/test/project/worktree.test.ts @@ -200,6 +200,35 @@ describe("Worktree", () => { ) }) + describe("list", () => { + it.live("uses parent folder name when worktree basename matches the primary worktree", () => + provideTmpdirInstance( + (dir) => + Effect.gen(function* () { + const svc = yield* Worktree.Service + const parent = path.join(path.dirname(dir), `${path.basename(dir)}-parent`) + const target = path.join(parent, path.basename(dir)) + const branch = `same-basename-list-${Date.now()}` + + yield* Effect.promise(() => fs.mkdir(parent, { recursive: true })) + yield* Effect.promise(() => $`git worktree add -b ${branch} ${target}`.cwd(dir).quiet()) + + const list = yield* svc.list() + const directory = yield* Effect.promise(() => fs.realpath(target).catch(() => target)) + + expect(list).toContainEqual({ + name: path.basename(parent), + branch, + directory: directory.toLowerCase(), + }) + + yield* svc.remove({ directory: target }) + }), + { git: true }, + ), + ) + }) + describe("remove edge cases", () => { it.live("remove non-existent directory succeeds silently", () => provideTmpdirInstance( From 8e9550d90df4153ef8e197e7df30879098732d6c Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 8 May 2026 22:45:54 -0400 Subject: [PATCH 033/189] fix(server): emit fixed workspace fence headers (#26443) --- .../instance/httpapi/middleware/fence.ts | 20 +++++++++++++++ .../server/routes/instance/httpapi/server.ts | 2 ++ .../test/server/httpapi-instance.test.ts | 25 +++++++++++++++++++ 3 files changed, 47 insertions(+) create mode 100644 packages/opencode/src/server/routes/instance/httpapi/middleware/fence.ts diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/fence.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/fence.ts new file mode 100644 index 0000000000..f3bfe06689 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/fence.ts @@ -0,0 +1,20 @@ +import { Flag } from "@opencode-ai/core/flag/flag" +import { Effect } from "effect" +import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" +import * as Fence from "@/server/shared/fence" + +const ignoredMethods = new Set(["GET", "HEAD", "OPTIONS"]) + +export const fenceLayer = HttpRouter.middleware<{ handles: unknown }>()((effect) => + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest + if (!Flag.OPENCODE_WORKSPACE_ID || ignoredMethods.has(request.method)) return yield* effect + + const previous = Fence.load() + const response = yield* effect + const current = Fence.diff(previous, Fence.load()) + if (Object.keys(current).length === 0) return response + + return HttpServerResponse.setHeader(response, Fence.HEADER, JSON.stringify(current)) + }), +).layer diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index 783a01ae63..4e07ab21ba 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -82,6 +82,7 @@ import { disposeMiddleware } from "./lifecycle" import { memoMap } from "@opencode-ai/core/effect/memo-map" import * as ServerBackend from "@/server/backend" import { errorLayer } from "./middleware/error" +import { fenceLayer } from "./middleware/fence" export const context = Context.makeUnsafe(new Map()) @@ -173,6 +174,7 @@ export function createRoutes(corsOptions?: CorsOptions) { return Layer.mergeAll(rootApiRoutes, eventApiRoutes, instanceRoutes, docRoute, uiRoute).pipe( Layer.provide([ errorLayer, + fenceLayer, cors(corsOptions), runtime, Account.defaultLayer, diff --git a/packages/opencode/test/server/httpapi-instance.test.ts b/packages/opencode/test/server/httpapi-instance.test.ts index 8adb21e463..930cc6d0aa 100644 --- a/packages/opencode/test/server/httpapi-instance.test.ts +++ b/packages/opencode/test/server/httpapi-instance.test.ts @@ -4,8 +4,11 @@ import { describe, expect } from "bun:test" import { Config, Effect, FileSystem, Layer, Path } from "effect" import { HttpClient, HttpClientRequest, HttpRouter, HttpServer } from "effect/unstable/http" import * as Socket from "effect/unstable/socket/Socket" +import { WorkspaceID } from "../../src/control-plane/schema" import { InstancePaths } from "../../src/server/routes/instance/httpapi/groups/instance" +import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session" import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server" +import { HEADER as FenceHeader } from "../../src/server/shared/fence" import { resetDatabase } from "../fixture/db" import { disposeAllInstances, tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" @@ -65,6 +68,28 @@ describe("instance HttpApi", () => { }), ) + it.live("emits a sync fence header for fixed-workspace mutations", () => + Effect.gen(function* () { + const originalWorkspaceID = Flag.OPENCODE_WORKSPACE_ID + Flag.OPENCODE_WORKSPACE_ID = WorkspaceID.ascending() + yield* Effect.addFinalizer(() => + Effect.sync(() => { + Flag.OPENCODE_WORKSPACE_ID = originalWorkspaceID + }), + ) + + const dir = yield* tmpdirScoped({ git: true }) + const response = yield* HttpClientRequest.post(SessionPaths.create).pipe( + directoryHeader(dir), + HttpClientRequest.bodyJson({ title: "fenced" }), + Effect.flatMap(HttpClient.execute), + ) + + expect(response.status).toBe(200) + expect(JSON.parse(response.headers[FenceHeader] ?? "{}")).not.toEqual({}) + }), + ) + it.live("serves path and VCS read endpoints", () => Effect.gen(function* () { const dir = yield* tmpdirScoped({ git: true }) From ffea6c7974efc335e80d4fcad07a6526b71ca176 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 8 May 2026 23:06:00 -0400 Subject: [PATCH 034/189] feat(server): add HTTP API response compression (#26440) --- .../httpapi/middleware/compression.ts | 64 +++++++ .../server/routes/instance/httpapi/server.ts | 2 + .../test/server/httpapi-compression.test.ts | 159 ++++++++++++++++++ 3 files changed, 225 insertions(+) create mode 100644 packages/opencode/src/server/routes/instance/httpapi/middleware/compression.ts create mode 100644 packages/opencode/test/server/httpapi-compression.test.ts diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/compression.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/compression.ts new file mode 100644 index 0000000000..9dc9bc01ec --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/compression.ts @@ -0,0 +1,64 @@ +import { deflateSync, gzipSync } from "node:zlib" +import { Effect } from "effect" +import { HttpBody, HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" + +// Mirror of Hono's compressible content-type set so wire behavior matches. +const COMPRESSIBLE_CONTENT_TYPE_REGEX = + /^\s*(?:text\/(?!event-stream(?:[;\s]|$))[^;\s]+|application\/(?:javascript|json|xml|xml-dtd|ecmascript|dart|postscript|rtf|tar|toml|vnd\.dart|vnd\.ms-fontobject|vnd\.ms-opentype|wasm|x-httpd-php|x-javascript|x-ns-proxy-autoconfig|x-sh|x-tar|x-www-form-urlencoded)|font\/(?:otf|ttf)|image\/(?:bmp|vnd\.adobe\.photoshop|vnd\.microsoft\.icon|vnd\.ms-dds|x-icon|x-ms-bmp)|message\/rfc822|model\/gltf-binary|x-shader\/x-fragment|x-shader\/x-vertex|[^;\s]+?\+(?:json|text|xml|yaml))(?:[;\s]|$)/i + +const NO_TRANSFORM_REGEX = /(?:^|,)\s*?no-transform\s*?(?:,|$)/i + +const STREAMING_PATHS = new Set(["/event", "/global/event"]) +const STREAMING_POST_REGEX = /^\/session\/[^/]+\/(?:message|prompt_async)$/ + +const THRESHOLD_BYTES = 1024 + +type Encoding = "gzip" | "deflate" + +function pickEncoding(acceptEncoding: string | undefined): Encoding | undefined { + if (!acceptEncoding) return undefined + const lower = acceptEncoding.toLowerCase() + if (lower.includes("gzip")) return "gzip" + if (lower.includes("deflate")) return "deflate" + return undefined +} + +function pathOf(url: string): string { + const queryIndex = url.indexOf("?") + return queryIndex === -1 ? url : url.slice(0, queryIndex) +} + +export const compressionLayer = HttpRouter.middleware<{ handles: unknown }>()((effect) => + Effect.gen(function* () { + const response = yield* effect + const request = yield* HttpServerRequest.HttpServerRequest + + if (request.method === "HEAD") return response + if (response.headers["content-encoding"]) return response + if (response.headers["transfer-encoding"]) return response + + const body = response.body + if (body._tag !== "Uint8Array") return response + if (body.body.byteLength < THRESHOLD_BYTES) return response + + const cacheControl = response.headers["cache-control"] + if (cacheControl && NO_TRANSFORM_REGEX.test(cacheControl)) return response + + const path = pathOf(request.url) + if (STREAMING_PATHS.has(path)) return response + if (request.method === "POST" && STREAMING_POST_REGEX.test(path)) return response + + const contentType = body.contentType + if (!COMPRESSIBLE_CONTENT_TYPE_REGEX.test(contentType)) return response + + const encoding = pickEncoding(request.headers["accept-encoding"]) + if (!encoding) return response + + const compressed = encoding === "gzip" ? gzipSync(body.body) : deflateSync(body.body) + return HttpServerResponse.setHeader( + HttpServerResponse.setBody(response, HttpBody.uint8Array(compressed, contentType)), + "content-encoding", + encoding, + ) + }), +).layer diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index 4e07ab21ba..fd7c3ec110 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -81,6 +81,7 @@ import { workspaceRouterMiddleware, workspaceRoutingLayer } from "./middleware/w import { disposeMiddleware } from "./lifecycle" import { memoMap } from "@opencode-ai/core/effect/memo-map" import * as ServerBackend from "@/server/backend" +import { compressionLayer } from "./middleware/compression" import { errorLayer } from "./middleware/error" import { fenceLayer } from "./middleware/fence" @@ -174,6 +175,7 @@ export function createRoutes(corsOptions?: CorsOptions) { return Layer.mergeAll(rootApiRoutes, eventApiRoutes, instanceRoutes, docRoute, uiRoute).pipe( Layer.provide([ errorLayer, + compressionLayer, fenceLayer, cors(corsOptions), runtime, diff --git a/packages/opencode/test/server/httpapi-compression.test.ts b/packages/opencode/test/server/httpapi-compression.test.ts new file mode 100644 index 0000000000..a7e119bd87 --- /dev/null +++ b/packages/opencode/test/server/httpapi-compression.test.ts @@ -0,0 +1,159 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { gunzipSync, inflateSync } from "node:zlib" +import { Flag } from "@opencode-ai/core/flag/flag" +import * as Log from "@opencode-ai/core/util/log" +import { Server } from "../../src/server/server" +import { resetDatabase } from "../fixture/db" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" + +void Log.init({ print: false }) + +const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI + +afterEach(async () => { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original + await disposeAllInstances() + await resetDatabase() +}) + +function app() { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true + return Server.Default().app +} + +// /config echoes the config back. Padding the config pushes the response body +// well past the 1024 B threshold so we can observe compression behavior. +function fatConfig() { + const instructions: string[] = [] + for (let i = 0; i < 50; i++) { + instructions.push(`padding-instruction-${i}-${"x".repeat(40)}`) + } + return { + formatter: false, + lsp: false, + username: "compression-test-user", + instructions, + } +} + +describe("HttpApi compression", () => { + describe("encodes responses", () => { + test("gzips JSON when Accept-Encoding includes gzip and body exceeds threshold", async () => { + await using tmp = await tmpdir({ config: fatConfig() }) + const response = await app().request("/config", { + headers: { "x-opencode-directory": tmp.path, "accept-encoding": "gzip" }, + }) + expect(response.status).toBe(200) + expect(response.headers.get("content-encoding")).toBe("gzip") + const compressed = new Uint8Array(await response.arrayBuffer()) + const decompressed = gunzipSync(compressed) + const json = JSON.parse(new TextDecoder().decode(decompressed)) + expect(json).toMatchObject({ username: "compression-test-user" }) + expect(compressed.byteLength).toBeLessThan(decompressed.byteLength) + }) + + test("uses deflate when only deflate is acceptable", async () => { + await using tmp = await tmpdir({ config: fatConfig() }) + const response = await app().request("/config", { + headers: { "x-opencode-directory": tmp.path, "accept-encoding": "deflate" }, + }) + expect(response.status).toBe(200) + expect(response.headers.get("content-encoding")).toBe("deflate") + const compressed = new Uint8Array(await response.arrayBuffer()) + const decompressed = inflateSync(compressed) + const json = JSON.parse(new TextDecoder().decode(decompressed)) + expect(json).toMatchObject({ username: "compression-test-user" }) + }) + + test("prefers gzip when both gzip and deflate are acceptable", async () => { + await using tmp = await tmpdir({ config: fatConfig() }) + const response = await app().request("/config", { + headers: { "x-opencode-directory": tmp.path, "accept-encoding": "gzip, deflate" }, + }) + expect(response.headers.get("content-encoding")).toBe("gzip") + }) + + test("does not include the original Content-Length when compressed", async () => { + await using tmp = await tmpdir({ config: fatConfig() }) + const response = await app().request("/config", { + headers: { "x-opencode-directory": tmp.path, "accept-encoding": "gzip" }, + }) + const compressed = new Uint8Array(await response.arrayBuffer()) + const declared = response.headers.get("content-length") + // Either absent (transfer-encoding chunked) or matches the compressed length. + if (declared !== null) expect(Number(declared)).toBe(compressed.byteLength) + }) + }) + + describe("skips", () => { + test("when no Accept-Encoding header is present", async () => { + await using tmp = await tmpdir({ config: fatConfig() }) + const response = await app().request("/config", { + headers: { "x-opencode-directory": tmp.path }, + }) + expect(response.headers.get("content-encoding")).toBeNull() + }) + + test("when Accept-Encoding only allows unsupported encodings", async () => { + await using tmp = await tmpdir({ config: fatConfig() }) + const response = await app().request("/config", { + headers: { "x-opencode-directory": tmp.path, "accept-encoding": "br" }, + }) + expect(response.headers.get("content-encoding")).toBeNull() + }) + + test("when the response body is below the 1024-byte threshold", async () => { + // A bare config produces a tiny response (~few hundred bytes). + await using tmp = await tmpdir({ config: { formatter: false, lsp: false } }) + const response = await app().request("/config", { + headers: { "x-opencode-directory": tmp.path, "accept-encoding": "gzip" }, + }) + expect(response.status).toBe(200) + const body = new Uint8Array(await response.arrayBuffer()) + expect(body.byteLength).toBeLessThan(1024) + expect(response.headers.get("content-encoding")).toBeNull() + }) + + test("HEAD requests", async () => { + await using tmp = await tmpdir({ config: fatConfig() }) + const response = await app().request("/config", { + method: "HEAD", + headers: { "x-opencode-directory": tmp.path, "accept-encoding": "gzip" }, + }) + expect(response.headers.get("content-encoding")).toBeNull() + }) + }) + + describe("streaming exclusions", () => { + test("/event SSE is not compressed", async () => { + await using tmp = await tmpdir({ config: { formatter: false, lsp: false } }) + const controller = new AbortController() + const response = await app().request("/event", { + headers: { "x-opencode-directory": tmp.path, "accept-encoding": "gzip" }, + signal: controller.signal, + }) + try { + expect(response.status).toBe(200) + expect(response.headers.get("content-encoding")).toBeNull() + } finally { + controller.abort() + await response.body?.cancel().catch(() => {}) + } + }) + + test("/global/event SSE is not compressed", async () => { + const controller = new AbortController() + const response = await app().request("/global/event", { + headers: { "accept-encoding": "gzip" }, + signal: controller.signal, + }) + try { + expect(response.status).toBe(200) + expect(response.headers.get("content-encoding")).toBeNull() + } finally { + controller.abort() + await response.body?.cancel().catch(() => {}) + } + }) + }) +}) From 357a74714acb37d21b5f0b8f14bae31aa00e0f2a Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 8 May 2026 23:12:08 -0400 Subject: [PATCH 035/189] fix(test): set OPENCODE_EXPERIMENTAL_WORKSPACES in fence header test (#26466) --- .../opencode/test/server/httpapi-instance.test.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/opencode/test/server/httpapi-instance.test.ts b/packages/opencode/test/server/httpapi-instance.test.ts index 930cc6d0aa..7a181aac65 100644 --- a/packages/opencode/test/server/httpapi-instance.test.ts +++ b/packages/opencode/test/server/httpapi-instance.test.ts @@ -14,17 +14,22 @@ import { disposeAllInstances, tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" // Flip the experimental HttpApi flag so backend selection telemetry on the -// production routes reports the right backend, and reset the database around -// the test so per-instance state does not leak between runs. resetDatabase() -// already calls disposeAllInstances(), so we don't repeat it. +// production routes reports the right backend, and the experimental +// workspaces flag so SyncEvent.run actually writes to EventSequenceTable +// (the source of truth the fence middleware reads). Reset the database +// around the test so per-instance state does not leak between runs. +// resetDatabase() already calls disposeAllInstances(), so we don't repeat it. const testStateLayer = Layer.effectDiscard( Effect.gen(function* () { const originalHttpApi = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI + const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true + Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true yield* Effect.promise(() => resetDatabase()) yield* Effect.addFinalizer(() => Effect.promise(async () => { Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = originalHttpApi + Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces await resetDatabase() }), ) From f0cb17a8129b0211161e7d9eb7c2f52437ea43e0 Mon Sep 17 00:00:00 2001 From: Jose Vargas Date: Fri, 8 May 2026 21:17:13 -0600 Subject: [PATCH 036/189] fix(tui): sort session picker by full updated timestamp (#24725) Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> --- .../cmd/tui/component/dialog-session-list.tsx | 24 +++++++++++++------ 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx index 31c8eb555d..8ff36ecba9 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx @@ -119,15 +119,25 @@ export function DialogSessionList() { )) } + function orderByRecency(sessionsList: NonNullable>) { + return sessionsList + .filter((x) => x.parentID === undefined) + .toSorted((a, b) => b.time.updated - a.time.updated) + .map((x) => x.id) + } + + const [browseOrder] = createSignal(orderByRecency(sync.data.session)) + const options = createMemo(() => { const today = new Date().toDateString() - return sessions() - .filter((x) => x.parentID === undefined) - .toSorted((a, b) => { - const updatedDay = new Date(b.time.updated).setHours(0, 0, 0, 0) - new Date(a.time.updated).setHours(0, 0, 0, 0) - if (updatedDay !== 0) return updatedDay - return b.time.created - a.time.created - }) + const sessionMap = new Map(sessions().filter((x) => x.parentID === undefined).map((x) => [x.id, x])) + + const searchResult = searchResults() + const displayOrder = searchResult ? orderByRecency(searchResult) : browseOrder() + + return displayOrder + .map((id) => sessionMap.get(id)) + .filter((x) => x !== undefined) .map((x) => { const workspace = x.workspaceID ? project.workspace.get(x.workspaceID) : undefined From 4d585464f39ca6d0db9b5a207de957d745015b10 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 8 May 2026 23:17:47 -0400 Subject: [PATCH 037/189] fix(server): include Origin in CORS preflight Vary header (#26445) --- .../instance/httpapi/middleware/cors-vary.ts | 29 +++++++ .../server/routes/instance/httpapi/server.ts | 2 + .../test/server/httpapi-cors-vary.test.ts | 82 +++++++++++++++++++ 3 files changed, 113 insertions(+) create mode 100644 packages/opencode/src/server/routes/instance/httpapi/middleware/cors-vary.ts create mode 100644 packages/opencode/test/server/httpapi-cors-vary.test.ts diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/cors-vary.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/cors-vary.ts new file mode 100644 index 0000000000..add533560c --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/cors-vary.ts @@ -0,0 +1,29 @@ +import { Effect } from "effect" +import { HttpRouter, HttpServerResponse } from "effect/unstable/http" + +// effect-smol's HttpMiddleware.cors builds OPTIONS preflight responses by +// spreading allowOrigin() and allowHeaders() into the same record. Both set +// the `vary` key, so allowHeaders' `Vary: Access-Control-Request-Headers` +// overwrites allowOrigin's `Vary: Origin`. With dynamic origin echoing, the +// missing `Vary: Origin` lets shared caches reuse a preflight cached for one +// origin against a different origin. +// +// TODO: upstream a fix that merges Vary values in headersFromRequestOptions +// (packages/effect/src/unstable/http/HttpMiddleware.ts ~line 332). +export const corsVaryFix = HttpRouter.middleware( + (effect) => + Effect.gen(function* () { + const response = yield* effect + const allowOrigin = response.headers["access-control-allow-origin"] + if (!allowOrigin || allowOrigin === "*") return response + + const vary = response.headers["vary"] + if (!vary) return HttpServerResponse.setHeader(response, "vary", "Origin") + + const tokens = vary.split(",").map((s) => s.trim().toLowerCase()) + if (tokens.includes("origin") || tokens.includes("*")) return response + + return HttpServerResponse.setHeader(response, "vary", `${vary}, Origin`) + }), + { global: true }, +) diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index fd7c3ec110..696fcc4c29 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -82,6 +82,7 @@ import { disposeMiddleware } from "./lifecycle" import { memoMap } from "@opencode-ai/core/effect/memo-map" import * as ServerBackend from "@/server/backend" import { compressionLayer } from "./middleware/compression" +import { corsVaryFix } from "./middleware/cors-vary" import { errorLayer } from "./middleware/error" import { fenceLayer } from "./middleware/fence" @@ -176,6 +177,7 @@ export function createRoutes(corsOptions?: CorsOptions) { Layer.provide([ errorLayer, compressionLayer, + corsVaryFix, fenceLayer, cors(corsOptions), runtime, diff --git a/packages/opencode/test/server/httpapi-cors-vary.test.ts b/packages/opencode/test/server/httpapi-cors-vary.test.ts new file mode 100644 index 0000000000..edec8e9f76 --- /dev/null +++ b/packages/opencode/test/server/httpapi-cors-vary.test.ts @@ -0,0 +1,82 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { Flag } from "@opencode-ai/core/flag/flag" +import * as Log from "@opencode-ai/core/util/log" +import { Server } from "../../src/server/server" +import { resetDatabase } from "../fixture/db" +import { disposeAllInstances } from "../fixture/fixture" + +void Log.init({ print: false }) + +const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI + +afterEach(async () => { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original + await disposeAllInstances() + await resetDatabase() +}) + +function app(experimental: boolean) { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = experimental + return experimental ? Server.Default().app : Server.Legacy().app +} + +const PREFLIGHT_HEADERS = { + origin: "http://localhost:3000", + "access-control-request-method": "POST", + "access-control-request-headers": "content-type, x-opencode-directory", +} + +// effect-smol's HttpMiddleware.cors overwrites `Vary: Origin` with +// `Vary: Access-Control-Request-Headers` on OPTIONS preflight responses +// (the two share the same record key during the spread). With dynamic +// origin echoing, missing Vary: Origin lets shared caches serve a preflight +// cached for one origin against a different origin. corsVaryFixLayer +// restores the merged form. +describe("CORS preflight Vary header", () => { + test("Hono backend preflight Vary contains Origin", async () => { + const response = await app(false).request("/global/config", { + method: "OPTIONS", + headers: PREFLIGHT_HEADERS, + }) + + expect([200, 204]).toContain(response.status) + expect(response.headers.get("access-control-allow-origin")).toBe("http://localhost:3000") + expect((response.headers.get("vary") ?? "").toLowerCase()).toContain("origin") + }) + + test("HTTP API backend preflight Vary contains Origin", async () => { + const response = await app(true).request("/global/config", { + method: "OPTIONS", + headers: PREFLIGHT_HEADERS, + }) + + expect([200, 204]).toContain(response.status) + expect(response.headers.get("access-control-allow-origin")).toBe("http://localhost:3000") + expect((response.headers.get("vary") ?? "").toLowerCase()).toContain("origin") + }) + + test("HTTP API backend preflight Vary still preserves Access-Control-Request-Headers", async () => { + const response = await app(true).request("/global/config", { + method: "OPTIONS", + headers: PREFLIGHT_HEADERS, + }) + + const vary = (response.headers.get("vary") ?? "").toLowerCase() + expect(vary).toContain("origin") + expect(vary).toContain("access-control-request-headers") + }) + + test("HTTP API backend does not duplicate Origin in Vary", async () => { + const response = await app(true).request("/global/config", { + method: "OPTIONS", + headers: PREFLIGHT_HEADERS, + }) + + const vary = response.headers.get("vary") ?? "" + const originCount = vary + .split(",") + .map((s) => s.trim().toLowerCase()) + .filter((s) => s === "origin").length + expect(originCount).toBe(1) + }) +}) From c0acf5c43fb2b33c948501612089ddc9a00e4cb1 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sat, 9 May 2026 03:18:57 +0000 Subject: [PATCH 038/189] chore: generate --- .../src/cli/cmd/tui/component/dialog-session-list.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx index 8ff36ecba9..35c966937c 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx @@ -130,7 +130,11 @@ export function DialogSessionList() { const options = createMemo(() => { const today = new Date().toDateString() - const sessionMap = new Map(sessions().filter((x) => x.parentID === undefined).map((x) => [x.id, x])) + const sessionMap = new Map( + sessions() + .filter((x) => x.parentID === undefined) + .map((x) => [x.id, x]), + ) const searchResult = searchResults() const displayOrder = searchResult ? orderByRecency(searchResult) : browseOrder() From f73a56c223202a4ccfb56fc0b33d8b5043c4eff2 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 8 May 2026 23:40:06 -0400 Subject: [PATCH 039/189] fix(server): log instance disposal failures from HTTP API lifecycle (#26446) --- .../src/server/routes/instance/httpapi/lifecycle.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/server/routes/instance/httpapi/lifecycle.ts b/packages/opencode/src/server/routes/instance/httpapi/lifecycle.ts index 53d54e2a81..4edfa80787 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/lifecycle.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/lifecycle.ts @@ -1,9 +1,12 @@ import { EffectBridge } from "@/effect/bridge" import type { InstanceContext } from "@/project/instance" import { InstanceStore } from "@/project/instance-store" +import * as Log from "@opencode-ai/core/util/log" import { Effect } from "effect" import { HttpEffect, HttpMiddleware, HttpServerRequest } from "effect/unstable/http" +const log = Log.create({ service: "server" }) + type MarkedInstance = { ctx: InstanceContext store: InstanceStore.Interface @@ -47,6 +50,8 @@ export const disposeMiddleware: HttpMiddleware.HttpMiddleware = (effect) => const marked = disposeAfterResponse.get(request.source) if (!marked) return response disposeAfterResponse.delete(request.source) - yield* Effect.uninterruptible(marked.bridge.run(marked.store.dispose(marked.ctx))) + yield* Effect.uninterruptible(marked.bridge.run(marked.store.dispose(marked.ctx))).pipe( + Effect.catchCause((cause) => Effect.sync(() => log.warn("instance disposal failed", { cause }))), + ) return response }) From eadda11ec9a83f4aab555896e79feebd7eb1f314 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 8 May 2026 23:41:20 -0400 Subject: [PATCH 040/189] refactor(server): use JSON response for OpenAPI doc route (#26447) --- .../src/server/routes/instance/httpapi/server.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index 696fcc4c29..1171df3e6a 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -155,13 +155,15 @@ const instanceRoutes = Layer.mergeAll(rawInstanceRoutes, instanceApiRoutes).pipe ]), ) -const openApiDocument = OpenApi.fromApi(PublicApi) -const openApiDocumentJson = JSON.stringify(openApiDocument) +// `OpenApi.fromApi` is non-trivial; defer until /doc is actually hit so +// processes that never serve it (CLI, scripts) don't pay at module load. +// `HttpServerResponse.jsonUnsafe` runs JSON.stringify eagerly, so caching +// the response also caches the serialized body — every /doc request reuses +// the same Uint8Array instead of re-stringifying the spec. +const docResponse = lazy(() => HttpServerResponse.jsonUnsafe(OpenApi.fromApi(PublicApi))) const docRoute = HttpRouter.use((router) => - router.add("GET", "/doc", () => - Effect.succeed(HttpServerResponse.text(openApiDocumentJson, { headers: { "content-type": "application/json" } })), - ), + router.add("GET", "/doc", () => Effect.succeed(docResponse())), ).pipe(Layer.provide(authOnlyRouterLayer)) const uiRoute = HttpRouter.use((router) => From 11d9e82eaf9992469322f5eba71d552f43e7f169 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sat, 9 May 2026 03:42:23 +0000 Subject: [PATCH 041/189] chore: generate --- .../opencode/src/server/routes/instance/httpapi/server.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index 1171df3e6a..eac579d7dc 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -162,9 +162,9 @@ const instanceRoutes = Layer.mergeAll(rawInstanceRoutes, instanceApiRoutes).pipe // the same Uint8Array instead of re-stringifying the spec. const docResponse = lazy(() => HttpServerResponse.jsonUnsafe(OpenApi.fromApi(PublicApi))) -const docRoute = HttpRouter.use((router) => - router.add("GET", "/doc", () => Effect.succeed(docResponse())), -).pipe(Layer.provide(authOnlyRouterLayer)) +const docRoute = HttpRouter.use((router) => router.add("GET", "/doc", () => Effect.succeed(docResponse()))).pipe( + Layer.provide(authOnlyRouterLayer), +) const uiRoute = HttpRouter.use((router) => Effect.gen(function* () { From 3615d5aab1dded682ac2d4794bb508197e6c463c Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 8 May 2026 23:44:28 -0400 Subject: [PATCH 042/189] fix(server): map Account failures to typed 500 instead of defect (#26448) --- .../instance/httpapi/groups/experimental.ts | 2 + .../instance/httpapi/handlers/experimental.ts | 10 +- .../httpapi-account-error-mapping.test.ts | 99 +++++++++++++++++++ 3 files changed, 109 insertions(+), 2 deletions(-) create mode 100644 packages/opencode/test/server/httpapi-account-error-mapping.test.ts diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts index e4a86ca139..3488d2616c 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts @@ -82,6 +82,7 @@ export const ExperimentalApi = HttpApi.make("experimental") .add( HttpApiEndpoint.get("console", ExperimentalPaths.console, { success: described(ConsoleStateResponse, "Active Console provider metadata"), + error: HttpApiError.InternalServerError, }).annotateMerge( OpenApi.annotations({ identifier: "experimental.console.get", @@ -91,6 +92,7 @@ export const ExperimentalApi = HttpApi.make("experimental") ), HttpApiEndpoint.get("consoleOrgs", ExperimentalPaths.consoleOrgs, { success: described(ConsoleOrgList, "Switchable Console orgs"), + error: HttpApiError.InternalServerError, }).annotateMerge( OpenApi.annotations({ identifier: "experimental.console.listOrgs", diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts index cc958da303..fa91b44117 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts @@ -26,7 +26,10 @@ export const experimentalHandlers = HttpApiBuilder.group(InstanceHttpApi, "exper const getConsole = Effect.fn("ExperimentalHttpApi.console")(function* () { const [state, groups] = yield* Effect.all( - [config.getConsoleState(), account.orgsByAccount().pipe(Effect.orDie)], + [ + config.getConsoleState(), + account.orgsByAccount().pipe(Effect.catch(() => Effect.fail(new HttpApiError.InternalServerError({})))), + ], { concurrency: "unbounded", }, @@ -40,7 +43,10 @@ export const experimentalHandlers = HttpApiBuilder.group(InstanceHttpApi, "exper const listConsoleOrgs = Effect.fn("ExperimentalHttpApi.consoleOrgs")(function* () { const [groups, active] = yield* Effect.all( - [account.orgsByAccount().pipe(Effect.orDie), account.active().pipe(Effect.orDie)], + [ + account.orgsByAccount().pipe(Effect.catch(() => Effect.fail(new HttpApiError.InternalServerError({})))), + account.active().pipe(Effect.catch(() => Effect.fail(new HttpApiError.InternalServerError({})))), + ], { concurrency: "unbounded", }, diff --git a/packages/opencode/test/server/httpapi-account-error-mapping.test.ts b/packages/opencode/test/server/httpapi-account-error-mapping.test.ts new file mode 100644 index 0000000000..b96e86365b --- /dev/null +++ b/packages/opencode/test/server/httpapi-account-error-mapping.test.ts @@ -0,0 +1,99 @@ +import { afterEach, describe, expect, mock, test } from "bun:test" +import { Effect, Layer, Option } from "effect" + +// Account.orgsByAccount() can fail with AccountServiceError when the +// upstream Anthropic Console API is unreachable. The HTTP API used to +// pipe the call through Effect.orDie, which converts the typed error +// into a defect — surfacing as a 500 with the raw stack trace embedded +// in the response body. +// +// The handlers now map the failure onto HttpApiError.InternalServerError +// and the endpoints declare it as a typed error. Operators get a +// structured 500 response with no stack-trace leak, and future error +// middleware can recognize the failure type instead of seeing a defect. +// +// To force the failure path, mock @/account/account so its defaultLayer +// provides an Account.Service whose orgsByAccount returns Effect.fail. + +const ORIG = await import("../../src/account/account") + +const failingAccountLayer = Layer.succeed( + ORIG.Service, + ORIG.Service.of({ + active: () => Effect.succeed(Option.none()), + activeOrg: () => Effect.succeed(Option.none()), + list: () => Effect.succeed([]), + orgsByAccount: () => + Effect.fail(new ORIG.AccountServiceError({ message: "simulated upstream failure" })), + remove: () => Effect.void, + use: () => Effect.void, + orgs: () => Effect.succeed([]), + config: () => Effect.succeed(Option.none()), + token: () => Effect.succeed(Option.none()), + login: () => Effect.fail(new ORIG.AccountServiceError({ message: "unused" })), + poll: () => Effect.fail(new ORIG.AccountServiceError({ message: "unused" })), + }), +) + +const mocked = { + ...ORIG, + defaultLayer: failingAccountLayer, + layer: failingAccountLayer, + Account: { + ...ORIG.Account, + defaultLayer: failingAccountLayer, + layer: failingAccountLayer, + }, +} + +void mock.module("@/account/account", () => mocked) +void mock.module("../../src/account/account", () => mocked) + +const { Flag } = await import("@opencode-ai/core/flag/flag") +const Log = await import("@opencode-ai/core/util/log") +const { Server } = await import("../../src/server/server") +const { ExperimentalPaths } = await import("../../src/server/routes/instance/httpapi/groups/experimental") +const { resetDatabase } = await import("../fixture/db") +const { disposeAllInstances, tmpdir } = await import("../fixture/fixture") + +void Log.init({ print: false }) + +const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI + +afterEach(async () => { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original + await disposeAllInstances() + await resetDatabase() +}) + +function httpApiApp() { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true + return Server.Default().app +} + +async function probe(path: string) { + await using tmp = await tmpdir({ config: { formatter: false, lsp: false } }) + return httpApiApp().request(path, { + headers: { "x-opencode-directory": tmp.path }, + }) +} + +describe("HTTP API account failure mapping", () => { + test("/experimental/console returns a structured 500, not a stack-trace defect", async () => { + const response = await probe(ExperimentalPaths.console) + expect(response.status).toBe(500) + const body = await response.text() + expect(body).not.toContain("\n at ") + const json = JSON.parse(body) + expect(json._tag).toBe("InternalServerError") + }) + + test("/experimental/console/orgs returns a structured 500, not a stack-trace defect", async () => { + const response = await probe(ExperimentalPaths.consoleOrgs) + expect(response.status).toBe(500) + const body = await response.text() + expect(body).not.toContain("\n at ") + const json = JSON.parse(body) + expect(json._tag).toBe("InternalServerError") + }) +}) From cff441909ad2a3461b561f84c84d07613c14cefd Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sat, 9 May 2026 03:45:35 +0000 Subject: [PATCH 043/189] chore: generate --- .../httpapi-account-error-mapping.test.ts | 3 +- packages/sdk/js/src/v2/gen/sdk.gen.ts | 14 +++++++-- packages/sdk/js/src/v2/gen/types.gen.ts | 23 ++++++++++++++ packages/sdk/openapi.json | 31 +++++++++++++++++++ 4 files changed, 67 insertions(+), 4 deletions(-) diff --git a/packages/opencode/test/server/httpapi-account-error-mapping.test.ts b/packages/opencode/test/server/httpapi-account-error-mapping.test.ts index b96e86365b..784e07f840 100644 --- a/packages/opencode/test/server/httpapi-account-error-mapping.test.ts +++ b/packages/opencode/test/server/httpapi-account-error-mapping.test.ts @@ -23,8 +23,7 @@ const failingAccountLayer = Layer.succeed( active: () => Effect.succeed(Option.none()), activeOrg: () => Effect.succeed(Option.none()), list: () => Effect.succeed([]), - orgsByAccount: () => - Effect.fail(new ORIG.AccountServiceError({ message: "simulated upstream failure" })), + orgsByAccount: () => Effect.fail(new ORIG.AccountServiceError({ message: "simulated upstream failure" })), remove: () => Effect.void, use: () => Effect.void, orgs: () => Effect.succeed([]), diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 03b02fcebf..f25596011e 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -24,7 +24,9 @@ import type { EventTuiPromptAppend2, EventTuiSessionSelect2, EventTuiToastShow2, + ExperimentalConsoleGetErrors, ExperimentalConsoleGetResponses, + ExperimentalConsoleListOrgsErrors, ExperimentalConsoleListOrgsResponses, ExperimentalConsoleSwitchOrgResponses, ExperimentalResourceListResponses, @@ -687,7 +689,11 @@ export class Console extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get< + ExperimentalConsoleGetResponses, + ExperimentalConsoleGetErrors, + ThrowOnError + >({ url: "/experimental/console", ...options, ...params, @@ -717,7 +723,11 @@ export class Console extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get< + ExperimentalConsoleListOrgsResponses, + ExperimentalConsoleListOrgsErrors, + ThrowOnError + >({ url: "/experimental/console/orgs", ...options, ...params, diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 7ddaea0a25..612b4479c6 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1361,6 +1361,10 @@ export type ConsoleState = { switchableOrgCount: number } +export type EffectHttpApiErrorInternalServerError = { + _tag: "InternalServerError" +} + export type ToolListItem = { id: string description: string @@ -3612,6 +3616,15 @@ export type ExperimentalConsoleGetData = { url: "/experimental/console" } +export type ExperimentalConsoleGetErrors = { + /** + * InternalServerError + */ + 500: EffectHttpApiErrorInternalServerError +} + +export type ExperimentalConsoleGetError = ExperimentalConsoleGetErrors[keyof ExperimentalConsoleGetErrors] + export type ExperimentalConsoleGetResponses = { /** * Active Console provider metadata @@ -3631,6 +3644,16 @@ export type ExperimentalConsoleListOrgsData = { url: "/experimental/console/orgs" } +export type ExperimentalConsoleListOrgsErrors = { + /** + * InternalServerError + */ + 500: EffectHttpApiErrorInternalServerError +} + +export type ExperimentalConsoleListOrgsError = + ExperimentalConsoleListOrgsErrors[keyof ExperimentalConsoleListOrgsErrors] + export type ExperimentalConsoleListOrgsResponses = { /** * Switchable Console orgs diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 23fb1e8414..a40689883d 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -680,6 +680,16 @@ } } } + }, + "500": { + "description": "InternalServerError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/effect_HttpApiError_InternalServerError" + } + } + } } }, "description": "Get the active Console org name and the set of provider IDs managed by that Console org.", @@ -757,6 +767,16 @@ } } } + }, + "500": { + "description": "InternalServerError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/effect_HttpApiError_InternalServerError" + } + } + } } }, "description": "Get the available Console orgs across logged-in accounts, including the current active org.", @@ -12521,6 +12541,17 @@ "required": ["consoleManagedProviders", "switchableOrgCount"], "additionalProperties": false }, + "effect_HttpApiError_InternalServerError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": ["InternalServerError"] + } + }, + "required": ["_tag"], + "additionalProperties": false + }, "ToolListItem": { "type": "object", "properties": { From cd1d1e81a6a432f1836f7145cfa7d7581dfe1fe2 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 8 May 2026 23:48:29 -0400 Subject: [PATCH 044/189] test(server): run httpapi exercise effect mode in test:httpapi (#26452) --- packages/opencode/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 04a9cf0271..e4f87a7943 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -9,7 +9,7 @@ "typecheck": "tsgo --noEmit", "test": "bun test --timeout 30000", "test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml", - "test:httpapi": "bun run script/httpapi-exercise.ts --mode coverage --fail-on-missing --fail-on-skip && bun run script/httpapi-exercise.ts --mode auth --fail-on-missing --fail-on-skip", + "test:httpapi": "bun run script/httpapi-exercise.ts --mode coverage --fail-on-missing --fail-on-skip && bun run script/httpapi-exercise.ts --mode auth --fail-on-missing --fail-on-skip && bun run script/httpapi-exercise.ts --mode effect --fail-on-missing --fail-on-skip", "build": "bun run script/build.ts", "fix-node-pty": "bun run script/fix-node-pty.ts", "dev": "bun run --conditions=browser ./src/index.ts", From aab82cc1a76a614b5bd1c66797755358c4783b5f Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 8 May 2026 23:53:42 -0400 Subject: [PATCH 045/189] test(project): rescue non-Hono InstanceBootstrap boundary tests (#26453) --- .../test/project/instance-bootstrap.test.ts | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 packages/opencode/test/project/instance-bootstrap.test.ts diff --git a/packages/opencode/test/project/instance-bootstrap.test.ts b/packages/opencode/test/project/instance-bootstrap.test.ts new file mode 100644 index 0000000000..baad8df592 --- /dev/null +++ b/packages/opencode/test/project/instance-bootstrap.test.ts @@ -0,0 +1,78 @@ +import { afterEach, expect, test } from "bun:test" +import { existsSync } from "node:fs" +import path from "node:path" +import { pathToFileURL } from "node:url" +import { bootstrap as cliBootstrap } from "../../src/cli/bootstrap" +import { WithInstance } from "../../src/project/with-instance" +import { InstanceRuntime } from "../../src/project/instance-runtime" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" + +// InstanceBootstrap must run before any code touches the instance — +// originally tracked by PRs #25389 and #25449, now a permanent +// invariant. The plugin config hook writes a marker file; the test +// bodies deliberately avoid Plugin/config directly. The marker only +// appears if InstanceBootstrap ran at the instance boundary. +// +// The Hono variant of this check lived alongside these tests and is +// going away with the Hono backend. The boundaries below are backend- +// agnostic and stay. + +afterEach(async () => { + await disposeAllInstances() +}) + +async function bootstrapFixture() { + return tmpdir({ + init: async (dir) => { + const marker = path.join(dir, "config-hook-fired") + const pluginFile = path.join(dir, "plugin.ts") + await Bun.write( + pluginFile, + [ + `const MARKER = ${JSON.stringify(marker)}`, + "export default async () => ({", + " config: async () => {", + ' await Bun.write(MARKER, "ran")', + " },", + "})", + "", + ].join("\n"), + ) + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + plugin: [pathToFileURL(pluginFile).href], + }), + ) + return marker + }, + }) +} + +test("WithInstance.provide runs InstanceBootstrap before fn", async () => { + await using tmp = await bootstrapFixture() + + await WithInstance.provide({ + directory: tmp.path, + fn: async () => "ok", + }) + + expect(existsSync(tmp.extra)).toBe(true) +}) + +test("CLI bootstrap runs InstanceBootstrap before callback", async () => { + await using tmp = await bootstrapFixture() + + await cliBootstrap(tmp.path, async () => "ok") + + expect(existsSync(tmp.extra)).toBe(true) +}) + +test("InstanceRuntime.reloadInstance runs InstanceBootstrap", async () => { + await using tmp = await bootstrapFixture() + + await InstanceRuntime.reloadInstance({ directory: tmp.path }) + + expect(existsSync(tmp.extra)).toBe(true) +}) From dcb8ed8eb0a891eea3ec3ba76bd13eefb3b97f30 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 8 May 2026 23:55:47 -0400 Subject: [PATCH 046/189] test(server): cover workspace sync fence protocol (#26441) --- .../test/server/httpapi-instance.test.ts | 26 ++++++++ .../server/httpapi-workspace-routing.test.ts | 61 ++++++++++++++++++- 2 files changed, 86 insertions(+), 1 deletion(-) diff --git a/packages/opencode/test/server/httpapi-instance.test.ts b/packages/opencode/test/server/httpapi-instance.test.ts index 7a181aac65..da8f8fb56d 100644 --- a/packages/opencode/test/server/httpapi-instance.test.ts +++ b/packages/opencode/test/server/httpapi-instance.test.ts @@ -5,6 +5,7 @@ import { Config, Effect, FileSystem, Layer, Path } from "effect" import { HttpClient, HttpClientRequest, HttpRouter, HttpServer } from "effect/unstable/http" import * as Socket from "effect/unstable/socket/Socket" import { WorkspaceID } from "../../src/control-plane/schema" +import { ControlPaths } from "../../src/server/routes/instance/httpapi/groups/control" import { InstancePaths } from "../../src/server/routes/instance/httpapi/groups/instance" import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session" import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server" @@ -95,6 +96,31 @@ describe("instance HttpApi", () => { }), ) + it.live("does not emit sync fence headers for fixed-workspace reads or no-op mutations", () => + Effect.gen(function* () { + const originalWorkspaceID = Flag.OPENCODE_WORKSPACE_ID + Flag.OPENCODE_WORKSPACE_ID = WorkspaceID.ascending() + yield* Effect.addFinalizer(() => + Effect.sync(() => { + Flag.OPENCODE_WORKSPACE_ID = originalWorkspaceID + }), + ) + + const dir = yield* tmpdirScoped({ git: true }) + const read = yield* HttpClientRequest.get(InstancePaths.path).pipe(directoryHeader(dir), HttpClient.execute) + const log = yield* HttpClientRequest.post(ControlPaths.log).pipe( + directoryHeader(dir), + HttpClientRequest.bodyJson({ service: "fence-test", level: "info", message: "noop" }), + Effect.flatMap(HttpClient.execute), + ) + + expect(read.status).toBe(200) + expect(read.headers[FenceHeader]).toBeUndefined() + expect(log.status).toBe(200) + expect(log.headers[FenceHeader]).toBeUndefined() + }), + ) + it.live("serves path and VCS read endpoints", () => Effect.gen(function* () { const dir = yield* tmpdirScoped({ git: true }) diff --git a/packages/opencode/test/server/httpapi-workspace-routing.test.ts b/packages/opencode/test/server/httpapi-workspace-routing.test.ts index 379b71a91e..a62ca1db74 100644 --- a/packages/opencode/test/server/httpapi-workspace-routing.test.ts +++ b/packages/opencode/test/server/httpapi-workspace-routing.test.ts @@ -1,7 +1,7 @@ import { NodeHttpServer, NodeServices } from "@effect/platform-node" import { Flag } from "@opencode-ai/core/flag/flag" import { describe, expect } from "bun:test" -import { Context, Effect, Layer, Queue } from "effect" +import { Context, Effect, Layer, Queue, Ref } from "effect" import { FetchHttpClient, HttpClient, @@ -28,6 +28,7 @@ import { WorkspaceRouteContext, workspaceRouterMiddleware, } from "../../src/server/routes/instance/httpapi/middleware/workspace-routing" +import { HEADER as FenceHeader } from "../../src/server/shared/fence" import { Database } from "../../src/storage/db" import { resetDatabase } from "../fixture/db" import { tmpdirScoped } from "../fixture/fixture" @@ -289,6 +290,64 @@ describe("HttpApi workspace routing middleware", () => { }), ) + it.live("waits for sync fence headers from remote workspace HTTP responses", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + const project = yield* Project.use.fromDirectory(dir) + const workspaceID = WorkspaceID.ascending() + const type = "remote-http-fence-target" + const waited = yield* Ref.make<{ workspaceID: WorkspaceID; state: Record } | undefined>(undefined) + + const remoteUrl = yield* startRemoteWorkspaceHttpServer(() => + HttpServerResponse.json( + { proxied: true }, + { status: 202, headers: { [FenceHeader]: JSON.stringify({ aggregate: 3 }) } }, + ), + ) + registerAdapter(project.project.id, type, remoteAdapter(path.join(dir, `.${type}`), `${remoteUrl}/base`)) + + const workspace = Workspace.Service.of({ + create: () => Effect.die("unused"), + sessionWarp: () => Effect.die("unused"), + list: () => Effect.die("unused"), + syncList: () => Effect.die("unused"), + get: (id) => + Effect.succeed( + id === workspaceID + ? { + id: workspaceID, + type, + branch: null, + name: "remote-http-fence-target", + directory: null, + extra: null, + projectID: project.project.id, + timeUsed: Date.now(), + } + : undefined, + ), + remove: () => Effect.die("unused"), + status: () => Effect.die("unused"), + isSyncing: () => Effect.succeed(true), + waitForSync: (id, state) => Ref.set(waited, { workspaceID: id, state }), + startWorkspaceSyncing: () => Effect.die("unused"), + }) + + yield* HttpRouter.add("PATCH", "/probe", HttpServerResponse.text("route called")).pipe( + Layer.provide(workspaceRoutingTestLayer), + Layer.provide(Layer.succeed(Workspace.Service, workspace)), + HttpRouter.serve, + Layer.build, + ) + + const response = yield* HttpClientRequest.patch(`/probe?workspace=${workspaceID}`).pipe(HttpClient.execute) + + expect(response.status).toBe(202) + expect(yield* response.json).toEqual({ proxied: true }) + expect(yield* Ref.get(waited)).toEqual({ workspaceID, state: { aggregate: 3 } }) + }), + ) + it.live("returns 503 when a remote workspace is not actively syncing", () => Effect.gen(function* () { const dir = yield* tmpdirScoped({ git: true }) From 96bde05f6ea610aa8b0798765a267c5e9b560308 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 8 May 2026 23:58:33 -0400 Subject: [PATCH 047/189] docs(server): explain why HTTP API PTY handler has no early-frame buffer (#26464) --- .../src/server/routes/instance/httpapi/handlers/pty.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/pty.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/pty.ts index 7b8395d809..369ca91d02 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/pty.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/pty.ts @@ -153,6 +153,12 @@ export const ptyConnectRoute = HttpRouter.use((router) => return HttpServerResponse.empty() } + // No `pending[]`-style early-frame buffer (the legacy Hono handler had one). + // `request.upgrade` returns a Socket without running the WS handshake; the + // handshake fires inside `socket.runRaw` below, AFTER `pty.connect` resolves + // and the message callback is registered. The client therefore can't fire + // `open` and start sending until the listener is already wired. Don't move + // `runRaw` ahead of `pty.connect` without re-introducing a buffer. yield* socket .runRaw((message) => handlePtyInput(handler, message)) .pipe( From cbdb2d9825ce5ec22a700b4edb231f2e22807c4e Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 9 May 2026 00:00:18 -0400 Subject: [PATCH 048/189] test(server): expand workspace routing fixed-id coverage (#26458) --- packages/opencode/test/fixture/flag.ts | 20 ++++++ .../server/httpapi-instance-context.test.ts | 71 ++++++++++++++++--- 2 files changed, 83 insertions(+), 8 deletions(-) create mode 100644 packages/opencode/test/fixture/flag.ts diff --git a/packages/opencode/test/fixture/flag.ts b/packages/opencode/test/fixture/flag.ts new file mode 100644 index 0000000000..224c5ef1f4 --- /dev/null +++ b/packages/opencode/test/fixture/flag.ts @@ -0,0 +1,20 @@ +import type { WorkspaceID } from "@/control-plane/schema" +import { Flag } from "@opencode-ai/core/flag/flag" +import { Effect, Scope } from "effect" + +/** + * Scoped override for `Flag.OPENCODE_WORKSPACE_ID`. Saves the previous value + * on entry and restores it via finalizer when the surrounding scope closes — + * preserves the original try/finally semantics regardless of test outcome. + */ +export function withFixedWorkspaceID(id: WorkspaceID): Effect.Effect { + return Effect.gen(function* () { + const previous = Flag.OPENCODE_WORKSPACE_ID + Flag.OPENCODE_WORKSPACE_ID = id + yield* Effect.addFinalizer(() => + Effect.sync(() => { + Flag.OPENCODE_WORKSPACE_ID = previous + }), + ) + }) +} diff --git a/packages/opencode/test/server/httpapi-instance-context.test.ts b/packages/opencode/test/server/httpapi-instance-context.test.ts index 0e5c752371..1b72f34775 100644 --- a/packages/opencode/test/server/httpapi-instance-context.test.ts +++ b/packages/opencode/test/server/httpapi-instance-context.test.ts @@ -21,6 +21,7 @@ import { instanceRouterMiddleware } from "../../src/server/routes/instance/httpa import { workspaceRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/workspace-routing" import { resetDatabase } from "../fixture/db" import { disposeAllInstances, tmpdirScoped } from "../fixture/fixture" +import { withFixedWorkspaceID } from "../fixture/flag" import { waitGlobalBusEvent } from "./global-bus" import { testEffect } from "../lib/effect" @@ -204,16 +205,10 @@ describe("HttpApi instance context middleware", () => { }), ) - it.live("uses configured workspace id instead of routing to requested workspaces", () => + it.live("uses configured workspace id instead of routing to the requested workspace", () => Effect.gen(function* () { - const originalWorkspaceID = Flag.OPENCODE_WORKSPACE_ID const fixedWorkspaceID = WorkspaceID.ascending() - Flag.OPENCODE_WORKSPACE_ID = fixedWorkspaceID - yield* Effect.addFinalizer(() => - Effect.sync(() => { - Flag.OPENCODE_WORKSPACE_ID = originalWorkspaceID - }), - ) + yield* withFixedWorkspaceID(fixedWorkspaceID) const dir = yield* tmpdirScoped({ git: true }) const project = yield* Project.use.fromDirectory(dir) @@ -238,6 +233,66 @@ describe("HttpApi instance context middleware", () => { }), ) + it.live("falls through to local instead of MissingWorkspace when configured workspace id is set", () => + Effect.gen(function* () { + const fixedWorkspaceID = WorkspaceID.ascending() + yield* withFixedWorkspaceID(fixedWorkspaceID) + + const dir = yield* tmpdirScoped({ git: true }) + yield* Project.use.fromDirectory(dir) + yield* serveProbe() + + // Reference a workspace id that is not registered locally. Without the + // configured env override, this would short-circuit to a 500 + // MissingWorkspace response. With the env set, planRequest must skip the + // MissingWorkspace branch and fall through to Local with the configured + // workspace id. + const unknownWorkspaceID = WorkspaceID.ascending() + const response = yield* HttpClientRequest.get(`/probe?workspace=${unknownWorkspaceID}`).pipe( + HttpClientRequest.setHeader("x-opencode-directory", dir), + HttpClient.execute, + ) + + expect(response.status).toBe(200) + expect(yield* response.json).toMatchObject({ + directory: dir, + workspaceID: fixedWorkspaceID, + }) + }), + ) + + it.live("keeps configured workspace id on control-plane routes without remote routing", () => + Effect.gen(function* () { + const fixedWorkspaceID = WorkspaceID.ascending() + yield* withFixedWorkspaceID(fixedWorkspaceID) + + const dir = yield* tmpdirScoped({ git: true }) + const project = yield* Project.use.fromDirectory(dir) + const workspaceDir = path.join(dir, ".workspace-local") + const workspace = yield* createLocalWorkspace({ + projectID: project.project.id, + type: "instance-context-fixed-workspace-control-plane", + directory: workspaceDir, + }) + // /session is matched by isLocalWorkspaceRoute, so shouldStayOnControlPlane + // is true. Combined with the env override, the route must stay Local with + // the configured workspace id (not divert to the requested workspace's + // local directory). + yield* serveProbe("/session") + + const response = yield* HttpClientRequest.get(`/session?workspace=${workspace.id}`).pipe( + HttpClientRequest.setHeader("x-opencode-directory", dir), + HttpClient.execute, + ) + + expect(response.status).toBe(200) + expect(yield* response.json).toMatchObject({ + directory: dir, + workspaceID: fixedWorkspaceID, + }) + }), + ) + it.live("preserves selected workspace id on instance disposal events", () => Effect.gen(function* () { const dir = yield* tmpdirScoped({ git: true }) From 7f3e51453bd441117fed899c19991f3fc3aeb6a9 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 9 May 2026 00:05:05 -0400 Subject: [PATCH 049/189] test(server): use Layer.mock for partial Account service stub (#26472) --- packages/opencode/test/AGENTS.md | 16 ++++++++++++++ .../httpapi-account-error-mapping.test.ts | 22 +++++-------------- 2 files changed, 21 insertions(+), 17 deletions(-) diff --git a/packages/opencode/test/AGENTS.md b/packages/opencode/test/AGENTS.md index 41372b15a0..335c6db622 100644 --- a/packages/opencode/test/AGENTS.md +++ b/packages/opencode/test/AGENTS.md @@ -142,3 +142,19 @@ Use `provideTmpdirInstance(...)` or `tmpdirScoped()` plus `provideInstance(...)` - Yield services directly with `yield* MyService.Service` or `yield* MyTool`. - Avoid custom `ManagedRuntime`, `attach(...)`, or ad hoc `run(...)` wrappers when `testEffect(...)` already provides the runtime. - When a test needs instance-local state, prefer `it.instance(...)` over manual `Instance.provide(...)` inside Promise-style tests. + +### Partial Service Stubs + +When a test only needs to override one or two methods of a service, prefer `Layer.mock` over a hand-rolled `Layer.succeed(Service, Service.of({ ... }))`. `Layer.mock` lets you supply just the methods that matter — anything else throws an `UnimplementedError` defect if the test accidentally calls it, which is exactly the signal you want. + +```typescript +import { Effect, Layer } from "effect" +import { Account } from "@/account/account" + +const failingAccountLayer = Layer.mock(Account.Service, { + orgsByAccount: () => + Effect.fail(new Account.AccountServiceError({ message: "simulated upstream failure" })), +}) +``` + +This is much shorter than stubbing every method with `Effect.void` / `Effect.succeed(...)` placeholders, and it keeps the test focused on the behaviour under test. diff --git a/packages/opencode/test/server/httpapi-account-error-mapping.test.ts b/packages/opencode/test/server/httpapi-account-error-mapping.test.ts index 784e07f840..77f48caa4d 100644 --- a/packages/opencode/test/server/httpapi-account-error-mapping.test.ts +++ b/packages/opencode/test/server/httpapi-account-error-mapping.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, mock, test } from "bun:test" -import { Effect, Layer, Option } from "effect" +import { Effect, Layer } from "effect" // Account.orgsByAccount() can fail with AccountServiceError when the // upstream Anthropic Console API is unreachable. The HTTP API used to @@ -17,22 +17,10 @@ import { Effect, Layer, Option } from "effect" const ORIG = await import("../../src/account/account") -const failingAccountLayer = Layer.succeed( - ORIG.Service, - ORIG.Service.of({ - active: () => Effect.succeed(Option.none()), - activeOrg: () => Effect.succeed(Option.none()), - list: () => Effect.succeed([]), - orgsByAccount: () => Effect.fail(new ORIG.AccountServiceError({ message: "simulated upstream failure" })), - remove: () => Effect.void, - use: () => Effect.void, - orgs: () => Effect.succeed([]), - config: () => Effect.succeed(Option.none()), - token: () => Effect.succeed(Option.none()), - login: () => Effect.fail(new ORIG.AccountServiceError({ message: "unused" })), - poll: () => Effect.fail(new ORIG.AccountServiceError({ message: "unused" })), - }), -) +const failingAccountLayer = Layer.mock(ORIG.Service, { + orgsByAccount: () => + Effect.fail(new ORIG.AccountServiceError({ message: "simulated upstream failure" })), +}) const mocked = { ...ORIG, From 82359c4b1bad2633cf7d5f920f42847867d0805b Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sat, 9 May 2026 04:06:08 +0000 Subject: [PATCH 050/189] chore: generate --- packages/opencode/test/AGENTS.md | 3 +-- .../opencode/test/server/httpapi-account-error-mapping.test.ts | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/opencode/test/AGENTS.md b/packages/opencode/test/AGENTS.md index 335c6db622..52bd6ee98e 100644 --- a/packages/opencode/test/AGENTS.md +++ b/packages/opencode/test/AGENTS.md @@ -152,8 +152,7 @@ import { Effect, Layer } from "effect" import { Account } from "@/account/account" const failingAccountLayer = Layer.mock(Account.Service, { - orgsByAccount: () => - Effect.fail(new Account.AccountServiceError({ message: "simulated upstream failure" })), + orgsByAccount: () => Effect.fail(new Account.AccountServiceError({ message: "simulated upstream failure" })), }) ``` diff --git a/packages/opencode/test/server/httpapi-account-error-mapping.test.ts b/packages/opencode/test/server/httpapi-account-error-mapping.test.ts index 77f48caa4d..736dfa2a27 100644 --- a/packages/opencode/test/server/httpapi-account-error-mapping.test.ts +++ b/packages/opencode/test/server/httpapi-account-error-mapping.test.ts @@ -18,8 +18,7 @@ import { Effect, Layer } from "effect" const ORIG = await import("../../src/account/account") const failingAccountLayer = Layer.mock(ORIG.Service, { - orgsByAccount: () => - Effect.fail(new ORIG.AccountServiceError({ message: "simulated upstream failure" })), + orgsByAccount: () => Effect.fail(new ORIG.AccountServiceError({ message: "simulated upstream failure" })), }) const mocked = { From 8cbc43fbb006eadec8bf655150a7615f4559dd3e Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 9 May 2026 00:15:20 -0400 Subject: [PATCH 051/189] fix(server): include auth challenge on typed 401 (#26455) --- .../routes/instance/httpapi/middleware/authorization.ts | 9 +++++++-- .../opencode/test/server/httpapi-authorization.test.ts | 2 ++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts index 6f5648f30a..73676bd665 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts @@ -1,6 +1,6 @@ import { ServerAuth } from "@/server/auth" import { Effect, Encoding, Layer, Redacted } from "effect" -import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" +import { HttpEffect, HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" import { HttpApiError, HttpApiMiddleware } from "effect/unstable/httpapi" import { hasPtyConnectTicketURL } from "@/server/shared/pty-ticket" import { isPublicUIPath } from "@/server/shared/public-ui" @@ -33,7 +33,12 @@ function validateCredential( ) { return Effect.gen(function* () { if (!ServerAuth.required(config)) return yield* effect - if (!ServerAuth.authorized(credential, config)) return yield* new HttpApiError.Unauthorized({}) + if (!ServerAuth.authorized(credential, config)) { + yield* HttpEffect.appendPreResponseHandler((_request, response) => + Effect.succeed(HttpServerResponse.setHeader(response, "www-authenticate", WWW_AUTHENTICATE)), + ) + return yield* new HttpApiError.Unauthorized({}) + } return yield* effect }) } diff --git a/packages/opencode/test/server/httpapi-authorization.test.ts b/packages/opencode/test/server/httpapi-authorization.test.ts index 850098926a..e99a91e1d0 100644 --- a/packages/opencode/test/server/httpapi-authorization.test.ts +++ b/packages/opencode/test/server/httpapi-authorization.test.ts @@ -72,7 +72,9 @@ describe("HttpApi authorization middleware", () => { ) expect(missing.status).toBe(401) + expect(missing.headers["www-authenticate"] ?? "").toContain("Basic") expect(badPassword.status).toBe(401) + expect(badPassword.headers["www-authenticate"] ?? "").toContain("Basic") expect(good.status).toBe(200) }), ) From dc978cb8892d076c40c263a7c1e28f80e297ffb1 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 9 May 2026 00:20:28 -0400 Subject: [PATCH 052/189] fix(server): validate permission and question ids (#26456) --- packages/opencode/src/permission/schema.ts | 2 +- packages/opencode/src/question/schema.ts | 2 +- .../test/server/httpapi-instance.test.ts | 31 ++++++++++++++++++- 3 files changed, 32 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/permission/schema.ts b/packages/opencode/src/permission/schema.ts index 4eddc6a47a..e8bdb2ea20 100644 --- a/packages/opencode/src/permission/schema.ts +++ b/packages/opencode/src/permission/schema.ts @@ -6,7 +6,7 @@ import { Newtype } from "@/util/schema" export class PermissionID extends Newtype()( "PermissionID", - Schema.String.annotate({ [ZodOverride]: Identifier.schema("permission") }), + Schema.String.check(Schema.isStartsWith("per")).annotate({ [ZodOverride]: Identifier.schema("permission") }), ) { static ascending(id?: string): PermissionID { return this.make(Identifier.ascending("permission", id)) diff --git a/packages/opencode/src/question/schema.ts b/packages/opencode/src/question/schema.ts index f7a0e096a3..7dade9cdfa 100644 --- a/packages/opencode/src/question/schema.ts +++ b/packages/opencode/src/question/schema.ts @@ -6,7 +6,7 @@ import { Newtype } from "@/util/schema" export class QuestionID extends Newtype()( "QuestionID", - Schema.String.annotate({ [ZodOverride]: Identifier.schema("question") }), + Schema.String.check(Schema.isStartsWith("que")).annotate({ [ZodOverride]: Identifier.schema("question") }), ) { static ascending(id?: string): QuestionID { return this.make(Identifier.ascending("question", id)) diff --git a/packages/opencode/test/server/httpapi-instance.test.ts b/packages/opencode/test/server/httpapi-instance.test.ts index da8f8fb56d..90eb6538c7 100644 --- a/packages/opencode/test/server/httpapi-instance.test.ts +++ b/packages/opencode/test/server/httpapi-instance.test.ts @@ -1,7 +1,7 @@ import { NodeHttpServer, NodeServices } from "@effect/platform-node" import { Flag } from "@opencode-ai/core/flag/flag" import { describe, expect } from "bun:test" -import { Config, Effect, FileSystem, Layer, Path } from "effect" +import { Config, Context, Effect, FileSystem, Layer, Path } from "effect" import { HttpClient, HttpClientRequest, HttpRouter, HttpServer } from "effect/unstable/http" import * as Socket from "effect/unstable/socket/Socket" import { WorkspaceID } from "../../src/control-plane/schema" @@ -53,6 +53,7 @@ const httpApiServerLayer = servedRoutes.pipe( ) const it = testEffect(Layer.mergeAll(testStateLayer, httpApiServerLayer)) +const handlerContext = Context.empty() as Context.Context const directoryHeader = (dir: string) => HttpClientRequest.setHeader("x-opencode-directory", dir) @@ -121,6 +122,34 @@ describe("instance HttpApi", () => { }), ) + it.live("rejects malformed permission and question request ids", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + const request = (path: string, init?: RequestInit) => + Effect.promise(() => + ExperimentalHttpApiServer.webHandler().handler( + new Request(`http://localhost${path}`, { + ...init, + headers: { "x-opencode-directory": dir, "content-type": "application/json", ...init?.headers }, + }), + handlerContext, + ), + ) + const [permission, questionReply, questionReject] = yield* Effect.all( + [ + request("/permission/invalid-permission-id/reply", { method: "POST", body: JSON.stringify({ reply: "once" }) }), + request("/question/invalid-question-id/reply", { method: "POST", body: JSON.stringify({ answers: [["Yes"]] }) }), + request("/question/invalid-question-id/reject", { method: "POST" }), + ], + { concurrency: "unbounded" }, + ) + + expect(permission.status).toBe(400) + expect(questionReply.status).toBe(400) + expect(questionReject.status).toBe(400) + }), + ) + it.live("serves path and VCS read endpoints", () => Effect.gen(function* () { const dir = yield* tmpdirScoped({ git: true }) From ebe6087e8f0cac0e8a89560f2d3a8b4d28182925 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 9 May 2026 00:21:19 -0400 Subject: [PATCH 053/189] fix(server): return structured validation errors (#26457) --- .../instance/httpapi/middleware/error.ts | 30 ++++++++++++++++++- .../opencode/test/server/httpapi-sync.test.ts | 22 +++++++++++++- 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/error.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/error.ts index 6f3c33a647..523b141a54 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/middleware/error.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/error.ts @@ -6,13 +6,41 @@ import { NamedError } from "@opencode-ai/core/util/error" import * as Log from "@opencode-ai/core/util/log" import { Cause, Effect } from "effect" import { HttpRouter, HttpServerError, HttpServerRespondable, HttpServerResponse } from "effect/unstable/http" +import { HttpApiError } from "effect/unstable/httpapi" +import { HttpApiSchemaError } from "effect/unstable/httpapi/HttpApiError" const log = Log.create({ service: "server" }) +function badRequestResponse() { + return HttpServerResponse.jsonUnsafe( + { + data: {}, + errors: [], + success: false, + }, + { status: 400 }, + ) +} + +function normalizeEmptyBadRequest(response: HttpServerResponse.HttpServerResponse) { + if (response.status !== 400 || response.body._tag !== "Empty") return response + return badRequestResponse() +} + // Keep typed HttpApi failures on their declared error path; this boundary only replaces defect-only empty 500s. export const errorLayer = HttpRouter.middleware<{ handles: unknown }>()((effect) => effect.pipe( + Effect.catch((error) => { + if (error instanceof HttpApiError.BadRequest) return Effect.succeed(badRequestResponse()) + return Effect.fail(error) + }), + Effect.map(normalizeEmptyBadRequest), Effect.catchCause((cause) => { + const schemaError = cause.reasons + .filter(Cause.isDieReason) + .find((reason) => HttpApiSchemaError.is(reason.defect)) + if (schemaError) return Effect.succeed(badRequestResponse()) + const defect = cause.reasons.filter(Cause.isDieReason).find((reason) => { if (HttpServerResponse.isHttpServerResponse(reason.defect)) return false if (HttpServerError.isHttpServerError(reason.defect)) return false @@ -35,7 +63,7 @@ export const errorLayer = HttpRouter.middleware<{ handles: unknown }>()((effect) return 500 }), }), - ) +) } if (error instanceof Session.BusyError) { return Effect.succeed( diff --git a/packages/opencode/test/server/httpapi-sync.test.ts b/packages/opencode/test/server/httpapi-sync.test.ts index b85658ea1e..c4d2397afd 100644 --- a/packages/opencode/test/server/httpapi-sync.test.ts +++ b/packages/opencode/test/server/httpapi-sync.test.ts @@ -1,10 +1,11 @@ import { afterEach, describe, expect, mock, spyOn, test } from "bun:test" -import { Effect } from "effect" +import { Context, Effect } from "effect" import { Flag } from "@opencode-ai/core/flag/flag" import { Instance } from "../../src/project/instance" import { WithInstance } from "../../src/project/with-instance" import { Server } from "../../src/server/server" import { SyncPaths } from "../../src/server/routes/instance/httpapi/groups/sync" +import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server" import { Session } from "@/session/session" import * as Log from "@opencode-ai/core/util/log" import { resetDatabase } from "../fixture/db" @@ -14,6 +15,7 @@ void Log.init({ print: false }) const originalHttpApi = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES +const context = Context.empty() as Context.Context function app(httpapi = true) { Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = httpapi @@ -128,4 +130,22 @@ describe("sync HttpApi", () => { expect(httpapi.status).toBe(400) } }) + + test("returns structured validation errors", async () => { + await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) + const response = await ExperimentalHttpApiServer.webHandler().handler( + new Request(`http://localhost${SyncPaths.history}`, { + method: "POST", + headers: { "x-opencode-directory": tmp.path, "content-type": "application/json" }, + body: JSON.stringify({ aggregate: -1 }), + }), + context, + ) + + expect(response.status).toBe(400) + expect(response.headers.get("content-type") ?? "").toContain("application/json") + const body = (await response.json()) as Record + expect(body.success).toBe(false) + expect(Array.isArray(body.error) || Array.isArray(body.errors)).toBe(true) + }) }) From a9ccb0804fdc899f6d8a2052af7e10c0b611b1c8 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sat, 9 May 2026 04:22:25 +0000 Subject: [PATCH 054/189] chore: generate --- .../routes/instance/httpapi/middleware/error.ts | 6 ++---- .../opencode/test/server/httpapi-instance.test.ts | 10 ++++++++-- packages/sdk/openapi.json | 15 ++++++++++----- 3 files changed, 20 insertions(+), 11 deletions(-) diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/error.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/error.ts index 523b141a54..5a018f16da 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/middleware/error.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/error.ts @@ -36,9 +36,7 @@ export const errorLayer = HttpRouter.middleware<{ handles: unknown }>()((effect) }), Effect.map(normalizeEmptyBadRequest), Effect.catchCause((cause) => { - const schemaError = cause.reasons - .filter(Cause.isDieReason) - .find((reason) => HttpApiSchemaError.is(reason.defect)) + const schemaError = cause.reasons.filter(Cause.isDieReason).find((reason) => HttpApiSchemaError.is(reason.defect)) if (schemaError) return Effect.succeed(badRequestResponse()) const defect = cause.reasons.filter(Cause.isDieReason).find((reason) => { @@ -63,7 +61,7 @@ export const errorLayer = HttpRouter.middleware<{ handles: unknown }>()((effect) return 500 }), }), -) + ) } if (error instanceof Session.BusyError) { return Effect.succeed( diff --git a/packages/opencode/test/server/httpapi-instance.test.ts b/packages/opencode/test/server/httpapi-instance.test.ts index 90eb6538c7..365fa1220f 100644 --- a/packages/opencode/test/server/httpapi-instance.test.ts +++ b/packages/opencode/test/server/httpapi-instance.test.ts @@ -137,8 +137,14 @@ describe("instance HttpApi", () => { ) const [permission, questionReply, questionReject] = yield* Effect.all( [ - request("/permission/invalid-permission-id/reply", { method: "POST", body: JSON.stringify({ reply: "once" }) }), - request("/question/invalid-question-id/reply", { method: "POST", body: JSON.stringify({ answers: [["Yes"]] }) }), + request("/permission/invalid-permission-id/reply", { + method: "POST", + body: JSON.stringify({ reply: "once" }), + }), + request("/question/invalid-question-id/reply", { + method: "POST", + body: JSON.stringify({ answers: [["Yes"]] }), + }), request("/question/invalid-question-id/reject", { method: "POST" }), ], { concurrency: "unbounded" }, diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index a40689883d..ed1146517f 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -9057,7 +9057,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^per" }, "sessionID": { "type": "string" @@ -9355,7 +9356,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^que" }, "sessionID": { "type": "string" @@ -9387,7 +9389,8 @@ "type": "string" }, "requestID": { - "type": "string" + "type": "string", + "pattern": "^que" }, "answers": { "type": "array", @@ -9406,7 +9409,8 @@ "type": "string" }, "requestID": { - "type": "string" + "type": "string", + "pattern": "^que" } }, "required": ["sessionID", "requestID"], @@ -15815,7 +15819,8 @@ "type": "string" }, "requestID": { - "type": "string" + "type": "string", + "pattern": "^per" }, "reply": { "type": "string", From e7cc8259b52fc730cc2357efa91a91e7f7006c71 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 9 May 2026 00:29:31 -0400 Subject: [PATCH 055/189] test(server): drop flaky account error-mapping test (#26475) --- .../httpapi-account-error-mapping.test.ts | 85 ------------------- 1 file changed, 85 deletions(-) delete mode 100644 packages/opencode/test/server/httpapi-account-error-mapping.test.ts diff --git a/packages/opencode/test/server/httpapi-account-error-mapping.test.ts b/packages/opencode/test/server/httpapi-account-error-mapping.test.ts deleted file mode 100644 index 736dfa2a27..0000000000 --- a/packages/opencode/test/server/httpapi-account-error-mapping.test.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { afterEach, describe, expect, mock, test } from "bun:test" -import { Effect, Layer } from "effect" - -// Account.orgsByAccount() can fail with AccountServiceError when the -// upstream Anthropic Console API is unreachable. The HTTP API used to -// pipe the call through Effect.orDie, which converts the typed error -// into a defect — surfacing as a 500 with the raw stack trace embedded -// in the response body. -// -// The handlers now map the failure onto HttpApiError.InternalServerError -// and the endpoints declare it as a typed error. Operators get a -// structured 500 response with no stack-trace leak, and future error -// middleware can recognize the failure type instead of seeing a defect. -// -// To force the failure path, mock @/account/account so its defaultLayer -// provides an Account.Service whose orgsByAccount returns Effect.fail. - -const ORIG = await import("../../src/account/account") - -const failingAccountLayer = Layer.mock(ORIG.Service, { - orgsByAccount: () => Effect.fail(new ORIG.AccountServiceError({ message: "simulated upstream failure" })), -}) - -const mocked = { - ...ORIG, - defaultLayer: failingAccountLayer, - layer: failingAccountLayer, - Account: { - ...ORIG.Account, - defaultLayer: failingAccountLayer, - layer: failingAccountLayer, - }, -} - -void mock.module("@/account/account", () => mocked) -void mock.module("../../src/account/account", () => mocked) - -const { Flag } = await import("@opencode-ai/core/flag/flag") -const Log = await import("@opencode-ai/core/util/log") -const { Server } = await import("../../src/server/server") -const { ExperimentalPaths } = await import("../../src/server/routes/instance/httpapi/groups/experimental") -const { resetDatabase } = await import("../fixture/db") -const { disposeAllInstances, tmpdir } = await import("../fixture/fixture") - -void Log.init({ print: false }) - -const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI - -afterEach(async () => { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original - await disposeAllInstances() - await resetDatabase() -}) - -function httpApiApp() { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true - return Server.Default().app -} - -async function probe(path: string) { - await using tmp = await tmpdir({ config: { formatter: false, lsp: false } }) - return httpApiApp().request(path, { - headers: { "x-opencode-directory": tmp.path }, - }) -} - -describe("HTTP API account failure mapping", () => { - test("/experimental/console returns a structured 500, not a stack-trace defect", async () => { - const response = await probe(ExperimentalPaths.console) - expect(response.status).toBe(500) - const body = await response.text() - expect(body).not.toContain("\n at ") - const json = JSON.parse(body) - expect(json._tag).toBe("InternalServerError") - }) - - test("/experimental/console/orgs returns a structured 500, not a stack-trace defect", async () => { - const response = await probe(ExperimentalPaths.consoleOrgs) - expect(response.status).toBe(500) - const body = await response.text() - expect(body).not.toContain("\n at ") - const json = JSON.parse(body) - expect(json._tag).toBe("InternalServerError") - }) -}) From dbd48d423d44d6acc8eb494d0763265e59eb219f Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 9 May 2026 00:30:00 -0400 Subject: [PATCH 056/189] fix(server): match Hono wire format for authorize undefined and share errors (#26474) --- .../src/server/routes/instance/httpapi/groups/session.ts | 4 ++-- .../server/routes/instance/httpapi/handlers/provider.ts | 6 ++++-- .../server/routes/instance/httpapi/handlers/session.ts | 9 +++++++-- 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts index 1159c88030..967cc80206 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts @@ -261,7 +261,7 @@ export const SessionApi = HttpApi.make("session") HttpApiEndpoint.post("share", SessionPaths.share, { params: { sessionID: SessionID }, success: described(Session.Info, "Successfully shared session"), - error: [HttpApiError.BadRequest, ApiNotFoundError], + error: [HttpApiError.InternalServerError, ApiNotFoundError], }).annotateMerge( OpenApi.annotations({ identifier: "session.share", @@ -272,7 +272,7 @@ export const SessionApi = HttpApi.make("session") HttpApiEndpoint.delete("unshare", SessionPaths.share, { params: { sessionID: SessionID }, success: described(Session.Info, "Successfully unshared session"), - error: [HttpApiError.BadRequest, ApiNotFoundError], + error: [HttpApiError.InternalServerError, ApiNotFoundError], }).annotateMerge( OpenApi.annotations({ identifier: "session.unshare", diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/provider.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/provider.ts index f9df530a92..15049fcc55 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/provider.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/provider.ts @@ -61,9 +61,11 @@ export const providerHandlers = HttpApiBuilder.group(InstanceHttpApi, "provider" const payload = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(ProviderAuth.AuthorizeInput))(body).pipe( Effect.mapError(() => new HttpApiError.BadRequest({})), ) + // Match legacy Hono behavior: when authorize() resolves without a + // result (e.g. no further redirect), serialize as JSON `null` instead + // of an empty body so clients can `.json()` parse the response. const result = yield* authorize({ params: ctx.params, payload }) - if (result === undefined) return HttpServerResponse.empty({ status: 200 }) - return HttpServerResponse.jsonUnsafe(result) + return HttpServerResponse.jsonUnsafe(result ?? null) }) const callback = Effect.fn("ProviderHttpApi.callback")(function* (ctx: { diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts index 56fa7adb15..7b60cd1fc3 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts @@ -213,13 +213,18 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", return true }) + // share/unshare errors aren't all client-induced — storage and network + // failures from SessionShare are real possibilities. Map to a typed 500 + // (matches the legacy Hono path which routed any failure through + // ErrorMiddleware → NamedError.Unknown 500) instead of blanket-mapping + // every failure to a 400 BadRequest. const share = Effect.fn("SessionHttpApi.share")(function* (ctx: { params: { sessionID: SessionID } }) { - yield* shareSvc.share(ctx.params.sessionID).pipe(Effect.mapError(() => new HttpApiError.BadRequest({}))) + yield* shareSvc.share(ctx.params.sessionID).pipe(Effect.mapError(() => new HttpApiError.InternalServerError({}))) return yield* SessionError.mapStorageNotFound(session.get(ctx.params.sessionID)) }) const unshare = Effect.fn("SessionHttpApi.unshare")(function* (ctx: { params: { sessionID: SessionID } }) { - yield* shareSvc.unshare(ctx.params.sessionID).pipe(Effect.mapError(() => new HttpApiError.BadRequest({}))) + yield* shareSvc.unshare(ctx.params.sessionID).pipe(Effect.mapError(() => new HttpApiError.InternalServerError({}))) return yield* SessionError.mapStorageNotFound(session.get(ctx.params.sessionID)) }) From b2baddcd378e45ed366a45ee07db896eca6b8ff9 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sat, 9 May 2026 04:31:07 +0000 Subject: [PATCH 057/189] chore: generate --- .../instance/httpapi/handlers/session.ts | 4 +- packages/sdk/js/src/v2/gen/types.gen.ts | 16 ++++---- packages/sdk/openapi.json | 40 +++++++++---------- 3 files changed, 31 insertions(+), 29 deletions(-) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts index 7b60cd1fc3..2328375b97 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts @@ -224,7 +224,9 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", }) const unshare = Effect.fn("SessionHttpApi.unshare")(function* (ctx: { params: { sessionID: SessionID } }) { - yield* shareSvc.unshare(ctx.params.sessionID).pipe(Effect.mapError(() => new HttpApiError.InternalServerError({}))) + yield* shareSvc + .unshare(ctx.params.sessionID) + .pipe(Effect.mapError(() => new HttpApiError.InternalServerError({}))) return yield* SessionError.mapStorageNotFound(session.get(ctx.params.sessionID)) }) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 612b4479c6..220278b8c2 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -5680,14 +5680,14 @@ export type SessionUnshareData = { } export type SessionUnshareErrors = { - /** - * Bad request - */ - 400: BadRequestError /** * NotFoundError */ 404: NotFoundError + /** + * InternalServerError + */ + 500: EffectHttpApiErrorInternalServerError } export type SessionUnshareError = SessionUnshareErrors[keyof SessionUnshareErrors] @@ -5714,14 +5714,14 @@ export type SessionShareData = { } export type SessionShareErrors = { - /** - * Bad request - */ - 400: BadRequestError /** * NotFoundError */ 404: NotFoundError + /** + * InternalServerError + */ + 500: EffectHttpApiErrorInternalServerError } export type SessionShareError = SessionShareErrors[keyof SessionShareErrors] diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index ed1146517f..e19ead9b32 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -5781,16 +5781,6 @@ } } }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } - }, "404": { "description": "NotFoundError", "content": { @@ -5800,6 +5790,16 @@ } } } + }, + "500": { + "description": "InternalServerError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/effect_HttpApiError_InternalServerError" + } + } + } } }, "description": "Create a shareable link for a session, allowing others to view the conversation.", @@ -5852,16 +5852,6 @@ } } }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } - }, "404": { "description": "NotFoundError", "content": { @@ -5871,6 +5861,16 @@ } } } + }, + "500": { + "description": "InternalServerError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/effect_HttpApiError_InternalServerError" + } + } + } } }, "description": "Remove the shareable link for a session, making it private again.", From 32684e70e664f6a365bc9acc8c0d94d4735fc4f0 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 9 May 2026 08:50:00 -0400 Subject: [PATCH 058/189] test(server): expect null body from HTTP API authorize() with no redirect (#26515) --- packages/opencode/test/server/httpapi-provider.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/opencode/test/server/httpapi-provider.test.ts b/packages/opencode/test/server/httpapi-provider.test.ts index c45a81838a..fa32608fc8 100644 --- a/packages/opencode/test/server/httpapi-provider.test.ts +++ b/packages/opencode/test/server/httpapi-provider.test.ts @@ -128,7 +128,12 @@ describe("provider HttpApi", () => { headers, }) expect(apiLegacy).toEqual({ status: 200, body: "" }) - expect(apiHttpApi).toEqual(apiLegacy) + // #26474 changed the HTTP API authorize handler to serialize an + // undefined service result as JSON `null` instead of an empty body + // so clients can `.json()` parse the response uniformly. The legacy + // Hono path still emits an empty body (`c.json(undefined)`); the new + // backend's body diverges intentionally. + expect(apiHttpApi).toEqual({ status: 200, body: "null" }) const oauthLegacy = yield* requestAuthorize({ app: legacy, From 28b03595bf8c8348936448dae433c172c71a0ba1 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 9 May 2026 09:10:42 -0400 Subject: [PATCH 059/189] research: delete Hono backend (do not merge) (#25667) --- bun.lock | 10 - packages/core/src/flag/flag.ts | 13 - packages/opencode/package.json | 17 +- packages/opencode/specs/effect/http-api.md | 5 + packages/opencode/src/bus/bus-event.ts | 19 - packages/opencode/src/cli/cmd/generate.ts | 23 +- packages/opencode/src/control-plane/types.ts | 10 +- .../opencode/src/control-plane/workspace.ts | 10 +- packages/opencode/src/server/adapter.bun.ts | 44 - packages/opencode/src/server/adapter.node.ts | 75 -- packages/opencode/src/server/adapter.ts | 26 - packages/opencode/src/server/backend.ts | 32 - packages/opencode/src/server/error.ts | 39 - packages/opencode/src/server/fence.ts | 20 - .../src/server/httpapi-server.node.ts | 3 +- packages/opencode/src/server/middleware.ts | 91 -- packages/opencode/src/server/proxy.ts | 149 --- .../src/server/routes/control/index.ts | 160 --- .../src/server/routes/control/workspace.ts | 228 ---- packages/opencode/src/server/routes/global.ts | 286 ----- .../src/server/routes/instance/AGENTS.md | 8 - .../src/server/routes/instance/config.ts | 109 -- .../src/server/routes/instance/event.ts | 90 -- .../server/routes/instance/experimental.ts | 419 ------ .../src/server/routes/instance/file.ts | 190 --- .../routes/instance/httpapi/groups/tui.ts | 5 +- .../server/routes/instance/httpapi/server.ts | 4 +- .../src/server/routes/instance/index.ts | 502 -------- .../src/server/routes/instance/mcp.ts | 277 ---- .../src/server/routes/instance/middleware.ts | 32 - .../src/server/routes/instance/permission.ts | 73 -- .../src/server/routes/instance/project.ts | 116 -- .../src/server/routes/instance/provider.ts | 158 --- .../src/server/routes/instance/pty.ts | 340 ----- .../src/server/routes/instance/question.ts | 111 -- .../src/server/routes/instance/session.ts | 1124 ----------------- .../src/server/routes/instance/sync.ts | 199 --- .../src/server/routes/instance/trace.ts | 59 - .../src/server/routes/instance/tui.ts | 387 ------ packages/opencode/src/server/routes/ui.ts | 40 - packages/opencode/src/server/server.ts | 227 +--- .../opencode/src/server/shared/tui-control.ts | 10 +- packages/opencode/src/server/workspace.ts | 93 -- packages/opencode/src/session/message-v2.ts | 14 +- packages/opencode/src/sync/index.ts | 22 - packages/opencode/src/worktree/index.ts | 18 +- .../test/control-plane/workspace.test.ts | 9 +- .../instance-bootstrap-regression.test.ts | 86 -- .../test/server/httpapi-bridge.test.ts | 501 -------- .../test/server/httpapi-compression.test.ts | 5 - .../test/server/httpapi-config.test.ts | 4 - .../test/server/httpapi-cors-vary.test.ts | 30 +- .../opencode/test/server/httpapi-cors.test.ts | 24 +- .../test/server/httpapi-event.test.ts | 23 +- .../test/server/httpapi-exercise/backend.ts | 10 - .../server/httpapi-exercise/environment.ts | 1 - .../test/server/httpapi-exercise/index.ts | 16 +- .../test/server/httpapi-exercise/report.ts | 3 +- .../test/server/httpapi-exercise/routing.ts | 2 +- .../test/server/httpapi-exercise/runner.ts | 54 - .../test/server/httpapi-exercise/runtime.ts | 3 - .../test/server/httpapi-exercise/types.ts | 4 +- .../test/server/httpapi-experimental.test.ts | 4 - .../server/httpapi-instance.legacy.test.ts | 122 -- .../test/server/httpapi-instance.test.ts | 14 +- .../test/server/httpapi-json-parity.test.ts | 254 ---- .../test/server/httpapi-listen.test.ts | 54 +- .../opencode/test/server/httpapi-mcp.test.ts | 18 +- .../test/server/httpapi-parity.test.ts | 127 -- .../test/server/httpapi-provider.test.ts | 48 +- .../opencode/test/server/httpapi-pty.test.ts | 22 +- .../server/httpapi-raw-route-auth.test.ts | 4 - .../opencode/test/server/httpapi-sdk.test.ts | 108 +- .../test/server/httpapi-session.test.ts | 71 +- .../opencode/test/server/httpapi-sync.test.ts | 21 +- .../opencode/test/server/httpapi-tui.test.ts | 129 -- .../opencode/test/server/httpapi-ui.test.ts | 13 - .../test/server/httpapi-workspace.test.ts | 31 +- .../test/server/trace-attributes.test.ts | 76 -- .../server/worktree-endpoint-repro.test.ts | 3 - packages/sdk/js/script/build.ts | 9 +- 81 files changed, 224 insertions(+), 7566 deletions(-) delete mode 100644 packages/opencode/src/server/adapter.bun.ts delete mode 100644 packages/opencode/src/server/adapter.node.ts delete mode 100644 packages/opencode/src/server/adapter.ts delete mode 100644 packages/opencode/src/server/backend.ts delete mode 100644 packages/opencode/src/server/error.ts delete mode 100644 packages/opencode/src/server/fence.ts delete mode 100644 packages/opencode/src/server/middleware.ts delete mode 100644 packages/opencode/src/server/proxy.ts delete mode 100644 packages/opencode/src/server/routes/control/index.ts delete mode 100644 packages/opencode/src/server/routes/control/workspace.ts delete mode 100644 packages/opencode/src/server/routes/global.ts delete mode 100644 packages/opencode/src/server/routes/instance/AGENTS.md delete mode 100644 packages/opencode/src/server/routes/instance/config.ts delete mode 100644 packages/opencode/src/server/routes/instance/event.ts delete mode 100644 packages/opencode/src/server/routes/instance/experimental.ts delete mode 100644 packages/opencode/src/server/routes/instance/file.ts delete mode 100644 packages/opencode/src/server/routes/instance/index.ts delete mode 100644 packages/opencode/src/server/routes/instance/mcp.ts delete mode 100644 packages/opencode/src/server/routes/instance/middleware.ts delete mode 100644 packages/opencode/src/server/routes/instance/permission.ts delete mode 100644 packages/opencode/src/server/routes/instance/project.ts delete mode 100644 packages/opencode/src/server/routes/instance/provider.ts delete mode 100644 packages/opencode/src/server/routes/instance/pty.ts delete mode 100644 packages/opencode/src/server/routes/instance/question.ts delete mode 100644 packages/opencode/src/server/routes/instance/session.ts delete mode 100644 packages/opencode/src/server/routes/instance/sync.ts delete mode 100644 packages/opencode/src/server/routes/instance/trace.ts delete mode 100644 packages/opencode/src/server/routes/instance/tui.ts delete mode 100644 packages/opencode/src/server/routes/ui.ts delete mode 100644 packages/opencode/src/server/workspace.ts delete mode 100644 packages/opencode/test/project/instance-bootstrap-regression.test.ts delete mode 100644 packages/opencode/test/server/httpapi-bridge.test.ts delete mode 100644 packages/opencode/test/server/httpapi-instance.legacy.test.ts delete mode 100644 packages/opencode/test/server/httpapi-json-parity.test.ts delete mode 100644 packages/opencode/test/server/httpapi-parity.test.ts delete mode 100644 packages/opencode/test/server/httpapi-tui.test.ts delete mode 100644 packages/opencode/test/server/trace-attributes.test.ts diff --git a/bun.lock b/bun.lock index fa3924bf39..3f22ce5b47 100644 --- a/bun.lock +++ b/bun.lock @@ -393,10 +393,6 @@ "@effect/opentelemetry": "catalog:", "@effect/platform-node": "catalog:", "@gitlab/opencode-gitlab-auth": "1.3.3", - "@hono/node-server": "1.19.11", - "@hono/node-ws": "1.3.0", - "@hono/standard-validator": "0.1.5", - "@hono/zod-validator": "catalog:", "@lydell/node-pty": "catalog:", "@modelcontextprotocol/sdk": "1.27.1", "@octokit/graphql": "9.0.2", @@ -437,8 +433,6 @@ "glob": "13.0.5", "google-auth-library": "10.5.0", "gray-matter": "4.0.3", - "hono": "catalog:", - "hono-openapi": "catalog:", "ignore": "7.0.5", "immer": "11.1.4", "jsonc-parser": "3.3.1", @@ -465,7 +459,6 @@ "xdg-basedir": "5.1.0", "yargs": "18.0.0", "zod": "catalog:", - "zod-to-json-schema": "3.24.5", }, "devDependencies": { "@babel/core": "7.28.4", @@ -498,7 +491,6 @@ "typescript": "catalog:", "vscode-languageserver-types": "3.17.5", "why-is-node-running": "3.2.2", - "zod-to-json-schema": "3.24.5", }, }, "packages/plugin": { @@ -1239,8 +1231,6 @@ "@hono/node-server": ["@hono/node-server@1.19.11", "", { "peerDependencies": { "hono": "^4" } }, "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g=="], - "@hono/node-ws": ["@hono/node-ws@1.3.0", "", { "dependencies": { "ws": "^8.17.0" }, "peerDependencies": { "@hono/node-server": "^1.19.2", "hono": "^4.6.0" } }, "sha512-ju25YbbvLuXdqBCmLZLqnNYu1nbHIQjoyUqA8ApZOeL1k4skuiTcw5SW77/5SUYo2Xi2NVBJoVlfQurnKEp03Q=="], - "@hono/standard-validator": ["@hono/standard-validator@0.1.5", "", { "peerDependencies": { "@standard-schema/spec": "1.0.0", "hono": ">=3.9.0" } }, "sha512-EIyZPPwkyLn6XKwFj5NBEWHXhXbgmnVh2ceIFo5GO7gKI9WmzTjPDKnppQB0KrqKeAkq3kpoW4SIbu5X1dgx3w=="], "@hono/zod-validator": ["@hono/zod-validator@0.4.2", "", { "peerDependencies": { "hono": ">=3.9.0", "zod": "^3.19.1" } }, "sha512-1rrlBg+EpDPhzOV4hT9pxr5+xDVmKuz6YJl+la7VCwK6ass5ldyKm5fD+umJdV2zhHD6jROoCCv8NbTwyfhT0g=="], diff --git a/packages/core/src/flag/flag.ts b/packages/core/src/flag/flag.ts index 9aa4a568da..175c723c5f 100644 --- a/packages/core/src/flag/flag.ts +++ b/packages/core/src/flag/flag.ts @@ -1,5 +1,4 @@ import { Config } from "effect" -import { InstallationChannel } from "../installation/version" function truthy(key: string) { const value = process.env[key]?.toLowerCase() @@ -11,10 +10,6 @@ function falsy(key: string) { return value === "false" || value === "0" } -// Channels that default to the new effect-httpapi server backend. The legacy -// hono backend remains the default for stable (`prod`/`latest`) installs. -const HTTPAPI_DEFAULT_ON_CHANNELS = new Set(["dev", "beta", "local"]) - function number(key: string) { const value = process.env[key] if (!value) return undefined @@ -88,14 +83,6 @@ export const Flag = { OPENCODE_STRICT_CONFIG_DEPS: truthy("OPENCODE_STRICT_CONFIG_DEPS"), OPENCODE_WORKSPACE_ID: process.env["OPENCODE_WORKSPACE_ID"], - // Defaults to true on dev/beta/local channels so internal users exercise the - // new effect-httpapi server backend. Stable (`prod`/`latest`) installs stay - // on the legacy hono backend until the rollout is complete. An explicit env - // var ("true"/"1" or "false"/"0") always wins, providing an opt-in for - // stable users and an escape hatch for dev/beta users. - OPENCODE_EXPERIMENTAL_HTTPAPI: - truthy("OPENCODE_EXPERIMENTAL_HTTPAPI") || - (!falsy("OPENCODE_EXPERIMENTAL_HTTPAPI") && HTTPAPI_DEFAULT_ON_CHANNELS.has(InstallationChannel)), OPENCODE_EXPERIMENTAL_WORKSPACES: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_WORKSPACES"), OPENCODE_EXPERIMENTAL_EVENT_SYSTEM: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_EVENT_SYSTEM"), diff --git a/packages/opencode/package.json b/packages/opencode/package.json index e4f87a7943..3126914416 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -33,11 +33,6 @@ "node": "./src/pty/pty.node.ts", "default": "./src/pty/pty.bun.ts" }, - "#hono": { - "bun": "./src/server/adapter.bun.ts", - "node": "./src/server/adapter.node.ts", - "default": "./src/server/adapter.bun.ts" - }, "#httpapi-server": { "bun": "./src/server/httpapi-server.node.ts", "node": "./src/server/httpapi-server.node.ts", @@ -74,8 +69,7 @@ "prettier": "3.6.2", "typescript": "catalog:", "vscode-languageserver-types": "3.17.5", - "why-is-node-running": "3.2.2", - "zod-to-json-schema": "3.24.5" + "why-is-node-running": "3.2.2" }, "dependencies": { "@actions/core": "1.11.1", @@ -106,10 +100,6 @@ "@effect/opentelemetry": "catalog:", "@effect/platform-node": "catalog:", "@gitlab/opencode-gitlab-auth": "1.3.3", - "@hono/node-server": "1.19.11", - "@hono/node-ws": "1.3.0", - "@hono/standard-validator": "0.1.5", - "@hono/zod-validator": "catalog:", "@lydell/node-pty": "catalog:", "@modelcontextprotocol/sdk": "1.27.1", "@octokit/graphql": "9.0.2", @@ -150,8 +140,6 @@ "glob": "13.0.5", "google-auth-library": "10.5.0", "gray-matter": "4.0.3", - "hono": "catalog:", - "hono-openapi": "catalog:", "ignore": "7.0.5", "immer": "11.1.4", "jsonc-parser": "3.3.1", @@ -177,8 +165,7 @@ "which": "6.0.1", "xdg-basedir": "5.1.0", "yargs": "18.0.0", - "zod": "catalog:", - "zod-to-json-schema": "3.24.5" + "zod": "catalog:" }, "overrides": { "drizzle-orm": "catalog:" diff --git a/packages/opencode/specs/effect/http-api.md b/packages/opencode/specs/effect/http-api.md index 99b7f1b156..3697559549 100644 --- a/packages/opencode/specs/effect/http-api.md +++ b/packages/opencode/specs/effect/http-api.md @@ -1,5 +1,10 @@ # HttpApi migration +> Historical migration notes. PR #25667 deletes the Hono backend and removes the +> `OPENCODE_EXPERIMENTAL_HTTPAPI` backend selection path, so sections below that +> describe Hono as the default, SDK generation from `hono-openapi`, bridge parity, +> or dual-backend rollout are pre-deletion context rather than current guidance. + Plan for replacing instance Hono route implementations with Effect `HttpApi` while preserving behavior, OpenAPI, and SDK output during the transition. ## End State diff --git a/packages/opencode/src/bus/bus-event.ts b/packages/opencode/src/bus/bus-event.ts index 3250c166ab..3533706318 100644 --- a/packages/opencode/src/bus/bus-event.ts +++ b/packages/opencode/src/bus/bus-event.ts @@ -1,6 +1,4 @@ -import z from "zod" import { Schema } from "effect" -import { zodObject } from "@/util/effect-zod" export type Definition = { type: Type @@ -18,23 +16,6 @@ export function define( return result } -export function payloads() { - return registry - .entries() - .map(([type, def]) => { - return z - .object({ - id: z.string(), - type: z.literal(type), - properties: zodObject(def.properties), - }) - .meta({ - ref: `Event.${def.type}`, - }) - }) - .toArray() -} - export function effectPayloads() { return registry .entries() diff --git a/packages/opencode/src/cli/cmd/generate.ts b/packages/opencode/src/cli/cmd/generate.ts index cb15b484e3..2555c3ad7b 100644 --- a/packages/opencode/src/cli/cmd/generate.ts +++ b/packages/opencode/src/cli/cmd/generate.ts @@ -1,28 +1,13 @@ import { Server } from "../../server/server" import type { CommandModule } from "yargs" -type Args = { - httpapi: boolean - hono: boolean -} +type Args = {} export const GenerateCommand = { command: "generate", - builder: (yargs) => - yargs - .option("httpapi", { - type: "boolean", - default: false, - description: - "Generate OpenAPI from the Effect HttpApi contract (default; flag retained for backwards compatibility)", - }) - .option("hono", { - type: "boolean", - default: false, - description: "Generate OpenAPI from the legacy Hono backend (parity-diff only; will be removed)", - }), - handler: async (args) => { - const specs = args.hono ? await Server.openapiHono() : await Server.openapi() + builder: (yargs) => yargs, + handler: async () => { + const specs = (await Server.openapi()) as { paths: Record> } for (const item of Object.values(specs.paths)) { for (const method of ["get", "post", "put", "delete", "patch"] as const) { const operation = item[method] diff --git a/packages/opencode/src/control-plane/types.ts b/packages/opencode/src/control-plane/types.ts index 5acb5c827e..f5b1807462 100644 --- a/packages/opencode/src/control-plane/types.ts +++ b/packages/opencode/src/control-plane/types.ts @@ -1,8 +1,7 @@ import { Schema, Struct } from "effect" import { ProjectID } from "@/project/schema" import { WorkspaceID } from "./schema" -import { zod } from "@/util/effect-zod" -import { type DeepMutable, withStatics } from "@/util/schema" +import type { DeepMutable } from "@/util/schema" export const WorkspaceInfo = Schema.Struct({ id: WorkspaceID, @@ -12,21 +11,18 @@ export const WorkspaceInfo = Schema.Struct({ directory: Schema.NullOr(Schema.String), extra: Schema.NullOr(Schema.Unknown), projectID: ProjectID, -}) - .annotate({ identifier: "Workspace" }) - .pipe(withStatics((s) => ({ zod: zod(s) }))) +}).annotate({ identifier: "Workspace" }) export type WorkspaceInfo = DeepMutable> export const WorkspaceListedInfo = Schema.Struct(Struct.omit(WorkspaceInfo.fields, ["id"])) .annotate({ identifier: "WorkspaceListedInfo" }) - .pipe(withStatics((s) => ({ zod: zod(s) }))) export type WorkspaceListedInfo = DeepMutable> export const WorkspaceAdapterEntry = Schema.Struct({ type: Schema.String, name: Schema.String, description: Schema.String, -}).pipe(withStatics((s) => ({ zod: zod(s) }))) +}) export type WorkspaceAdapterEntry = Schema.Schema.Type export type Target = diff --git a/packages/opencode/src/control-plane/workspace.ts b/packages/opencode/src/control-plane/workspace.ts index b30536ec02..e825eaddcf 100644 --- a/packages/opencode/src/control-plane/workspace.ts +++ b/packages/opencode/src/control-plane/workspace.ts @@ -28,8 +28,6 @@ import { errorData } from "@/util/error" import { waitEvent } from "./util" import { WorkspaceContext } from "./workspace-context" import { EffectBridge } from "@/effect/bridge" -import { withStatics } from "@/util/schema" -import { zod as effectZod, zodObject } from "@/util/effect-zod" import { Vcs } from "@/project/vcs" import { InstanceStore } from "@/project/instance-store" import { InstanceBootstrap } from "@/project/bootstrap" @@ -37,9 +35,7 @@ import { InstanceBootstrap } from "@/project/bootstrap" export const Info = Schema.Struct({ ...WorkspaceInfoSchema.fields, timeUsed: Schema.Number, -}) - .annotate({ identifier: "Workspace" }) - .pipe(withStatics((s) => ({ zod: effectZod(s) }))) +}).annotate({ identifier: "Workspace" }) export type Info = WorkspaceInfo & { timeUsed: number } export const ConnectionStatus = Schema.Struct({ @@ -88,14 +84,14 @@ export const CreateInput = Schema.Struct({ branch: Info.fields.branch, projectID: ProjectID, extra: Schema.optional(Info.fields.extra), -}).pipe(withStatics((s) => ({ zod: effectZod(s), zodObject: zodObject(s) }))) +}) export type CreateInput = Schema.Schema.Type export const SessionWarpInput = Schema.Struct({ workspaceID: Schema.NullOr(WorkspaceID), sessionID: SessionID, copyChanges: Schema.optional(Schema.Boolean), -}).pipe(withStatics((s) => ({ zod: effectZod(s), zodObject: zodObject(s) }))) +}) export type SessionWarpInput = Schema.Schema.Type export class SyncHttpError extends Schema.TaggedErrorClass()("WorkspaceSyncHttpError", { diff --git a/packages/opencode/src/server/adapter.bun.ts b/packages/opencode/src/server/adapter.bun.ts deleted file mode 100644 index b1f3bae27a..0000000000 --- a/packages/opencode/src/server/adapter.bun.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { Hono } from "hono" -import { createBunWebSocket } from "hono/bun" -import type { Adapter, FetchApp, Opts } from "./adapter" - -function listen(app: FetchApp, opts: Opts, websocket?: ReturnType["websocket"]) { - const start = (port: number) => { - try { - if (websocket) { - return Bun.serve({ fetch: app.fetch, hostname: opts.hostname, idleTimeout: 0, websocket, port }) - } - return Bun.serve({ fetch: app.fetch, hostname: opts.hostname, idleTimeout: 0, port }) - } catch { - return - } - } - const server = opts.port === 0 ? (start(4096) ?? start(0)) : start(opts.port) - if (!server) { - throw new Error(`Failed to start server on port ${opts.port}`) - } - if (!server.port) { - throw new Error(`Failed to resolve server address for port ${opts.port}`) - } - return { - port: server.port, - stop(close?: boolean) { - return Promise.resolve(server.stop(close)) - }, - } -} - -export const adapter: Adapter = { - create(app: Hono) { - const ws = createBunWebSocket() - return { - upgradeWebSocket: ws.upgradeWebSocket, - listen: (opts) => Promise.resolve(listen(app, opts, ws.websocket)), - } - }, - createFetch(app) { - return { - listen: (opts) => Promise.resolve(listen(app, opts)), - } - }, -} diff --git a/packages/opencode/src/server/adapter.node.ts b/packages/opencode/src/server/adapter.node.ts deleted file mode 100644 index 55ced40f77..0000000000 --- a/packages/opencode/src/server/adapter.node.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { EventEmitter } from "node:events" -import { createAdaptorServer, type ServerType } from "@hono/node-server" -import { createNodeWebSocket } from "@hono/node-ws" -import type { Hono } from "hono" -import type { Adapter, FetchApp, Opts } from "./adapter" - -async function listen(app: FetchApp, opts: Opts, inject?: (server: ServerType) => void) { - const start = (port: number) => - new Promise((resolve, reject) => { - const server = createAdaptorServer({ fetch: app.fetch }) - const events = server as EventEmitter - inject?.(server) - const fail = (err: Error) => { - cleanup() - reject(err) - } - const ready = () => { - cleanup() - resolve(server) - } - const cleanup = () => { - events.off("error", fail) - events.off("listening", ready) - } - events.once("error", fail) - events.once("listening", ready) - server.listen(port, opts.hostname) - }) - - const server = opts.port === 0 ? await start(4096).catch(() => start(0)) : await start(opts.port) - const addr = server.address() - if (!addr || typeof addr === "string") { - throw new Error(`Failed to resolve server address for port ${opts.port}`) - } - - let closing: Promise | undefined - return { - port: addr.port, - stop(close?: boolean) { - closing ??= new Promise((resolve, reject) => { - server.close((err) => { - if (err) { - reject(err) - return - } - resolve() - }) - if (close) { - if ("closeAllConnections" in server && typeof server.closeAllConnections === "function") { - server.closeAllConnections() - } - if ("closeIdleConnections" in server && typeof server.closeIdleConnections === "function") { - server.closeIdleConnections() - } - } - }) - return closing - }, - } -} - -export const adapter: Adapter = { - create(app: Hono) { - const ws = createNodeWebSocket({ app }) - return { - upgradeWebSocket: ws.upgradeWebSocket, - listen: (opts) => listen(app, opts, ws.injectWebSocket), - } - }, - createFetch(app) { - return { - listen: (opts) => listen(app, opts), - } - }, -} diff --git a/packages/opencode/src/server/adapter.ts b/packages/opencode/src/server/adapter.ts deleted file mode 100644 index 7f4edd2c17..0000000000 --- a/packages/opencode/src/server/adapter.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type { Hono } from "hono" -import type { UpgradeWebSocket } from "hono/ws" - -export type FetchApp = { - fetch(request: Request): Response | Promise -} - -export type Opts = { - port: number - hostname: string -} - -export type Listener = { - port: number - stop: (close?: boolean) => Promise -} - -export interface Runtime { - upgradeWebSocket: UpgradeWebSocket - listen(opts: Opts): Promise -} - -export interface Adapter { - create(app: Hono): Runtime - createFetch(app: FetchApp): Omit -} diff --git a/packages/opencode/src/server/backend.ts b/packages/opencode/src/server/backend.ts deleted file mode 100644 index f456dc0be5..0000000000 --- a/packages/opencode/src/server/backend.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { Flag } from "@opencode-ai/core/flag/flag" -import { InstallationChannel, InstallationVersion } from "@opencode-ai/core/installation/version" - -export type Backend = "effect-httpapi" | "hono" - -export type Selection = { - backend: Backend - reason: "env" | "stable" | "explicit" -} - -export type Attributes = ReturnType - -export function select(): Selection { - if (Flag.OPENCODE_EXPERIMENTAL_HTTPAPI) return { backend: "effect-httpapi", reason: "env" } - return { backend: "hono", reason: "stable" } -} - -export function attributes(selection: Selection): Record { - return { - "opencode.server.backend": selection.backend, - "opencode.server.backend.reason": selection.reason, - "opencode.installation.channel": InstallationChannel, - "opencode.installation.version": InstallationVersion, - } -} - -export function force(selection: Selection, backend: Backend): Selection { - return { - backend, - reason: selection.backend === backend ? selection.reason : "explicit", - } -} diff --git a/packages/opencode/src/server/error.ts b/packages/opencode/src/server/error.ts deleted file mode 100644 index 506e798187..0000000000 --- a/packages/opencode/src/server/error.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { resolver } from "hono-openapi" -import z from "zod" -import { NotFoundError } from "@/storage/storage" - -export const ERRORS = { - 400: { - description: "Bad request", - content: { - "application/json": { - schema: resolver( - z - .object({ - data: z.any(), - errors: z.array(z.record(z.string(), z.any())), - success: z.literal(false), - }) - .meta({ - ref: "BadRequestError", - }), - ), - }, - }, - }, - 403: { - description: "Forbidden", - }, - 404: { - description: "Not found", - content: { - "application/json": { - schema: resolver(NotFoundError.Schema), - }, - }, - }, -} as const - -export function errors(...codes: number[]) { - return Object.fromEntries(codes.map((code) => [code, ERRORS[code as keyof typeof ERRORS]])) -} diff --git a/packages/opencode/src/server/fence.ts b/packages/opencode/src/server/fence.ts deleted file mode 100644 index 1b8c42c899..0000000000 --- a/packages/opencode/src/server/fence.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { MiddlewareHandler } from "hono" -import * as Log from "@opencode-ai/core/util/log" -import { HEADER, diff, load } from "./shared/fence" - -const log = Log.create({ service: "fence-middleware" }) - -export const FenceMiddleware: MiddlewareHandler = async (c, next) => { - if (c.req.method === "GET" || c.req.method === "HEAD" || c.req.method === "OPTIONS") return next() - - const prev = load() - await next() - const current = diff(prev, load()) - - if (Object.keys(current).length > 0) { - log.info("header", { - diff: current, - }) - c.res.headers.set(HEADER, JSON.stringify(current)) - } -} diff --git a/packages/opencode/src/server/httpapi-server.node.ts b/packages/opencode/src/server/httpapi-server.node.ts index 5d29fae33f..d6c6cbd2fd 100644 --- a/packages/opencode/src/server/httpapi-server.node.ts +++ b/packages/opencode/src/server/httpapi-server.node.ts @@ -1,13 +1,14 @@ import { NodeHttpServer } from "@effect/platform-node" import { Effect, Layer } from "effect" import { createServer } from "node:http" -import type { Opts } from "./adapter" import { Service } from "./httpapi-server" export { Service } export const name = "node-http-server" +export type Opts = { port: number; hostname: string } + export const layer = (opts: Opts) => { const server = createServer() const serverRef = { closeStarted: false, forceStop: false } diff --git a/packages/opencode/src/server/middleware.ts b/packages/opencode/src/server/middleware.ts deleted file mode 100644 index 160d258796..0000000000 --- a/packages/opencode/src/server/middleware.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { Provider } from "@/provider/provider" -import { NamedError } from "@opencode-ai/core/util/error" -import { NotFoundError } from "@/storage/storage" -import { Session } from "@/session/session" -import type { ContentfulStatusCode } from "hono/utils/http-status" -import type { ErrorHandler, MiddlewareHandler } from "hono" -import { HTTPException } from "hono/http-exception" -import * as Log from "@opencode-ai/core/util/log" -import { Flag } from "@opencode-ai/core/flag/flag" -import { basicAuth } from "hono/basic-auth" -import { cors } from "hono/cors" -import { compress } from "hono/compress" -import * as ServerBackend from "./backend" -import { isAllowedCorsOrigin, type CorsOptions } from "./cors" -import { isPtyConnectPath, PTY_CONNECT_TICKET_QUERY } from "./shared/pty-ticket" -import { isPublicUIPath } from "./shared/public-ui" - -const log = Log.create({ service: "server" }) - -export const ErrorMiddleware: ErrorHandler = (err, c) => { - log.error("failed", { - error: err, - }) - if (err instanceof NamedError) { - let status: ContentfulStatusCode - if (err instanceof NotFoundError) status = 404 - else if (err instanceof Provider.ModelNotFoundError) status = 400 - else if (err.name === "ProviderAuthValidationFailed") status = 400 - else if (err.name.startsWith("Worktree")) status = 400 - else status = 500 - return c.json(err.toObject(), { status }) - } - if (err instanceof Session.BusyError) { - return c.json(new NamedError.Unknown({ message: err.message }).toObject(), { status: 400 }) - } - if (err instanceof HTTPException) return err.getResponse() - const message = err instanceof Error && err.stack ? err.stack : err.toString() - return c.json(new NamedError.Unknown({ message }).toObject(), { - status: 500, - }) -} - -export const AuthMiddleware: MiddlewareHandler = (c, next) => { - // Allow CORS preflight requests to succeed without auth. - // Browser clients sending Authorization headers will preflight with OPTIONS. - if (c.req.method === "OPTIONS") return next() - const password = Flag.OPENCODE_SERVER_PASSWORD - if (!password) return next() - if (isPublicUIPath(c.req.method, c.req.path)) return next() - if (isPtyConnectPath(c.req.path) && c.req.query(PTY_CONNECT_TICKET_QUERY)) return next() - const username = Flag.OPENCODE_SERVER_USERNAME ?? "opencode" - - if (c.req.query("auth_token")) c.req.raw.headers.set("authorization", `Basic ${c.req.query("auth_token")}`) - - return basicAuth({ username, password })(c, next) -} - -export function LoggerMiddleware(backendAttributes: ServerBackend.Attributes): MiddlewareHandler { - return async (c, next) => { - const skip = c.req.path === "/log" - if (skip) return next() - const attributes = { - method: c.req.method, - path: c.req.path, - // If this logger grows full-URL fields, redact auth_token and ticket query params. - ...backendAttributes, - } - log.info("request", attributes) - const timer = log.time("request", attributes) - await next() - timer.stop() - } -} - -export function CorsMiddleware(opts?: CorsOptions): MiddlewareHandler { - return cors({ - maxAge: 86_400, - origin(input) { - if (isAllowedCorsOrigin(input, opts)) return input - }, - }) -} - -const zipped = compress() -export const CompressionMiddleware: MiddlewareHandler = (c, next) => { - const path = c.req.path - const method = c.req.method - if (path === "/event" || path === "/global/event") return next() - if (method === "POST" && /\/session\/[^/]+\/(message|prompt_async)$/.test(path)) return next() - return zipped(c, next) -} diff --git a/packages/opencode/src/server/proxy.ts b/packages/opencode/src/server/proxy.ts deleted file mode 100644 index 069f308512..0000000000 --- a/packages/opencode/src/server/proxy.ts +++ /dev/null @@ -1,149 +0,0 @@ -import { Hono } from "hono" -import type { UpgradeWebSocket } from "hono/ws" -import * as Log from "@opencode-ai/core/util/log" -import * as Fence from "./shared/fence" -import type { WorkspaceID } from "@/control-plane/schema" -import { Workspace } from "@/control-plane/workspace" -import { AppRuntime } from "@/effect/app-runtime" -import { ProxyUtil } from "./proxy-util" -import { Effect, Stream } from "effect" -import { FetchHttpClient, HttpBody, HttpClient, HttpClientRequest } from "effect/unstable/http" - -type Msg = string | ArrayBuffer | Uint8Array - -function send(ws: { send(data: string | ArrayBuffer | Uint8Array): void }, data: any) { - if (data instanceof Blob) { - return data.arrayBuffer().then((x) => ws.send(x)) - } - return ws.send(data) -} - -const app = (upgrade: UpgradeWebSocket) => - new Hono().get( - "/__workspace_ws", - upgrade((c) => { - const url = c.req.header("x-opencode-proxy-url") - const queue: Msg[] = [] - let remote: WebSocket | undefined - return { - onOpen(_, ws) { - if (!url) { - ws.close(1011, "missing proxy target") - return - } - remote = new WebSocket(url, ProxyUtil.websocketProtocols(c.req.raw)) - remote.binaryType = "arraybuffer" - remote.onopen = () => { - for (const item of queue) remote?.send(item) - queue.length = 0 - } - remote.onmessage = (event) => { - void send(ws, event.data) - } - remote.onerror = () => { - ws.close(1011, "proxy error") - } - remote.onclose = (event) => { - ws.close(event.code, event.reason) - } - }, - onMessage(event) { - const data = event.data - if (typeof data !== "string" && !(data instanceof Uint8Array) && !(data instanceof ArrayBuffer)) return - if (remote?.readyState === WebSocket.OPEN) { - remote.send(data) - return - } - queue.push(data) - }, - onClose(event) { - remote?.close(event.code, event.reason) - }, - } - }), - ) - -const log = Log.create({ service: "server-proxy" }) - -function statusText(response: unknown) { - return (response as { source?: Response }).source?.statusText -} - -export function httpEffect(url: string | URL, extra: HeadersInit | undefined, req: Request, workspaceID: WorkspaceID) { - return Effect.gen(function* () { - const syncing = yield* Workspace.Service.use((workspace) => workspace.isSyncing(workspaceID)) - if (!syncing) { - return new Response(`broken sync connection for workspace: ${workspaceID}`, { - status: 503, - headers: { - "content-type": "text/plain; charset=utf-8", - }, - }) - } - - const response = yield* HttpClient.execute( - HttpClientRequest.make(req.method as never)(url, { - headers: ProxyUtil.headers(req, extra), - body: - req.method === "GET" || req.method === "HEAD" - ? HttpBody.empty - : HttpBody.raw(req.body, { - contentType: req.headers.get("content-type") ?? undefined, - contentLength: req.headers.get("content-length") - ? Number(req.headers.get("content-length")) - : undefined, - }), - }), - ) - const next = new Headers(response.headers as HeadersInit) - const sync = Fence.parse(next) - next.delete("content-encoding") - next.delete("content-length") - - if (sync) yield* Fence.waitEffect(workspaceID, sync, req.signal) - const body = yield* Stream.toReadableStreamEffect(response.stream.pipe(Stream.catchCause(() => Stream.empty))) - return new Response(body, { - status: response.status, - statusText: statusText(response), - headers: next, - }) - }).pipe( - Effect.provide(FetchHttpClient.layer), - Effect.catch(() => Effect.succeed(new Response(null, { status: 500 }))), - ) -} - -export function http(url: string | URL, extra: HeadersInit | undefined, req: Request, workspaceID: WorkspaceID) { - return AppRuntime.runPromise(httpEffect(url, extra, req, workspaceID)) -} - -export function websocket( - upgrade: UpgradeWebSocket, - target: string | URL, - extra: HeadersInit | undefined, - req: Request, - env: unknown, -) { - const proxy = new URL(req.url) - proxy.pathname = "/__workspace_ws" - proxy.search = "" - const next = new Headers(req.headers) - next.set("x-opencode-proxy-url", ProxyUtil.websocketTargetURL(target)) - for (const [key, value] of new Headers(extra).entries()) { - next.set(key, value) - } - log.info("proxy websocket", { - request: req.url, - target: String(target), - }) - return app(upgrade).fetch( - new Request(proxy, { - method: req.method, - headers: next, - signal: req.signal, - }), - env as never, - ) -} - -export * as ServerProxy from "./proxy" diff --git a/packages/opencode/src/server/routes/control/index.ts b/packages/opencode/src/server/routes/control/index.ts deleted file mode 100644 index c5b39abde1..0000000000 --- a/packages/opencode/src/server/routes/control/index.ts +++ /dev/null @@ -1,160 +0,0 @@ -import { Auth } from "@/auth" -import { AppRuntime } from "@/effect/app-runtime" -import * as Log from "@opencode-ai/core/util/log" -import { Effect } from "effect" -import { ProviderID } from "@/provider/schema" -import { Hono } from "hono" -import { describeRoute, resolver, validator, openAPIRouteHandler } from "hono-openapi" -import z from "zod" -import { errors } from "../../error" - -export function ControlPlaneRoutes(): Hono { - const app = new Hono() - return app - .put( - "/auth/:providerID", - describeRoute({ - summary: "Set auth credentials", - description: "Set authentication credentials", - operationId: "auth.set", - responses: { - 200: { - description: "Successfully set authentication credentials", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - ...errors(400), - }, - }), - validator( - "param", - z.object({ - providerID: ProviderID.zod, - }), - ), - validator("json", Auth.Info.zod), - async (c) => { - const providerID = c.req.valid("param").providerID - const info = c.req.valid("json") - await AppRuntime.runPromise( - Effect.gen(function* () { - const auth = yield* Auth.Service - yield* auth.set(providerID, info) - }), - ) - return c.json(true) - }, - ) - .delete( - "/auth/:providerID", - describeRoute({ - summary: "Remove auth credentials", - description: "Remove authentication credentials", - operationId: "auth.remove", - responses: { - 200: { - description: "Successfully removed authentication credentials", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - ...errors(400), - }, - }), - validator( - "param", - z.object({ - providerID: ProviderID.zod, - }), - ), - async (c) => { - const providerID = c.req.valid("param").providerID - await AppRuntime.runPromise( - Effect.gen(function* () { - const auth = yield* Auth.Service - yield* auth.remove(providerID) - }), - ) - return c.json(true) - }, - ) - .get( - "/doc", - openAPIRouteHandler(app, { - documentation: { - info: { - title: "opencode", - version: "0.0.3", - description: "opencode api", - }, - openapi: "3.1.1", - }, - }), - ) - .use( - validator( - "query", - z.object({ - directory: z.string().optional(), - workspace: z.string().optional(), - }), - ), - ) - .post( - "/log", - describeRoute({ - summary: "Write log", - description: "Write a log entry to the server logs with specified level and metadata.", - operationId: "app.log", - responses: { - 200: { - description: "Log entry written successfully", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - ...errors(400), - }, - }), - validator( - "json", - z.object({ - service: z.string().meta({ description: "Service name for the log entry" }), - level: z.enum(["debug", "info", "error", "warn"]).meta({ description: "Log level" }), - message: z.string().meta({ description: "Log message" }), - extra: z - .record(z.string(), z.any()) - .optional() - .meta({ description: "Additional metadata for the log entry" }), - }), - ), - async (c) => { - const { service, level, message, extra } = c.req.valid("json") - const logger = Log.create({ service }) - - switch (level) { - case "debug": - logger.debug(message, extra) - break - case "info": - logger.info(message, extra) - break - case "error": - logger.error(message, extra) - break - case "warn": - logger.warn(message, extra) - break - } - - return c.json(true) - }, - ) -} diff --git a/packages/opencode/src/server/routes/control/workspace.ts b/packages/opencode/src/server/routes/control/workspace.ts deleted file mode 100644 index 799294b261..0000000000 --- a/packages/opencode/src/server/routes/control/workspace.ts +++ /dev/null @@ -1,228 +0,0 @@ -import { Hono } from "hono" -import { describeRoute, resolver, validator } from "hono-openapi" -import z from "zod" -import { Effect } from "effect" -import { listAdapters } from "@/control-plane/adapters" -import { Workspace } from "@/control-plane/workspace" -import { AppRuntime } from "@/effect/app-runtime" -import { WorkspaceAdapterEntry } from "@/control-plane/types" -import { zodObject } from "@/util/effect-zod" -import { Instance } from "@/project/instance" -import { Vcs } from "@/project/vcs" -import { errors } from "../../error" -import { lazy } from "@/util/lazy" - -export const WorkspaceRoutes = lazy(() => - new Hono() - .get( - "/adapter", - describeRoute({ - summary: "List workspace adapters", - description: "List all available workspace adapters for the current project.", - operationId: "experimental.workspace.adapter.list", - responses: { - 200: { - description: "Workspace adapters", - content: { - "application/json": { - schema: resolver(z.array(zodObject(WorkspaceAdapterEntry))), - }, - }, - }, - }, - }), - async (c) => { - return c.json(await listAdapters(Instance.project.id)) - }, - ) - .post( - "/", - describeRoute({ - summary: "Create workspace", - description: "Create a workspace for the current project.", - operationId: "experimental.workspace.create", - responses: { - 200: { - description: "Workspace created", - content: { - "application/json": { - schema: resolver(Workspace.Info.zod), - }, - }, - }, - ...errors(400), - }, - }), - validator( - "json", - Workspace.CreateInput.zodObject.omit({ - projectID: true, - }), - ), - async (c) => { - const body = c.req.valid("json") as Omit - const workspace = await AppRuntime.runPromise( - Workspace.Service.use((svc) => - svc.create({ - projectID: Instance.project.id, - ...body, - }), - ), - ) - return c.json(workspace) - }, - ) - .get( - "/", - describeRoute({ - summary: "List workspaces", - description: "List all workspaces.", - operationId: "experimental.workspace.list", - responses: { - 200: { - description: "Workspaces", - content: { - "application/json": { - schema: resolver(z.array(Workspace.Info.zod)), - }, - }, - }, - }, - }), - async (c) => { - return c.json(await AppRuntime.runPromise(Workspace.Service.use((svc) => svc.list(Instance.project)))) - }, - ) - .post( - "/sync-list", - describeRoute({ - summary: "Sync workspace list", - description: "Register missing workspaces returned by workspace adapters.", - operationId: "experimental.workspace.syncList", - responses: { - 204: { - description: "Workspace list synced", - }, - }, - }), - async (c) => { - await AppRuntime.runPromise(Workspace.Service.use((svc) => svc.syncList(Instance.project))) - return c.body(null, 204) - }, - ) - .get( - "/status", - describeRoute({ - summary: "Workspace status", - description: "Get connection status for workspaces in the current project.", - operationId: "experimental.workspace.status", - responses: { - 200: { - description: "Workspace status", - content: { - "application/json": { - schema: resolver(z.array(zodObject(Workspace.ConnectionStatus))), - }, - }, - }, - }, - }), - async (c) => { - const result = await AppRuntime.runPromise( - Workspace.Service.use((svc) => Effect.all([svc.list(Instance.project), svc.status()])), - ) - const ids = new Set(result[0].map((item) => item.id)) - return c.json(result[1].filter((item) => ids.has(item.workspaceID))) - }, - ) - .delete( - "/:id", - describeRoute({ - summary: "Remove workspace", - description: "Remove an existing workspace.", - operationId: "experimental.workspace.remove", - responses: { - 200: { - description: "Workspace removed", - content: { - "application/json": { - schema: resolver(Workspace.Info.zod.optional()), - }, - }, - }, - ...errors(400), - }, - }), - validator( - "param", - z.object({ - id: zodObject(Workspace.Info).shape.id, - }), - ), - async (c) => { - const { id } = c.req.valid("param") - return c.json(await AppRuntime.runPromise(Workspace.Service.use((svc) => svc.remove(id)))) - }, - ) - .post( - "/warp", - describeRoute({ - summary: "Warp session into workspace", - description: "Move a session's sync history into the target workspace, or detach it to the local project.", - operationId: "experimental.workspace.warp", - responses: { - 204: { - description: "Session warped", - }, - ...errors(400), - }, - }), - validator( - "json", - z.object({ - id: zodObject(Workspace.Info).shape.id.nullable(), - sessionID: Workspace.SessionWarpInput.zodObject.shape.sessionID, - copyChanges: z.boolean().optional(), - }), - ), - async (c) => { - const body = c.req.valid("json") - return AppRuntime.runPromise( - Workspace.Service.use((workspace) => - workspace.sessionWarp({ - workspaceID: body.id, - sessionID: body.sessionID, - copyChanges: body.copyChanges, - }), - ).pipe( - Effect.match({ - onFailure: (error) => { - if (error instanceof Vcs.PatchApplyError) { - return c.json( - { - name: "VcsApplyError", - data: { - message: error.message, - reason: error.reason, - }, - }, - 400, - ) - } - return c.json( - { - name: "WorkspaceWarpError", - data: { - message: error.message, - }, - }, - 400, - ) - }, - onSuccess: () => c.body(null, 204), - }), - ), - ) - }, - ), -) diff --git a/packages/opencode/src/server/routes/global.ts b/packages/opencode/src/server/routes/global.ts deleted file mode 100644 index da3614d228..0000000000 --- a/packages/opencode/src/server/routes/global.ts +++ /dev/null @@ -1,286 +0,0 @@ -import { Hono, type Context } from "hono" -import { describeRoute, resolver, validator } from "hono-openapi" -import { streamSSE } from "hono/streaming" -import { Effect } from "effect" -import z from "zod" -import { BusEvent } from "@/bus/bus-event" -import { SyncEvent } from "@/sync" -import { GlobalBus } from "@/bus/global" -import { Bus } from "@/bus" -import { AppRuntime } from "@/effect/app-runtime" -import { AsyncQueue } from "@/util/queue" -import { Installation } from "@/installation" -import { InstallationVersion } from "@opencode-ai/core/installation/version" -import * as Log from "@opencode-ai/core/util/log" -import { lazy } from "../../util/lazy" -import { Config } from "@/config/config" -import { errors } from "../error" -import { disposeAllInstancesAndEmitGlobalDisposed } from "../global-lifecycle" - -const log = Log.create({ service: "server" }) - -async function streamEvents(c: Context, subscribe: (q: AsyncQueue) => () => void) { - return streamSSE(c, async (stream) => { - const q = new AsyncQueue() - let done = false - - q.push( - JSON.stringify({ - payload: { - id: Bus.createID(), - type: "server.connected", - properties: {}, - }, - }), - ) - - // Send heartbeat every 10s to prevent stalled proxy streams. - const heartbeat = setInterval(() => { - q.push( - JSON.stringify({ - payload: { - id: Bus.createID(), - type: "server.heartbeat", - properties: {}, - }, - }), - ) - }, 10_000) - - const stop = () => { - if (done) return - done = true - clearInterval(heartbeat) - unsub() - q.push(null) - log.info("global event disconnected") - } - - const unsub = subscribe(q) - - stream.onAbort(stop) - - try { - for await (const data of q) { - if (data === null) return - await stream.writeSSE({ data }) - } - } finally { - stop() - } - }) -} - -export const GlobalRoutes = lazy(() => - new Hono() - .get( - "/health", - describeRoute({ - summary: "Get health", - description: "Get health information about the OpenCode server.", - operationId: "global.health", - responses: { - 200: { - description: "Health information", - content: { - "application/json": { - schema: resolver(z.object({ healthy: z.literal(true), version: z.string() })), - }, - }, - }, - }, - }), - async (c) => { - return c.json({ healthy: true, version: InstallationVersion }) - }, - ) - .get( - "/event", - describeRoute({ - summary: "Get global events", - description: "Subscribe to global events from the OpenCode system using server-sent events.", - operationId: "global.event", - responses: { - 200: { - description: "Event stream", - content: { - "text/event-stream": { - schema: resolver( - z - .object({ - directory: z.string(), - project: z.string().optional(), - workspace: z.string().optional(), - payload: z.union([...BusEvent.payloads(), ...SyncEvent.payloads()]), - }) - .meta({ - ref: "GlobalEvent", - }), - ), - }, - }, - }, - }, - }), - async (c) => { - log.info("global event connected") - c.header("Cache-Control", "no-cache, no-transform") - c.header("X-Accel-Buffering", "no") - c.header("X-Content-Type-Options", "nosniff") - - return streamEvents(c, (q) => { - async function handler(event: any) { - q.push(JSON.stringify(event)) - } - GlobalBus.on("event", handler) - return () => GlobalBus.off("event", handler) - }) - }, - ) - .get( - "/config", - describeRoute({ - summary: "Get global configuration", - description: "Retrieve the current global OpenCode configuration settings and preferences.", - operationId: "global.config.get", - responses: { - 200: { - description: "Get global config info", - content: { - "application/json": { - schema: resolver(Config.Info.zod), - }, - }, - }, - }, - }), - async (c) => { - return c.json(await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.getGlobal()))) - }, - ) - .patch( - "/config", - describeRoute({ - summary: "Update global configuration", - description: "Update global OpenCode configuration settings and preferences.", - operationId: "global.config.update", - responses: { - 200: { - description: "Successfully updated global config", - content: { - "application/json": { - schema: resolver(Config.Info.zod), - }, - }, - }, - ...errors(400), - }, - }), - validator("json", Config.Info.zod), - async (c) => { - const config = c.req.valid("json") - const result = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.updateGlobal(config))) - if (result.changed) { - void AppRuntime.runPromise(disposeAllInstancesAndEmitGlobalDisposed({ swallowErrors: true })).catch( - () => undefined, - ) - } - return c.json(result.info) - }, - ) - .post( - "/dispose", - describeRoute({ - summary: "Dispose instance", - description: "Clean up and dispose all OpenCode instances, releasing all resources.", - operationId: "global.dispose", - responses: { - 200: { - description: "Global disposed", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - }, - }), - async (c) => { - await AppRuntime.runPromise(disposeAllInstancesAndEmitGlobalDisposed()) - return c.json(true) - }, - ) - .post( - "/upgrade", - describeRoute({ - summary: "Upgrade opencode", - description: "Upgrade opencode to the specified version or latest if not specified.", - operationId: "global.upgrade", - responses: { - 200: { - description: "Upgrade result", - content: { - "application/json": { - schema: resolver( - z.union([ - z.object({ - success: z.literal(true), - version: z.string(), - }), - z.object({ - success: z.literal(false), - error: z.string(), - }), - ]), - ), - }, - }, - }, - ...errors(400), - }, - }), - validator( - "json", - z.object({ - target: z.string().optional(), - }), - ), - async (c) => { - const result = await AppRuntime.runPromise( - Installation.Service.use((svc) => - Effect.gen(function* () { - const method = yield* svc.method() - if (method === "unknown") { - return { success: false as const, status: 400 as const, error: "Unknown installation method" } - } - - const target = c.req.valid("json").target || (yield* svc.latest(method)) - const result = yield* Effect.catch( - svc.upgrade(method, target).pipe(Effect.as({ success: true as const, version: target })), - (err) => - Effect.succeed({ - success: false as const, - status: 500 as const, - error: err instanceof Error ? err.message : String(err), - }), - ) - if (!result.success) return result - return { ...result, status: 200 as const } - }), - ), - ) - if (!result.success) { - return c.json({ success: false, error: result.error }, result.status) - } - const target = result.version - GlobalBus.emit("event", { - directory: "global", - payload: { - type: Installation.Event.Updated.type, - properties: { version: target }, - }, - }) - return c.json({ success: true, version: target }) - }, - ), -) diff --git a/packages/opencode/src/server/routes/instance/AGENTS.md b/packages/opencode/src/server/routes/instance/AGENTS.md deleted file mode 100644 index c94fa64af7..0000000000 --- a/packages/opencode/src/server/routes/instance/AGENTS.md +++ /dev/null @@ -1,8 +0,0 @@ -# Instance Route Parity - -This directory contains the legacy Hono instance routes and the experimental Effect HttpApi implementation under `httpapi/`. Keep them behaviorally aligned. - -- When adding, removing, or changing a legacy Hono route, update the matching Effect HttpApi group and handler in `httpapi/` in the same change unless the route is intentionally unsupported. -- When changing an Effect HttpApi route, verify the legacy Hono route has the same public behavior, request shape, response shape, status codes, and instance/workspace routing semantics. -- Keep OpenAPI/SDK-visible schemas aligned. If a difference is only an OpenAPI generation artifact, prefer fixing the source schema first; use `httpapi/public.ts` normalization only for compatibility shims that cannot be represented cleanly in the source schema. -- Add or update parity coverage in `test/server/httpapi-bridge.test.ts` or the focused HttpApi tests when behavior or schema parity could regress. diff --git a/packages/opencode/src/server/routes/instance/config.ts b/packages/opencode/src/server/routes/instance/config.ts deleted file mode 100644 index 949734f81a..0000000000 --- a/packages/opencode/src/server/routes/instance/config.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { Hono } from "hono" -import { describeRoute, validator, resolver } from "hono-openapi" -import { Config } from "@/config/config" -import { InstanceState } from "@/effect/instance-state" -import { InstanceStore } from "@/project/instance-store" -import { Provider } from "@/provider/provider" -import { errors } from "../../error" -import { lazy } from "@/util/lazy" -import { jsonRequest, runRequest } from "./trace" -import { Effect } from "effect" -import * as Log from "@opencode-ai/core/util/log" - -const log = Log.create({ service: "server.config" }) - -export const ConfigRoutes = lazy(() => - new Hono() - .get( - "/", - describeRoute({ - summary: "Get configuration", - description: "Retrieve the current OpenCode configuration settings and preferences.", - operationId: "config.get", - responses: { - 200: { - description: "Get config info", - content: { - "application/json": { - schema: resolver(Config.Info.zod), - }, - }, - }, - }, - }), - async (c) => - jsonRequest("ConfigRoutes.get", c, function* () { - const cfg = yield* Config.Service - return yield* cfg.get() - }), - ) - .patch( - "/", - describeRoute({ - summary: "Update configuration", - description: "Update OpenCode configuration settings and preferences.", - operationId: "config.update", - responses: { - 200: { - description: "Successfully updated config", - content: { - "application/json": { - schema: resolver(Config.Info.zod), - }, - }, - }, - ...errors(400), - }, - }), - validator("json", Config.Info.zod), - async (c) => { - const result = await runRequest( - "ConfigRoutes.update", - c, - Effect.gen(function* () { - const config = c.req.valid("json") - const cfg = yield* Config.Service - yield* cfg.update(config) - return { config, ctx: yield* InstanceState.context } - }), - ) - const response = c.json(result.config) - void runRequest( - "ConfigRoutes.update.dispose", - c, - InstanceStore.Service.use((store) => store.dispose(result.ctx)).pipe( - Effect.uninterruptible, - Effect.catchCause((cause) => Effect.sync(() => log.warn("instance disposal failed", { cause }))), - ), - ) - return response - }, - ) - .get( - "/providers", - describeRoute({ - summary: "List config providers", - description: "Get a list of all configured AI providers and their default models.", - operationId: "config.providers", - responses: { - 200: { - description: "List of providers", - content: { - "application/json": { - schema: resolver(Provider.ConfigProvidersResult.zod), - }, - }, - }, - }, - }), - async (c) => - jsonRequest("ConfigRoutes.providers", c, function* () { - const svc = yield* Provider.Service - const providers = yield* svc.list() - return { - providers: Object.values(providers), - default: Provider.defaultModelIDs(providers), - } - }), - ), -) diff --git a/packages/opencode/src/server/routes/instance/event.ts b/packages/opencode/src/server/routes/instance/event.ts deleted file mode 100644 index aeb1da5393..0000000000 --- a/packages/opencode/src/server/routes/instance/event.ts +++ /dev/null @@ -1,90 +0,0 @@ -import z from "zod" -import { Hono } from "hono" -import { describeRoute, resolver } from "hono-openapi" -import { streamSSE } from "hono/streaming" -import * as Log from "@opencode-ai/core/util/log" -import { BusEvent } from "@/bus/bus-event" -import { Bus } from "@/bus" -import { AsyncQueue } from "@/util/queue" - -const log = Log.create({ service: "server" }) - -export const EventRoutes = () => - new Hono().get( - "/event", - describeRoute({ - summary: "Subscribe to events", - description: "Get events", - operationId: "event.subscribe", - responses: { - 200: { - description: "Event stream", - content: { - "text/event-stream": { - schema: resolver( - z.union(BusEvent.payloads()).meta({ - ref: "Event", - }), - ), - }, - }, - }, - }, - }), - async (c) => { - log.info("event connected") - c.header("Cache-Control", "no-cache, no-transform") - c.header("X-Accel-Buffering", "no") - c.header("X-Content-Type-Options", "nosniff") - return streamSSE(c, async (stream) => { - const q = new AsyncQueue() - let done = false - - q.push( - JSON.stringify({ - id: Bus.createID(), - type: "server.connected", - properties: {}, - }), - ) - - // Send heartbeat every 10s to prevent stalled proxy streams. - const heartbeat = setInterval(() => { - q.push( - JSON.stringify({ - id: Bus.createID(), - type: "server.heartbeat", - properties: {}, - }), - ) - }, 10_000) - - const stop = () => { - if (done) return - done = true - clearInterval(heartbeat) - unsub() - q.push(null) - log.info("event disconnected") - } - - const unsub = Bus.subscribeAll((event) => { - q.push(JSON.stringify(event)) - if (event.type === Bus.InstanceDisposed.type) { - stop() - } - }) - - stream.onAbort(stop) - - try { - for await (const data of q) { - if (data === null) return - await stream.writeSSE({ data }) - } - } finally { - stop() - } - }) - }, - ) diff --git a/packages/opencode/src/server/routes/instance/experimental.ts b/packages/opencode/src/server/routes/instance/experimental.ts deleted file mode 100644 index 7e09fb9ad3..0000000000 --- a/packages/opencode/src/server/routes/instance/experimental.ts +++ /dev/null @@ -1,419 +0,0 @@ -import { Hono } from "hono" -import { describeRoute, validator, resolver } from "hono-openapi" -import z from "zod" -import * as EffectZod from "@/util/effect-zod" -import { ProviderID, ModelID } from "@/provider/schema" -import { ToolRegistry } from "@/tool/registry" -import { Worktree } from "@/worktree" -import { Instance } from "@/project/instance" -import { Project } from "@/project/project" -import { MCP } from "@/mcp" -import { Session } from "@/session/session" -import { Config } from "@/config/config" -import { ConsoleState } from "@/config/console-state" -import { Account } from "@/account/account" -import { AccountID, OrgID } from "@/account/schema" -import { errors } from "../../error" -import { lazy } from "@/util/lazy" -import { Effect, Option } from "effect" -import { Agent } from "@/agent/agent" -import { jsonRequest, runRequest } from "./trace" - -const ConsoleOrgOption = z.object({ - accountID: z.string(), - accountEmail: z.string(), - accountUrl: z.string(), - orgID: z.string(), - orgName: z.string(), - active: z.boolean(), -}) - -const ConsoleOrgList = z.object({ - orgs: z.array(ConsoleOrgOption), -}) - -const ConsoleSwitchBody = z.object({ - accountID: z.string(), - orgID: z.string(), -}) - -const QueryBoolean = z.union([ - z.preprocess((value) => (value === "true" ? true : value === "false" ? false : value), z.boolean()), - z.enum(["true", "false"]), -]) - -function queryBoolean(value: z.infer | undefined) { - if (value === undefined) return - return value === true || value === "true" -} - -export const ExperimentalRoutes = lazy(() => - new Hono() - .get( - "/console", - describeRoute({ - summary: "Get active Console provider metadata", - description: "Get the active Console org name and the set of provider IDs managed by that Console org.", - operationId: "experimental.console.get", - responses: { - 200: { - description: "Active Console provider metadata", - content: { - "application/json": { - schema: resolver(ConsoleState.zod), - }, - }, - }, - }, - }), - async (c) => - jsonRequest("ExperimentalRoutes.console.get", c, function* () { - const config = yield* Config.Service - const account = yield* Account.Service - const [state, groups] = yield* Effect.all([config.getConsoleState(), account.orgsByAccount()], { - concurrency: "unbounded", - }) - return { - ...state, - switchableOrgCount: groups.reduce((count, group) => count + group.orgs.length, 0), - } - }), - ) - .get( - "/console/orgs", - describeRoute({ - summary: "List switchable Console orgs", - description: "Get the available Console orgs across logged-in accounts, including the current active org.", - operationId: "experimental.console.listOrgs", - responses: { - 200: { - description: "Switchable Console orgs", - content: { - "application/json": { - schema: resolver(ConsoleOrgList), - }, - }, - }, - }, - }), - async (c) => - jsonRequest("ExperimentalRoutes.console.listOrgs", c, function* () { - const account = yield* Account.Service - const [groups, active] = yield* Effect.all([account.orgsByAccount(), account.active()], { - concurrency: "unbounded", - }) - const info = Option.getOrUndefined(active) - const orgs = groups.flatMap((group) => - group.orgs.map((org) => ({ - accountID: group.account.id, - accountEmail: group.account.email, - accountUrl: group.account.url, - orgID: org.id, - orgName: org.name, - active: !!info && info.id === group.account.id && info.active_org_id === org.id, - })), - ) - return { orgs } - }), - ) - .post( - "/console/switch", - describeRoute({ - summary: "Switch active Console org", - description: "Persist a new active Console account/org selection for the current local OpenCode state.", - operationId: "experimental.console.switchOrg", - responses: { - 200: { - description: "Switch success", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - }, - }), - validator("json", ConsoleSwitchBody), - async (c) => - jsonRequest("ExperimentalRoutes.console.switchOrg", c, function* () { - const body = c.req.valid("json") - const account = yield* Account.Service - yield* account.use(AccountID.make(body.accountID), Option.some(OrgID.make(body.orgID))) - return true - }), - ) - .get( - "/tool/ids", - describeRoute({ - summary: "List tool IDs", - description: - "Get a list of all available tool IDs, including both built-in tools and dynamically registered tools.", - operationId: "tool.ids", - responses: { - 200: { - description: "Tool IDs", - content: { - "application/json": { - schema: resolver(z.array(z.string()).meta({ ref: "ToolIDs" })), - }, - }, - }, - ...errors(400), - }, - }), - async (c) => - jsonRequest("ExperimentalRoutes.tool.ids", c, function* () { - const registry = yield* ToolRegistry.Service - return yield* registry.ids() - }), - ) - .get( - "/tool", - describeRoute({ - summary: "List tools", - description: - "Get a list of available tools with their JSON schema parameters for a specific provider and model combination.", - operationId: "tool.list", - responses: { - 200: { - description: "Tools", - content: { - "application/json": { - schema: resolver( - z - .array( - z - .object({ - id: z.string(), - description: z.string(), - parameters: z.any(), - }) - .meta({ ref: "ToolListItem" }), - ) - .meta({ ref: "ToolList" }), - ), - }, - }, - }, - ...errors(400), - }, - }), - validator( - "query", - z.object({ - provider: z.string(), - model: z.string(), - }), - ), - async (c) => { - const { provider, model } = c.req.valid("query") - const tools = await runRequest( - "ExperimentalRoutes.tool.list", - c, - Effect.gen(function* () { - const agents = yield* Agent.Service - const registry = yield* ToolRegistry.Service - return yield* registry.tools({ - providerID: ProviderID.make(provider), - modelID: ModelID.make(model), - agent: yield* agents.get(yield* agents.defaultAgent()), - }) - }), - ) - return c.json( - tools.map((t) => ({ - id: t.id, - description: t.description, - parameters: EffectZod.toJsonSchema(t.parameters), - })), - ) - }, - ) - .post( - "/worktree", - describeRoute({ - summary: "Create worktree", - description: "Create a new git worktree for the current project and run any configured startup scripts.", - operationId: "worktree.create", - responses: { - 200: { - description: "Worktree created", - content: { - "application/json": { - schema: resolver(Worktree.Info.zod), - }, - }, - }, - ...errors(400), - }, - }), - validator("json", Worktree.CreateInput.zod.optional()), - async (c) => - jsonRequest("ExperimentalRoutes.worktree.create", c, function* () { - const body = c.req.valid("json") - const svc = yield* Worktree.Service - return yield* svc.create(body) - }), - ) - .get( - "/worktree", - describeRoute({ - summary: "List worktrees", - description: "List all sandbox worktrees for the current project.", - operationId: "worktree.list", - responses: { - 200: { - description: "List of worktree directories", - content: { - "application/json": { - schema: resolver(z.array(z.string())), - }, - }, - }, - }, - }), - async (c) => - jsonRequest("ExperimentalRoutes.worktree.list", c, function* () { - const svc = yield* Project.Service - return yield* svc.sandboxes(Instance.project.id) - }), - ) - .delete( - "/worktree", - describeRoute({ - summary: "Remove worktree", - description: "Remove a git worktree and delete its branch.", - operationId: "worktree.remove", - responses: { - 200: { - description: "Worktree removed", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - ...errors(400), - }, - }), - validator("json", Worktree.RemoveInput.zod), - async (c) => - jsonRequest("ExperimentalRoutes.worktree.remove", c, function* () { - const body = c.req.valid("json") - const worktree = yield* Worktree.Service - const project = yield* Project.Service - yield* worktree.remove(body) - yield* project.removeSandbox(Instance.project.id, body.directory) - return true - }), - ) - .post( - "/worktree/reset", - describeRoute({ - summary: "Reset worktree", - description: "Reset a worktree branch to the primary default branch.", - operationId: "worktree.reset", - responses: { - 200: { - description: "Worktree reset", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - ...errors(400), - }, - }), - validator("json", Worktree.ResetInput.zod), - async (c) => - jsonRequest("ExperimentalRoutes.worktree.reset", c, function* () { - const body = c.req.valid("json") - const svc = yield* Worktree.Service - yield* svc.reset(body) - return true - }), - ) - .get( - "/session", - describeRoute({ - summary: "List sessions", - description: - "Get a list of all OpenCode sessions across projects, sorted by most recently updated. Archived sessions are excluded by default.", - operationId: "experimental.session.list", - responses: { - 200: { - description: "List of sessions", - content: { - "application/json": { - schema: resolver(Session.GlobalInfo.zod.array()), - }, - }, - }, - }, - }), - validator( - "query", - z.object({ - directory: z.string().optional().meta({ description: "Filter sessions by project directory" }), - roots: QueryBoolean.optional().meta({ description: "Only return root sessions (no parentID)" }), - start: z.coerce - .number() - .optional() - .meta({ description: "Filter sessions updated on or after this timestamp (milliseconds since epoch)" }), - cursor: z.coerce - .number() - .optional() - .meta({ description: "Return sessions updated before this timestamp (milliseconds since epoch)" }), - search: z.string().optional().meta({ description: "Filter sessions by title (case-insensitive)" }), - limit: z.coerce.number().optional().meta({ description: "Maximum number of sessions to return" }), - archived: QueryBoolean.optional().meta({ description: "Include archived sessions (default false)" }), - }), - ), - async (c) => { - const query = c.req.valid("query") - const limit = query.limit ?? 100 - const sessions: Session.GlobalInfo[] = [] - for await (const session of Session.listGlobal({ - directory: query.directory, - roots: queryBoolean(query.roots), - start: query.start, - cursor: query.cursor, - search: query.search, - limit: limit + 1, - archived: queryBoolean(query.archived), - })) { - sessions.push(session) - } - const hasMore = sessions.length > limit - const list = hasMore ? sessions.slice(0, limit) : sessions - if (hasMore && list.length > 0) { - c.header("x-next-cursor", String(list[list.length - 1].time.updated)) - } - return c.json(list) - }, - ) - .get( - "/resource", - describeRoute({ - summary: "Get MCP resources", - description: "Get all available MCP resources from connected servers. Optionally filter by name.", - operationId: "experimental.resource.list", - responses: { - 200: { - description: "MCP resources", - content: { - "application/json": { - schema: resolver(z.record(z.string(), MCP.Resource.zod)), - }, - }, - }, - }, - }), - async (c) => - jsonRequest("ExperimentalRoutes.resource.list", c, function* () { - const mcp = yield* MCP.Service - return yield* mcp.resources() - }), - ), -) diff --git a/packages/opencode/src/server/routes/instance/file.ts b/packages/opencode/src/server/routes/instance/file.ts deleted file mode 100644 index d0e9ee6186..0000000000 --- a/packages/opencode/src/server/routes/instance/file.ts +++ /dev/null @@ -1,190 +0,0 @@ -import { Hono } from "hono" -import { describeRoute, validator, resolver } from "hono-openapi" -import z from "zod" -import { File } from "@/file" -import { Ripgrep } from "@/file/ripgrep" -import { LSP } from "@/lsp/lsp" -import { Instance } from "@/project/instance" -import { lazy } from "@/util/lazy" -import { jsonRequest } from "./trace" - -export const FileRoutes = lazy(() => - new Hono() - .get( - "/find", - describeRoute({ - summary: "Find text", - description: "Search for text patterns across files in the project using ripgrep.", - operationId: "find.text", - responses: { - 200: { - description: "Matches", - content: { - "application/json": { - schema: resolver(Ripgrep.SearchMatch.zod.array()), - }, - }, - }, - }, - }), - validator( - "query", - z.object({ - pattern: z.string(), - }), - ), - async (c) => - jsonRequest("FileRoutes.findText", c, function* () { - const pattern = c.req.valid("query").pattern - const svc = yield* Ripgrep.Service - const result = yield* svc.search({ cwd: Instance.directory, pattern, limit: 10 }) - return result.items - }), - ) - .get( - "/find/file", - describeRoute({ - summary: "Find files", - description: "Search for files or directories by name or pattern in the project directory.", - operationId: "find.files", - responses: { - 200: { - description: "File paths", - content: { - "application/json": { - schema: resolver(z.string().array()), - }, - }, - }, - }, - }), - validator( - "query", - z.object({ - query: z.string(), - dirs: z.enum(["true", "false"]).optional(), - type: z.enum(["file", "directory"]).optional(), - limit: z.coerce.number().int().min(1).max(200).optional(), - }), - ), - async (c) => - jsonRequest("FileRoutes.findFile", c, function* () { - const query = c.req.valid("query") - const svc = yield* File.Service - return yield* svc.search({ - query: query.query, - limit: query.limit ?? 10, - dirs: query.dirs !== "false", - type: query.type, - }) - }), - ) - .get( - "/find/symbol", - describeRoute({ - summary: "Find symbols", - description: "Search for workspace symbols like functions, classes, and variables using LSP.", - operationId: "find.symbols", - responses: { - 200: { - description: "Symbols", - content: { - "application/json": { - schema: resolver(LSP.Symbol.zod.array()), - }, - }, - }, - }, - }), - validator( - "query", - z.object({ - query: z.string(), - }), - ), - async (c) => { - return c.json([]) - }, - ) - .get( - "/file", - describeRoute({ - summary: "List files", - description: "List files and directories in a specified path.", - operationId: "file.list", - responses: { - 200: { - description: "Files and directories", - content: { - "application/json": { - schema: resolver(File.Node.zod.array()), - }, - }, - }, - }, - }), - validator( - "query", - z.object({ - path: z.string(), - }), - ), - async (c) => - jsonRequest("FileRoutes.list", c, function* () { - const svc = yield* File.Service - return yield* svc.list(c.req.valid("query").path) - }), - ) - .get( - "/file/content", - describeRoute({ - summary: "Read file", - description: "Read the content of a specified file.", - operationId: "file.read", - responses: { - 200: { - description: "File content", - content: { - "application/json": { - schema: resolver(File.Content.zod), - }, - }, - }, - }, - }), - validator( - "query", - z.object({ - path: z.string(), - }), - ), - async (c) => - jsonRequest("FileRoutes.read", c, function* () { - const svc = yield* File.Service - return yield* svc.read(c.req.valid("query").path) - }), - ) - .get( - "/file/status", - describeRoute({ - summary: "Get file status", - description: "Get the git status of all files in the project.", - operationId: "file.status", - responses: { - 200: { - description: "File status", - content: { - "application/json": { - schema: resolver(File.Info.zod.array()), - }, - }, - }, - }, - }), - async (c) => - jsonRequest("FileRoutes.status", c, function* () { - const svc = yield* File.Service - return yield* svc.status() - }), - ), -) diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/tui.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/tui.ts index 8ab43f6654..3676fc1964 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/tui.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/tui.ts @@ -1,4 +1,5 @@ import { TuiEvent } from "@/cli/cmd/tui/event" +import { TuiRequest as TuiRequestPayload } from "@/server/shared/tui-control" import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "../middleware/authorization" @@ -9,10 +10,6 @@ import { described } from "./metadata" const root = "/tui" export const CommandPayload = Schema.Struct({ command: Schema.String }) -export const TuiRequestPayload = Schema.Struct({ - path: Schema.String, - body: Schema.Unknown, -}) const EventTuiPromptAppend = Schema.Struct({ type: Schema.Literal(TuiEvent.PromptAppend.type), properties: TuiEvent.PromptAppend.properties, diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index eac579d7dc..495497ecb4 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -80,7 +80,6 @@ import { instanceContextLayer, instanceRouterMiddleware } from "./middleware/ins import { workspaceRouterMiddleware, workspaceRoutingLayer } from "./middleware/workspace-routing" import { disposeMiddleware } from "./lifecycle" import { memoMap } from "@opencode-ai/core/effect/memo-map" -import * as ServerBackend from "@/server/backend" import { compressionLayer } from "./middleware/compression" import { corsVaryFix } from "./middleware/cors-vary" import { errorLayer } from "./middleware/error" @@ -91,8 +90,7 @@ export const context = Context.makeUnsafe(new Map()) const runtime = HttpRouter.middleware()( Effect.succeed((effect) => Effect.gen(function* () { - const selected = ServerBackend.select() - yield* Effect.annotateCurrentSpan(ServerBackend.attributes(ServerBackend.force(selected, "effect-httpapi"))) + yield* Effect.annotateCurrentSpan({ "opencode.server.backend": "effect-httpapi" }) return yield* effect }), ), diff --git a/packages/opencode/src/server/routes/instance/index.ts b/packages/opencode/src/server/routes/instance/index.ts deleted file mode 100644 index b6bf8baa74..0000000000 --- a/packages/opencode/src/server/routes/instance/index.ts +++ /dev/null @@ -1,502 +0,0 @@ -import { describeRoute, resolver, validator } from "hono-openapi" -import { Hono } from "hono" -import type { UpgradeWebSocket } from "hono/ws" -import { Context, Effect } from "effect" -import { Flag } from "@opencode-ai/core/flag/flag" -import z from "zod" -import { Format } from "@/format" -import { TuiRoutes } from "./tui" -import { Instance } from "@/project/instance" -import { InstanceRuntime } from "@/project/instance-runtime" -import { Vcs } from "@/project/vcs" -import { Agent } from "@/agent/agent" -import { Skill } from "@/skill" -import { Global } from "@opencode-ai/core/global" -import { LSP } from "@/lsp/lsp" -import { Command } from "@/command" -import { QuestionRoutes } from "./question" -import { PermissionRoutes } from "./permission" -import { ProjectRoutes } from "./project" -import { SessionRoutes } from "./session" -import { PtyRoutes } from "./pty" -import { McpRoutes } from "./mcp" -import { FileRoutes } from "./file" -import { ConfigRoutes } from "./config" -import { ExperimentalRoutes } from "./experimental" -import { ProviderRoutes } from "./provider" -import { EventRoutes } from "./event" -import { SyncRoutes } from "./sync" -import { InstanceMiddleware } from "./middleware" -import { jsonRequest, runRequest } from "./trace" -import { ExperimentalHttpApiServer } from "./httpapi/server" -import { EventPaths } from "./httpapi/event" -import { ExperimentalPaths } from "./httpapi/groups/experimental" -import { FilePaths } from "./httpapi/groups/file" -import { InstancePaths } from "./httpapi/groups/instance" -import { McpPaths } from "./httpapi/groups/mcp" -import { PtyPaths } from "./httpapi/groups/pty" -import { SessionPaths } from "./httpapi/groups/session" -import { SyncPaths } from "./httpapi/groups/sync" -import { TuiPaths } from "./httpapi/groups/tui" -import { WorkspacePaths } from "./httpapi/groups/workspace" -import type { CorsOptions } from "@/server/cors" -import { errors } from "@/server/error" - -export const InstanceRoutes = (upgrade: UpgradeWebSocket, opts?: CorsOptions): Hono => { - const app = new Hono() - const handler = ExperimentalHttpApiServer.webHandler(opts).handler - const context = Context.empty() as Context.Context - - app.all("/api/*", (c) => handler(c.req.raw, context)) - - if (Flag.OPENCODE_EXPERIMENTAL_HTTPAPI) { - app.get(EventPaths.event, (c) => handler(c.req.raw, context)) - app.get("/question", (c) => handler(c.req.raw, context)) - app.post("/question/:requestID/reply", (c) => handler(c.req.raw, context)) - app.post("/question/:requestID/reject", (c) => handler(c.req.raw, context)) - app.get("/permission", (c) => handler(c.req.raw, context)) - app.post("/permission/:requestID/reply", (c) => handler(c.req.raw, context)) - app.get("/config", (c) => handler(c.req.raw, context)) - app.patch("/config", (c) => handler(c.req.raw, context)) - app.get("/config/providers", (c) => handler(c.req.raw, context)) - app.get(ExperimentalPaths.console, (c) => handler(c.req.raw, context)) - app.get(ExperimentalPaths.consoleOrgs, (c) => handler(c.req.raw, context)) - app.post(ExperimentalPaths.consoleSwitch, (c) => handler(c.req.raw, context)) - app.get(ExperimentalPaths.tool, (c) => handler(c.req.raw, context)) - app.get(ExperimentalPaths.toolIDs, (c) => handler(c.req.raw, context)) - app.get(ExperimentalPaths.worktree, (c) => handler(c.req.raw, context)) - app.post(ExperimentalPaths.worktree, (c) => handler(c.req.raw, context)) - app.delete(ExperimentalPaths.worktree, (c) => handler(c.req.raw, context)) - app.post(ExperimentalPaths.worktreeReset, (c) => handler(c.req.raw, context)) - app.get(ExperimentalPaths.session, (c) => handler(c.req.raw, context)) - app.get(ExperimentalPaths.resource, (c) => handler(c.req.raw, context)) - app.get("/provider", (c) => handler(c.req.raw, context)) - app.get("/provider/auth", (c) => handler(c.req.raw, context)) - app.post("/provider/:providerID/oauth/authorize", (c) => handler(c.req.raw, context)) - app.post("/provider/:providerID/oauth/callback", (c) => handler(c.req.raw, context)) - app.get("/project", (c) => handler(c.req.raw, context)) - app.get("/project/current", (c) => handler(c.req.raw, context)) - app.post("/project/git/init", (c) => handler(c.req.raw, context)) - app.patch("/project/:projectID", (c) => handler(c.req.raw, context)) - app.get(FilePaths.findText, (c) => handler(c.req.raw, context)) - app.get(FilePaths.findFile, (c) => handler(c.req.raw, context)) - app.get(FilePaths.findSymbol, (c) => handler(c.req.raw, context)) - app.get(FilePaths.list, (c) => handler(c.req.raw, context)) - app.get(FilePaths.content, (c) => handler(c.req.raw, context)) - app.get(FilePaths.status, (c) => handler(c.req.raw, context)) - app.get(InstancePaths.path, (c) => handler(c.req.raw, context)) - app.post(InstancePaths.dispose, (c) => handler(c.req.raw, context)) - app.get(InstancePaths.vcs, (c) => handler(c.req.raw, context)) - app.get(InstancePaths.vcsStatus, (c) => handler(c.req.raw, context)) - app.get(InstancePaths.vcsDiff, (c) => handler(c.req.raw, context)) - app.get(InstancePaths.vcsDiffRaw, (c) => handler(c.req.raw, context)) - app.post(InstancePaths.vcsApply, (c) => handler(c.req.raw, context)) - app.get(InstancePaths.command, (c) => handler(c.req.raw, context)) - app.get(InstancePaths.agent, (c) => handler(c.req.raw, context)) - app.get(InstancePaths.skill, (c) => handler(c.req.raw, context)) - app.get(InstancePaths.lsp, (c) => handler(c.req.raw, context)) - app.get(InstancePaths.formatter, (c) => handler(c.req.raw, context)) - app.get(McpPaths.status, (c) => handler(c.req.raw, context)) - app.post(McpPaths.status, (c) => handler(c.req.raw, context)) - app.post(McpPaths.auth, (c) => handler(c.req.raw, context)) - app.post(McpPaths.authCallback, (c) => handler(c.req.raw, context)) - app.post(McpPaths.authAuthenticate, (c) => handler(c.req.raw, context)) - app.delete(McpPaths.auth, (c) => handler(c.req.raw, context)) - app.post(McpPaths.connect, (c) => handler(c.req.raw, context)) - app.post(McpPaths.disconnect, (c) => handler(c.req.raw, context)) - app.post(SyncPaths.start, (c) => handler(c.req.raw, context)) - app.post(SyncPaths.replay, (c) => handler(c.req.raw, context)) - app.post(SyncPaths.history, (c) => handler(c.req.raw, context)) - app.get(PtyPaths.list, (c) => handler(c.req.raw, context)) - app.post(PtyPaths.create, (c) => handler(c.req.raw, context)) - app.get(PtyPaths.get, (c) => handler(c.req.raw, context)) - app.put(PtyPaths.update, (c) => handler(c.req.raw, context)) - app.delete(PtyPaths.remove, (c) => handler(c.req.raw, context)) - app.post(PtyPaths.connectToken, (c) => handler(c.req.raw, context)) - app.get(PtyPaths.connect, (c) => handler(c.req.raw, context)) - app.get(SessionPaths.list, (c) => handler(c.req.raw, context)) - app.get(SessionPaths.status, (c) => handler(c.req.raw, context)) - app.get(SessionPaths.get, (c) => handler(c.req.raw, context)) - app.get(SessionPaths.children, (c) => handler(c.req.raw, context)) - app.get(SessionPaths.todo, (c) => handler(c.req.raw, context)) - app.get(SessionPaths.diff, (c) => handler(c.req.raw, context)) - app.get(SessionPaths.messages, (c) => handler(c.req.raw, context)) - app.get(SessionPaths.message, (c) => handler(c.req.raw, context)) - app.post(SessionPaths.create, (c) => handler(c.req.raw, context)) - app.delete(SessionPaths.remove, (c) => handler(c.req.raw, context)) - app.patch(SessionPaths.update, (c) => handler(c.req.raw, context)) - app.post(SessionPaths.init, (c) => handler(c.req.raw, context)) - app.post(SessionPaths.fork, (c) => handler(c.req.raw, context)) - app.post(SessionPaths.abort, (c) => handler(c.req.raw, context)) - app.post(SessionPaths.share, (c) => handler(c.req.raw, context)) - app.delete(SessionPaths.share, (c) => handler(c.req.raw, context)) - app.post(SessionPaths.summarize, (c) => handler(c.req.raw, context)) - app.post(SessionPaths.prompt, (c) => handler(c.req.raw, context)) - app.post(SessionPaths.promptAsync, (c) => handler(c.req.raw, context)) - app.post(SessionPaths.command, (c) => handler(c.req.raw, context)) - app.post(SessionPaths.shell, (c) => handler(c.req.raw, context)) - app.post(SessionPaths.revert, (c) => handler(c.req.raw, context)) - app.post(SessionPaths.unrevert, (c) => handler(c.req.raw, context)) - app.post(SessionPaths.permissions, (c) => handler(c.req.raw, context)) - app.delete(SessionPaths.deleteMessage, (c) => handler(c.req.raw, context)) - app.delete(SessionPaths.deletePart, (c) => handler(c.req.raw, context)) - app.patch(SessionPaths.updatePart, (c) => handler(c.req.raw, context)) - app.post(TuiPaths.appendPrompt, (c) => handler(c.req.raw, context)) - app.post(TuiPaths.openHelp, (c) => handler(c.req.raw, context)) - app.post(TuiPaths.openSessions, (c) => handler(c.req.raw, context)) - app.post(TuiPaths.openThemes, (c) => handler(c.req.raw, context)) - app.post(TuiPaths.openModels, (c) => handler(c.req.raw, context)) - app.post(TuiPaths.submitPrompt, (c) => handler(c.req.raw, context)) - app.post(TuiPaths.clearPrompt, (c) => handler(c.req.raw, context)) - app.post(TuiPaths.executeCommand, (c) => handler(c.req.raw, context)) - app.post(TuiPaths.showToast, (c) => handler(c.req.raw, context)) - app.post(TuiPaths.publish, (c) => handler(c.req.raw, context)) - app.post(TuiPaths.selectSession, (c) => handler(c.req.raw, context)) - app.get(TuiPaths.controlNext, (c) => handler(c.req.raw, context)) - app.post(TuiPaths.controlResponse, (c) => handler(c.req.raw, context)) - app.get(WorkspacePaths.adapters, (c) => handler(c.req.raw, context)) - app.post(WorkspacePaths.list, (c) => handler(c.req.raw, context)) - app.get(WorkspacePaths.list, (c) => handler(c.req.raw, context)) - app.get(WorkspacePaths.status, (c) => handler(c.req.raw, context)) - app.delete(WorkspacePaths.remove, (c) => handler(c.req.raw, context)) - app.post(WorkspacePaths.warp, (c) => handler(c.req.raw, context)) - } - - return app - .route("/project", ProjectRoutes()) - .route("/pty", PtyRoutes(upgrade, opts)) - .route("/config", ConfigRoutes()) - .route("/experimental", ExperimentalRoutes()) - .route("/session", SessionRoutes()) - .route("/permission", PermissionRoutes()) - .route("/question", QuestionRoutes()) - .route("/provider", ProviderRoutes()) - .route("/sync", SyncRoutes()) - .route("/", FileRoutes()) - .route("/", EventRoutes()) - .route("/mcp", McpRoutes()) - .route("/tui", TuiRoutes()) - .post( - "/instance/dispose", - describeRoute({ - summary: "Dispose instance", - description: "Clean up and dispose the current OpenCode instance, releasing all resources.", - operationId: "instance.dispose", - responses: { - 200: { - description: "Instance disposed", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - }, - }), - async (c) => { - await InstanceRuntime.disposeInstance(Instance.current) - return c.json(true) - }, - ) - .get( - "/path", - describeRoute({ - summary: "Get paths", - description: "Retrieve the current working directory and related path information for the OpenCode instance.", - operationId: "path.get", - responses: { - 200: { - description: "Path", - content: { - "application/json": { - schema: resolver( - z - .object({ - home: z.string(), - state: z.string(), - config: z.string(), - worktree: z.string(), - directory: z.string(), - }) - .meta({ - ref: "Path", - }), - ), - }, - }, - }, - }, - }), - async (c) => { - return c.json({ - home: Global.Path.home, - state: Global.Path.state, - config: Global.Path.config, - worktree: Instance.worktree, - directory: Instance.directory, - }) - }, - ) - .get( - "/vcs", - describeRoute({ - summary: "Get VCS info", - description: "Retrieve version control system (VCS) information for the current project, such as git branch.", - operationId: "vcs.get", - responses: { - 200: { - description: "VCS info", - content: { - "application/json": { - schema: resolver(Vcs.Info.zod), - }, - }, - }, - }, - }), - async (c) => - jsonRequest("InstanceRoutes.vcs.get", c, function* () { - const vcs = yield* Vcs.Service - const [branch, default_branch] = yield* Effect.all([vcs.branch(), vcs.defaultBranch()], { - concurrency: 2, - }) - return { branch, default_branch } - }), - ) - .get( - "/vcs/diff", - describeRoute({ - summary: "Get VCS diff", - description: "Retrieve the current git diff for the working tree or against the default branch.", - operationId: "vcs.diff", - responses: { - 200: { - description: "VCS diff", - content: { - "application/json": { - schema: resolver(Vcs.FileDiff.zod.array()), - }, - }, - }, - }, - }), - validator( - "query", - z.object({ - mode: Vcs.Mode.zod, - }), - ), - async (c) => - jsonRequest("InstanceRoutes.vcs.diff", c, function* () { - const vcs = yield* Vcs.Service - return yield* vcs.diff(c.req.valid("query").mode) - }), - ) - .get( - "/vcs/status", - describeRoute({ - summary: "Get VCS status", - description: "Retrieve changed files in the current working tree without patches.", - operationId: "vcs.status", - responses: { - 200: { - description: "VCS status", - content: { - "application/json": { - schema: resolver(Vcs.FileStatus.zod.array()), - }, - }, - }, - }, - }), - async (c) => - jsonRequest("InstanceRoutes.vcs.status", c, function* () { - const vcs = yield* Vcs.Service - return yield* vcs.status() - }), - ) - .get( - "/vcs/diff/raw", - describeRoute({ - summary: "Get raw VCS diff", - description: "Retrieve a raw patch for current uncommitted changes.", - operationId: "vcs.diff.raw", - responses: { - 200: { - description: "Raw VCS diff", - content: { - "text/x-diff": { - schema: resolver(z.string()), - }, - }, - }, - }, - }), - async (c) => { - const patch = await runRequest( - "InstanceRoutes.vcs.diffRaw", - c, - Vcs.Service.use((vcs) => vcs.diffRaw()), - ) - return c.text(patch, 200, { "content-type": "text/x-diff; charset=utf-8" }) - }, - ) - .post( - "/vcs/apply", - describeRoute({ - summary: "Apply VCS patch", - description: "Apply a raw patch to the current working tree.", - operationId: "vcs.apply", - responses: { - 200: { - description: "VCS patch applied", - content: { - "application/json": { - schema: resolver(Vcs.ApplyResult.zod), - }, - }, - }, - ...errors(400), - }, - }), - validator("json", Vcs.ApplyInput.zodObject), - async (c) => { - const result = await runRequest( - "InstanceRoutes.vcs.apply", - c, - Vcs.Service.use((vcs) => vcs.apply(c.req.valid("json") as Vcs.ApplyInput)).pipe( - Effect.match({ - onFailure: (error) => ({ ok: false as const, error }), - onSuccess: (value) => ({ ok: true as const, value }), - }), - ), - ) - if (result.ok) return c.json(result.value) - return c.json( - { - name: "VcsApplyError", - data: { - message: result.error.message, - reason: result.error.reason, - }, - }, - 400, - ) - }, - ) - .get( - "/command", - describeRoute({ - summary: "List commands", - description: "Get a list of all available commands in the OpenCode system.", - operationId: "command.list", - responses: { - 200: { - description: "List of commands", - content: { - "application/json": { - schema: resolver(Command.Info.zod.array()), - }, - }, - }, - }, - }), - async (c) => - jsonRequest("InstanceRoutes.command.list", c, function* () { - const svc = yield* Command.Service - return yield* svc.list() - }), - ) - .get( - "/agent", - describeRoute({ - summary: "List agents", - description: "Get a list of all available AI agents in the OpenCode system.", - operationId: "app.agents", - responses: { - 200: { - description: "List of agents", - content: { - "application/json": { - schema: resolver(Agent.Info.zod.array()), - }, - }, - }, - }, - }), - async (c) => - jsonRequest("InstanceRoutes.agent.list", c, function* () { - const svc = yield* Agent.Service - return yield* svc.list() - }), - ) - .get( - "/skill", - describeRoute({ - summary: "List skills", - description: "Get a list of all available skills in the OpenCode system.", - operationId: "app.skills", - responses: { - 200: { - description: "List of skills", - content: { - "application/json": { - schema: resolver(Skill.Info.zod.array()), - }, - }, - }, - }, - }), - async (c) => - jsonRequest("InstanceRoutes.skill.list", c, function* () { - const skill = yield* Skill.Service - return yield* skill.all() - }), - ) - .get( - "/lsp", - describeRoute({ - summary: "Get LSP status", - description: "Get LSP server status", - operationId: "lsp.status", - responses: { - 200: { - description: "LSP server status", - content: { - "application/json": { - schema: resolver(LSP.Status.zod.array()), - }, - }, - }, - }, - }), - async (c) => - jsonRequest("InstanceRoutes.lsp.status", c, function* () { - const lsp = yield* LSP.Service - return yield* lsp.status() - }), - ) - .get( - "/formatter", - describeRoute({ - summary: "Get formatter status", - description: "Get formatter status", - operationId: "formatter.status", - responses: { - 200: { - description: "Formatter status", - content: { - "application/json": { - schema: resolver(Format.Status.zod.array()), - }, - }, - }, - }, - }), - async (c) => - jsonRequest("InstanceRoutes.formatter.status", c, function* () { - const svc = yield* Format.Service - return yield* svc.status() - }), - ) -} diff --git a/packages/opencode/src/server/routes/instance/mcp.ts b/packages/opencode/src/server/routes/instance/mcp.ts deleted file mode 100644 index d5542f042b..0000000000 --- a/packages/opencode/src/server/routes/instance/mcp.ts +++ /dev/null @@ -1,277 +0,0 @@ -import { Hono } from "hono" -import { describeRoute, validator, resolver } from "hono-openapi" -import z from "zod" -import { MCP } from "@/mcp" -import { ConfigMCP } from "@/config/mcp" -import { errors } from "../../error" -import { lazy } from "@/util/lazy" -import { Effect } from "effect" -import { jsonRequest, runRequest } from "./trace" - -const UnsupportedOAuthError = z - .object({ - error: z.string(), - }) - .meta({ ref: "McpUnsupportedOAuthError" }) - -const unsupportedOAuthErrorResponse = { - description: "MCP server does not support OAuth", - content: { - "application/json": { - schema: resolver(UnsupportedOAuthError), - }, - }, -} - -export const McpRoutes = lazy(() => - new Hono() - .get( - "/", - describeRoute({ - summary: "Get MCP status", - description: "Get the status of all Model Context Protocol (MCP) servers.", - operationId: "mcp.status", - responses: { - 200: { - description: "MCP server status", - content: { - "application/json": { - schema: resolver(z.record(z.string(), MCP.Status.zod)), - }, - }, - }, - }, - }), - async (c) => - jsonRequest("McpRoutes.status", c, function* () { - const mcp = yield* MCP.Service - return yield* mcp.status() - }), - ) - .post( - "/", - describeRoute({ - summary: "Add MCP server", - description: "Dynamically add a new Model Context Protocol (MCP) server to the system.", - operationId: "mcp.add", - responses: { - 200: { - description: "MCP server added successfully", - content: { - "application/json": { - schema: resolver(z.record(z.string(), MCP.Status.zod)), - }, - }, - }, - ...errors(400), - }, - }), - validator( - "json", - z.object({ - name: z.string(), - config: ConfigMCP.Info.zod, - }), - ), - async (c) => - jsonRequest("McpRoutes.add", c, function* () { - const { name, config } = c.req.valid("json") - const mcp = yield* MCP.Service - const result = yield* mcp.add(name, config) - return result.status - }), - ) - .post( - "/:name/auth", - describeRoute({ - summary: "Start MCP OAuth", - description: "Start OAuth authentication flow for a Model Context Protocol (MCP) server.", - operationId: "mcp.auth.start", - responses: { - 200: { - description: "OAuth flow started", - content: { - "application/json": { - schema: resolver( - z.object({ - authorizationUrl: z.string().describe("URL to open in browser for authorization"), - }), - ), - }, - }, - }, - 400: unsupportedOAuthErrorResponse, - ...errors(404), - }, - }), - async (c) => { - const name = c.req.param("name") - const result = await runRequest( - "McpRoutes.auth.start", - c, - Effect.gen(function* () { - const mcp = yield* MCP.Service - const supports = yield* mcp.supportsOAuth(name) - if (!supports) return { supports } - return { - supports, - auth: yield* mcp.startAuth(name), - } - }), - ) - if (!result.supports) { - return c.json({ error: `MCP server ${name} does not support OAuth` }, 400) - } - return c.json(result.auth) - }, - ) - .post( - "/:name/auth/callback", - describeRoute({ - summary: "Complete MCP OAuth", - description: - "Complete OAuth authentication for a Model Context Protocol (MCP) server using the authorization code.", - operationId: "mcp.auth.callback", - responses: { - 200: { - description: "OAuth authentication completed", - content: { - "application/json": { - schema: resolver(MCP.Status.zod), - }, - }, - }, - ...errors(400, 404), - }, - }), - validator( - "json", - z.object({ - code: z.string().describe("Authorization code from OAuth callback"), - }), - ), - async (c) => - jsonRequest("McpRoutes.auth.callback", c, function* () { - const name = c.req.param("name") - const { code } = c.req.valid("json") - const mcp = yield* MCP.Service - return yield* mcp.finishAuth(name, code) - }), - ) - .post( - "/:name/auth/authenticate", - describeRoute({ - summary: "Authenticate MCP OAuth", - description: "Start OAuth flow and wait for callback (opens browser)", - operationId: "mcp.auth.authenticate", - responses: { - 200: { - description: "OAuth authentication completed", - content: { - "application/json": { - schema: resolver(MCP.Status.zod), - }, - }, - }, - 400: unsupportedOAuthErrorResponse, - ...errors(404), - }, - }), - async (c) => { - const name = c.req.param("name") - const result = await runRequest( - "McpRoutes.auth.authenticate", - c, - Effect.gen(function* () { - const mcp = yield* MCP.Service - const supports = yield* mcp.supportsOAuth(name) - if (!supports) return { supports } - return { - supports, - status: yield* mcp.authenticate(name), - } - }), - ) - if (!result.supports) { - return c.json({ error: `MCP server ${name} does not support OAuth` }, 400) - } - return c.json(result.status) - }, - ) - .delete( - "/:name/auth", - describeRoute({ - summary: "Remove MCP OAuth", - description: "Remove OAuth credentials for an MCP server", - operationId: "mcp.auth.remove", - responses: { - 200: { - description: "OAuth credentials removed", - content: { - "application/json": { - schema: resolver(z.object({ success: z.literal(true) })), - }, - }, - }, - ...errors(404), - }, - }), - async (c) => - jsonRequest("McpRoutes.auth.remove", c, function* () { - const name = c.req.param("name") - const mcp = yield* MCP.Service - yield* mcp.removeAuth(name) - return { success: true as const } - }), - ) - .post( - "/:name/connect", - describeRoute({ - description: "Connect an MCP server", - operationId: "mcp.connect", - responses: { - 200: { - description: "MCP server connected successfully", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - }, - }), - validator("param", z.object({ name: z.string() })), - async (c) => - jsonRequest("McpRoutes.connect", c, function* () { - const { name } = c.req.valid("param") - const mcp = yield* MCP.Service - yield* mcp.connect(name) - return true - }), - ) - .post( - "/:name/disconnect", - describeRoute({ - description: "Disconnect an MCP server", - operationId: "mcp.disconnect", - responses: { - 200: { - description: "MCP server disconnected successfully", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - }, - }), - validator("param", z.object({ name: z.string() })), - async (c) => - jsonRequest("McpRoutes.disconnect", c, function* () { - const { name } = c.req.valid("param") - const mcp = yield* MCP.Service - yield* mcp.disconnect(name) - return true - }), - ), -) diff --git a/packages/opencode/src/server/routes/instance/middleware.ts b/packages/opencode/src/server/routes/instance/middleware.ts deleted file mode 100644 index 23707faf79..0000000000 --- a/packages/opencode/src/server/routes/instance/middleware.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type { MiddlewareHandler } from "hono" -import { WithInstance } from "@/project/with-instance" -import { AppFileSystem } from "@opencode-ai/core/filesystem" -import { WorkspaceContext } from "@/control-plane/workspace-context" -import { WorkspaceID } from "@/control-plane/schema" - -export function InstanceMiddleware(workspaceID?: WorkspaceID): MiddlewareHandler { - return async (c, next) => { - const raw = c.req.query("directory") || c.req.header("x-opencode-directory") || process.cwd() - const directory = AppFileSystem.resolve( - (() => { - try { - return decodeURIComponent(raw) - } catch { - return raw - } - })(), - ) - - return WorkspaceContext.provide({ - workspaceID, - async fn() { - return WithInstance.provide({ - directory, - async fn() { - return next() - }, - }) - }, - }) - } -} diff --git a/packages/opencode/src/server/routes/instance/permission.ts b/packages/opencode/src/server/routes/instance/permission.ts deleted file mode 100644 index c18f4734b4..0000000000 --- a/packages/opencode/src/server/routes/instance/permission.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { Hono } from "hono" -import { describeRoute, validator, resolver } from "hono-openapi" -import z from "zod" -import { Permission } from "@/permission" -import { PermissionID } from "@/permission/schema" -import { errors } from "../../error" -import { lazy } from "@/util/lazy" -import { jsonRequest } from "./trace" - -export const PermissionRoutes = lazy(() => - new Hono() - .post( - "/:requestID/reply", - describeRoute({ - summary: "Respond to permission request", - description: "Approve or deny a permission request from the AI assistant.", - operationId: "permission.reply", - responses: { - 200: { - description: "Permission processed successfully", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - ...errors(400, 404), - }, - }), - validator( - "param", - z.object({ - requestID: PermissionID.zod, - }), - ), - validator("json", z.object({ reply: Permission.Reply.zod, message: z.string().optional() })), - async (c) => - jsonRequest("PermissionRoutes.reply", c, function* () { - const params = c.req.valid("param") - const json = c.req.valid("json") - const svc = yield* Permission.Service - yield* svc.reply({ - requestID: params.requestID, - reply: json.reply, - message: json.message, - }) - return true - }), - ) - .get( - "/", - describeRoute({ - summary: "List pending permissions", - description: "Get all pending permission requests across all sessions.", - operationId: "permission.list", - responses: { - 200: { - description: "List of pending permissions", - content: { - "application/json": { - schema: resolver(Permission.Request.zod.array()), - }, - }, - }, - }, - }), - async (c) => - jsonRequest("PermissionRoutes.list", c, function* () { - const svc = yield* Permission.Service - return yield* svc.list() - }), - ), -) diff --git a/packages/opencode/src/server/routes/instance/project.ts b/packages/opencode/src/server/routes/instance/project.ts deleted file mode 100644 index 3d8bb605bd..0000000000 --- a/packages/opencode/src/server/routes/instance/project.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { Hono } from "hono" -import { describeRoute, validator } from "hono-openapi" -import { resolver } from "hono-openapi" -import { Instance } from "@/project/instance" -import { InstanceRuntime } from "@/project/instance-runtime" -import { Project } from "@/project/project" -import z from "zod" -import { ProjectID } from "@/project/schema" -import { errors } from "../../error" -import { lazy } from "@/util/lazy" -import { jsonRequest, runRequest } from "./trace" - -export const ProjectRoutes = lazy(() => - new Hono() - .get( - "/", - describeRoute({ - summary: "List all projects", - description: "Get a list of projects that have been opened with OpenCode.", - operationId: "project.list", - responses: { - 200: { - description: "List of projects", - content: { - "application/json": { - schema: resolver(Project.Info.zod.array()), - }, - }, - }, - }, - }), - async (c) => { - const projects = Project.list() - return c.json(projects) - }, - ) - .get( - "/current", - describeRoute({ - summary: "Get current project", - description: "Retrieve the currently active project that OpenCode is working with.", - operationId: "project.current", - responses: { - 200: { - description: "Current project information", - content: { - "application/json": { - schema: resolver(Project.Info.zod), - }, - }, - }, - }, - }), - async (c) => { - return c.json(Instance.project) - }, - ) - .post( - "/git/init", - describeRoute({ - summary: "Initialize git repository", - description: "Create a git repository for the current project and return the refreshed project info.", - operationId: "project.initGit", - responses: { - 200: { - description: "Project information after git initialization", - content: { - "application/json": { - schema: resolver(Project.Info.zod), - }, - }, - }, - }, - }), - async (c) => { - const dir = Instance.directory - const prev = Instance.project - const next = await runRequest( - "ProjectRoutes.initGit", - c, - Project.Service.use((svc) => svc.initGit({ directory: dir, project: prev })), - ) - if (next.id === prev.id && next.vcs === prev.vcs && next.worktree === prev.worktree) return c.json(next) - await InstanceRuntime.reloadInstance({ directory: dir, worktree: dir, project: next }) - return c.json(next) - }, - ) - .patch( - "/:projectID", - describeRoute({ - summary: "Update project", - description: "Update project properties such as name, icon, and commands.", - operationId: "project.update", - responses: { - 200: { - description: "Updated project information", - content: { - "application/json": { - schema: resolver(Project.Info.zod), - }, - }, - }, - ...errors(400, 404), - }, - }), - validator("param", z.object({ projectID: ProjectID.zod })), - validator("json", Project.UpdateInput.omit({ projectID: true })), - async (c) => - jsonRequest("ProjectRoutes.update", c, function* () { - const projectID = c.req.valid("param").projectID - const body = c.req.valid("json") - const svc = yield* Project.Service - return yield* svc.update({ ...body, projectID }) - }), - ), -) diff --git a/packages/opencode/src/server/routes/instance/provider.ts b/packages/opencode/src/server/routes/instance/provider.ts deleted file mode 100644 index 8ff7bc3103..0000000000 --- a/packages/opencode/src/server/routes/instance/provider.ts +++ /dev/null @@ -1,158 +0,0 @@ -import { Hono } from "hono" -import { describeRoute, validator, resolver } from "hono-openapi" -import z from "zod" -import { Config } from "@/config/config" -import { Provider } from "@/provider/provider" -import { ModelsDev } from "@/provider/models" -import { ProviderAuth } from "@/provider/auth" -import { ProviderID } from "@/provider/schema" -import { mapValues } from "remeda" -import { errors } from "../../error" -import { lazy } from "@/util/lazy" -import { Effect } from "effect" -import { jsonRequest } from "./trace" - -export const ProviderRoutes = lazy(() => - new Hono() - .get( - "/", - describeRoute({ - summary: "List providers", - description: "Get a list of all available AI providers, including both available and connected ones.", - operationId: "provider.list", - responses: { - 200: { - description: "List of providers", - content: { - "application/json": { - schema: resolver(Provider.ListResult.zod), - }, - }, - }, - }, - }), - async (c) => - jsonRequest("ProviderRoutes.list", c, function* () { - const svc = yield* Provider.Service - const cfg = yield* Config.Service - const config = yield* cfg.get() - const all = yield* ModelsDev.Service.use((s) => s.get()) - const disabled = new Set(config.disabled_providers ?? []) - const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined - const filtered: Record = {} - for (const [key, value] of Object.entries(all)) { - if ((enabled ? enabled.has(key) : true) && !disabled.has(key)) { - filtered[key] = value - } - } - const connected = yield* svc.list() - const providers = Object.assign( - mapValues(filtered, (x) => Provider.fromModelsDevProvider(x)), - connected, - ) - return { - all: Object.values(providers), - default: Provider.defaultModelIDs(providers), - connected: Object.keys(connected), - } - }), - ) - .get( - "/auth", - describeRoute({ - summary: "Get provider auth methods", - description: "Retrieve available authentication methods for all AI providers.", - operationId: "provider.auth", - responses: { - 200: { - description: "Provider auth methods", - content: { - "application/json": { - schema: resolver(ProviderAuth.Methods.zod), - }, - }, - }, - }, - }), - async (c) => - jsonRequest("ProviderRoutes.auth", c, function* () { - const svc = yield* ProviderAuth.Service - return yield* svc.methods() - }), - ) - .post( - "/:providerID/oauth/authorize", - describeRoute({ - summary: "OAuth authorize", - description: "Initiate OAuth authorization for a specific AI provider to get an authorization URL.", - operationId: "provider.oauth.authorize", - responses: { - 200: { - description: "Authorization URL and method", - content: { - "application/json": { - schema: resolver(ProviderAuth.Authorization.zod.optional()), - }, - }, - }, - ...errors(400), - }, - }), - validator( - "param", - z.object({ - providerID: ProviderID.zod.meta({ description: "Provider ID" }), - }), - ), - validator("json", ProviderAuth.AuthorizeInput.zod), - async (c) => - jsonRequest("ProviderRoutes.oauth.authorize", c, function* () { - const providerID = c.req.valid("param").providerID - const { method, inputs } = c.req.valid("json") - const svc = yield* ProviderAuth.Service - return yield* svc.authorize({ - providerID, - method, - inputs, - }) - }), - ) - .post( - "/:providerID/oauth/callback", - describeRoute({ - summary: "OAuth callback", - description: "Handle the OAuth callback from a provider after user authorization.", - operationId: "provider.oauth.callback", - responses: { - 200: { - description: "OAuth callback processed successfully", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - ...errors(400), - }, - }), - validator( - "param", - z.object({ - providerID: ProviderID.zod.meta({ description: "Provider ID" }), - }), - ), - validator("json", ProviderAuth.CallbackInput.zod), - async (c) => - jsonRequest("ProviderRoutes.oauth.callback", c, function* () { - const providerID = c.req.valid("param").providerID - const { method, code } = c.req.valid("json") - const svc = yield* ProviderAuth.Service - yield* svc.callback({ - providerID, - method, - code, - }) - return true - }), - ), -) diff --git a/packages/opencode/src/server/routes/instance/pty.ts b/packages/opencode/src/server/routes/instance/pty.ts deleted file mode 100644 index fb8d5e356d..0000000000 --- a/packages/opencode/src/server/routes/instance/pty.ts +++ /dev/null @@ -1,340 +0,0 @@ -import { Hono } from "hono" -import type { Context } from "hono" -import { describeRoute, validator, resolver } from "hono-openapi" -import type { UpgradeWebSocket } from "hono/ws" -import { Effect, Schema } from "effect" -import z from "zod" -import { AppRuntime } from "@/effect/app-runtime" -import { Pty } from "@/pty" -import { PtyID } from "@/pty/schema" -import { PtyTicket } from "@/pty/ticket" -import { Shell } from "@/shell/shell" -import { NotFoundError } from "@/storage/storage" -import { errors } from "../../error" -import { jsonRequest, runRequest } from "./trace" -import { HTTPException } from "hono/http-exception" -import { isAllowedRequestOrigin, type CorsOptions } from "@/server/cors" -import { - PTY_CONNECT_TICKET_QUERY, - PTY_CONNECT_TOKEN_HEADER, - PTY_CONNECT_TOKEN_HEADER_VALUE, -} from "@/server/shared/pty-ticket" -import { zod as effectZod } from "@/util/effect-zod" - -const ShellItem = z.object({ - path: z.string(), - name: z.string(), - acceptable: z.boolean(), -}) -const decodePtyID = Schema.decodeUnknownSync(PtyID) - -function validOrigin(c: Context, opts?: CorsOptions) { - return isAllowedRequestOrigin(c.req.header("origin"), c.req.header("host"), opts) -} - -export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket, opts?: CorsOptions) { - return new Hono() - .get( - "/shells", - describeRoute({ - summary: "List available shells", - description: "Get a list of available shells on the system.", - operationId: "pty.shells", - responses: { - 200: { - description: "List of shells", - content: { - "application/json": { - schema: resolver(z.array(ShellItem)), - }, - }, - }, - }, - }), - async (c) => { - return c.json(await Shell.list()) - }, - ) - .get( - "/", - describeRoute({ - summary: "List PTY sessions", - description: "Get a list of all active pseudo-terminal (PTY) sessions managed by OpenCode.", - operationId: "pty.list", - responses: { - 200: { - description: "List of sessions", - content: { - "application/json": { - schema: resolver(Pty.Info.zod.array()), - }, - }, - }, - }, - }), - async (c) => - jsonRequest("PtyRoutes.list", c, function* () { - const pty = yield* Pty.Service - return yield* pty.list() - }), - ) - .post( - "/", - describeRoute({ - summary: "Create PTY session", - description: "Create a new pseudo-terminal (PTY) session for running shell commands and processes.", - operationId: "pty.create", - responses: { - 200: { - description: "Created session", - content: { - "application/json": { - schema: resolver(Pty.Info.zod), - }, - }, - }, - ...errors(400), - }, - }), - validator("json", Pty.CreateInput.zod), - async (c) => - jsonRequest("PtyRoutes.create", c, function* () { - const pty = yield* Pty.Service - return yield* pty.create(c.req.valid("json") as Pty.CreateInput) - }), - ) - .get( - "/:ptyID", - describeRoute({ - summary: "Get PTY session", - description: "Retrieve detailed information about a specific pseudo-terminal (PTY) session.", - operationId: "pty.get", - responses: { - 200: { - description: "Session info", - content: { - "application/json": { - schema: resolver(Pty.Info.zod), - }, - }, - }, - ...errors(404), - }, - }), - validator("param", z.object({ ptyID: PtyID.zod })), - async (c) => { - const info = await runRequest( - "PtyRoutes.get", - c, - Effect.gen(function* () { - const pty = yield* Pty.Service - return yield* pty.get(c.req.valid("param").ptyID) - }), - ) - if (!info) { - throw new NotFoundError({ message: "Session not found" }) - } - return c.json(info) - }, - ) - .put( - "/:ptyID", - describeRoute({ - summary: "Update PTY session", - description: "Update properties of an existing pseudo-terminal (PTY) session.", - operationId: "pty.update", - responses: { - 200: { - description: "Updated session", - content: { - "application/json": { - schema: resolver(Pty.Info.zod), - }, - }, - }, - ...errors(400), - }, - }), - validator("param", z.object({ ptyID: PtyID.zod })), - validator("json", Pty.UpdateInput.zod), - async (c) => - jsonRequest("PtyRoutes.update", c, function* () { - const pty = yield* Pty.Service - return yield* pty.update(c.req.valid("param").ptyID, c.req.valid("json") as Pty.UpdateInput) - }), - ) - .delete( - "/:ptyID", - describeRoute({ - summary: "Remove PTY session", - description: "Remove and terminate a specific pseudo-terminal (PTY) session.", - operationId: "pty.remove", - responses: { - 200: { - description: "Session removed", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - ...errors(404), - }, - }), - validator("param", z.object({ ptyID: PtyID.zod })), - async (c) => - jsonRequest("PtyRoutes.remove", c, function* () { - const pty = yield* Pty.Service - yield* pty.remove(c.req.valid("param").ptyID) - return true - }), - ) - .post( - "/:ptyID/connect-token", - describeRoute({ - summary: "Create PTY WebSocket token", - description: "Create a short-lived token for opening a PTY WebSocket connection.", - operationId: "pty.connectToken", - responses: { - 200: { - description: "WebSocket connect token", - content: { - "application/json": { - schema: resolver(effectZod(PtyTicket.ConnectToken)), - }, - }, - }, - ...errors(403, 404), - }, - }), - validator("param", z.object({ ptyID: PtyID.zod })), - async (c) => { - if (c.req.header(PTY_CONNECT_TOKEN_HEADER) !== PTY_CONNECT_TOKEN_HEADER_VALUE || !validOrigin(c, opts)) - throw new HTTPException(403) - const result = await runRequest( - "PtyRoutes.connectToken", - c, - Effect.gen(function* () { - const pty = yield* Pty.Service - const id = c.req.valid("param").ptyID - if (!(yield* pty.get(id))) return - const tickets = yield* PtyTicket.Service - return yield* tickets.issue({ ptyID: id, ...(yield* PtyTicket.scope) }) - }), - ) - if (!result) throw new NotFoundError({ message: "Session not found" }) - return c.json(result) - }, - ) - .get( - "/:ptyID/connect", - describeRoute({ - summary: "Connect to PTY session", - description: "Establish a WebSocket connection to interact with a pseudo-terminal (PTY) session in real-time.", - operationId: "pty.connect", - responses: { - 200: { - description: "Connected session", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - ...errors(403, 404), - }, - }), - validator("param", z.object({ ptyID: PtyID.zod })), - upgradeWebSocket(async (c) => { - type Handler = { - onMessage: (message: string | ArrayBuffer) => void - onClose: () => void - } - - const id = decodePtyID(c.req.param("ptyID")) - if ( - !(await runRequest( - "PtyRoutes.connect", - c, - Effect.gen(function* () { - const pty = yield* Pty.Service - return yield* pty.get(id) - }), - )) - ) { - throw new NotFoundError({ message: "Session not found" }) - } - const ticket = c.req.query(PTY_CONNECT_TICKET_QUERY) - if (ticket) { - if (!validOrigin(c, opts)) throw new HTTPException(403) - const valid = await runRequest( - "PtyRoutes.connect.ticket", - c, - Effect.gen(function* () { - const tickets = yield* PtyTicket.Service - return yield* tickets.consume({ ticket, ptyID: id, ...(yield* PtyTicket.scope) }) - }), - ) - if (!valid) throw new HTTPException(403) - } - const cursor = (() => { - const value = c.req.query("cursor") - if (!value) return - const parsed = Number(value) - if (!Number.isSafeInteger(parsed) || parsed < -1) return - return parsed - })() - let handler: Handler | undefined - - type Socket = { - readyState: number - send: (data: string | Uint8Array | ArrayBuffer) => void - close: (code?: number, reason?: string) => void - } - - const isSocket = (value: unknown): value is Socket => { - if (!value || typeof value !== "object") return false - if (!("readyState" in value)) return false - if (!("send" in value) || typeof (value as { send?: unknown }).send !== "function") return false - if (!("close" in value) || typeof (value as { close?: unknown }).close !== "function") return false - return typeof (value as { readyState?: unknown }).readyState === "number" - } - - const pending: string[] = [] - let ready = false - - return { - async onOpen(_event, ws) { - const socket = ws.raw - if (!isSocket(socket)) { - ws.close() - return - } - handler = await AppRuntime.runPromise( - Effect.gen(function* () { - const pty = yield* Pty.Service - return yield* pty.connect(id, socket, cursor) - }).pipe(Effect.withSpan("PtyRoutes.connect.open")), - ) - ready = true - for (const msg of pending) handler?.onMessage(msg) - pending.length = 0 - }, - onMessage(event) { - if (typeof event.data !== "string") return - if (!ready) { - pending.push(event.data) - return - } - handler?.onMessage(event.data) - }, - onClose() { - handler?.onClose() - }, - onError() { - handler?.onClose() - }, - } - }), - ) -} diff --git a/packages/opencode/src/server/routes/instance/question.ts b/packages/opencode/src/server/routes/instance/question.ts deleted file mode 100644 index 51ecb48ccd..0000000000 --- a/packages/opencode/src/server/routes/instance/question.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { Hono } from "hono" -import { describeRoute, validator } from "hono-openapi" -import { resolver } from "hono-openapi" -import { QuestionID } from "@/question/schema" -import { Question } from "@/question" -import z from "zod" -import { errors } from "../../error" -import { lazy } from "@/util/lazy" -import { jsonRequest } from "./trace" - -const Reply = z.object({ - answers: Question.Answer.zod - .array() - .describe("User answers in order of questions (each answer is an array of selected labels)"), -}) - -export const QuestionRoutes = lazy(() => - new Hono() - .get( - "/", - describeRoute({ - summary: "List pending questions", - description: "Get all pending question requests across all sessions.", - operationId: "question.list", - responses: { - 200: { - description: "List of pending questions", - content: { - "application/json": { - schema: resolver(Question.Request.zod.array()), - }, - }, - }, - }, - }), - async (c) => - jsonRequest("QuestionRoutes.list", c, function* () { - const svc = yield* Question.Service - return yield* svc.list() - }), - ) - .post( - "/:requestID/reply", - describeRoute({ - summary: "Reply to question request", - description: "Provide answers to a question request from the AI assistant.", - operationId: "question.reply", - responses: { - 200: { - description: "Question answered successfully", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - ...errors(400, 404), - }, - }), - validator( - "param", - z.object({ - requestID: QuestionID.zod, - }), - ), - validator("json", Reply), - async (c) => - jsonRequest("QuestionRoutes.reply", c, function* () { - const params = c.req.valid("param") - const json = c.req.valid("json") - const svc = yield* Question.Service - yield* svc.reply({ - requestID: params.requestID, - answers: json.answers, - }) - return true - }), - ) - .post( - "/:requestID/reject", - describeRoute({ - summary: "Reject question request", - description: "Reject a question request from the AI assistant.", - operationId: "question.reject", - responses: { - 200: { - description: "Question rejected successfully", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - ...errors(400, 404), - }, - }), - validator( - "param", - z.object({ - requestID: QuestionID.zod, - }), - ), - async (c) => - jsonRequest("QuestionRoutes.reject", c, function* () { - const params = c.req.valid("param") - const svc = yield* Question.Service - yield* svc.reject(params.requestID) - return true - }), - ), -) diff --git a/packages/opencode/src/server/routes/instance/session.ts b/packages/opencode/src/server/routes/instance/session.ts deleted file mode 100644 index a16a92f927..0000000000 --- a/packages/opencode/src/server/routes/instance/session.ts +++ /dev/null @@ -1,1124 +0,0 @@ -import { Hono } from "hono" -import { stream } from "hono/streaming" -import { describeRoute, validator, resolver } from "hono-openapi" -import { SessionID, MessageID, PartID } from "@/session/schema" -import z from "zod" -import { Session } from "@/session/session" -import { MessageV2 } from "@/session/message-v2" -import { SessionPrompt } from "@/session/prompt" -import { SessionRunState } from "@/session/run-state" -import { SessionCompaction } from "@/session/compaction" -import { SessionRevert } from "@/session/revert" -import { SessionShare } from "@/share/session" -import { SessionStatus } from "@/session/status" -import { SessionSummary } from "@/session/summary" -import { Todo } from "@/session/todo" -import { Effect } from "effect" -import { Agent } from "@/agent/agent" -import { Snapshot } from "@/snapshot" -import { Command } from "@/command" -import * as Log from "@opencode-ai/core/util/log" -import { Permission } from "@/permission" -import { PermissionID } from "@/permission/schema" -import { ModelID, ProviderID } from "@/provider/schema" -import { errors } from "../../error" -import { lazy } from "@/util/lazy" -import { zodObject } from "@/util/effect-zod" -import { Bus } from "@/bus" -import { NamedError } from "@opencode-ai/core/util/error" -import { jsonRequest, runRequest } from "./trace" - -const log = Log.create({ service: "server" }) - -const QueryBoolean = z.union([ - z.preprocess((value) => (value === "true" ? true : value === "false" ? false : value), z.boolean()), - z.enum(["true", "false"]), -]) - -function queryBoolean(value: z.infer | undefined) { - if (value === undefined) return - return value === true || value === "true" -} - -export const SessionRoutes = lazy(() => - new Hono() - .get( - "/", - describeRoute({ - summary: "List sessions", - description: "Get a list of all OpenCode sessions, sorted by most recently updated.", - operationId: "session.list", - responses: { - 200: { - description: "List of sessions", - content: { - "application/json": { - schema: resolver(Session.Info.zod.array()), - }, - }, - }, - }, - }), - validator( - "query", - z.object({ - directory: z.string().optional().meta({ description: "Filter sessions by directory" }), - // TODO: in 2.0 remove `scope` and `directory` and default - // to list all sessions for a project - scope: z.enum(["project"]).optional().meta({ description: "List all sessions for the current project" }), - path: z.string().optional().meta({ description: "Filter sessions by project-relative path" }), - roots: QueryBoolean.optional().meta({ description: "Only return root sessions (no parentID)" }), - start: z.coerce - .number() - .optional() - .meta({ description: "Filter sessions updated on or after this timestamp (milliseconds since epoch)" }), - search: z.string().optional().meta({ description: "Filter sessions by title (case-insensitive)" }), - limit: z.coerce.number().optional().meta({ description: "Maximum number of sessions to return" }), - }), - ), - async (c) => { - const query = c.req.valid("query") - return c.json( - await runRequest( - "SessionRoutes.list", - c, - Session.Service.use((svc) => - svc.list({ - directory: query.scope === "project" ? undefined : query.directory, - path: query.path, - roots: queryBoolean(query.roots), - start: query.start, - search: query.search, - limit: query.limit, - }), - ), - ), - ) - }, - ) - .get( - "/status", - describeRoute({ - summary: "Get session status", - description: "Retrieve the current status of all sessions, including active, idle, and completed states.", - operationId: "session.status", - responses: { - 200: { - description: "Get session status", - content: { - "application/json": { - schema: resolver(z.record(z.string(), SessionStatus.Info.zod)), - }, - }, - }, - ...errors(400), - }, - }), - async (c) => - jsonRequest("SessionRoutes.status", c, function* () { - const svc = yield* SessionStatus.Service - return Object.fromEntries(yield* svc.list()) - }), - ) - .get( - "/:sessionID", - describeRoute({ - summary: "Get session", - description: "Retrieve detailed information about a specific OpenCode session.", - tags: ["Session"], - operationId: "session.get", - responses: { - 200: { - description: "Get session", - content: { - "application/json": { - schema: resolver(Session.Info.zod), - }, - }, - }, - ...errors(400, 404), - }, - }), - validator( - "param", - z.object({ - sessionID: Session.GetInput.zod, - }), - ), - async (c) => { - const sessionID = c.req.valid("param").sessionID - return jsonRequest("SessionRoutes.get", c, function* () { - const session = yield* Session.Service - return yield* session.get(sessionID) - }) - }, - ) - .get( - "/:sessionID/children", - describeRoute({ - summary: "Get session children", - tags: ["Session"], - description: "Retrieve all child sessions that were forked from the specified parent session.", - operationId: "session.children", - responses: { - 200: { - description: "List of children", - content: { - "application/json": { - schema: resolver(Session.Info.zod.array()), - }, - }, - }, - ...errors(400, 404), - }, - }), - validator( - "param", - z.object({ - sessionID: Session.ChildrenInput.zod, - }), - ), - async (c) => { - const sessionID = c.req.valid("param").sessionID - return jsonRequest("SessionRoutes.children", c, function* () { - const session = yield* Session.Service - return yield* session.children(sessionID) - }) - }, - ) - .get( - "/:sessionID/todo", - describeRoute({ - summary: "Get session todos", - description: "Retrieve the todo list associated with a specific session, showing tasks and action items.", - operationId: "session.todo", - responses: { - 200: { - description: "Todo list", - content: { - "application/json": { - schema: resolver(Todo.Info.zod.array()), - }, - }, - }, - ...errors(400, 404), - }, - }), - validator( - "param", - z.object({ - sessionID: SessionID.zod, - }), - ), - async (c) => { - const sessionID = c.req.valid("param").sessionID - return jsonRequest("SessionRoutes.todo", c, function* () { - const todo = yield* Todo.Service - return yield* todo.get(sessionID) - }) - }, - ) - .post( - "/", - describeRoute({ - summary: "Create session", - description: "Create a new OpenCode session for interacting with AI assistants and managing conversations.", - operationId: "session.create", - responses: { - ...errors(400), - 200: { - description: "Successfully created session", - content: { - "application/json": { - schema: resolver(Session.Info.zod), - }, - }, - }, - }, - }), - validator("json", Session.CreateInput.zod), - async (c) => - jsonRequest("SessionRoutes.create", c, function* () { - const body = c.req.valid("json") ?? {} - const svc = yield* SessionShare.Service - return yield* svc.create(body) - }), - ) - .delete( - "/:sessionID", - describeRoute({ - summary: "Delete session", - description: "Delete a session and permanently remove all associated data, including messages and history.", - operationId: "session.delete", - responses: { - 200: { - description: "Successfully deleted session", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - ...errors(400, 404), - }, - }), - validator( - "param", - z.object({ - sessionID: Session.RemoveInput.zod, - }), - ), - async (c) => - jsonRequest("SessionRoutes.delete", c, function* () { - const sessionID = c.req.valid("param").sessionID - const svc = yield* Session.Service - yield* svc.remove(sessionID) - return true - }), - ) - .patch( - "/:sessionID", - describeRoute({ - summary: "Update session", - description: "Update properties of an existing session, such as title or other metadata.", - operationId: "session.update", - responses: { - 200: { - description: "Successfully updated session", - content: { - "application/json": { - schema: resolver(Session.Info.zod), - }, - }, - }, - ...errors(400, 404), - }, - }), - validator( - "param", - z.object({ - sessionID: SessionID.zod, - }), - ), - validator( - "json", - z.object({ - title: z.string().optional(), - permission: Permission.Ruleset.zod.optional(), - time: z - .object({ - archived: z.number().optional(), - }) - .optional(), - }), - ), - async (c) => - jsonRequest("SessionRoutes.update", c, function* () { - const sessionID = c.req.valid("param").sessionID - const updates = c.req.valid("json") - const session = yield* Session.Service - const current = yield* session.get(sessionID) - - if (updates.title !== undefined) { - yield* session.setTitle({ sessionID, title: updates.title }) - } - if (updates.permission !== undefined) { - yield* session.setPermission({ - sessionID, - permission: Permission.merge(current.permission ?? [], updates.permission), - }) - } - if (updates.time?.archived !== undefined) { - yield* session.setArchived({ sessionID, time: updates.time.archived }) - } - - return yield* session.get(sessionID) - }), - ) - // TODO(v2): remove this dedicated route and rely on the normal `/init` command flow. - .post( - "/:sessionID/init", - describeRoute({ - summary: "Initialize session", - description: - "Analyze the current application and create an AGENTS.md file with project-specific agent configurations.", - operationId: "session.init", - responses: { - 200: { - description: "200", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - ...errors(400, 404), - }, - }), - validator( - "param", - z.object({ - sessionID: SessionID.zod, - }), - ), - validator( - "json", - z.object({ - modelID: ModelID.zod, - providerID: ProviderID.zod, - messageID: MessageID.zod, - }), - ), - async (c) => - jsonRequest("SessionRoutes.init", c, function* () { - const sessionID = c.req.valid("param").sessionID - const body = c.req.valid("json") - const svc = yield* SessionPrompt.Service - yield* svc.command({ - sessionID, - messageID: body.messageID, - model: body.providerID + "/" + body.modelID, - command: Command.Default.INIT, - arguments: "", - }) - return true - }), - ) - .post( - "/:sessionID/fork", - describeRoute({ - summary: "Fork session", - description: "Create a new session by forking an existing session at a specific message point.", - operationId: "session.fork", - responses: { - 200: { - description: "200", - content: { - "application/json": { - schema: resolver(Session.Info.zod), - }, - }, - }, - }, - }), - validator( - "param", - z.object({ - sessionID: SessionID.zod, - }), - ), - validator("json", zodObject(Session.ForkInput).omit({ sessionID: true })), - async (c) => - jsonRequest("SessionRoutes.fork", c, function* () { - const sessionID = c.req.valid("param").sessionID - const body = c.req.valid("json") as { messageID?: MessageID } - const svc = yield* Session.Service - return yield* svc.fork({ ...body, sessionID }) - }), - ) - .post( - "/:sessionID/abort", - describeRoute({ - summary: "Abort session", - description: "Abort an active session and stop any ongoing AI processing or command execution.", - operationId: "session.abort", - responses: { - 200: { - description: "Aborted session", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - ...errors(400, 404), - }, - }), - validator( - "param", - z.object({ - sessionID: SessionID.zod, - }), - ), - async (c) => - jsonRequest("SessionRoutes.abort", c, function* () { - const svc = yield* SessionPrompt.Service - yield* svc.cancel(c.req.valid("param").sessionID) - return true - }), - ) - .post( - "/:sessionID/share", - describeRoute({ - summary: "Share session", - description: "Create a shareable link for a session, allowing others to view the conversation.", - operationId: "session.share", - responses: { - 200: { - description: "Successfully shared session", - content: { - "application/json": { - schema: resolver(Session.Info.zod), - }, - }, - }, - ...errors(400, 404), - }, - }), - validator( - "param", - z.object({ - sessionID: SessionID.zod, - }), - ), - async (c) => - jsonRequest("SessionRoutes.share", c, function* () { - const sessionID = c.req.valid("param").sessionID - const share = yield* SessionShare.Service - const session = yield* Session.Service - yield* share.share(sessionID) - return yield* session.get(sessionID) - }), - ) - .get( - "/:sessionID/diff", - describeRoute({ - summary: "Get message diff", - description: "Get the file changes (diff) that resulted from a specific user message in the session.", - operationId: "session.diff", - responses: { - 200: { - description: "Successfully retrieved diff", - content: { - "application/json": { - schema: resolver(Snapshot.FileDiff.zod.array()), - }, - }, - }, - }, - }), - validator( - "param", - z.object({ - sessionID: SessionID.zod, - }), - ), - validator("query", zodObject(SessionSummary.DiffInput).omit({ sessionID: true })), - async (c) => - jsonRequest("SessionRoutes.diff", c, function* () { - const query = c.req.valid("query") as Omit - const params = c.req.valid("param") - const summary = yield* SessionSummary.Service - return yield* summary.diff({ - sessionID: params.sessionID, - messageID: query.messageID, - }) - }), - ) - .delete( - "/:sessionID/share", - describeRoute({ - summary: "Unshare session", - description: "Remove the shareable link for a session, making it private again.", - operationId: "session.unshare", - responses: { - 200: { - description: "Successfully unshared session", - content: { - "application/json": { - schema: resolver(Session.Info.zod), - }, - }, - }, - ...errors(400, 404), - }, - }), - validator( - "param", - z.object({ - sessionID: SessionID.zod, - }), - ), - async (c) => - jsonRequest("SessionRoutes.unshare", c, function* () { - const sessionID = c.req.valid("param").sessionID - const share = yield* SessionShare.Service - const session = yield* Session.Service - yield* share.unshare(sessionID) - return yield* session.get(sessionID) - }), - ) - .post( - "/:sessionID/summarize", - describeRoute({ - summary: "Summarize session", - description: "Generate a concise summary of the session using AI compaction to preserve key information.", - operationId: "session.summarize", - responses: { - 200: { - description: "Summarized session", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - ...errors(400, 404), - }, - }), - validator( - "param", - z.object({ - sessionID: SessionID.zod, - }), - ), - validator( - "json", - z.object({ - providerID: ProviderID.zod, - modelID: ModelID.zod, - auto: z.boolean().optional().default(false), - }), - ), - async (c) => - jsonRequest("SessionRoutes.summarize", c, function* () { - const sessionID = c.req.valid("param").sessionID - const body = c.req.valid("json") - const session = yield* Session.Service - const revert = yield* SessionRevert.Service - const compact = yield* SessionCompaction.Service - const prompt = yield* SessionPrompt.Service - const agent = yield* Agent.Service - - yield* revert.cleanup(yield* session.get(sessionID)) - const msgs = yield* session.messages({ sessionID }) - const defaultAgent = yield* agent.defaultAgent() - let currentAgent = defaultAgent - for (let i = msgs.length - 1; i >= 0; i--) { - const info = msgs[i].info - if (info.role === "user") { - currentAgent = info.agent || defaultAgent - break - } - } - - yield* compact.create({ - sessionID, - agent: currentAgent, - model: { - providerID: body.providerID, - modelID: body.modelID, - }, - auto: body.auto, - }) - yield* prompt.loop({ sessionID }) - return true - }), - ) - .get( - "/:sessionID/message", - describeRoute({ - summary: "Get session messages", - description: "Retrieve all messages in a session, including user prompts and AI responses.", - operationId: "session.messages", - responses: { - 200: { - description: "List of messages", - content: { - "application/json": { - schema: resolver(MessageV2.WithParts.zod.array()), - }, - }, - }, - ...errors(400, 404), - }, - }), - validator( - "param", - z.object({ - sessionID: SessionID.zod, - }), - ), - validator( - "query", - z - .object({ - limit: z.coerce - .number() - .int() - .min(0) - .optional() - .meta({ description: "Maximum number of messages to return" }), - before: z - .string() - .optional() - .meta({ description: "Opaque cursor for loading older messages" }) - .refine( - (value) => { - if (!value) return true - try { - MessageV2.cursor.decode(value) - return true - } catch { - return false - } - }, - { message: "Invalid cursor" }, - ), - }) - .refine((value) => !value.before || value.limit !== undefined, { - message: "before requires limit", - path: ["before"], - }), - ), - async (c) => { - const query = c.req.valid("query") - const sessionID = c.req.valid("param").sessionID - if (query.limit === undefined || query.limit === 0) { - const messages = await runRequest( - "SessionRoutes.messages", - c, - Effect.gen(function* () { - const session = yield* Session.Service - yield* session.get(sessionID) - return yield* session.messages({ sessionID }) - }), - ) - return c.json(messages) - } - - const page = await MessageV2.page({ - sessionID, - limit: query.limit, - before: query.before, - }) - if (page.cursor) { - const url = new URL(c.req.url) - url.searchParams.set("limit", query.limit.toString()) - url.searchParams.set("before", page.cursor) - c.header("Access-Control-Expose-Headers", "Link, X-Next-Cursor") - c.header("Link", `<${url.toString()}>; rel="next"`) - c.header("X-Next-Cursor", page.cursor) - } - return c.json(page.items) - }, - ) - .get( - "/:sessionID/message/:messageID", - describeRoute({ - summary: "Get message", - description: "Retrieve a specific message from a session by its message ID.", - operationId: "session.message", - responses: { - 200: { - description: "Message", - content: { - "application/json": { - schema: resolver( - z.object({ - info: MessageV2.Info.zod, - parts: MessageV2.Part.zod.array(), - }), - ), - }, - }, - }, - ...errors(400, 404), - }, - }), - validator( - "param", - z.object({ - sessionID: SessionID.zod, - messageID: MessageID.zod, - }), - ), - async (c) => { - const params = c.req.valid("param") - const message = await MessageV2.get({ - sessionID: params.sessionID, - messageID: params.messageID, - }) - return c.json(message) - }, - ) - .delete( - "/:sessionID/message/:messageID", - describeRoute({ - summary: "Delete message", - description: - "Permanently delete a specific message (and all of its parts) from a session. This does not revert any file changes that may have been made while processing the message.", - operationId: "session.deleteMessage", - responses: { - 200: { - description: "Successfully deleted message", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - ...errors(400, 404), - }, - }), - validator( - "param", - z.object({ - sessionID: SessionID.zod, - messageID: MessageID.zod, - }), - ), - async (c) => - jsonRequest("SessionRoutes.deleteMessage", c, function* () { - const params = c.req.valid("param") - const state = yield* SessionRunState.Service - const session = yield* Session.Service - yield* state.assertNotBusy(params.sessionID) - yield* session.removeMessage({ - sessionID: params.sessionID, - messageID: params.messageID, - }) - return true - }), - ) - .delete( - "/:sessionID/message/:messageID/part/:partID", - describeRoute({ - description: "Delete a part from a message", - operationId: "part.delete", - responses: { - 200: { - description: "Successfully deleted part", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - ...errors(400, 404), - }, - }), - validator( - "param", - z.object({ - sessionID: SessionID.zod, - messageID: MessageID.zod, - partID: PartID.zod, - }), - ), - async (c) => - jsonRequest("SessionRoutes.deletePart", c, function* () { - const params = c.req.valid("param") - const svc = yield* Session.Service - yield* svc.removePart({ - sessionID: params.sessionID, - messageID: params.messageID, - partID: params.partID, - }) - return true - }), - ) - .patch( - "/:sessionID/message/:messageID/part/:partID", - describeRoute({ - description: "Update a part in a message", - operationId: "part.update", - responses: { - 200: { - description: "Successfully updated part", - content: { - "application/json": { - schema: resolver(MessageV2.Part.zod), - }, - }, - }, - ...errors(400, 404), - }, - }), - validator( - "param", - z.object({ - sessionID: SessionID.zod, - messageID: MessageID.zod, - partID: PartID.zod, - }), - ), - validator("json", MessageV2.Part.zod), - async (c) => { - const params = c.req.valid("param") - const body = c.req.valid("json") - if (body.id !== params.partID || body.messageID !== params.messageID || body.sessionID !== params.sessionID) { - throw new Error( - `Part mismatch: body.id='${body.id}' vs partID='${params.partID}', body.messageID='${body.messageID}' vs messageID='${params.messageID}', body.sessionID='${body.sessionID}' vs sessionID='${params.sessionID}'`, - ) - } - return jsonRequest("SessionRoutes.updatePart", c, function* () { - const svc = yield* Session.Service - return yield* svc.updatePart(body) - }) - }, - ) - .post( - "/:sessionID/message", - describeRoute({ - summary: "Send message", - description: "Create and send a new message to a session, streaming the AI response.", - operationId: "session.prompt", - responses: { - 200: { - description: "Created message", - content: { - "application/json": { - schema: resolver( - z.object({ - info: MessageV2.Assistant.zod, - parts: MessageV2.Part.zod.array(), - }), - ), - }, - }, - }, - ...errors(400, 404), - }, - }), - validator( - "param", - z.object({ - sessionID: SessionID.zod, - }), - ), - validator("json", zodObject(SessionPrompt.PromptInput).omit({ sessionID: true })), - async (c) => { - c.status(200) - c.header("Content-Type", "application/json") - return stream(c, async (stream) => { - const sessionID = c.req.valid("param").sessionID - const body = c.req.valid("json") - const msg = await runRequest( - "SessionRoutes.prompt", - c, - SessionPrompt.Service.use((svc) => - svc.prompt({ ...body, sessionID } as unknown as SessionPrompt.PromptInput), - ), - ) - void stream.write(JSON.stringify(msg)) - }) - }, - ) - .post( - "/:sessionID/prompt_async", - describeRoute({ - summary: "Send async message", - description: - "Create and send a new message to a session asynchronously, starting the session if needed and returning immediately.", - operationId: "session.prompt_async", - responses: { - 204: { - description: "Prompt accepted", - }, - ...errors(400, 404), - }, - }), - validator( - "param", - z.object({ - sessionID: SessionID.zod, - }), - ), - validator("json", zodObject(SessionPrompt.PromptInput).omit({ sessionID: true })), - async (c) => { - const sessionID = c.req.valid("param").sessionID - const body = c.req.valid("json") - void runRequest( - "SessionRoutes.prompt_async", - c, - SessionPrompt.Service.use((svc) => - svc.prompt({ ...body, sessionID } as unknown as SessionPrompt.PromptInput), - ), - ).catch((err) => { - log.error("prompt_async failed", { sessionID, error: err }) - void Bus.publish(Session.Event.Error, { - sessionID, - error: new NamedError.Unknown({ message: err instanceof Error ? err.message : String(err) }).toObject(), - }) - }) - - return c.body(null, 204) - }, - ) - .post( - "/:sessionID/command", - describeRoute({ - summary: "Send command", - description: "Send a new command to a session for execution by the AI assistant.", - operationId: "session.command", - responses: { - 200: { - description: "Created message", - content: { - "application/json": { - schema: resolver( - z.object({ - info: MessageV2.Assistant.zod, - parts: MessageV2.Part.zod.array(), - }), - ), - }, - }, - }, - ...errors(400, 404), - }, - }), - validator( - "param", - z.object({ - sessionID: SessionID.zod, - }), - ), - validator("json", zodObject(SessionPrompt.CommandInput).omit({ sessionID: true })), - async (c) => - jsonRequest("SessionRoutes.command", c, function* () { - const sessionID = c.req.valid("param").sessionID - const body = c.req.valid("json") as Omit - const svc = yield* SessionPrompt.Service - return yield* svc.command({ ...body, sessionID }) - }), - ) - .post( - "/:sessionID/shell", - describeRoute({ - summary: "Run shell command", - description: "Execute a shell command within the session context and return the AI's response.", - operationId: "session.shell", - responses: { - 200: { - description: "Created message", - content: { - "application/json": { - schema: resolver(MessageV2.WithParts.zod), - }, - }, - }, - ...errors(400, 404), - }, - }), - validator( - "param", - z.object({ - sessionID: SessionID.zod, - }), - ), - validator("json", zodObject(SessionPrompt.ShellInput).omit({ sessionID: true })), - async (c) => - jsonRequest("SessionRoutes.shell", c, function* () { - const sessionID = c.req.valid("param").sessionID - const body = c.req.valid("json") as Omit - const svc = yield* SessionPrompt.Service - return yield* svc.shell({ ...body, sessionID }) - }), - ) - .post( - "/:sessionID/revert", - describeRoute({ - summary: "Revert message", - description: "Revert a specific message in a session, undoing its effects and restoring the previous state.", - operationId: "session.revert", - responses: { - 200: { - description: "Updated session", - content: { - "application/json": { - schema: resolver(Session.Info.zod), - }, - }, - }, - ...errors(400, 404), - }, - }), - validator( - "param", - z.object({ - sessionID: SessionID.zod, - }), - ), - validator("json", zodObject(SessionRevert.RevertInput).omit({ sessionID: true })), - async (c) => { - const sessionID = c.req.valid("param").sessionID - const body = c.req.valid("json") as Omit - log.info("revert", body) - return jsonRequest("SessionRoutes.revert", c, function* () { - const svc = yield* SessionRevert.Service - return yield* svc.revert({ sessionID, ...body }) - }) - }, - ) - .post( - "/:sessionID/unrevert", - describeRoute({ - summary: "Restore reverted messages", - description: "Restore all previously reverted messages in a session.", - operationId: "session.unrevert", - responses: { - 200: { - description: "Updated session", - content: { - "application/json": { - schema: resolver(Session.Info.zod), - }, - }, - }, - ...errors(400, 404), - }, - }), - validator( - "param", - z.object({ - sessionID: SessionID.zod, - }), - ), - async (c) => - jsonRequest("SessionRoutes.unrevert", c, function* () { - const sessionID = c.req.valid("param").sessionID - const svc = yield* SessionRevert.Service - return yield* svc.unrevert({ sessionID }) - }), - ) - .post( - "/:sessionID/permissions/:permissionID", - describeRoute({ - summary: "Respond to permission", - deprecated: true, - description: "Approve or deny a permission request from the AI assistant.", - operationId: "permission.respond", - responses: { - 200: { - description: "Permission processed successfully", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - ...errors(400, 404), - }, - }), - validator( - "param", - z.object({ - sessionID: SessionID.zod, - permissionID: PermissionID.zod, - }), - ), - validator("json", z.object({ response: Permission.Reply.zod })), - async (c) => - jsonRequest("SessionRoutes.permissionRespond", c, function* () { - const params = c.req.valid("param") - const svc = yield* Permission.Service - yield* svc.reply({ - requestID: params.permissionID, - reply: c.req.valid("json").response, - }) - return true - }), - ), -) diff --git a/packages/opencode/src/server/routes/instance/sync.ts b/packages/opencode/src/server/routes/instance/sync.ts deleted file mode 100644 index 9894d8c8ee..0000000000 --- a/packages/opencode/src/server/routes/instance/sync.ts +++ /dev/null @@ -1,199 +0,0 @@ -import z from "zod" -import { Hono } from "hono" -import { describeRoute, validator, resolver } from "hono-openapi" -import { SyncEvent } from "@/sync" -import { Database } from "@/storage/db" -import { asc } from "drizzle-orm" -import { and } from "drizzle-orm" -import { not } from "drizzle-orm" -import { or } from "drizzle-orm" -import { lte } from "drizzle-orm" -import { eq } from "drizzle-orm" -import { EventTable } from "@/sync/event.sql" -import { lazy } from "@/util/lazy" -import * as Log from "@opencode-ai/core/util/log" -import { Workspace } from "@/control-plane/workspace" -import { AppRuntime } from "@/effect/app-runtime" -import { Instance } from "@/project/instance" -import { errors } from "../../error" -import { Session } from "@/session/session" -import { WorkspaceContext } from "@/control-plane/workspace-context" -import { SessionID } from "@/session/schema" - -const ReplayEvent = z.object({ - id: z.string(), - aggregateID: z.string(), - seq: z.number().int().min(0), - type: z.string(), - data: z.record(z.string(), z.unknown()), -}) -const SessionPayload = z.object({ - sessionID: SessionID.zod, -}) - -const log = Log.create({ service: "server.sync" }) - -export const SyncRoutes = lazy(() => - new Hono() - .post( - "/start", - describeRoute({ - summary: "Start workspace sync", - description: "Start sync loops for workspaces in the current project that have active sessions.", - operationId: "sync.start", - responses: { - 200: { - description: "Workspace sync started", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - }, - }), - async (c) => { - void AppRuntime.runPromise( - Workspace.Service.use((workspace) => workspace.startWorkspaceSyncing(Instance.project.id)), - ) - return c.json(true) - }, - ) - .post( - "/replay", - describeRoute({ - summary: "Replay sync events", - description: "Validate and replay a complete sync event history.", - operationId: "sync.replay", - responses: { - 200: { - description: "Replayed sync events", - content: { - "application/json": { - schema: resolver( - z.object({ - sessionID: z.string(), - }), - ), - }, - }, - }, - ...errors(400), - }, - }), - validator( - "json", - z.object({ - directory: z.string(), - events: z.array(ReplayEvent).min(1), - }), - ), - async (c) => { - const body = c.req.valid("json") - const events = body.events - const source = events[0].aggregateID - - log.info("sync replay requested", { - sessionID: source, - events: events.length, - first: events[0]?.seq, - last: events.at(-1)?.seq, - directory: body.directory, - }) - await AppRuntime.runPromise(SyncEvent.use.replayAll(events)) - - log.info("sync replay complete", { - sessionID: source, - events: events.length, - first: events[0]?.seq, - last: events.at(-1)?.seq, - }) - - return c.json({ - sessionID: source, - }) - }, - ) - .post( - "/steal", - describeRoute({ - summary: "Steal session into workspace", - description: "Update a session to belong to the current workspace through the sync event system.", - operationId: "sync.steal", - responses: { - 200: { - description: "Session stolen into workspace", - content: { - "application/json": { - schema: resolver(SessionPayload), - }, - }, - }, - ...errors(400), - }, - }), - validator("json", SessionPayload), - async (c) => { - const body = c.req.valid("json") - const workspaceID = WorkspaceContext.workspaceID - if (!workspaceID) throw new Error("Cannot steal session without workspace context") - - SyncEvent.run(Session.Event.Updated, { - sessionID: body.sessionID, - info: { - workspaceID, - }, - }) - - log.info("sync session stolen", { - sessionID: body.sessionID, - workspaceID, - }) - - return c.json({ - sessionID: body.sessionID, - }) - }, - ) - .post( - "/history", - describeRoute({ - summary: "List sync events", - description: - "List sync events for all aggregates. Keys are aggregate IDs the client already knows about, values are the last known sequence ID. Events with seq > value are returned for those aggregates. Aggregates not listed in the input get their full history.", - operationId: "sync.history.list", - responses: { - 200: { - description: "Sync events", - content: { - "application/json": { - schema: resolver( - z.array( - z.object({ - id: z.string(), - aggregate_id: z.string(), - seq: z.number(), - type: z.string(), - data: z.record(z.string(), z.unknown()), - }), - ), - ), - }, - }, - }, - ...errors(400), - }, - }), - validator("json", z.record(z.string(), z.number().int().min(0))), - async (c) => { - const body = c.req.valid("json") - const exclude = Object.entries(body) - const where = - exclude.length > 0 - ? not(or(...exclude.map(([id, seq]) => and(eq(EventTable.aggregate_id, id), lte(EventTable.seq, seq))))!) - : undefined - const rows = Database.use((db) => db.select().from(EventTable).where(where).orderBy(asc(EventTable.seq)).all()) - return c.json(rows) - }, - ), -) diff --git a/packages/opencode/src/server/routes/instance/trace.ts b/packages/opencode/src/server/routes/instance/trace.ts deleted file mode 100644 index 4c7119ef3a..0000000000 --- a/packages/opencode/src/server/routes/instance/trace.ts +++ /dev/null @@ -1,59 +0,0 @@ -import type { Context } from "hono" -import { Effect } from "effect" -import { AppRuntime } from "@/effect/app-runtime" - -type AppEnv = Parameters[0] extends Effect.Effect ? R : never - -// Build the base span attributes for an HTTP handler: method, path, and every -// matched route param. Names follow OTel attribute-naming guidance: -// domain-first (`session.id`, `message.id`, …) so they match the existing -// OTel `session.id` semantic convention and the bare `message.id` we -// already emit from Tool.execute. Non-standard route params fall back to -// `opencode.` since those are internal implementation details -// (per https://opentelemetry.io/blog/2025/how-to-name-your-span-attributes/). -export interface RequestLike { - readonly req: { - readonly method: string - readonly url: string - param(): Record - } -} - -// Normalize a Hono route param key (e.g. `sessionID`, `messageID`, `name`) -// to an OTel attribute key. `fooID` → `foo.id` for ID-shaped params; any -// other param is namespaced under `opencode.` to avoid colliding with -// standard conventions. -export function paramToAttributeKey(key: string): string { - const m = key.match(/^(.+)ID$/) - if (m) return `${m[1].toLowerCase()}.id` - return `opencode.${key}` -} - -export function requestAttributes(c: RequestLike): Record { - const attributes: Record = { - "http.method": c.req.method, - "http.path": new URL(c.req.url).pathname, - } - for (const [key, value] of Object.entries(c.req.param())) { - attributes[paramToAttributeKey(key)] = value - } - return attributes -} - -export function runRequest(name: string, c: Context, effect: Effect.Effect) { - return AppRuntime.runPromise(effect.pipe(Effect.withSpan(name, { attributes: requestAttributes(c) }))) -} - -export async function jsonRequest( - name: string, - c: C, - effect: (c: C) => Effect.gen.Return, -) { - return c.json( - await runRequest( - name, - c, - Effect.gen(() => effect(c)), - ), - ) -} diff --git a/packages/opencode/src/server/routes/instance/tui.ts b/packages/opencode/src/server/routes/instance/tui.ts deleted file mode 100644 index a7a0c9cbdc..0000000000 --- a/packages/opencode/src/server/routes/instance/tui.ts +++ /dev/null @@ -1,387 +0,0 @@ -import { Hono, type Context } from "hono" -import { describeRoute, validator, resolver } from "hono-openapi" -import { Schema } from "effect" -import z from "zod" -import { Bus } from "@/bus" -import { Session } from "@/session/session" -import type { SessionID } from "@/session/schema" -import { TuiEvent } from "@/cli/cmd/tui/event" -import { zodObject } from "@/util/effect-zod" -import { errors } from "../../error" -import { lazy } from "@/util/lazy" -import { runRequest } from "./trace" -import { - TuiRequest, - nextTuiRequest, - nextTuiResponse, - submitTuiRequest, - submitTuiResponse, -} from "@/server/shared/tui-control" - -export async function callTui(ctx: Context) { - const body = await ctx.req.json() - submitTuiRequest({ - path: ctx.req.path, - body, - }) - return nextTuiResponse() -} - -const TuiControlRoutes = new Hono() - .get( - "/next", - describeRoute({ - summary: "Get next TUI request", - description: "Retrieve the next TUI (Terminal User Interface) request from the queue for processing.", - operationId: "tui.control.next", - responses: { - 200: { - description: "Next TUI request", - content: { - "application/json": { - schema: resolver(TuiRequest), - }, - }, - }, - }, - }), - async (c) => { - const req = await nextTuiRequest() - return c.json(req) - }, - ) - .post( - "/response", - describeRoute({ - summary: "Submit TUI response", - description: "Submit a response to the TUI request queue to complete a pending request.", - operationId: "tui.control.response", - responses: { - 200: { - description: "Response submitted successfully", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - }, - }), - validator("json", z.any()), - async (c) => { - const body = c.req.valid("json") - submitTuiResponse(body) - return c.json(true) - }, - ) - -export const TuiRoutes = lazy(() => - new Hono() - .post( - "/append-prompt", - describeRoute({ - summary: "Append TUI prompt", - description: "Append prompt to the TUI", - operationId: "tui.appendPrompt", - responses: { - 200: { - description: "Prompt processed successfully", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - ...errors(400), - }, - }), - validator("json", zodObject(TuiEvent.PromptAppend.properties)), - async (c) => { - await Bus.publish(TuiEvent.PromptAppend, c.req.valid("json") as { text: string }) - return c.json(true) - }, - ) - .post( - "/open-help", - describeRoute({ - summary: "Open help dialog", - description: "Open the help dialog in the TUI to display user assistance information.", - operationId: "tui.openHelp", - responses: { - 200: { - description: "Help dialog opened successfully", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - }, - }), - async (c) => { - await Bus.publish(TuiEvent.CommandExecute, { - command: "help.show", - }) - return c.json(true) - }, - ) - .post( - "/open-sessions", - describeRoute({ - summary: "Open sessions dialog", - description: "Open the session dialog", - operationId: "tui.openSessions", - responses: { - 200: { - description: "Session dialog opened successfully", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - }, - }), - async (c) => { - await Bus.publish(TuiEvent.CommandExecute, { - command: "session.list", - }) - return c.json(true) - }, - ) - .post( - "/open-themes", - describeRoute({ - summary: "Open themes dialog", - description: "Open the theme dialog", - operationId: "tui.openThemes", - responses: { - 200: { - description: "Theme dialog opened successfully", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - }, - }), - async (c) => { - await Bus.publish(TuiEvent.CommandExecute, { - command: "session.list", - }) - return c.json(true) - }, - ) - .post( - "/open-models", - describeRoute({ - summary: "Open models dialog", - description: "Open the model dialog", - operationId: "tui.openModels", - responses: { - 200: { - description: "Model dialog opened successfully", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - }, - }), - async (c) => { - await Bus.publish(TuiEvent.CommandExecute, { - command: "model.list", - }) - return c.json(true) - }, - ) - .post( - "/submit-prompt", - describeRoute({ - summary: "Submit TUI prompt", - description: "Submit the prompt", - operationId: "tui.submitPrompt", - responses: { - 200: { - description: "Prompt submitted successfully", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - }, - }), - async (c) => { - await Bus.publish(TuiEvent.CommandExecute, { - command: "prompt.submit", - }) - return c.json(true) - }, - ) - .post( - "/clear-prompt", - describeRoute({ - summary: "Clear TUI prompt", - description: "Clear the prompt", - operationId: "tui.clearPrompt", - responses: { - 200: { - description: "Prompt cleared successfully", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - }, - }), - async (c) => { - await Bus.publish(TuiEvent.CommandExecute, { - command: "prompt.clear", - }) - return c.json(true) - }, - ) - .post( - "/execute-command", - describeRoute({ - summary: "Execute TUI command", - description: "Execute a TUI command (e.g. agent_cycle)", - operationId: "tui.executeCommand", - responses: { - 200: { - description: "Command executed successfully", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - ...errors(400), - }, - }), - validator("json", z.object({ command: z.string() })), - async (c) => { - const command = c.req.valid("json").command - await Bus.publish(TuiEvent.CommandExecute, { - // @ts-expect-error - command: { - session_new: "session.new", - session_share: "session.share", - session_interrupt: "session.interrupt", - session_compact: "session.compact", - messages_page_up: "session.page.up", - messages_page_down: "session.page.down", - messages_line_up: "session.line.up", - messages_line_down: "session.line.down", - messages_half_page_up: "session.half.page.up", - messages_half_page_down: "session.half.page.down", - messages_first: "session.first", - messages_last: "session.last", - agent_cycle: "agent.cycle", - }[command], - }) - return c.json(true) - }, - ) - .post( - "/show-toast", - describeRoute({ - summary: "Show TUI toast", - description: "Show a toast notification in the TUI", - operationId: "tui.showToast", - responses: { - 200: { - description: "Toast notification shown successfully", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - }, - }), - validator("json", zodObject(TuiEvent.ToastShow.properties)), - async (c) => { - await Bus.publish( - TuiEvent.ToastShow, - c.req.valid("json") as Schema.Schema.Type, - ) - return c.json(true) - }, - ) - .post( - "/publish", - describeRoute({ - summary: "Publish TUI event", - description: "Publish a TUI event", - operationId: "tui.publish", - responses: { - 200: { - description: "Event published successfully", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - ...errors(400), - }, - }), - validator( - "json", - z.union( - Object.values(TuiEvent).map((def) => { - return z - .object({ - type: z.literal(def.type), - properties: zodObject(def.properties), - }) - .meta({ - ref: `Event.${def.type}`, - }) - }), - ), - ), - async (c) => { - const evt = c.req.valid("json") as { type: string; properties: Record } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - await Bus.publish(Object.values(TuiEvent).find((def) => def.type === evt.type)! as any, evt.properties as any) - return c.json(true) - }, - ) - .post( - "/select-session", - describeRoute({ - summary: "Select session", - description: "Navigate the TUI to display the specified session.", - operationId: "tui.selectSession", - responses: { - 200: { - description: "Session selected successfully", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - ...errors(400, 404), - }, - }), - validator("json", zodObject(TuiEvent.SessionSelect.properties)), - async (c) => { - const { sessionID } = c.req.valid("json") as { sessionID: SessionID } - await runRequest( - "TuiRoutes.sessionSelect", - c, - Session.Service.use((svc) => svc.get(sessionID)), - ) - await Bus.publish(TuiEvent.SessionSelect, { sessionID }) - return c.json(true) - }, - ) - .route("/control", TuiControlRoutes), -) diff --git a/packages/opencode/src/server/routes/ui.ts b/packages/opencode/src/server/routes/ui.ts deleted file mode 100644 index 608525b63a..0000000000 --- a/packages/opencode/src/server/routes/ui.ts +++ /dev/null @@ -1,40 +0,0 @@ -import fs from "node:fs/promises" -import { AppFileSystem } from "@opencode-ai/core/filesystem" -import { Hono } from "hono" -import { proxy } from "hono/proxy" -import { ProxyUtil } from "../proxy-util" -import { UI_UPSTREAM, csp, cspForHtml, embeddedUI, upstreamURL } from "../shared/ui" - -export async function serveUI(request: Request) { - const embeddedWebUI = await embeddedUI() - const path = new URL(request.url).pathname - - if (embeddedWebUI) { - const match = embeddedWebUI[path.replace(/^\//, "")] ?? embeddedWebUI["index.html"] ?? null - if (!match) return Response.json({ error: "Not Found" }, { status: 404 }) - - if (await fs.exists(match)) { - const mime = AppFileSystem.mimeType(match) - const headers = new Headers({ "content-type": mime }) - const body = new Uint8Array(await fs.readFile(match)) - if (mime.startsWith("text/html")) { - headers.set("content-security-policy", cspForHtml(new TextDecoder().decode(body))) - } - return new Response(body, { headers }) - } - - return Response.json({ error: "Not Found" }, { status: 404 }) - } - - const response = await proxy(upstreamURL(path), { - raw: request, - headers: ProxyUtil.headers(request, { host: UI_UPSTREAM.host }), - }) - response.headers.set( - "Content-Security-Policy", - response.headers.get("content-type")?.includes("text/html") ? cspForHtml(await response.clone().text()) : csp(), - ) - return response -} - -export const UIRoutes = (): Hono => new Hono().all("/*", (c) => serveUI(c.req.raw)) diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index bc09667c29..67a728b801 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -1,30 +1,14 @@ -import { generateSpecs } from "hono-openapi" -import { Hono } from "hono" -import { adapter } from "#hono" -import { lazy } from "@/util/lazy" import * as Log from "@opencode-ai/core/util/log" -import { Flag } from "@opencode-ai/core/flag/flag" -import { WorkspaceID } from "@/control-plane/schema" import { ConfigProvider, Context, Effect, Exit, Layer, Scope } from "effect" import { HttpRouter, HttpServer } from "effect/unstable/http" import { OpenApi } from "effect/unstable/httpapi" import * as HttpApiServer from "#httpapi-server" import { MDNS } from "./mdns" -import { AuthMiddleware, CompressionMiddleware, CorsMiddleware, ErrorMiddleware, LoggerMiddleware } from "./middleware" -import { FenceMiddleware } from "./fence" import { initProjectors } from "./projectors" -import { InstanceRoutes } from "./routes/instance" -import { ControlPlaneRoutes } from "./routes/control" -import { UIRoutes } from "./routes/ui" -import { GlobalRoutes } from "./routes/global" -import { WorkspaceRouterMiddleware } from "./workspace" -import { InstanceMiddleware } from "./routes/instance/middleware" -import { WorkspaceRoutes } from "./routes/control/workspace" import { ExperimentalHttpApiServer } from "./routes/instance/httpapi/server" import { disposeMiddleware } from "./routes/instance/httpapi/lifecycle" import { WebSocketTracker } from "./routes/instance/httpapi/websocket-tracker" import { PublicApi } from "./routes/instance/httpapi/public" -import * as ServerBackend from "./backend" import type { CorsOptions } from "./cors" // @ts-ignore This global is needed to prevent ai-sdk from logging warnings to stdout https://github.com/vercel/ai/blob/2dc67e0ef538307f21368db32d5a12345d98831b/packages/ai/src/logger/log-warnings.ts#L85 @@ -53,203 +37,27 @@ type ListenOptions = CorsOptions & { mdnsDomain?: string } -const DefaultHono = lazy(() => - withBackend({ backend: "hono", reason: "stable" }, createHono({}, { backend: "hono", reason: "stable" })), -) -const DefaultHttpApi = lazy(() => createDefaultHttpApi()) - -function select() { - return ServerBackend.select() -} - -export const backend = select - -export const Default = () => { - const selected = select() - return selected.backend === "effect-httpapi" ? DefaultHttpApi() : DefaultHono() -} - -function create(opts: ListenOptions) { - const selected = select() - return selected.backend === "effect-httpapi" - ? withBackend(selected, createHttpApi(opts)) - : withBackend(selected, createHono(opts, selected)) -} - -export function Legacy(opts: CorsOptions = {}) { - return withBackend({ backend: "hono", reason: "explicit" }, createHono(opts, { backend: "hono", reason: "explicit" })) -} - -function createDefaultHttpApi() { - return withBackend(select(), createHttpApi()) -} - -function withBackend(selection: ServerBackend.Selection, built: T) { - log.info("server backend selected", ServerBackend.attributes(selection)) - return built -} - -function createHttpApi(corsOptions?: CorsOptions) { - const handler = ExperimentalHttpApiServer.webHandler(corsOptions).handler +const defaultHttpApi = (() => { + const handler = ExperimentalHttpApiServer.webHandler().handler const app: ServerApp = { fetch: (request: Request) => handler(request, ExperimentalHttpApiServer.context), request(input, init) { return app.fetch(input instanceof Request ? input : new Request(new URL(input, "http://localhost"), init)) }, } - return { - app, - runtime: adapter.createFetch(app), - } -} + return { app } +})() -function createHono(opts: CorsOptions, selection: ServerBackend.Selection = ServerBackend.force(select(), "hono")) { - const backendAttributes = ServerBackend.attributes(selection) - const app = new Hono() - .onError(ErrorMiddleware) - .use(CorsMiddleware(opts)) - .use(LoggerMiddleware(backendAttributes)) - .use(AuthMiddleware) - .use(CompressionMiddleware) - .route("/global", GlobalRoutes()) +export const Default = () => defaultHttpApi - const runtime = adapter.create(app) - - if (Flag.OPENCODE_WORKSPACE_ID) { - return { - app: app - .use(InstanceMiddleware(Flag.OPENCODE_WORKSPACE_ID ? WorkspaceID.make(Flag.OPENCODE_WORKSPACE_ID) : undefined)) - .use(FenceMiddleware) - .route("/", InstanceRoutes(runtime.upgradeWebSocket, opts)), - runtime, - } - } - - const workspaceApp = new Hono() - const workspaceLegacyApp = new Hono() - .use(InstanceMiddleware()) - .route("/experimental/workspace", WorkspaceRoutes()) - .use(WorkspaceRouterMiddleware(runtime.upgradeWebSocket)) - workspaceApp.route("/", workspaceLegacyApp) - - return { - app: app - .route("/", ControlPlaneRoutes()) - .route("/", workspaceApp) - .route("/", InstanceRoutes(runtime.upgradeWebSocket, opts)) - .route("/", UIRoutes()), - runtime, - } -} - -/** - * Generate the OpenAPI document used by the SDK build. - * - * Since the Effect HttpApi backend now covers every Hono route (plus the new - * `/api/session/*` v2 routes — see `httpapi-bridge.test.ts` for the parity - * audit), `Server.openapi()` derives the spec from `OpenApi.fromApi(PublicApi)`. - * `PublicApi` is `OpenCodeHttpApi` annotated with the `matchLegacyOpenApi` - * transform that injects instance query parameters, strips Effect's optional - * null arms, normalizes component names, and patches SSE response schemas so - * the generated SDK keeps the legacy Hono shape. - * - * The Hono-derived spec is still reachable via `openapiHono()` so reviewers - * can diff the two outputs while the Hono backend lingers; once the Hono - * backend is deleted that helper goes with it. - */ export async function openapi() { return OpenApi.fromApi(PublicApi) } -/** - * Hono-derived OpenAPI spec, retained for parity diffing only. Delete once - * the Hono backend is removed. - */ -export async function openapiHono() { - // Build a fresh app with all routes registered directly so - // hono-openapi can see describeRoute metadata (`.route()` wraps - // handlers when the sub-app has a custom errorHandler, which - // strips the metadata symbol). - const { app } = createHono({}) - const result = await generateSpecs(app, { - documentation: { - info: { - title: "opencode", - version: "1.0.0", - description: "opencode api", - }, - openapi: "3.1.1", - }, - }) - return result -} - export let url: URL export async function listen(opts: ListenOptions): Promise { - const selected = select() - const inner: Listener = - selected.backend === "effect-httpapi" ? await listenHttpApi(opts, selected) : await listenLegacy(opts) - - const next = new URL(inner.url) - url = next - - const mdns = - opts.mdns && inner.port && opts.hostname !== "127.0.0.1" && opts.hostname !== "localhost" && opts.hostname !== "::1" - if (mdns) { - MDNS.publish(inner.port, opts.mdnsDomain) - } else if (opts.mdns) { - log.warn("mDNS enabled but hostname is loopback; skipping mDNS publish") - } - - let closing: Promise | undefined - let mdnsUnpublished = false - const unpublish = () => { - if (!mdns || mdnsUnpublished) return - mdnsUnpublished = true - MDNS.unpublish() - } - return { - hostname: inner.hostname, - port: inner.port, - url: next, - stop(close?: boolean) { - unpublish() - // Always forward stop(true), even if a graceful stop was requested - // first, so native listeners can escalate shutdown in-place. - const next = inner.stop(close) - closing ??= next - return close ? next.then(() => closing!) : closing - }, - } -} - -async function listenLegacy(opts: ListenOptions): Promise { - const built = create(opts) - const server = await built.runtime.listen(opts) - const innerUrl = new URL("http://localhost") - innerUrl.hostname = opts.hostname - innerUrl.port = String(server.port) - return { - hostname: opts.hostname, - port: server.port, - url: innerUrl, - stop: (close?: boolean) => server.stop(close), - } -} - -/** - * Run the effect-httpapi backend on a native Effect HTTP server. This - * lets HttpApi routes that call `request.upgrade` (PTY connect, the - * workspace-routing proxy WS bridge) work end-to-end; the legacy Hono - * adapter path can't surface `request.upgrade` because its fetch handler has - * no reference to the platform server instance for websocket upgrades. - */ -async function listenHttpApi(opts: ListenOptions, selection: ServerBackend.Selection): Promise { - log.info("server backend selected", { - ...ServerBackend.attributes(selection), - "opencode.server.runtime": HttpApiServer.name, - }) + log.info("server backend", { "opencode.server.runtime": HttpApiServer.name }) const buildLayer = (port: number) => HttpRouter.serve(ExperimentalHttpApiServer.createRoutes(opts), { @@ -270,10 +78,6 @@ async function listenHttpApi(opts: ListenOptions, selection: ServerBackend.Selec const start = async (port: number) => { const scope = Scope.makeUnsafe() try { - // Effect's `HttpMiddleware` interface returns `Effect<…, any, any>` by - // design, which leaks `R = any` through `HttpRouter.serve`. The actual - // requirements at this point are fully satisfied by `createRoutes` and the - // platform HTTP server layer; cast away the `any` to satisfy `runPromise`. const layer = buildLayer(port) as Layer.Layer< HttpServer.HttpServer | WebSocketTracker.Service | HttpApiServer.Service, unknown, @@ -308,8 +112,24 @@ async function listenHttpApi(opts: ListenOptions, selection: ServerBackend.Selec const innerUrl = new URL("http://localhost") innerUrl.hostname = opts.hostname innerUrl.port = String(port) + url = innerUrl + + const mdns = + opts.mdns && port && opts.hostname !== "127.0.0.1" && opts.hostname !== "localhost" && opts.hostname !== "::1" + if (mdns) { + MDNS.publish(port, opts.mdnsDomain) + } else if (opts.mdns) { + log.warn("mDNS enabled but hostname is loopback; skipping mDNS publish") + } + let forceStopPromise: Promise | undefined let stopPromise: Promise | undefined + let mdnsUnpublished = false + const unpublish = () => { + if (!mdns || mdnsUnpublished) return + mdnsUnpublished = true + MDNS.unpublish() + } const forceStop = () => { forceStopPromise ??= Effect.runPromiseExit( Effect.gen(function* () { @@ -325,9 +145,8 @@ async function listenHttpApi(opts: ListenOptions, selection: ServerBackend.Selec port, url: innerUrl, stop: (close?: boolean) => { + unpublish() const requested = close ? forceStop() : Promise.resolve() - // The first call starts scope shutdown. A later stop(true) cannot undo - // that, but it still runs forceStop() before awaiting the original close. stopPromise ??= requested .then(() => Effect.runPromiseExit(Scope.close(resolved!.scope, Exit.void))) .then(() => undefined) diff --git a/packages/opencode/src/server/shared/tui-control.ts b/packages/opencode/src/server/shared/tui-control.ts index 40aaf04a96..03b62bbab0 100644 --- a/packages/opencode/src/server/shared/tui-control.ts +++ b/packages/opencode/src/server/shared/tui-control.ts @@ -1,12 +1,12 @@ -import z from "zod" import { AsyncQueue } from "@/util/queue" +import { Schema } from "effect" -export const TuiRequest = z.object({ - path: z.string(), - body: z.any(), +export const TuiRequest = Schema.Struct({ + path: Schema.String, + body: Schema.Unknown, }) -export type TuiRequest = z.infer +export type TuiRequest = Schema.Schema.Type const request = new AsyncQueue() const response = new AsyncQueue() diff --git a/packages/opencode/src/server/workspace.ts b/packages/opencode/src/server/workspace.ts deleted file mode 100644 index 0972875305..0000000000 --- a/packages/opencode/src/server/workspace.ts +++ /dev/null @@ -1,93 +0,0 @@ -import type { MiddlewareHandler } from "hono" -import type { UpgradeWebSocket } from "hono/ws" -import { getAdapter } from "@/control-plane/adapters" -import { WorkspaceID } from "@/control-plane/schema" -import { WorkspaceContext } from "@/control-plane/workspace-context" -import { Workspace } from "@/control-plane/workspace" -import { Flag } from "@opencode-ai/core/flag/flag" -import { AppRuntime } from "@/effect/app-runtime" -import { WithInstance } from "@/project/with-instance" -import { Session } from "@/session/session" -import { Effect } from "effect" -import * as Log from "@opencode-ai/core/util/log" -import { ServerProxy } from "./proxy" -import { getWorkspaceRouteSessionID, isLocalWorkspaceRoute, workspaceProxyURL } from "./shared/workspace-routing" - -async function getSessionWorkspace(url: URL) { - const id = getWorkspaceRouteSessionID(url) - if (!id) return null - - const session = await AppRuntime.runPromise( - Session.Service.use((svc) => svc.get(id)).pipe(Effect.withSpan("WorkspaceRouter.lookup")), - ).catch(() => undefined) - return session?.workspaceID -} - -export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): MiddlewareHandler { - const log = Log.create({ service: "workspace-router" }) - - return async (c, next) => { - const url = new URL(c.req.url) - - const sessionWorkspaceID = await getSessionWorkspace(url) - const workspaceID = sessionWorkspaceID || url.searchParams.get("workspace") - - if (!workspaceID || url.pathname.startsWith("/console") || Flag.OPENCODE_WORKSPACE_ID) { - return next() - } - - const workspace = await AppRuntime.runPromise( - Workspace.Service.use((svc) => svc.get(WorkspaceID.make(workspaceID))), - ) - - if (!workspace) { - return new Response(`Workspace not found: ${workspaceID}`, { - status: 500, - headers: { - "content-type": "text/plain; charset=utf-8", - }, - }) - } - - if (isLocalWorkspaceRoute(c.req.method, url.pathname)) { - // No instance provided because we are serving cached data; there - // is no instance to work with - return next() - } - - const adapter = getAdapter(workspace.projectID, workspace.type) - const target = await adapter.target(workspace) - - if (target.type === "local") { - return WorkspaceContext.provide({ - workspaceID: WorkspaceID.make(workspaceID), - fn: () => - WithInstance.provide({ - directory: target.directory, - async fn() { - return next() - }, - }), - }) - } - - const proxyURL = workspaceProxyURL(target.url, url) - - log.info("workspace proxy forwarding", { - workspaceID, - request: url.toString(), - target: String(target.url), - proxy: proxyURL.toString(), - }) - - if (c.req.header("upgrade")?.toLowerCase() === "websocket") { - return ServerProxy.websocket(upgrade, proxyURL, target.headers, c.req.raw, c.env) - } - - const headers = new Headers(c.req.raw.headers) - headers.delete("x-opencode-workspace") - - const req = new Request(c.req.raw, { headers }) - return ServerProxy.http(proxyURL, target.headers, req, workspace.id) - } -} diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 2930dbaeb3..85f6124505 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -446,19 +446,6 @@ export type Part = | RetryPart | CompactionPart -// Zod discriminated union kept for the legacy Hono OpenAPI path. -const AssistantErrorZod = z.discriminatedUnion("name", [ - AuthError.Schema, - NamedError.Unknown.Schema, - OutputLengthError.Schema, - AbortedError.Schema, - StructuredOutputError.Schema, - ContextOverflowError.Schema, - APIError.Schema, -]) -type AssistantError = z.infer - -// Effect Schema for the same union — used by HttpApi OpenAPI generation. const AssistantErrorSchema = Schema.Union([ AuthError.EffectSchema, Schema.Struct({ name: Schema.Literal("UnknownError"), data: Schema.Struct({ message: Schema.String }) }).annotate({ @@ -470,6 +457,7 @@ const AssistantErrorSchema = Schema.Union([ ContextOverflowError.EffectSchema, APIError.EffectSchema, ]).annotate({ discriminator: "name" }) +type AssistantError = Schema.Schema.Type // ── Prompt input schemas ───────────────────────────────────────────────────── // diff --git a/packages/opencode/src/sync/index.ts b/packages/opencode/src/sync/index.ts index 62b30ccf9a..05d9727891 100644 --- a/packages/opencode/src/sync/index.ts +++ b/packages/opencode/src/sync/index.ts @@ -1,4 +1,3 @@ -import z from "zod" import { Database } from "@/storage/db" import { eq } from "drizzle-orm" import { GlobalBus } from "@/bus/global" @@ -10,7 +9,6 @@ import type { WorkspaceID } from "@/control-plane/schema" import { EventID } from "./schema" import { Flag } from "@opencode-ai/core/flag/flag" import { Context, Effect, Layer, Schema as EffectSchema } from "effect" -import { zodObject } from "@/util/effect-zod" import type { DeepMutable } from "@/util/schema" import { makeRuntime } from "@/effect/run-service" import { serviceUse } from "@/effect/service-use" @@ -364,26 +362,6 @@ export function claim(aggregateID: string, ownerID: string) { ) } -export function payloads() { - return registry - .entries() - .map(([type, def]) => { - return z - .object({ - type: z.literal("sync"), - name: z.literal(type), - id: z.string(), - seq: z.number(), - aggregateID: z.literal(def.aggregate), - data: zodObject(def.schema), - }) - .meta({ - ref: `SyncEvent.${def.type}`, - }) - }) - .toArray() -} - export function effectPayloads() { return registry .entries() diff --git a/packages/opencode/src/worktree/index.ts b/packages/opencode/src/worktree/index.ts index 27d2e41cd6..a6599debdf 100644 --- a/packages/opencode/src/worktree/index.ts +++ b/packages/opencode/src/worktree/index.ts @@ -21,8 +21,6 @@ import { AppFileSystem } from "@opencode-ai/core/filesystem" import { BootstrapRuntime } from "@/effect/bootstrap-runtime" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { InstanceState } from "@/effect/instance-state" -import { zod as effectZod } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" const log = Log.create({ service: "worktree" }) @@ -46,9 +44,7 @@ export const Info = Schema.Struct({ name: Schema.String, branch: Schema.String, directory: Schema.String, -}) - .annotate({ identifier: "Worktree" }) - .pipe(withStatics((s) => ({ zod: effectZod(s) }))) +}).annotate({ identifier: "Worktree" }) export type Info = Schema.Schema.Type export const CreateInput = Schema.Struct({ @@ -56,23 +52,17 @@ export const CreateInput = Schema.Struct({ startCommand: Schema.optional( Schema.String.annotate({ description: "Additional startup script to run after the project's start command" }), ), -}) - .annotate({ identifier: "WorktreeCreateInput" }) - .pipe(withStatics((s) => ({ zod: effectZod(s) }))) +}).annotate({ identifier: "WorktreeCreateInput" }) export type CreateInput = Schema.Schema.Type export const RemoveInput = Schema.Struct({ directory: Schema.String, -}) - .annotate({ identifier: "WorktreeRemoveInput" }) - .pipe(withStatics((s) => ({ zod: effectZod(s) }))) +}).annotate({ identifier: "WorktreeRemoveInput" }) export type RemoveInput = Schema.Schema.Type export const ResetInput = Schema.Struct({ directory: Schema.String, -}) - .annotate({ identifier: "WorktreeResetInput" }) - .pipe(withStatics((s) => ({ zod: effectZod(s) }))) +}).annotate({ identifier: "WorktreeResetInput" }) export type ResetInput = Schema.Schema.Type export const NotGitError = NamedError.create( diff --git a/packages/opencode/test/control-plane/workspace.test.ts b/packages/opencode/test/control-plane/workspace.test.ts index 8333d9573f..3c4837e318 100644 --- a/packages/opencode/test/control-plane/workspace.test.ts +++ b/packages/opencode/test/control-plane/workspace.test.ts @@ -5,7 +5,7 @@ import Http from "node:http" import path from "node:path" import { setTimeout as delay } from "node:timers/promises" import { NodeHttpServer } from "@effect/platform-node" -import { Effect, Layer } from "effect" +import { Effect, Layer, Schema } from "effect" import { HttpServer, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" import { eq } from "drizzle-orm" import * as Log from "@opencode-ai/core/util/log" @@ -376,9 +376,10 @@ describe("workspace schemas and exports", () => { extra: { nested: true }, } - expect(Workspace.CreateInput.zod.parse(input)).toEqual(input) - expect(() => Workspace.CreateInput.zod.parse({ ...input, id: "bad" })).toThrow() - expect(() => Workspace.CreateInput.zod.parse({ ...input, branch: 1 })).toThrow() + const decode = Schema.decodeUnknownSync(Workspace.CreateInput) + expect(decode(input)).toEqual(input) + expect(() => decode({ ...input, id: 1 })).toThrow() + expect(() => decode({ ...input, branch: 1 })).toThrow() }) }) diff --git a/packages/opencode/test/project/instance-bootstrap-regression.test.ts b/packages/opencode/test/project/instance-bootstrap-regression.test.ts deleted file mode 100644 index c01450549b..0000000000 --- a/packages/opencode/test/project/instance-bootstrap-regression.test.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { afterEach, expect, test } from "bun:test" -import { Hono } from "hono" -import { existsSync } from "node:fs" -import path from "node:path" -import { pathToFileURL } from "node:url" -import { bootstrap as cliBootstrap } from "../../src/cli/bootstrap" -import { Instance } from "../../src/project/instance" -import { WithInstance } from "../../src/project/with-instance" -import { InstanceRuntime } from "../../src/project/instance-runtime" -import { InstanceMiddleware } from "../../src/server/routes/instance/middleware" -import { disposeAllInstances, tmpdir } from "../fixture/fixture" - -// These regressions cover the legacy instance-loading paths fixed by PRs -// #25389 and #25449. The plugin config hook writes a marker file, and the test -// bodies deliberately avoid touching Plugin or config directly. The marker only -// exists if InstanceBootstrap ran at the instance boundary. - -afterEach(async () => { - await disposeAllInstances() -}) - -async function bootstrapFixture() { - return tmpdir({ - init: async (dir) => { - const marker = path.join(dir, "config-hook-fired") - const pluginFile = path.join(dir, "plugin.ts") - await Bun.write( - pluginFile, - [ - `const MARKER = ${JSON.stringify(marker)}`, - "export default async () => ({", - " config: async () => {", - ' await Bun.write(MARKER, "ran")', - " },", - "})", - "", - ].join("\n"), - ) - await Bun.write( - path.join(dir, "opencode.json"), - JSON.stringify({ - $schema: "https://opencode.ai/config.json", - plugin: [pathToFileURL(pluginFile).href], - }), - ) - return marker - }, - }) -} - -test("Instance.provide runs InstanceBootstrap before fn (boundary invariant)", async () => { - await using tmp = await bootstrapFixture() - - await WithInstance.provide({ - directory: tmp.path, - fn: async () => "ok", - }) - - expect(existsSync(tmp.extra)).toBe(true) -}) - -test("CLI bootstrap runs InstanceBootstrap before callback", async () => { - await using tmp = await bootstrapFixture() - - await cliBootstrap(tmp.path, async () => "ok") - - expect(existsSync(tmp.extra)).toBe(true) -}) - -test("legacy Hono instance middleware runs InstanceBootstrap before next handler", async () => { - await using tmp = await bootstrapFixture() - const app = new Hono().use(InstanceMiddleware()).get("/probe", (c) => c.text("ok")) - - const response = await app.request("/probe", { headers: { "x-opencode-directory": tmp.path } }) - - expect(response.status).toBe(200) - expect(existsSync(tmp.extra)).toBe(true) -}) - -test("InstanceRuntime.reloadInstance runs InstanceBootstrap", async () => { - await using tmp = await bootstrapFixture() - - await InstanceRuntime.reloadInstance({ directory: tmp.path }) - - expect(existsSync(tmp.extra)).toBe(true) -}) diff --git a/packages/opencode/test/server/httpapi-bridge.test.ts b/packages/opencode/test/server/httpapi-bridge.test.ts deleted file mode 100644 index 8476ece0e7..0000000000 --- a/packages/opencode/test/server/httpapi-bridge.test.ts +++ /dev/null @@ -1,501 +0,0 @@ -import { afterEach, describe, expect, test } from "bun:test" -import { Flag } from "@opencode-ai/core/flag/flag" -import { Instance } from "../../src/project/instance" -import { ControlPaths } from "../../src/server/routes/instance/httpapi/groups/control" -import { FilePaths } from "../../src/server/routes/instance/httpapi/groups/file" -import { GlobalPaths } from "../../src/server/routes/instance/httpapi/groups/global" -import { PublicApi } from "../../src/server/routes/instance/httpapi/public" -import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server" -import { Server } from "../../src/server/server" -import * as Log from "@opencode-ai/core/util/log" -import { ConfigProvider, Layer } from "effect" -import { HttpRouter } from "effect/unstable/http" -import { OpenApi } from "effect/unstable/httpapi" -import { resetDatabase } from "../fixture/db" -import { disposeAllInstances, tmpdir } from "../fixture/fixture" - -void Log.init({ print: false }) - -const original = { - OPENCODE_EXPERIMENTAL_HTTPAPI: Flag.OPENCODE_EXPERIMENTAL_HTTPAPI, - OPENCODE_SERVER_PASSWORD: Flag.OPENCODE_SERVER_PASSWORD, - OPENCODE_SERVER_USERNAME: Flag.OPENCODE_SERVER_USERNAME, -} - -const methods = ["get", "post", "put", "delete", "patch"] as const -let effectSpec: ReturnType | undefined - -function effectOpenApi() { - return (effectSpec ??= OpenApi.fromApi(PublicApi)) -} - -function app(input?: { password?: string; username?: string }) { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true - Flag.OPENCODE_SERVER_PASSWORD = input?.password - Flag.OPENCODE_SERVER_USERNAME = input?.username - - const handler = HttpRouter.toWebHandler( - ExperimentalHttpApiServer.routes.pipe( - Layer.provide( - ConfigProvider.layer( - ConfigProvider.fromUnknown({ - OPENCODE_SERVER_PASSWORD: input?.password, - OPENCODE_SERVER_USERNAME: input?.username, - }), - ), - ), - ), - { disableLogger: true }, - ).handler - return { - fetch: (request: Request) => handler(request, ExperimentalHttpApiServer.context), - request(input: string | URL | Request, init?: RequestInit) { - return this.fetch(input instanceof Request ? input : new Request(new URL(input, "http://localhost"), init)) - }, - } -} - -function openApiRouteKeys(spec: { paths: Record>> }) { - return Object.entries(spec.paths) - .flatMap(([path, item]) => - methods.filter((method) => item[method]).map((method) => `${method.toUpperCase()} ${path}`), - ) - .sort() -} - -function openApiParameters(spec: { paths: Record>> }) { - return Object.fromEntries( - Object.entries(spec.paths).flatMap(([path, item]) => - methods - .filter((method) => item[method]) - .map((method) => [ - `${method.toUpperCase()} ${path}`, - (item[method]?.parameters ?? []) - .map(parameterKey) - .filter((param) => param !== undefined) - .sort(), - ]), - ), - ) -} - -function openApiRequestBodies(spec: OpenApiSpec) { - return Object.fromEntries( - Object.entries(spec.paths).flatMap(([path, item]) => - methods - .filter((method) => item[method]) - .map((method) => [`${method.toUpperCase()} ${path}`, requestBodyKey(spec, item[method]?.requestBody)]), - ), - ) -} - -type OpenApiSpec = { - components?: { - schemas?: Record - } - paths: Record>> -} - -type OpenApiSchema = { - $ref?: string - allOf?: unknown[] - anyOf?: unknown[] - oneOf?: unknown[] - properties?: Record - type?: string | string[] -} - -type Operation = { - parameters?: unknown[] - responses?: unknown - requestBody?: unknown -} - -type RequestBody = { - content?: Record - required?: boolean -} - -function parameterKey(param: unknown): string | undefined { - if (!param || typeof param !== "object" || !("in" in param) || !("name" in param)) return undefined - if (typeof param.in !== "string" || typeof param.name !== "string") return undefined - return `${param.in}:${param.name}:${"required" in param && param.required === true}:${stableSchema( - "schema" in param ? param.schema : undefined, - )}` -} - -function stableSchema(input: unknown): string { - return JSON.stringify(sortSchema(input)) -} - -function sortSchema(input: unknown): unknown { - if (Array.isArray(input)) return input.map(sortSchema) - if (!input || typeof input !== "object") return input - return Object.fromEntries( - Object.entries(input) - .sort(([left], [right]) => left.localeCompare(right)) - .map(([key, value]) => [key, sortSchema(value)]), - ) -} - -function parameterSchema(input: { - spec: { paths: Record>> } - path: string - method: (typeof methods)[number] - name: string -}): unknown { - const param = input.spec.paths[input.path]?.[input.method]?.parameters?.find( - (param) => !!param && typeof param === "object" && "name" in param && param.name === input.name, - ) - if (!param || typeof param !== "object" || !("schema" in param)) return undefined - return param.schema -} - -function requestBodyKey(spec: OpenApiSpec, body: unknown) { - if (!body || typeof body !== "object" || !("content" in body)) return "" - // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- Guarded above; test helper only needs this OpenAPI subset. - const requestBody = body as RequestBody - return JSON.stringify({ - required: requestBody.required === true, - content: Object.entries(requestBody.content ?? {}) - .map(([type, value]) => [type, requestBodySchemaKind(spec, value.schema)] as const) - .sort(([left], [right]) => left.localeCompare(right)), - }) -} - -function requestBodySchemaKind(spec: OpenApiSpec, schema: OpenApiSchema | undefined) { - if (!schema) return "" - // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- `$ref` lookup is constrained to OpenAPI schema components in this test helper. - const resolved = ( - schema.$ref ? spec.components?.schemas?.[schema.$ref.replace("#/components/schemas/", "")] : schema - ) as OpenApiSchema | undefined - if (resolved?.properties) return "object" - if (resolved?.anyOf ?? resolved?.oneOf ?? resolved?.allOf) return "object" - return resolved?.type ?? schema.type ?? "inline" -} - -function responseContentTypes(input: { - spec: { paths: Record>> } - path: string - method: (typeof methods)[number] - status: string -}) { - const responses = input.spec.paths[input.path]?.[input.method]?.responses - if (!responses || typeof responses !== "object" || !(input.status in responses)) return [] - // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- Guarded dynamic OpenAPI response lookup. - const response = (responses as Record)[input.status] - if (!response || typeof response !== "object" || !("content" in response)) return [] - const content = (response as { content?: unknown }).content - if (!content || typeof content !== "object") { - return [] - } - return Object.keys(content).sort() -} - -function authorization(username: string, password: string) { - return `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}` -} - -function fileUrl(input?: { directory?: string; token?: string }) { - const url = new URL(`http://localhost${FilePaths.content}`) - url.searchParams.set("path", "hello.txt") - if (input?.directory) url.searchParams.set("directory", input.directory) - if (input?.token) url.searchParams.set("auth_token", input.token) - return url -} - -afterEach(async () => { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original.OPENCODE_EXPERIMENTAL_HTTPAPI - Flag.OPENCODE_SERVER_PASSWORD = original.OPENCODE_SERVER_PASSWORD - Flag.OPENCODE_SERVER_USERNAME = original.OPENCODE_SERVER_USERNAME - await disposeAllInstances() - await resetDatabase() -}) - -describe("HttpApi server", () => { - test("keeps Effect HttpApi behind the feature flag", () => { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = false - expect(Server.backend()).toEqual({ backend: "hono", reason: "stable" }) - - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true - expect(Server.backend()).toEqual({ backend: "effect-httpapi", reason: "env" }) - }) - - test("covers every generated OpenAPI route with Effect HttpApi contracts", async () => { - const honoRoutes = openApiRouteKeys(await Server.openapiHono()) - const effectRoutes = openApiRouteKeys(effectOpenApi()) - - expect(honoRoutes.filter((route) => !effectRoutes.includes(route))).toEqual([]) - expect(effectRoutes.filter((route) => !honoRoutes.includes(route))).toEqual([ - "GET /api/session", - "GET /api/session/{sessionID}/context", - "GET /api/session/{sessionID}/message", - "POST /api/session/{sessionID}/compact", - "POST /api/session/{sessionID}/prompt", - "POST /api/session/{sessionID}/wait", - ]) - }) - - test("matches generated OpenAPI route parameters", async () => { - const hono = openApiParameters(await Server.openapiHono()) - const effect = openApiParameters(effectOpenApi()) - - expect( - Object.keys(hono) - .filter((route) => JSON.stringify(hono[route]) !== JSON.stringify(effect[route])) - .map((route) => ({ route, hono: hono[route], effect: effect[route] })), - ).toEqual([]) - }) - - test("matches generated OpenAPI request body shape", async () => { - const hono = openApiRequestBodies(await Server.openapiHono()) - const effect = openApiRequestBodies(effectOpenApi()) - - expect( - Object.keys(hono) - .filter((route) => hono[route] !== effect[route]) - .map((route) => ({ route, hono: hono[route], effect: effect[route] })), - ).toEqual([]) - }) - - test("matches SDK-affecting query parameter schemas", async () => { - const effect = effectOpenApi() - - expect(parameterSchema({ spec: effect, path: "/session", method: "get", name: "roots" })).toEqual({ - anyOf: [{ type: "boolean" }, { type: "string", enum: ["true", "false"] }], - }) - expect(parameterSchema({ spec: effect, path: "/session", method: "get", name: "start" })).toEqual({ - type: "number", - }) - expect(parameterSchema({ spec: effect, path: "/find/file", method: "get", name: "limit" })).toEqual({ - type: "integer", - minimum: 1, - maximum: 200, - }) - expect( - parameterSchema({ spec: effect, path: "/session/{sessionID}/message", method: "get", name: "limit" }), - ).toEqual({ - type: "integer", - minimum: 0, - maximum: Number.MAX_SAFE_INTEGER, - }) - }) - - test("matches SDK-affecting request schema details", () => { - const effect = effectOpenApi() - const sessionUpdate = effect.paths["/session/{sessionID}"]?.patch?.requestBody - const sessionUpdateSchema = - typeof sessionUpdate === "object" && sessionUpdate && "content" in sessionUpdate - ? sessionUpdate.content?.["application/json"]?.schema - : undefined - const sessionUpdateProperties = sessionUpdateSchema?.properties as Record | undefined - const time = sessionUpdateProperties?.time - expect(time?.properties?.archived).toEqual({ type: "number" }) - }) - - test("documents event routes as server-sent events", () => { - const effect = effectOpenApi() - - expect(responseContentTypes({ spec: effect, path: "/event", method: "get", status: "200" })).toEqual([ - "text/event-stream", - ]) - expect(responseContentTypes({ spec: effect, path: "/global/event", method: "get", status: "200" })).toEqual([ - "text/event-stream", - ]) - }) - - test("allows requests when auth is disabled", async () => { - await using tmp = await tmpdir({ git: true }) - await Bun.write(`${tmp.path}/hello.txt`, "hello") - - const response = await app().request(fileUrl(), { - headers: { - "x-opencode-directory": tmp.path, - }, - }) - - expect(response.status).toBe(200) - expect(await response.json()).toMatchObject({ content: "hello" }) - }) - - test("provides instance context to bridged handlers", async () => { - await using tmp = await tmpdir({ git: true }) - - const response = await app().request("/project/current", { - headers: { - "x-opencode-directory": tmp.path, - }, - }) - - expect(response.status).toBe(200) - expect(await response.json()).toMatchObject({ worktree: tmp.path }) - }) - - test("requires credentials when auth is enabled", async () => { - await using tmp = await tmpdir({ git: true }) - await Bun.write(`${tmp.path}/hello.txt`, "hello") - - const [missing, bad, good] = await Promise.all([ - app({ password: "secret" }).request(fileUrl(), { - headers: { "x-opencode-directory": tmp.path }, - }), - app({ password: "secret" }).request(fileUrl(), { - headers: { - authorization: authorization("opencode", "wrong"), - "x-opencode-directory": tmp.path, - }, - }), - app({ password: "secret" }).request(fileUrl(), { - headers: { - authorization: authorization("opencode", "secret"), - "x-opencode-directory": tmp.path, - }, - }), - ]) - - expect(missing.status).toBe(401) - expect(bad.status).toBe(401) - expect(good.status).toBe(200) - }) - - test("requires credentials for root routes when auth is enabled", async () => { - const server = app({ password: "secret" }) - const auth = { authorization: authorization("opencode", "secret") } - const wrongAuth = { authorization: authorization("opencode", "wrong") } - - const [missingHealth, goodHealth, missingConfig, wrongConfig, goodConfig] = await Promise.all([ - server.request(GlobalPaths.health), - server.request(GlobalPaths.health, { headers: auth }), - server.request(GlobalPaths.config), - server.request(GlobalPaths.config, { headers: wrongAuth }), - server.request(GlobalPaths.config, { headers: auth }), - ]) - - expect(missingHealth.status).toBe(401) - expect(goodHealth.status).toBe(200) - expect(missingConfig.status).toBe(401) - expect(wrongConfig.status).toBe(401) - expect(goodConfig.status).toBe(200) - - const missingDispose = await server.request(GlobalPaths.dispose, { method: "POST" }) - expect(missingDispose.status).toBe(401) - - const missingUpgrade = await server.request(GlobalPaths.upgrade, { - method: "POST", - headers: { "content-type": "application/json" }, - body: "not-json", - }) - expect(missingUpgrade.status).toBe(401) - - const invalidUpgrade = await server.request(GlobalPaths.upgrade, { - method: "POST", - headers: { ...auth, "content-type": "application/json" }, - body: "not-json", - }) - expect(invalidUpgrade.status).toBe(400) - - const missingLog = await server.request(ControlPaths.log, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ service: "httpapi-auth-test", level: "info", message: "hello" }), - }) - expect(missingLog.status).toBe(401) - - const missingAuth = await server.request(ControlPaths.auth.replace(":providerID", "test"), { - method: "PUT", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ type: "api", key: "secret" }), - }) - expect(missingAuth.status).toBe(401) - - const invalidAuth = await server.request(ControlPaths.auth.replace(":providerID", "test"), { - method: "PUT", - headers: { ...auth, "content-type": "application/json" }, - body: JSON.stringify({ type: "api" }), - }) - expect(invalidAuth.status).toBe(400) - }) - - test("accepts auth_token query credentials", async () => { - await using tmp = await tmpdir({ git: true }) - await Bun.write(`${tmp.path}/hello.txt`, "hello") - - const response = await app({ password: "secret" }).request( - fileUrl({ token: Buffer.from("opencode:secret").toString("base64") }), - { - headers: { - "x-opencode-directory": tmp.path, - }, - }, - ) - - expect(response.status).toBe(200) - }) - - test("selects instance from query before directory header", async () => { - await using header = await tmpdir({ git: true }) - await using query = await tmpdir({ git: true }) - await Bun.write(`${header.path}/hello.txt`, "header") - await Bun.write(`${query.path}/hello.txt`, "query") - - const response = await app().request(fileUrl({ directory: query.path }), { - headers: { - "x-opencode-directory": header.path, - }, - }) - - expect(response.status).toBe(200) - expect(await response.json()).toMatchObject({ content: "query" }) - }) - - test("serves global health from Effect HttpApi", async () => { - const response = await app().request(`${GlobalPaths.health}?directory=/does/not/exist/opencode-test`) - - expect(response.status).toBe(200) - expect(await response.json()).toMatchObject({ healthy: true }) - }) - - test("serves global event stream from Effect HttpApi", async () => { - const response = await app().request(GlobalPaths.event) - if (!response.body) throw new Error("missing event stream body") - const reader = response.body.getReader() - const chunk = await reader.read() - await reader.cancel() - - expect(response.status).toBe(200) - expect(response.headers.get("content-type")).toContain("text/event-stream") - expect(new TextDecoder().decode(chunk.value)).toContain("server.connected") - }) - - test("serves control log from Effect HttpApi", async () => { - const response = await app().request(ControlPaths.log, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ service: "httpapi-test", level: "info", message: "hello" }), - }) - - expect(response.status).toBe(200) - expect(await response.json()).toBe(true) - }) - - test("validates control auth without falling through to 404", async () => { - const response = await app().request(ControlPaths.auth.replace(":providerID", "test"), { - method: "PUT", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ type: "api" }), - }) - - expect(response.status).toBe(400) - }) - - test("validates global upgrade without invoking installers", async () => { - const response = await app().request(GlobalPaths.upgrade, { - method: "POST", - headers: { "content-type": "application/json" }, - body: "not-json", - }) - - expect(response.status).toBe(400) - expect(await response.json()).toMatchObject({ success: false }) - }) -}) diff --git a/packages/opencode/test/server/httpapi-compression.test.ts b/packages/opencode/test/server/httpapi-compression.test.ts index a7e119bd87..4fcf8864fe 100644 --- a/packages/opencode/test/server/httpapi-compression.test.ts +++ b/packages/opencode/test/server/httpapi-compression.test.ts @@ -1,6 +1,5 @@ import { afterEach, describe, expect, test } from "bun:test" import { gunzipSync, inflateSync } from "node:zlib" -import { Flag } from "@opencode-ai/core/flag/flag" import * as Log from "@opencode-ai/core/util/log" import { Server } from "../../src/server/server" import { resetDatabase } from "../fixture/db" @@ -8,16 +7,12 @@ import { disposeAllInstances, tmpdir } from "../fixture/fixture" void Log.init({ print: false }) -const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI - afterEach(async () => { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original await disposeAllInstances() await resetDatabase() }) function app() { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true return Server.Default().app } diff --git a/packages/opencode/test/server/httpapi-config.test.ts b/packages/opencode/test/server/httpapi-config.test.ts index 16e8975ea1..fbbe29a9c2 100644 --- a/packages/opencode/test/server/httpapi-config.test.ts +++ b/packages/opencode/test/server/httpapi-config.test.ts @@ -1,6 +1,5 @@ import { afterEach, describe, expect, test } from "bun:test" import path from "path" -import { Flag } from "@opencode-ai/core/flag/flag" import { Server } from "../../src/server/server" import * as Log from "@opencode-ai/core/util/log" import { resetDatabase } from "../fixture/db" @@ -9,10 +8,8 @@ import { waitGlobalBusEventPromise } from "./global-bus" void Log.init({ print: false }) -const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI function app() { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true return Server.Default().app } @@ -24,7 +21,6 @@ async function waitDisposed(directory: string) { } afterEach(async () => { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original await disposeAllInstances() await resetDatabase() }) diff --git a/packages/opencode/test/server/httpapi-cors-vary.test.ts b/packages/opencode/test/server/httpapi-cors-vary.test.ts index edec8e9f76..74a09cb253 100644 --- a/packages/opencode/test/server/httpapi-cors-vary.test.ts +++ b/packages/opencode/test/server/httpapi-cors-vary.test.ts @@ -1,5 +1,4 @@ import { afterEach, describe, expect, test } from "bun:test" -import { Flag } from "@opencode-ai/core/flag/flag" import * as Log from "@opencode-ai/core/util/log" import { Server } from "../../src/server/server" import { resetDatabase } from "../fixture/db" @@ -7,17 +6,13 @@ import { disposeAllInstances } from "../fixture/fixture" void Log.init({ print: false }) -const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI - afterEach(async () => { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original await disposeAllInstances() await resetDatabase() }) -function app(experimental: boolean) { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = experimental - return experimental ? Server.Default().app : Server.Legacy().app +function app() { + return Server.Default().app } const PREFLIGHT_HEADERS = { @@ -33,19 +28,8 @@ const PREFLIGHT_HEADERS = { // cached for one origin against a different origin. corsVaryFixLayer // restores the merged form. describe("CORS preflight Vary header", () => { - test("Hono backend preflight Vary contains Origin", async () => { - const response = await app(false).request("/global/config", { - method: "OPTIONS", - headers: PREFLIGHT_HEADERS, - }) - - expect([200, 204]).toContain(response.status) - expect(response.headers.get("access-control-allow-origin")).toBe("http://localhost:3000") - expect((response.headers.get("vary") ?? "").toLowerCase()).toContain("origin") - }) - test("HTTP API backend preflight Vary contains Origin", async () => { - const response = await app(true).request("/global/config", { + const response = await app().request("/global/config", { method: "OPTIONS", headers: PREFLIGHT_HEADERS, }) @@ -56,7 +40,7 @@ describe("CORS preflight Vary header", () => { }) test("HTTP API backend preflight Vary still preserves Access-Control-Request-Headers", async () => { - const response = await app(true).request("/global/config", { + const response = await app().request("/global/config", { method: "OPTIONS", headers: PREFLIGHT_HEADERS, }) @@ -67,7 +51,7 @@ describe("CORS preflight Vary header", () => { }) test("HTTP API backend does not duplicate Origin in Vary", async () => { - const response = await app(true).request("/global/config", { + const response = await app().request("/global/config", { method: "OPTIONS", headers: PREFLIGHT_HEADERS, }) @@ -75,8 +59,8 @@ describe("CORS preflight Vary header", () => { const vary = response.headers.get("vary") ?? "" const originCount = vary .split(",") - .map((s) => s.trim().toLowerCase()) - .filter((s) => s === "origin").length + .map((s: string) => s.trim().toLowerCase()) + .filter((s: string) => s === "origin").length expect(originCount).toBe(1) }) }) diff --git a/packages/opencode/test/server/httpapi-cors.test.ts b/packages/opencode/test/server/httpapi-cors.test.ts index 8d7e95dfbf..6c83b00d53 100644 --- a/packages/opencode/test/server/httpapi-cors.test.ts +++ b/packages/opencode/test/server/httpapi-cors.test.ts @@ -1,7 +1,7 @@ import { NodeHttpServer, NodeServices } from "@effect/platform-node" import { Flag } from "@opencode-ai/core/flag/flag" import { describe, expect } from "bun:test" -import { Config, Effect, Layer } from "effect" +import { Config, ConfigProvider, Effect, Layer } from "effect" import { HttpClient, HttpClientRequest, HttpRouter, HttpServer } from "effect/unstable/http" import * as Socket from "effect/unstable/socket/Socket" import { Server } from "../../src/server/server" @@ -13,15 +13,12 @@ import { testEffect } from "../lib/effect" const testStateLayer = Layer.effectDiscard( Effect.gen(function* () { const original = { - OPENCODE_EXPERIMENTAL_HTTPAPI: Flag.OPENCODE_EXPERIMENTAL_HTTPAPI, OPENCODE_SERVER_PASSWORD: Flag.OPENCODE_SERVER_PASSWORD, } - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true Flag.OPENCODE_SERVER_PASSWORD = "secret" yield* Effect.promise(() => resetDatabase()) yield* Effect.addFinalizer(() => Effect.promise(async () => { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original.OPENCODE_EXPERIMENTAL_HTTPAPI Flag.OPENCODE_SERVER_PASSWORD = original.OPENCODE_SERVER_PASSWORD await resetDatabase() }), @@ -63,12 +60,21 @@ describe("HttpApi CORS", () => { }), ) - it.live("adds CORS headers to legacy unauthorized responses", () => + it.live("adds CORS headers to unauthorized responses", () => Effect.gen(function* () { - const response = yield* Effect.promise(async () => - Server.Legacy().app.request("/global/config", { - headers: { origin: "https://app.opencode.ai" }, - }), + const handler = HttpRouter.toWebHandler( + ExperimentalHttpApiServer.createRoutes().pipe( + Layer.provide(ConfigProvider.layer(ConfigProvider.fromUnknown({ OPENCODE_SERVER_PASSWORD: "secret" }))), + ), + { disableLogger: true }, + ).handler + const response = yield* Effect.promise(() => + handler( + new Request(new URL("/global/config", "http://localhost"), { + headers: { origin: "https://app.opencode.ai" }, + }), + ExperimentalHttpApiServer.context, + ), ) expect(response.status).toBe(401) diff --git a/packages/opencode/test/server/httpapi-event.test.ts b/packages/opencode/test/server/httpapi-event.test.ts index 940efed9c3..df716ed096 100644 --- a/packages/opencode/test/server/httpapi-event.test.ts +++ b/packages/opencode/test/server/httpapi-event.test.ts @@ -1,5 +1,4 @@ import { afterEach, describe, expect, test } from "bun:test" -import { Flag } from "@opencode-ai/core/flag/flag" import { Instance } from "../../src/project/instance" import { Server } from "../../src/server/server" import { EventPaths } from "../../src/server/routes/instance/httpapi/event" @@ -9,11 +8,8 @@ import { disposeAllInstances, tmpdir } from "../fixture/fixture" void Log.init({ print: false }) -const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI - -function app(experimental = true) { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = experimental - return experimental ? Server.Default().app : Server.Legacy().app +function app() { + return Server.Default().app } async function readFirstChunk(response: Response) { @@ -36,13 +32,12 @@ async function readFirstEvent(response: Response) { } afterEach(async () => { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original await disposeAllInstances() await resetDatabase() }) -describe("event HttpApi bridge", () => { - test("serves event stream through experimental Effect route", async () => { +describe("event HttpApi", () => { + test("serves event stream", async () => { await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) const response = await app().request(EventPaths.event, { headers: { "x-opencode-directory": tmp.path } }) @@ -54,15 +49,11 @@ describe("event HttpApi bridge", () => { expect(await readFirstEvent(response)).toMatchObject({ type: "server.connected", properties: {} }) }) - test("matches legacy first event frame", async () => { + test("serves the initial server connected event", async () => { await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) const headers = { "x-opencode-directory": tmp.path } - const legacy = await app(false).request(EventPaths.event, { headers }) - const effect = await app(true).request(EventPaths.event, { headers }) + const response = await app().request(EventPaths.event, { headers }) - const legacyEvent = await readFirstEvent(legacy) - const effectEvent = await readFirstEvent(effect) - expect(effectEvent.type).toBe(legacyEvent.type) - expect(effectEvent.properties).toEqual(legacyEvent.properties) + expect(await readFirstEvent(response)).toMatchObject({ type: "server.connected", properties: {} }) }) }) diff --git a/packages/opencode/test/server/httpapi-exercise/backend.ts b/packages/opencode/test/server/httpapi-exercise/backend.ts index f66d15a3ee..fac5f699c3 100644 --- a/packages/opencode/test/server/httpapi-exercise/backend.ts +++ b/packages/opencode/test/server/httpapi-exercise/backend.ts @@ -1,4 +1,3 @@ -import { Flag } from "@opencode-ai/core/flag/flag" import { ConfigProvider, Effect, Layer } from "effect" import { HttpRouter } from "effect/unstable/http" import { parse } from "./assertions" @@ -56,16 +55,7 @@ function app(modules: Runtime, backend: Backend, options: CallOptions) { const username = options.auth?.username const password = options.auth?.password const cacheKey = `${backend}:${username ?? ""}:${password ?? ""}` - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = backend === "effect" - Flag.OPENCODE_SERVER_PASSWORD = password - Flag.OPENCODE_SERVER_USERNAME = username if (appCache[cacheKey]) return appCache[cacheKey] - if (backend === "legacy") { - const legacy = modules.Server.Legacy().app - return (appCache[cacheKey] = { - request: (input, init) => legacy.request(input, init), - }) - } const handler = HttpRouter.toWebHandler( modules.ExperimentalHttpApiServer.routes.pipe( diff --git a/packages/opencode/test/server/httpapi-exercise/environment.ts b/packages/opencode/test/server/httpapi-exercise/environment.ts index 7962f7df94..9d3eaa0e53 100644 --- a/packages/opencode/test/server/httpapi-exercise/environment.ts +++ b/packages/opencode/test/server/httpapi-exercise/environment.ts @@ -22,7 +22,6 @@ process.env.OPENCODE_DB = exerciseDatabasePath Flag.OPENCODE_DB = exerciseDatabasePath export const original = { - OPENCODE_EXPERIMENTAL_HTTPAPI: Flag.OPENCODE_EXPERIMENTAL_HTTPAPI, OPENCODE_SERVER_PASSWORD: Flag.OPENCODE_SERVER_PASSWORD, OPENCODE_SERVER_USERNAME: Flag.OPENCODE_SERVER_USERNAME, } diff --git a/packages/opencode/test/server/httpapi-exercise/index.ts b/packages/opencode/test/server/httpapi-exercise/index.ts index bc876d9f07..4560973abe 100644 --- a/packages/opencode/test/server/httpapi-exercise/index.ts +++ b/packages/opencode/test/server/httpapi-exercise/index.ts @@ -1,10 +1,10 @@ /** - * End-to-end exerciser for the legacy Hono instance routes and the Effect HttpApi routes. + * End-to-end exerciser for the Effect HttpApi routes. * - * The goal is not to be a normal unit test file. This is a route-coverage and parity - * harness we can run while deleting Hono: every public route should eventually have a - * small scenario that proves the Effect route decodes requests, uses the right instance - * context, mutates storage when expected, and returns a compatible response shape. + * The goal is not to be a normal unit test file. This is a route-coverage harness: + * every public route should have a small scenario that proves the route decodes + * requests, uses the right instance context, mutates storage when expected, and + * returns the expected response shape. * * The script intentionally isolates `OPENCODE_DB` before importing modules that touch * storage. Scenarios may create/delete sessions and reset the database after each run, @@ -15,8 +15,7 @@ * - `.seeded(...)` creates typed per-scenario state using Effect helpers on `ctx`. * - `.at(...)` builds the request from that typed state. * - `.json(...)` / `.jsonEffect(...)` assert response shape and optional side effects. - * - `.mutating()` tells parity mode to run Effect and Hono in separate isolated contexts - * so destructive routes compare equivalent fresh setups instead of sharing one DB. + * - `.mutating()` tells the runner to reset isolated state after destructive routes. */ import { Effect } from "effect" import { OpenApi } from "effect/unstable/httpapi" @@ -1263,7 +1262,6 @@ const main = Effect.gen(function* () { const options = parseOptions(Bun.argv.slice(2)) const modules = yield* Effect.promise(() => runtime()) const effectRoutes = routeKeys(OpenApi.fromApi(modules.PublicApi)) - const honoRoutes = routeKeys(yield* Effect.promise(() => modules.Server.openapiHono())) const selected = selectedScenarios(options, scenarios) const missing = effectRoutes.filter((route) => !scenarios.some((scenario) => route === routeKey(scenario))) const extra = scenarios.filter((scenario) => !effectRoutes.includes(routeKey(scenario))) @@ -1274,7 +1272,7 @@ const main = Effect.gen(function* () { } } - printHeader(options, effectRoutes, honoRoutes, selected, missing, extra, { + printHeader(options, effectRoutes, selected, missing, extra, { database: exerciseDatabasePath, global: exerciseGlobalRoot, }) diff --git a/packages/opencode/test/server/httpapi-exercise/report.ts b/packages/opencode/test/server/httpapi-exercise/report.ts index cf674f1db9..7e79e972cb 100644 --- a/packages/opencode/test/server/httpapi-exercise/report.ts +++ b/packages/opencode/test/server/httpapi-exercise/report.ts @@ -14,7 +14,6 @@ export const color = { export function printHeader( options: Options, effectRoutes: string[], - honoRoutes: string[], selected: Scenario[], missing: string[], extra: Scenario[], @@ -24,7 +23,7 @@ export function printHeader( console.log(`${color.dim}db=${paths.database}${color.reset}`) console.log(`${color.dim}global=${paths.global}${color.reset}`) console.log( - `${color.dim}mode=${options.mode} selected=${selected.length} scenarioTimeout=${Duration.format(options.scenarioTimeout)} effectRoutes=${effectRoutes.length} missing=${missing.length} extra=${extra.length} onlyEffect=${effectRoutes.filter((route) => !honoRoutes.includes(route)).length} onlyHono=${honoRoutes.filter((route) => !effectRoutes.includes(route)).length}${color.reset}`, + `${color.dim}mode=${options.mode} selected=${selected.length} scenarioTimeout=${Duration.format(options.scenarioTimeout)} effectRoutes=${effectRoutes.length} missing=${missing.length} extra=${extra.length}${color.reset}`, ) console.log("") } diff --git a/packages/opencode/test/server/httpapi-exercise/routing.ts b/packages/opencode/test/server/httpapi-exercise/routing.ts index b2ca3eb5af..f1f9bddb4f 100644 --- a/packages/opencode/test/server/httpapi-exercise/routing.ts +++ b/packages/opencode/test/server/httpapi-exercise/routing.ts @@ -41,7 +41,7 @@ export function coverageResult(scenario: Scenario): Result { export function parseOptions(args: string[]): Options { const mode = option(args, "--mode") ?? "effect" - if (mode !== "effect" && mode !== "parity" && mode !== "coverage" && mode !== "auth") + if (mode !== "effect" && mode !== "coverage" && mode !== "auth") throw new Error(`invalid --mode ${mode}`) return { mode, diff --git a/packages/opencode/test/server/httpapi-exercise/runner.ts b/packages/opencode/test/server/httpapi-exercise/runner.ts index c735cc125c..0263ee697b 100644 --- a/packages/opencode/test/server/httpapi-exercise/runner.ts +++ b/packages/opencode/test/server/httpapi-exercise/runner.ts @@ -5,13 +5,11 @@ import type { Config } from "../../../src/config/config" import { ModelID, ProviderID } from "../../../src/provider/schema" import type { MessageV2 } from "../../../src/session/message-v2" import { MessageID, PartID } from "../../../src/session/schema" -import { stable } from "./assertions" import { call, callAuthProbe } from "./backend" import { original } from "./environment" import { runtime } from "./runtime" import type { ActiveScenario, - CallResult, Options, ProjectOptions, Result, @@ -38,16 +36,6 @@ export function runScenario(options: Options) { function runActive(options: Options, scenario: ActiveScenario) { if (options.mode === "auth") return runAuth(scenario) - if (options.mode === "parity" && scenario.mutates && scenario.compare !== "none") { - return Effect.gen(function* () { - const effect = yield* runBackend(options, "effect", scenario) - const legacy = yield* runBackend(options, "legacy", scenario) - yield* trace(options, scenario, "compare start") - yield* compare(scenario, effect, legacy) - yield* trace(options, scenario, "compare done") - }) - } - return withContext(options, scenario, "shared", (ctx) => Effect.gen(function* () { yield* trace(options, scenario, "effect request start") @@ -56,17 +44,6 @@ function runActive(options: Options, scenario: ActiveScenario) { yield* trace(options, scenario, "effect expect start") yield* scenario.expect(ctx, ctx.state, effect) yield* trace(options, scenario, "effect expect done") - if (options.mode === "parity" && scenario.compare !== "none") { - yield* trace(options, scenario, "legacy request start") - const legacy = yield* call("legacy", scenario, ctx) - yield* trace(options, scenario, `legacy response ${legacy.status}`) - yield* trace(options, scenario, "legacy expect start") - yield* scenario.expect(ctx, ctx.state, legacy) - yield* trace(options, scenario, "legacy expect done") - yield* trace(options, scenario, "compare start") - yield* compare(scenario, effect, legacy) - yield* trace(options, scenario, "compare done") - } }), ) } @@ -74,38 +51,18 @@ function runActive(options: Options, scenario: ActiveScenario) { function runAuth(scenario: ActiveScenario) { return Effect.gen(function* () { const effect = yield* callAuthProbe("effect", scenario, "missing") - const legacy = yield* callAuthProbe("legacy", scenario, "missing") if (scenario.auth === "protected") { if (effect.status !== 401) throw new Error(`effect auth expected 401, got ${effect.status}`) - if (legacy.status !== 401) throw new Error(`legacy auth expected 401, got ${legacy.status}`) const effectAuthed = yield* callAuthProbe("effect", scenario, "valid") - const legacyAuthed = yield* callAuthProbe("legacy", scenario, "valid") if (effectAuthed.status === 401) throw new Error("effect auth rejected valid credentials") - if (legacyAuthed.status === 401) throw new Error("legacy auth rejected valid credentials") return } if (effect.status === 401) throw new Error("effect auth expected public access, got 401") - if (legacy.status === 401) throw new Error("legacy auth expected public access, got 401") if (effect.timedOut) throw new Error("effect auth expected public access, probe timed out") - if (legacy.timedOut) throw new Error("legacy auth expected public access, probe timed out") }) } -function runBackend(options: Options, backend: "effect" | "legacy", scenario: ActiveScenario) { - return withContext(options, scenario, backend, (ctx) => - Effect.gen(function* () { - yield* trace(options, scenario, `${backend} request start`) - const result = yield* call(backend, scenario, ctx) - yield* trace(options, scenario, `${backend} response ${result.status}`) - yield* trace(options, scenario, `${backend} expect start`) - yield* scenario.expect(ctx, ctx.state, result) - yield* trace(options, scenario, `${backend} expect done`) - return result - }), - ) -} - function withContext( options: Options, scenario: ActiveScenario, @@ -300,19 +257,8 @@ function fakeLlmConfig(url: string): Partial { } } -function compare(scenario: ActiveScenario, effect: CallResult, legacy: CallResult) { - return Effect.sync(() => { - if (effect.status !== legacy.status) - throw new Error(`legacy returned ${legacy.status}, effect returned ${effect.status}`) - if (scenario.compare === "status") return - if (stable(effect.body) !== stable(legacy.body)) - throw new Error(`JSON parity mismatch\nlegacy: ${stable(legacy.body)}\neffect: ${stable(effect.body)}`) - }) -} - const resetState = Effect.promise(async () => { const modules = await runtime() - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original.OPENCODE_EXPERIMENTAL_HTTPAPI Flag.OPENCODE_SERVER_PASSWORD = original.OPENCODE_SERVER_PASSWORD Flag.OPENCODE_SERVER_USERNAME = original.OPENCODE_SERVER_USERNAME await modules.disposeAllInstances() diff --git a/packages/opencode/test/server/httpapi-exercise/runtime.ts b/packages/opencode/test/server/httpapi-exercise/runtime.ts index ef1c868208..7163cf0c5a 100644 --- a/packages/opencode/test/server/httpapi-exercise/runtime.ts +++ b/packages/opencode/test/server/httpapi-exercise/runtime.ts @@ -1,7 +1,6 @@ export type Runtime = { PublicApi: (typeof import("../../../src/server/routes/instance/httpapi/public"))["PublicApi"] ExperimentalHttpApiServer: (typeof import("../../../src/server/routes/instance/httpapi/server"))["ExperimentalHttpApiServer"] - Server: (typeof import("../../../src/server/server"))["Server"] AppLayer: (typeof import("../../../src/effect/app-runtime"))["AppLayer"] InstanceRef: (typeof import("../../../src/effect/instance-ref"))["InstanceRef"] Instance: (typeof import("../../../src/project/instance"))["Instance"] @@ -22,7 +21,6 @@ export function runtime() { return (runtimePromise ??= (async () => { const publicApi = await import("../../../src/server/routes/instance/httpapi/public") const httpApiServer = await import("../../../src/server/routes/instance/httpapi/server") - const server = await import("../../../src/server/server") const appRuntime = await import("../../../src/effect/app-runtime") const instanceRef = await import("../../../src/effect/instance-ref") const instance = await import("../../../src/project/instance") @@ -37,7 +35,6 @@ export function runtime() { return { PublicApi: publicApi.PublicApi, ExperimentalHttpApiServer: httpApiServer.ExperimentalHttpApiServer, - Server: server.Server, AppLayer: appRuntime.AppLayer, InstanceRef: instanceRef.InstanceRef, Instance: instance.Instance, diff --git a/packages/opencode/test/server/httpapi-exercise/types.ts b/packages/opencode/test/server/httpapi-exercise/types.ts index 2cdc822541..a0466d7b70 100644 --- a/packages/opencode/test/server/httpapi-exercise/types.ts +++ b/packages/opencode/test/server/httpapi-exercise/types.ts @@ -10,8 +10,8 @@ export const Methods = ["GET", "POST", "PUT", "DELETE", "PATCH"] as const export type Method = (typeof Methods)[number] export type OpenApiMethod = (typeof OpenApiMethods)[number] -export type Mode = "effect" | "parity" | "coverage" | "auth" -export type Backend = "effect" | "legacy" +export type Mode = "effect" | "coverage" | "auth" +export type Backend = "effect" export type Comparison = "none" | "status" | "json" export type CaptureMode = "full" | "stream" export type AuthPolicy = "protected" | "public" | "public-bypass" | "ticket-bypass" diff --git a/packages/opencode/test/server/httpapi-experimental.test.ts b/packages/opencode/test/server/httpapi-experimental.test.ts index 8684edf134..0b8d8051bc 100644 --- a/packages/opencode/test/server/httpapi-experimental.test.ts +++ b/packages/opencode/test/server/httpapi-experimental.test.ts @@ -1,6 +1,5 @@ import { afterEach, describe, expect, test } from "bun:test" import { Effect } from "effect" -import { Flag } from "@opencode-ai/core/flag/flag" import { Instance } from "../../src/project/instance" import { WithInstance } from "../../src/project/with-instance" import { Server } from "../../src/server/server" @@ -15,11 +14,9 @@ import { waitGlobalBusEventPromise } from "./global-bus" void Log.init({ print: false }) -const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI const testWorktreeMutations = process.platform === "win32" ? test.skip : test function app() { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true return Server.Default().app } @@ -39,7 +36,6 @@ async function waitReady(directory: string) { } afterEach(async () => { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original await disposeAllInstances() await resetDatabase() }) diff --git a/packages/opencode/test/server/httpapi-instance.legacy.test.ts b/packages/opencode/test/server/httpapi-instance.legacy.test.ts deleted file mode 100644 index b5f0805e4c..0000000000 --- a/packages/opencode/test/server/httpapi-instance.legacy.test.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { afterEach, describe, expect, test } from "bun:test" -import { Flag } from "@opencode-ai/core/flag/flag" -import { Server } from "../../src/server/server" -import { InstancePaths } from "../../src/server/routes/instance/httpapi/groups/instance" -import * as Log from "@opencode-ai/core/util/log" -import { resetDatabase } from "../fixture/db" -import { disposeAllInstances, tmpdir } from "../fixture/fixture" -import { waitGlobalBusEventPromise } from "./global-bus" - -void Log.init({ print: false }) - -const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI - -function app() { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true - return Server.Default().app -} - -async function waitDisposed(directory: string) { - await waitGlobalBusEventPromise({ - message: "timed out waiting for instance disposal", - predicate: (event) => event.payload.type === "server.instance.disposed" && event.directory === directory, - }) -} - -afterEach(async () => { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original - await disposeAllInstances() - await resetDatabase() -}) - -describe("instance HttpApi", () => { - test("serves catalog read endpoints through Hono bridge", async () => { - await using tmp = await tmpdir({ config: { formatter: false, lsp: false } }) - - const [commands, agents, skills, lsp, formatter] = await Promise.all([ - app().request(InstancePaths.command, { headers: { "x-opencode-directory": tmp.path } }), - app().request(InstancePaths.agent, { headers: { "x-opencode-directory": tmp.path } }), - app().request(InstancePaths.skill, { headers: { "x-opencode-directory": tmp.path } }), - app().request(InstancePaths.lsp, { headers: { "x-opencode-directory": tmp.path } }), - app().request(InstancePaths.formatter, { headers: { "x-opencode-directory": tmp.path } }), - ]) - - expect(commands.status).toBe(200) - expect(await commands.json()).toContainEqual(expect.objectContaining({ name: "init", source: "command" })) - - expect(agents.status).toBe(200) - expect(await agents.json()).toContainEqual(expect.objectContaining({ name: "build", mode: "primary" })) - - expect(skills.status).toBe(200) - expect(await skills.json()).toBeArray() - - expect(lsp.status).toBe(200) - expect(await lsp.json()).toEqual([]) - - expect(formatter.status).toBe(200) - expect(await formatter.json()).toEqual([]) - }) - - test("serves project git init through Hono bridge", async () => { - await using tmp = await tmpdir({ config: { formatter: false, lsp: false } }) - const disposed = waitDisposed(tmp.path) - - const response = await app().request("/project/git/init", { - method: "POST", - headers: { "x-opencode-directory": tmp.path }, - }) - - expect(response.status).toBe(200) - expect(await response.json()).toMatchObject({ vcs: "git", worktree: tmp.path }) - await disposed - - const current = await app().request("/project/current", { headers: { "x-opencode-directory": tmp.path } }) - expect(current.status).toBe(200) - expect(await current.json()).toMatchObject({ vcs: "git", worktree: tmp.path }) - }) - - test("serves project update through Hono bridge", async () => { - await using tmp = await tmpdir({ config: { formatter: false, lsp: false } }) - - const current = await app().request("/project/current", { headers: { "x-opencode-directory": tmp.path } }) - expect(current.status).toBe(200) - const project = (await current.json()) as { id: string } - - const response = await app().request(`/project/${project.id}`, { - method: "PATCH", - headers: { "x-opencode-directory": tmp.path, "content-type": "application/json" }, - body: JSON.stringify({ name: "patched-project", commands: { start: "bun dev" } }), - }) - - expect(response.status).toBe(200) - expect(await response.json()).toMatchObject({ - id: project.id, - name: "patched-project", - commands: { start: "bun dev" }, - }) - - const list = await app().request("/project", { headers: { "x-opencode-directory": tmp.path } }) - expect(list.status).toBe(200) - expect(await list.json()).toContainEqual( - expect.objectContaining({ id: project.id, name: "patched-project", commands: { start: "bun dev" } }), - ) - }) - - test("serves instance dispose through Hono bridge", async () => { - await using tmp = await tmpdir() - - const disposed = waitGlobalBusEventPromise({ - message: "timed out waiting for instance disposal", - predicate: (event) => event.payload.type === "server.instance.disposed", - }) - - const response = await app().request(InstancePaths.dispose, { - method: "POST", - headers: { "x-opencode-directory": tmp.path }, - }) - - expect(response.status).toBe(200) - expect(await response.json()).toBe(true) - expect((await disposed).directory).toBe(tmp.path) - }) -}) diff --git a/packages/opencode/test/server/httpapi-instance.test.ts b/packages/opencode/test/server/httpapi-instance.test.ts index 365fa1220f..946de2835c 100644 --- a/packages/opencode/test/server/httpapi-instance.test.ts +++ b/packages/opencode/test/server/httpapi-instance.test.ts @@ -14,22 +14,18 @@ import { resetDatabase } from "../fixture/db" import { disposeAllInstances, tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" -// Flip the experimental HttpApi flag so backend selection telemetry on the -// production routes reports the right backend, and the experimental -// workspaces flag so SyncEvent.run actually writes to EventSequenceTable -// (the source of truth the fence middleware reads). Reset the database -// around the test so per-instance state does not leak between runs. -// resetDatabase() already calls disposeAllInstances(), so we don't repeat it. +// Flip the experimental workspaces flag so SyncEvent.run actually writes to +// EventSequenceTable (the source of truth the fence middleware reads). Reset +// the database around the test so per-instance state does not leak between +// runs. resetDatabase() already calls disposeAllInstances(), so we don't +// repeat it. const testStateLayer = Layer.effectDiscard( Effect.gen(function* () { - const originalHttpApi = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true yield* Effect.promise(() => resetDatabase()) yield* Effect.addFinalizer(() => Effect.promise(async () => { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = originalHttpApi Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces await resetDatabase() }), diff --git a/packages/opencode/test/server/httpapi-json-parity.test.ts b/packages/opencode/test/server/httpapi-json-parity.test.ts deleted file mode 100644 index 656541be71..0000000000 --- a/packages/opencode/test/server/httpapi-json-parity.test.ts +++ /dev/null @@ -1,254 +0,0 @@ -import { afterEach, describe, expect } from "bun:test" -import { Effect } from "effect" -import { Flag } from "@opencode-ai/core/flag/flag" -import { ModelID, ProviderID } from "../../src/provider/schema" -import { Instance } from "../../src/project/instance" -import { Server } from "../../src/server/server" -import { ExperimentalPaths } from "../../src/server/routes/instance/httpapi/groups/experimental" -import { FilePaths } from "../../src/server/routes/instance/httpapi/groups/file" -import { GlobalPaths } from "../../src/server/routes/instance/httpapi/groups/global" -import { InstancePaths } from "../../src/server/routes/instance/httpapi/groups/instance" -import { McpPaths } from "../../src/server/routes/instance/httpapi/groups/mcp" -import { PtyPaths } from "../../src/server/routes/instance/httpapi/groups/pty" -import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session" -import { MessageID, PartID } from "../../src/session/schema" -import { Session } from "@/session/session" -import * as Log from "@opencode-ai/core/util/log" -import { resetDatabase } from "../fixture/db" -import { disposeAllInstances, provideInstance, tmpdir } from "../fixture/fixture" -import { it } from "../lib/effect" - -void Log.init({ print: false }) - -const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI - -function app(experimental: boolean) { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = experimental - return experimental ? Server.Default().app : Server.Legacy().app -} -type TestApp = ReturnType - -function pathFor(path: string, params: Record) { - return Object.entries(params).reduce((result, [key, value]) => result.replace(`:${key}`, value), path) -} - -const seedSessions = Effect.gen(function* () { - const svc = yield* Session.Service - const parent = yield* svc.create({ title: "parent" }) - yield* svc.create({ title: "child", parentID: parent.id }) - const message = yield* svc.updateMessage({ - id: MessageID.ascending(), - role: "user", - sessionID: parent.id, - agent: "build", - model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") }, - time: { created: Date.now() }, - }) - yield* svc.updatePart({ - id: PartID.ascending(), - sessionID: parent.id, - messageID: message.id, - type: "text", - text: "hello", - }) - return { parent, message } -}) - -function withTmp( - options: Parameters[0], - fn: (tmp: Awaited>) => Effect.Effect, -) { - return Effect.acquireRelease( - Effect.promise(() => tmpdir(options)), - (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), - ).pipe(Effect.flatMap((tmp) => fn(tmp).pipe(provideInstance(tmp.path)))) -} - -function readJson(label: string, serverApp: TestApp, path: string, headers: HeadersInit) { - return Effect.promise(async () => { - const response = await serverApp.request(path, { headers }) - if (response.status !== 200) throw new Error(`${label} returned ${response.status}: ${await response.text()}`) - return await response.json() - }) -} - -function expectJsonParity(input: { - label: string - legacy: TestApp - httpapi: TestApp - path: string - headers: HeadersInit -}) { - return Effect.gen(function* () { - const legacy = yield* readJson(input.label, input.legacy, input.path, input.headers) - const httpapi = yield* readJson(input.label, input.httpapi, input.path, input.headers) - expect({ label: input.label, body: httpapi }).toEqual({ label: input.label, body: legacy }) - return httpapi - }) -} - -afterEach(async () => { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original - await disposeAllInstances() - await resetDatabase() -}) - -describe("HttpApi JSON parity", () => { - it.live( - "matches legacy JSON shape for safe GET endpoints", - withTmp( - { - git: true, - config: { - formatter: false, - lsp: false, - mcp: { - demo: { - type: "local", - command: ["echo", "demo"], - enabled: false, - }, - }, - }, - }, - (tmp) => - Effect.gen(function* () { - yield* Effect.promise(() => Bun.write(`${tmp.path}/hello.txt`, "hello\n")) - - const headers = { "x-opencode-directory": tmp.path } - const legacy = app(false) - const httpapi = app(true) - - yield* Effect.forEach( - [ - { label: "global.health", path: GlobalPaths.health, headers: {} }, - { label: "global.config", path: GlobalPaths.config, headers: {} }, - { label: "instance.path", path: InstancePaths.path, headers }, - { label: "instance.vcs", path: InstancePaths.vcs, headers }, - { label: "instance.vcsDiff", path: `${InstancePaths.vcsDiff}?mode=git`, headers }, - { label: "instance.command", path: InstancePaths.command, headers }, - { label: "instance.agent", path: InstancePaths.agent, headers }, - { label: "instance.skill", path: InstancePaths.skill, headers }, - { label: "instance.lsp", path: InstancePaths.lsp, headers }, - { label: "instance.formatter", path: InstancePaths.formatter, headers }, - { label: "config.get", path: "/config", headers }, - { label: "config.providers", path: "/config/providers", headers }, - { label: "project.list", path: "/project", headers }, - { label: "project.current", path: "/project/current", headers }, - { label: "provider.list", path: "/provider", headers }, - { label: "provider.auth", path: "/provider/auth", headers }, - { label: "permission.list", path: "/permission", headers }, - { label: "question.list", path: "/question", headers }, - { label: "mcp.status", path: McpPaths.status, headers }, - { label: "pty.shells", path: PtyPaths.shells, headers }, - { label: "pty.list", path: PtyPaths.list, headers }, - { label: "file.list", path: `${FilePaths.list}?${new URLSearchParams({ path: "." })}`, headers }, - { - label: "file.content", - path: `${FilePaths.content}?${new URLSearchParams({ path: "hello.txt" })}`, - headers, - }, - { label: "file.status", path: FilePaths.status, headers }, - { - label: "find.file", - path: `${FilePaths.findFile}?${new URLSearchParams({ query: "hello", dirs: "false" })}`, - headers, - }, - { - label: "find.text", - path: `${FilePaths.findText}?${new URLSearchParams({ pattern: "hello" })}`, - headers, - }, - { - label: "find.symbol", - path: `${FilePaths.findSymbol}?${new URLSearchParams({ query: "hello" })}`, - headers, - }, - { label: "experimental.console", path: ExperimentalPaths.console, headers }, - { label: "experimental.consoleOrgs", path: ExperimentalPaths.consoleOrgs, headers }, - { label: "experimental.toolIDs", path: ExperimentalPaths.toolIDs, headers }, - { label: "experimental.worktree", path: ExperimentalPaths.worktree, headers }, - { label: "experimental.resource", path: ExperimentalPaths.resource, headers }, - ], - (input) => expectJsonParity({ ...input, legacy, httpapi }), - { concurrency: 1 }, - ) - }), - ), - ) - - it.live( - "matches legacy JSON shape for session read endpoints", - withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) => - Effect.gen(function* () { - const headers = { "x-opencode-directory": tmp.path } - const seeded = yield* seedSessions.pipe(Effect.provide(Session.defaultLayer)) - const legacy = app(false) - const httpapi = app(true) - - const rootsFalse = yield* expectJsonParity({ - label: "session.list roots false", - legacy, - httpapi, - path: `${SessionPaths.list}?roots=false`, - headers, - }) - expect((rootsFalse as Session.Info[]).map((session) => session.id)).toContain(seeded.parent.id) - expect((rootsFalse as Session.Info[]).length).toBe(2) - - const experimentalRootsFalse = yield* expectJsonParity({ - label: "experimental.session roots false", - legacy, - httpapi, - path: `${ExperimentalPaths.session}?${new URLSearchParams({ directory: tmp.path, limit: "10", roots: "false" })}`, - headers, - }) - expect((experimentalRootsFalse as Session.GlobalInfo[]).length).toBe(2) - - const experimentalArchivedFalse = yield* expectJsonParity({ - label: "experimental.session archived false", - legacy, - httpapi, - path: `${ExperimentalPaths.session}?${new URLSearchParams({ directory: tmp.path, limit: "10", archived: "false" })}`, - headers, - }) - expect((experimentalArchivedFalse as Session.GlobalInfo[]).length).toBe(2) - - yield* Effect.forEach( - [ - { label: "session.list roots", path: `${SessionPaths.list}?roots=true`, headers }, - { label: "session.list all", path: SessionPaths.list, headers }, - { label: "session.get", path: pathFor(SessionPaths.get, { sessionID: seeded.parent.id }), headers }, - { - label: "session.children", - path: pathFor(SessionPaths.children, { sessionID: seeded.parent.id }), - headers, - }, - { - label: "session.messages", - path: pathFor(SessionPaths.messages, { sessionID: seeded.parent.id }), - headers, - }, - { - label: "session.messages empty before", - path: `${pathFor(SessionPaths.messages, { sessionID: seeded.parent.id })}?before=`, - headers, - }, - { - label: "session.message", - path: pathFor(SessionPaths.message, { sessionID: seeded.parent.id, messageID: seeded.message.id }), - headers, - }, - { - label: "experimental.session", - path: `${ExperimentalPaths.session}?${new URLSearchParams({ directory: tmp.path, limit: "10" })}`, - headers, - }, - ], - (input) => expectJsonParity({ ...input, legacy, httpapi }), - { concurrency: 1 }, - ) - }), - ), - ) -}) diff --git a/packages/opencode/test/server/httpapi-listen.test.ts b/packages/opencode/test/server/httpapi-listen.test.ts index b49fbe98b5..b2ff28ec67 100644 --- a/packages/opencode/test/server/httpapi-listen.test.ts +++ b/packages/opencode/test/server/httpapi-listen.test.ts @@ -10,7 +10,6 @@ import { disposeAllInstances, tmpdir } from "../fixture/fixture" void Log.init({ print: false }) const original = { - OPENCODE_EXPERIMENTAL_HTTPAPI: Flag.OPENCODE_EXPERIMENTAL_HTTPAPI, OPENCODE_SERVER_PASSWORD: Flag.OPENCODE_SERVER_PASSWORD, OPENCODE_SERVER_USERNAME: Flag.OPENCODE_SERVER_USERNAME, envPassword: process.env.OPENCODE_SERVER_PASSWORD, @@ -20,7 +19,6 @@ const auth = { username: "opencode", password: "listen-secret" } const testPty = process.platform === "win32" ? test.skip : test afterEach(async () => { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original.OPENCODE_EXPERIMENTAL_HTTPAPI Flag.OPENCODE_SERVER_PASSWORD = original.OPENCODE_SERVER_PASSWORD Flag.OPENCODE_SERVER_USERNAME = original.OPENCODE_SERVER_USERNAME if (original.envPassword === undefined) delete process.env.OPENCODE_SERVER_PASSWORD @@ -31,8 +29,7 @@ afterEach(async () => { await resetDatabase() }) -async function startListener(backend: "effect-httpapi" | "hono" = "effect-httpapi") { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = backend === "effect-httpapi" +async function startListener() { Flag.OPENCODE_SERVER_PASSWORD = auth.password Flag.OPENCODE_SERVER_USERNAME = auth.username process.env.OPENCODE_SERVER_PASSWORD = auth.password @@ -40,8 +37,7 @@ async function startListener(backend: "effect-httpapi" | "hono" = "effect-httpap return Server.listen({ hostname: "127.0.0.1", port: 0 }) } -async function startNoAuthListener(backend: "effect-httpapi" | "hono" = "effect-httpapi") { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = backend === "effect-httpapi" +async function startNoAuthListener() { Flag.OPENCODE_SERVER_PASSWORD = undefined Flag.OPENCODE_SERVER_USERNAME = auth.username delete process.env.OPENCODE_SERVER_PASSWORD @@ -212,22 +208,6 @@ describe("HttpApi Server.listen", () => { } }) - testPty("serves PTY websocket tickets through legacy Hono Server.listen", async () => { - await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) - const listener = await startListener("hono") - try { - const info = await createCat(listener, tmp.path) - const ticket = await connectTicket(listener, info.id, tmp.path) - const ws = await openSocket(socketURL(listener, info.id, tmp.path, ticket.ticket)) - const message = waitForMessage(ws, (message) => message.includes("ping-hono-ticket")) - ws.send("ping-hono-ticket\n") - expect(await message).toContain("ping-hono-ticket") - ws.close(1000) - } finally { - await stop(listener, "timed out cleaning up hono listener").catch(() => undefined) - } - }) - testPty("rejects unsafe PTY ticket mint and connect requests", async () => { await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) const listener = await startListener() @@ -300,20 +280,18 @@ describe("HttpApi Server.listen", () => { } }) - for (const backend of ["effect-httpapi", "hono"] as const) { - testPty(`keeps PTY websocket tickets optional when server auth is disabled (${backend})`, async () => { - await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) - const listener = await startNoAuthListener(backend) - try { - const info = await createCat(listener, tmp.path) - const ws = await openSocket(socketURL(listener, info.id, tmp.path)) - const message = waitForMessage(ws, (message) => message.includes(`ping-no-auth-${backend}`)) - ws.send(`ping-no-auth-${backend}\n`) - expect(await message).toContain(`ping-no-auth-${backend}`) - ws.close(1000) - } finally { - await stop(listener, "timed out cleaning up no-auth listener").catch(() => undefined) - } - }) - } + testPty("keeps PTY websocket tickets optional when server auth is disabled", async () => { + await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) + const listener = await startNoAuthListener() + try { + const info = await createCat(listener, tmp.path) + const ws = await openSocket(socketURL(listener, info.id, tmp.path)) + const message = waitForMessage(ws, (message) => message.includes("ping-no-auth")) + ws.send("ping-no-auth\n") + expect(await message).toContain("ping-no-auth") + ws.close(1000) + } finally { + await stop(listener, "timed out cleaning up no-auth listener").catch(() => undefined) + } + }) }) diff --git a/packages/opencode/test/server/httpapi-mcp.test.ts b/packages/opencode/test/server/httpapi-mcp.test.ts index f442df5770..b6c7aebcd2 100644 --- a/packages/opencode/test/server/httpapi-mcp.test.ts +++ b/packages/opencode/test/server/httpapi-mcp.test.ts @@ -1,7 +1,6 @@ import { afterEach, describe, expect, test } from "bun:test" import { Context, Effect, FileSystem, Layer, Path } from "effect" import { NodeFileSystem, NodePath } from "@effect/platform-node" -import { Flag } from "@opencode-ai/core/flag/flag" import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server" import { McpPaths } from "../../src/server/routes/instance/httpapi/groups/mcp" import { Instance } from "../../src/project/instance" @@ -15,13 +14,11 @@ import { testEffect } from "../lib/effect" void Log.init({ print: false }) -const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI const context = Context.empty() as Context.Context const it = testEffect(Layer.mergeAll(NodeFileSystem.layer, NodePath.layer)) -function app(experimental: boolean) { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = experimental - return experimental ? Server.Default().app : Server.Legacy().app +function app() { + return Server.Default().app } type TestApp = ReturnType @@ -79,7 +76,6 @@ const readResponse = Effect.fnUntraced(function* (input: { app: TestApp; path: s }) afterEach(async () => { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original await disposeAllInstances() await resetDatabase() }) @@ -165,23 +161,19 @@ describe("mcp HttpApi", () => { }) it.live( - "matches legacy unsupported OAuth error responses", + "returns unsupported OAuth error responses", withMcpProject((dir) => Effect.gen(function* () { const headers = { "x-opencode-directory": dir } - const legacy = app(false) - const httpapi = app(true) yield* Effect.forEach(["/mcp/demo/auth", "/mcp/demo/auth/authenticate"], (path) => Effect.gen(function* () { - const legacyResponse = yield* readResponse({ app: legacy, path, headers }) - const httpapiResponse = yield* readResponse({ app: httpapi, path, headers }) + const response = yield* readResponse({ app: app(), path, headers }) - expect(legacyResponse).toEqual({ + expect(response).toEqual({ status: 400, body: JSON.stringify({ error: "MCP server demo does not support OAuth" }), }) - expect(httpapiResponse).toEqual(legacyResponse) }), ) }), diff --git a/packages/opencode/test/server/httpapi-parity.test.ts b/packages/opencode/test/server/httpapi-parity.test.ts deleted file mode 100644 index 9d7eff4964..0000000000 --- a/packages/opencode/test/server/httpapi-parity.test.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { afterEach, describe, expect, test } from "bun:test" -import { Effect } from "effect" -import { Flag } from "@opencode-ai/core/flag/flag" -import * as Log from "@opencode-ai/core/util/log" -import { WithInstance } from "../../src/project/with-instance" -import { Server } from "../../src/server/server" -import { Session } from "@/session/session" -import { MessageID } from "../../src/session/schema" -import { ModelID, ProviderID } from "../../src/provider/schema" -import { resetDatabase } from "../fixture/db" -import { disposeAllInstances, tmpdir } from "../fixture/fixture" - -void Log.init({ print: false }) - -const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI - -afterEach(async () => { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original - await disposeAllInstances() - await resetDatabase() -}) - -function app(experimental: boolean) { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = experimental - return experimental ? Server.Default().app : Server.Legacy().app -} - -function runSession(fx: Effect.Effect) { - return Effect.runPromise(fx.pipe(Effect.provide(Session.defaultLayer))) -} - -function createSessionWithMessages(directory: string, count: number) { - return WithInstance.provide({ - directory, - fn: async () => { - const session = await runSession(Session.Service.use((svc) => svc.create({}))) - for (let i = 0; i < count; i++) { - await runSession( - Effect.gen(function* () { - const svc = yield* Session.Service - yield* svc.updateMessage({ - id: MessageID.ascending(), - role: "user", - sessionID: session.id, - agent: "build", - model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") }, - time: { created: Date.now() }, - }) - }), - ) - } - return session.id - }, - }) -} - -// ────────────────────────────────────────────────────────────────────────────── -// Reproducer 1: Link header should reflect the request's actual Host header, -// not "localhost". HttpApi uses `new URL(request.url, "http://localhost")` -// which embeds localhost because request.url is path-only. Fix: use -// `HttpServerRequest.toURL(request)` which honors the Host header. -// ────────────────────────────────────────────────────────────────────────────── -describe("Link header host", () => { - test("HttpApi pagination Link header echoes request host", async () => { - await using tmp = await tmpdir({ config: { formatter: false, lsp: false } }) - const sessionID = await createSessionWithMessages(tmp.path, 3) - - const response = await app(true).request(`/session/${sessionID}/message?limit=2`, { - headers: { - host: "opencode.test:4096", - "x-opencode-directory": tmp.path, - }, - }) - - expect(response.status).toBe(200) - const link = response.headers.get("link") - expect(link).not.toBeNull() - // Link should contain the request's Host, not "localhost". - expect(link).toContain("opencode.test") - expect(link).not.toContain("localhost") - }) -}) - -// ────────────────────────────────────────────────────────────────────────────── -// Reproducer 2: GET /session/{missing-id}/todo should return 404, not 500. -// The session.todo handler in HttpApi doesn't wrap with `mapNotFound`, so a -// `NotFoundError` from the service surfaces as a defect → 500. Hono's -// equivalent maps to 404 via `errors.notFound`. -// -// Affected endpoints (handlers without mapNotFound): todo, diff, summarize, -// fork, abort, init, deleteMessage, command, shell, revert, unrevert. -// -// FIXME: unskip when mapNotFound coverage is added (next PR). -// ────────────────────────────────────────────────────────────────────────────── -describe("404 mapping for missing session", () => { - test.todo("HttpApi /session/{missing}/todo returns 404 not 500", async () => { - await using tmp = await tmpdir({ config: { formatter: false, lsp: false } }) - - const response = await app(true).request("/session/ses_does_not_exist/todo", { - headers: { "x-opencode-directory": tmp.path }, - }) - - expect(response.status).toBe(404) - }) -}) - -// ────────────────────────────────────────────────────────────────────────────── -// Reproducer 3: 404 response body shape should match Hono's public NamedError -// envelope `{ name, data: { message } }`. SDK consumers read -// `error.data.message`, so returning an Effect built-in `{ _tag }` body is a -// compatibility break. -// ────────────────────────────────────────────────────────────────────────────── -describe("Error JSON shape parity", () => { - test("HttpApi 404 body matches Hono shape", async () => { - await using tmp = await tmpdir({ config: { formatter: false, lsp: false } }) - const headers = { "x-opencode-directory": tmp.path } - - const hono = await app(false).request("/session/ses_does_not_exist", { headers }) - const httpapi = await app(true).request("/session/ses_does_not_exist", { headers }) - - expect(httpapi.status).toBe(hono.status) - const body = (await httpapi.json()) as { name?: string; data?: { message?: string } } - expect(body).toEqual(await hono.json()) - expect(body.name).toBe("NotFoundError") - expect(typeof body.data?.message).toBe("string") - }) -}) diff --git a/packages/opencode/test/server/httpapi-provider.test.ts b/packages/opencode/test/server/httpapi-provider.test.ts index fa32608fc8..12262e30ed 100644 --- a/packages/opencode/test/server/httpapi-provider.test.ts +++ b/packages/opencode/test/server/httpapi-provider.test.ts @@ -1,7 +1,6 @@ import { afterEach, describe, expect } from "bun:test" import { Effect, FileSystem, Layer, Path } from "effect" import { NodeFileSystem, NodePath } from "@effect/platform-node" -import { Flag } from "@opencode-ai/core/flag/flag" import { Instance } from "../../src/project/instance" import { WithInstance } from "../../src/project/with-instance" import { InstanceRuntime } from "../../src/project/instance-runtime" @@ -13,15 +12,13 @@ import { testEffect } from "../lib/effect" void Log.init({ print: false }) -const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI const it = testEffect(Layer.mergeAll(NodeFileSystem.layer, NodePath.layer)) const providerID = "test-oauth-parity" const oauthURL = "https://example.com/oauth" const oauthInstructions = "Finish OAuth" -function app(experimental: boolean) { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = experimental - return experimental ? Server.Default().app : Server.Legacy().app +function app() { + return Server.Default().app } function requestAuthorize(input: { @@ -101,54 +98,37 @@ function withProviderProject(self: (dir: string) => Effect.Effect { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original await disposeAllInstances() await resetDatabase() }) describe("provider HttpApi", () => { it.live( - "matches legacy OAuth authorize response shapes", + "serves OAuth authorize response shapes", withProviderProject((dir) => Effect.gen(function* () { const headers = { "x-opencode-directory": dir, "content-type": "application/json" } - const legacy = app(false) - const httpapi = app(true) + const server = app() - const apiLegacy = yield* requestAuthorize({ - app: legacy, + const api = yield* requestAuthorize({ + app: server, providerID, method: 0, headers, }) - const apiHttpApi = yield* requestAuthorize({ - app: httpapi, - providerID, - method: 0, - headers, - }) - expect(apiLegacy).toEqual({ status: 200, body: "" }) - // #26474 changed the HTTP API authorize handler to serialize an - // undefined service result as JSON `null` instead of an empty body - // so clients can `.json()` parse the response uniformly. The legacy - // Hono path still emits an empty body (`c.json(undefined)`); the new - // backend's body diverges intentionally. - expect(apiHttpApi).toEqual({ status: 200, body: "null" }) + // method 0 (api-key style) — authorize() resolves with no further + // redirect; #26474 changed the wire format to JSON `null` so clients + // can `.json()` parse uniformly instead of getting an empty body + // that throws. + expect(api).toEqual({ status: 200, body: "null" }) - const oauthLegacy = yield* requestAuthorize({ - app: legacy, + const oauth = yield* requestAuthorize({ + app: server, providerID, method: 1, headers, }) - const oauthHttpApi = yield* requestAuthorize({ - app: httpapi, - providerID, - method: 1, - headers, - }) - expect(oauthHttpApi).toEqual(oauthLegacy) - expect(JSON.parse(oauthHttpApi.body)).toEqual({ + expect(JSON.parse(oauth.body)).toEqual({ url: oauthURL, method: "code", instructions: oauthInstructions, diff --git a/packages/opencode/test/server/httpapi-pty.test.ts b/packages/opencode/test/server/httpapi-pty.test.ts index 5e63eae61c..987eba6b38 100644 --- a/packages/opencode/test/server/httpapi-pty.test.ts +++ b/packages/opencode/test/server/httpapi-pty.test.ts @@ -1,6 +1,5 @@ import { afterEach, describe, expect, test } from "bun:test" import { NodeHttpServer, NodeServices } from "@effect/platform-node" -import { Flag } from "@opencode-ai/core/flag/flag" import { PtyID } from "../../src/pty/schema" import { Instance } from "../../src/project/instance" import { Server } from "../../src/server/server" @@ -17,16 +16,13 @@ import { testEffect } from "../lib/effect" void Log.init({ print: false }) -const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI const testPty = process.platform === "win32" ? test.skip : test const testStateLayer = Layer.effectDiscard( Effect.gen(function* () { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true yield* Effect.promise(() => resetDatabase()) yield* Effect.addFinalizer(() => Effect.promise(async () => { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original await resetDatabase() }), ) @@ -50,9 +46,8 @@ const effectIt = testEffect( ), ) -function app(experimental = true) { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = experimental - return experimental ? Server.Default().app : Server.Legacy().app +function app() { + return Server.Default().app } function serverUrl() { @@ -62,7 +57,6 @@ function serverUrl() { const directoryHeader = (dir: string) => HttpClientRequest.setHeader("x-opencode-directory", dir) afterEach(async () => { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original await disposeAllInstances() await resetDatabase() }) @@ -121,18 +115,6 @@ describe("pty HttpApi bridge", () => { expect(missing.status).toBe(404) }) - test("matches Hono missing PTY error body", async () => { - await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) - const headers = { "x-opencode-directory": tmp.path } - const path = PtyPaths.get.replace(":ptyID", PtyID.ascending()) - - const hono = await app(false).request(path, { headers }) - const httpapi = await app().request(path, { headers }) - - expect(httpapi.status).toBe(hono.status) - expect(await httpapi.json()).toEqual(await hono.json()) - }) - test("returns 404 for missing PTY websocket before upgrade", async () => { await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) const response = await app().request(PtyPaths.connect.replace(":ptyID", PtyID.ascending()), { diff --git a/packages/opencode/test/server/httpapi-raw-route-auth.test.ts b/packages/opencode/test/server/httpapi-raw-route-auth.test.ts index fd82e78639..7e5757b1ea 100644 --- a/packages/opencode/test/server/httpapi-raw-route-auth.test.ts +++ b/packages/opencode/test/server/httpapi-raw-route-auth.test.ts @@ -1,7 +1,6 @@ import { afterEach, describe, expect, test } from "bun:test" import { ConfigProvider, Layer } from "effect" import { HttpRouter } from "effect/unstable/http" -import { Flag } from "@opencode-ai/core/flag/flag" import { Instance } from "../../src/project/instance" import { EventPaths } from "../../src/server/routes/instance/httpapi/event" import { PtyPaths } from "../../src/server/routes/instance/httpapi/groups/pty" @@ -13,10 +12,8 @@ import * as Log from "@opencode-ai/core/util/log" void Log.init({ print: false }) -const originalHttpApi = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI function app(input: { password?: string; username?: string }) { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true const handler = HttpRouter.toWebHandler( ExperimentalHttpApiServer.routes.pipe( Layer.provide( @@ -48,7 +45,6 @@ async function cancelBody(response: Response) { } afterEach(async () => { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = originalHttpApi await disposeAllInstances() await resetDatabase() }) diff --git a/packages/opencode/test/server/httpapi-sdk.test.ts b/packages/opencode/test/server/httpapi-sdk.test.ts index 493f890838..8a179a4dcc 100644 --- a/packages/opencode/test/server/httpapi-sdk.test.ts +++ b/packages/opencode/test/server/httpapi-sdk.test.ts @@ -22,23 +22,21 @@ import { disposeAllInstances, tmpdir } from "../fixture/fixture" import { it } from "../lib/effect" const original = { - OPENCODE_EXPERIMENTAL_HTTPAPI: Flag.OPENCODE_EXPERIMENTAL_HTTPAPI, OPENCODE_SERVER_PASSWORD: Flag.OPENCODE_SERVER_PASSWORD, OPENCODE_SERVER_USERNAME: Flag.OPENCODE_SERVER_USERNAME, } -type Backend = "legacy" | "httpapi" +type ServerPath = "default" | "raw" type Sdk = ReturnType type SdkResult = { response: Response; data?: unknown; error?: unknown } type Captured = { status: number; data?: unknown; error?: unknown } type ProjectFixture = { sdk: Sdk; directory: string } type LlmProjectFixture = ProjectFixture & { llm: TestLLMServer["Service"] } -function app(backend: Backend, input?: { password?: string; username?: string }) { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = backend === "httpapi" +function app(serverPath: ServerPath, input?: { password?: string; username?: string }) { Flag.OPENCODE_SERVER_PASSWORD = input?.password Flag.OPENCODE_SERVER_USERNAME = input?.username - if (backend === "legacy") return Server.Legacy().app + if (serverPath === "default") return Server.Default().app const handler = HttpRouter.toWebHandler( ExperimentalHttpApiServer.routes.pipe( @@ -62,7 +60,7 @@ function app(backend: Backend, input?: { password?: string; username?: string }) } function client( - backend: Backend, + serverPath: ServerPath, directory?: string, input?: { password?: string; username?: string; headers?: Record }, ) { @@ -70,12 +68,12 @@ function client( baseUrl: "http://localhost", directory, headers: input?.headers, - fetch: serverFetch(backend, input), + fetch: serverFetch(serverPath, input), }) } -function serverFetch(backend: Backend, input?: { password?: string; username?: string }) { - const serverApp = app(backend, input) +function serverFetch(serverPath: ServerPath, input?: { password?: string; username?: string }) { + const serverApp = app(serverPath, input) return Object.assign( async (request: RequestInfo | URL, init?: RequestInit) => await serverApp.fetch(request instanceof Request ? request : new Request(request, init)), @@ -194,20 +192,20 @@ function httpapi(name: string, effect: Effect.Effect) { it.live(name, effect) } -function parity(name: string, scenario: (backend: Backend) => Effect.Effect) { +function serverPathParity(name: string, scenario: (serverPath: ServerPath) => Effect.Effect) { it.live( name, Effect.gen(function* () { - const legacy = yield* scenario("legacy") + const standard = yield* scenario("default") yield* resetState() - const httpapi = yield* scenario("httpapi") - expect(httpapi).toEqual(legacy) + const raw = yield* scenario("raw") + expect(raw).toEqual(standard) }), ) } function withProject( - backend: Backend, + serverPath: ServerPath, options: { git?: boolean; config?: Partial; setup?: (dir: string) => Effect.Effect }, run: (input: ProjectFixture) => Effect.Effect, ) { @@ -216,30 +214,30 @@ function withProject( (tmp) => call(() => tmp[Symbol.asyncDispose]()).pipe(Effect.ignore), ).pipe( Effect.tap((tmp) => options.setup?.(tmp.path) ?? Effect.void), - Effect.flatMap((tmp) => run({ sdk: client(backend, tmp.path), directory: tmp.path })), + Effect.flatMap((tmp) => run({ sdk: client(serverPath, tmp.path), directory: tmp.path })), ) } -function withStandardProject(backend: Backend, run: (input: ProjectFixture) => Effect.Effect) { - return withProject(backend, { setup: writeStandardFiles }, run) +function withStandardProject(serverPath: ServerPath, run: (input: ProjectFixture) => Effect.Effect) { + return withProject(serverPath, { setup: writeStandardFiles }, run) } -function withFakeLlm(backend: Backend, run: (input: LlmProjectFixture) => Effect.Effect) { +function withFakeLlm(serverPath: ServerPath, run: (input: LlmProjectFixture) => Effect.Effect) { return Effect.gen(function* () { const llm = yield* TestLLMServer - return yield* withProject(backend, { config: providerConfig(llm.url) }, (input) => run({ ...input, llm })) + return yield* withProject(serverPath, { config: providerConfig(llm.url) }, (input) => run({ ...input, llm })) }).pipe(Effect.provide(TestLLMServer.layer)) } function withFakeLlmProject( - backend: Backend, + serverPath: ServerPath, options: { setup?: (dir: string) => Effect.Effect }, run: (input: LlmProjectFixture) => Effect.Effect, ) { return Effect.gen(function* () { const llm = yield* TestLLMServer return yield* withProject( - backend, + serverPath, { config: providerConfig(llm.url), setup: options.setup, @@ -306,7 +304,6 @@ function seedMessage(directory: string, sessionID: string) { } afterEach(async () => { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original.OPENCODE_EXPERIMENTAL_HTTPAPI Flag.OPENCODE_SERVER_PASSWORD = original.OPENCODE_SERVER_PASSWORD Flag.OPENCODE_SERVER_USERNAME = original.OPENCODE_SERVER_USERNAME await disposeAllInstances() @@ -317,7 +314,7 @@ describe("HttpApi SDK", () => { httpapi( "uses the generated SDK for global and control routes", Effect.gen(function* () { - const sdk = client("httpapi") + const sdk = client("raw") const health = yield* call(() => sdk.global.health()) const log = yield* call(() => sdk.app.log({ service: "httpapi-sdk-test", level: "info", message: "hello" })) @@ -334,7 +331,7 @@ describe("HttpApi SDK", () => { httpapi( "uses the generated SDK for safe instance routes", - withProject("httpapi", { git: false, setup: writeStandardFiles }, ({ sdk }) => + withProject("raw", { git: false, setup: writeStandardFiles }, ({ sdk }) => Effect.gen(function* () { const file = yield* call(() => sdk.file.read({ path: "hello.txt" })) const session = yield* call(() => sdk.session.create({ title: "sdk" })) @@ -357,9 +354,9 @@ describe("HttpApi SDK", () => { ), ) - parity("matches generated SDK global and control behavior across backends", (backend) => + serverPathParity("matches generated SDK global and control behavior", (serverPath) => Effect.gen(function* () { - const sdk = client(backend) + const sdk = client(serverPath) const health = yield* capture(() => sdk.global.health()) const log = yield* capture(() => sdk.app.log({ service: "sdk-parity", level: "info", message: "hello" })) const invalidAuth = yield* capture(() => sdk.auth.set({ providerID: "test" })) @@ -372,22 +369,22 @@ describe("HttpApi SDK", () => { }), ) - parity("matches generated SDK global event stream across backends", (backend) => - firstEvent(() => client(backend).global.event({ signal: AbortSignal.timeout(1_000) })).pipe( + serverPathParity("matches generated SDK global event stream", (serverPath) => + firstEvent(() => client(serverPath).global.event({ signal: AbortSignal.timeout(1_000) })).pipe( Effect.map((event) => ({ type: record(record(event).payload).type })), ), ) - parity("matches generated SDK instance event stream across backends", (backend) => - withStandardProject(backend, ({ sdk }) => + serverPathParity("matches generated SDK instance event stream", (serverPath) => + withStandardProject(serverPath, ({ sdk }) => firstEvent(() => sdk.event.subscribe(undefined, { signal: AbortSignal.timeout(1_000) })).pipe( Effect.map((event) => ({ type: record(record(event).payload).type })), ), ), ) - parity("matches generated SDK missing session errors across backends", (backend) => - withStandardProject(backend, ({ sdk }) => + serverPathParity("matches generated SDK missing session errors", (serverPath) => + withStandardProject(serverPath, ({ sdk }) => Effect.gen(function* () { const sessionID = "ses_missing" const expected = { @@ -408,8 +405,8 @@ describe("HttpApi SDK", () => { ), ) - parity("formats missing session validation errors for -s", (backend) => - withStandardProject(backend, ({ directory }) => + serverPathParity("formats missing session validation errors for -s", (serverPath) => + withStandardProject(serverPath, ({ directory }) => Effect.gen(function* () { const sessionID = "ses_206f84f18ffeZ6hhD7pFYAiW5T" const thrown = yield* captureThrown(() => @@ -417,7 +414,7 @@ describe("HttpApi SDK", () => { url: "http://localhost", directory, sessionID, - fetch: serverFetch(backend), + fetch: serverFetch(serverPath), }), ) expect(errorMessage(thrown)).toBe(`Session not found: ${sessionID}`) @@ -426,20 +423,21 @@ describe("HttpApi SDK", () => { ), ) - parity("matches generated SDK basic auth behavior across backends", (backend) => - withStandardProject(backend, ({ directory }) => + httpapi( + "uses generated SDK basic auth behavior", + withStandardProject("raw", ({ directory }) => Effect.gen(function* () { const missing = yield* capture(() => - client(backend, directory, { password: "secret" }).file.read({ path: "hello.txt" }), + client("raw", directory, { password: "secret" }).file.read({ path: "hello.txt" }), ) const bad = yield* capture(() => - client(backend, directory, { + client("raw", directory, { password: "secret", headers: { authorization: authorization("opencode", "wrong") }, }).file.read({ path: "hello.txt" }), ) const good = yield* capture(() => - client(backend, directory, { + client("raw", directory, { password: "secret", headers: { authorization: authorization("opencode", "secret") }, }).file.read({ path: "hello.txt" }), @@ -453,8 +451,8 @@ describe("HttpApi SDK", () => { ), ) - parity("matches generated SDK instance read routes across backends", (backend) => - withStandardProject(backend, ({ sdk, directory }) => + serverPathParity("matches generated SDK instance read routes", (serverPath) => + withStandardProject(serverPath, ({ sdk, directory }) => Effect.gen(function* () { const project = yield* capture(() => sdk.project.current()) const projects = yield* capture(() => sdk.project.list()) @@ -504,8 +502,8 @@ describe("HttpApi SDK", () => { ), ) - parity("matches generated SDK session lifecycle routes across backends", (backend) => - withStandardProject(backend, ({ sdk }) => + serverPathParity("matches generated SDK session lifecycle routes", (serverPath) => + withStandardProject(serverPath, ({ sdk }) => Effect.gen(function* () { const parent = yield* capture(() => sdk.session.create({ title: "parent" })) const parentID = String(record(parent.data).id) @@ -557,8 +555,8 @@ describe("HttpApi SDK", () => { ), ) - parity("matches generated SDK session message and part routes across backends", (backend) => - withStandardProject(backend, ({ sdk, directory }) => + serverPathParity("matches generated SDK session message and part routes", (serverPath) => + withStandardProject(serverPath, ({ sdk, directory }) => Effect.gen(function* () { const session = yield* capture(() => sdk.session.create({ title: "messages" })) const sessionID = String(record(session.data).id) @@ -609,8 +607,8 @@ describe("HttpApi SDK", () => { ), ) - parity("matches generated SDK prompt no-reply routes across backends", (backend) => - withStandardProject(backend, ({ sdk }) => + serverPathParity("matches generated SDK prompt no-reply routes", (serverPath) => + withStandardProject(serverPath, ({ sdk }) => Effect.gen(function* () { const session = yield* capture(() => sdk.session.create({ title: "prompt" })) const sessionID = String(record(session.data).id) @@ -646,8 +644,8 @@ describe("HttpApi SDK", () => { ), ) - parity("matches generated SDK prompt streaming through fake LLM across backends", (backend) => - withFakeLlm(backend, ({ sdk, llm }) => + serverPathParity("matches generated SDK prompt streaming through fake LLM", (serverPath) => + withFakeLlm(serverPath, ({ sdk, llm }) => Effect.gen(function* () { yield* llm.text("fake world", { usage: { input: 11, output: 7 } }) const session = yield* capture(() => @@ -682,7 +680,7 @@ describe("HttpApi SDK", () => { httpapi( "includes project skills in REST API async prompt context", - withFakeLlmProject("httpapi", { setup: writeProjectSkill }, ({ sdk, llm }) => + withFakeLlmProject("default", { setup: writeProjectSkill }, ({ sdk, llm }) => Effect.gen(function* () { yield* llm.text("skill context ok", { usage: { input: 11, output: 7 } }) const session = yield* capture(() => @@ -710,8 +708,8 @@ describe("HttpApi SDK", () => { ), ) - parity("matches generated SDK TUI validation and command routes across backends", (backend) => - withStandardProject(backend, ({ sdk }) => + serverPathParity("matches generated SDK TUI validation and command routes", (serverPath) => + withStandardProject(serverPath, ({ sdk }) => Effect.gen(function* () { const session = yield* capture(() => sdk.session.create({ title: "tui" })) const sessionID = String(record(session.data).id) @@ -761,8 +759,8 @@ describe("HttpApi SDK", () => { ), ) - parity("matches generated SDK project git initialization across backends", (backend) => - withProject(backend, { git: false }, ({ sdk, directory }) => + serverPathParity("matches generated SDK project git initialization", (serverPath) => + withProject(serverPath, { git: false }, ({ sdk, directory }) => Effect.gen(function* () { const before = yield* capture(() => sdk.project.current()) const init = yield* capture(() => sdk.project.initGit()) diff --git a/packages/opencode/test/server/httpapi-session.test.ts b/packages/opencode/test/server/httpapi-session.test.ts index c1d82446b9..6de704325b 100644 --- a/packages/opencode/test/server/httpapi-session.test.ts +++ b/packages/opencode/test/server/httpapi-session.test.ts @@ -30,16 +30,14 @@ import { it } from "../lib/effect" void Log.init({ print: false }) -const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES const workspaceLayer = Workspace.defaultLayer.pipe( Layer.provide(InstanceStore.defaultLayer), Layer.provide(InstanceBootstrap.defaultLayer), ) -function app(experimental = true) { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = experimental - return experimental ? Server.Default().app : Server.Legacy().app +function app() { + return Server.Default().app } function runSession(fx: Effect.Effect) { @@ -119,10 +117,6 @@ function request(path: string, init?: RequestInit) { return Effect.promise(async () => app().request(path, init)) } -function requestWithBackend(experimental: boolean, path: string, init?: RequestInit) { - return Effect.promise(async () => app(experimental).request(path, init)) -} - function json(response: Response) { return Effect.promise(async () => { if (response.status !== 200) throw new Error(await response.text()) @@ -149,7 +143,6 @@ function withTmp( } afterEach(async () => { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces await disposeAllInstances() await resetDatabase() @@ -198,7 +191,7 @@ describe("session HttpApi", () => { ) it.live( - "serves read routes through Hono bridge", + "serves read routes", withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) => Effect.gen(function* () { const headers = { "x-opencode-directory": tmp.path } @@ -305,7 +298,7 @@ describe("session HttpApi", () => { ) it.live( - "serves lifecycle mutation routes through Hono bridge", + "serves lifecycle mutation routes", withTmp({ git: true, config: { formatter: false, lsp: false, share: "disabled" } }, (tmp) => Effect.gen(function* () { const headers = { "x-opencode-directory": tmp.path, "content-type": "application/json" } @@ -389,39 +382,26 @@ describe("session HttpApi", () => { ) it.live( - "matches legacy archived timestamp validation", + "validates archived timestamp values", withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) => Effect.gen(function* () { const headers = { "x-opencode-directory": tmp.path, "content-type": "application/json" } - const legacy = yield* createSession(tmp.path, { title: "legacy" }) - const effect = yield* createSession(tmp.path, { title: "effect" }) + const session = yield* createSession(tmp.path, { title: "archived" }) const body = JSON.stringify({ time: { archived: -1 } }) - const legacyResponse = yield* requestWithBackend( - false, - pathFor(SessionPaths.update, { sessionID: legacy.id }), - { - method: "PATCH", - headers, - body, - }, - ) - expect(legacyResponse.status).toBe(200) - expect((yield* json(legacyResponse)).time.archived).toBe(-1) - - const effectResponse = yield* requestWithBackend(true, pathFor(SessionPaths.update, { sessionID: effect.id }), { + const response = yield* request(pathFor(SessionPaths.update, { sessionID: session.id }), { method: "PATCH", headers, body, }) - expect(effectResponse.status).toBe(legacyResponse.status) - expect((yield* json(effectResponse)).time.archived).toBe(-1) + expect(response.status).toBe(200) + expect((yield* json(response)).time.archived).toBe(-1) }), ), ) it.live( - "matches legacy project-scoped path and directory precedence", + "uses project-scoped path and directory precedence", withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) => Effect.gen(function* () { const currentDir = path.join(tmp.path, "packages", "opencode", "src") @@ -441,22 +421,18 @@ describe("session HttpApi", () => { directory: currentDir, }) const headers = { "x-opencode-directory": tmp.path } - const legacy = (yield* json( - yield* requestWithBackend(false, `${SessionPaths.list}?${query}`, { headers }), - )).map((item) => item.id) - const effect = (yield* json( - yield* requestWithBackend(true, `${SessionPaths.list}?${query}`, { headers }), - )).map((item) => item.id) + const sessions = (yield* json(yield* request(`${SessionPaths.list}?${query}`, { headers }))).map( + (item) => item.id, + ) - expect(legacy).toContain(pathSession.id) - expect(legacy).not.toContain(pathlessSession.id) - expect(effect).toEqual(legacy) + expect(sessions).toContain(pathSession.id) + expect(sessions).not.toContain(pathlessSession.id) }), ), ) it.live( - "matches legacy paginated message link headers", + "serves paginated message link headers", withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) => Effect.gen(function* () { const headers = { "x-opencode-directory": tmp.path } @@ -465,20 +441,17 @@ describe("session HttpApi", () => { yield* createTextMessage(tmp.path, session.id, "second") const route = `${pathFor(SessionPaths.messages, { sessionID: session.id })}?limit=1` - const legacy = yield* requestWithBackend(false, route, { headers }) - const effect = yield* requestWithBackend(true, route, { headers }) + const response = yield* request(route, { headers }) - expect(effect.headers.get("x-next-cursor")).toBe(legacy.headers.get("x-next-cursor")) - expect(effect.headers.get("link")).toBe(legacy.headers.get("link")) - expect(effect.headers.get("access-control-expose-headers")).toBe( - legacy.headers.get("access-control-expose-headers"), - ) + expect(response.headers.get("x-next-cursor")).toBeTruthy() + expect(response.headers.get("link")).toContain("limit=1") + expect(response.headers.get("access-control-expose-headers")?.toLowerCase()).toContain("x-next-cursor") }), ), ) it.live( - "serves message mutation routes through Hono bridge", + "serves message mutation routes", withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) => Effect.gen(function* () { const headers = { "x-opencode-directory": tmp.path, "content-type": "application/json" } @@ -522,7 +495,7 @@ describe("session HttpApi", () => { ) it.live( - "serves remaining non-LLM session mutation routes through Hono bridge", + "serves remaining non-LLM session mutation routes", withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) => Effect.gen(function* () { const headers = { "x-opencode-directory": tmp.path, "content-type": "application/json" } diff --git a/packages/opencode/test/server/httpapi-sync.test.ts b/packages/opencode/test/server/httpapi-sync.test.ts index c4d2397afd..c5ee637842 100644 --- a/packages/opencode/test/server/httpapi-sync.test.ts +++ b/packages/opencode/test/server/httpapi-sync.test.ts @@ -13,13 +13,11 @@ import { disposeAllInstances, tmpdir } from "../fixture/fixture" void Log.init({ print: false }) -const originalHttpApi = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES const context = Context.empty() as Context.Context -function app(httpapi = true) { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = httpapi - return httpapi ? Server.Default().app : Server.Legacy().app +function app() { + return Server.Default().app } function runSession(fx: Effect.Effect) { @@ -28,14 +26,13 @@ function runSession(fx: Effect.Effect) { afterEach(async () => { mock.restore() - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = originalHttpApi Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces await disposeAllInstances() await resetDatabase() }) describe("sync HttpApi", () => { - test("serves sync routes through Hono bridge", async () => { + test("serves sync routes", async () => { Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) const headers = { "x-opencode-directory": tmp.path, "content-type": "application/json" } @@ -87,7 +84,7 @@ describe("sync HttpApi", () => { expect(info.mock.calls.some(([message]) => message === "sync replay complete")).toBe(true) }) - test("matches legacy seq validation", async () => { + test("validates seq values", async () => { await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) const headers = { "x-opencode-directory": tmp.path, "content-type": "application/json" } const cases = [ @@ -116,18 +113,12 @@ describe("sync HttpApi", () => { ] for (const item of cases) { - const legacy = await app(false).request(item.path, { + const response = await app().request(item.path, { method: "POST", headers, body: JSON.stringify(item.body), }) - const httpapi = await app(true).request(item.path, { - method: "POST", - headers, - body: JSON.stringify(item.body), - }) - expect(httpapi.status).toBe(legacy.status) - expect(httpapi.status).toBe(400) + expect(response.status).toBe(400) } }) diff --git a/packages/opencode/test/server/httpapi-tui.test.ts b/packages/opencode/test/server/httpapi-tui.test.ts deleted file mode 100644 index 91cad362a9..0000000000 --- a/packages/opencode/test/server/httpapi-tui.test.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { afterEach, describe, expect, test } from "bun:test" -import type { Context } from "hono" -import { Flag } from "@opencode-ai/core/flag/flag" -import { TuiEvent } from "../../src/cli/cmd/tui/event" -import { SessionID } from "../../src/session/schema" -import { Instance } from "../../src/project/instance" -import { TuiApi, TuiPaths } from "../../src/server/routes/instance/httpapi/groups/tui" -import { callTui } from "../../src/server/routes/instance/tui" -import { Server } from "../../src/server/server" -import * as Log from "@opencode-ai/core/util/log" -import { OpenApi } from "effect/unstable/httpapi" -import { resetDatabase } from "../fixture/db" -import { disposeAllInstances, tmpdir } from "../fixture/fixture" -import { waitGlobalBusEventPromise } from "./global-bus" - -void Log.init({ print: false }) - -const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI - -function app(experimental = true) { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = experimental - return experimental ? Server.Default().app : Server.Legacy().app -} - -function nextCommandExecute() { - return waitGlobalBusEventPromise({ - predicate: (event) => event.payload.type === TuiEvent.CommandExecute.type, - }).then((event) => event.payload.properties?.command) -} - -async function expectTrue(path: string, headers: Record, body?: unknown) { - const response = await app().request(path, { - method: "POST", - headers: { ...headers, "content-type": "application/json" }, - body: JSON.stringify(body ?? {}), - }) - expect(response.status).toBe(200) - expect(await response.json()).toBe(true) -} - -afterEach(async () => { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original - await disposeAllInstances() - await resetDatabase() -}) - -describe("tui HttpApi bridge", () => { - test("documents legacy bad request responses", async () => { - const legacy = await Server.openapiHono() - const effect = OpenApi.fromApi(TuiApi) - for (const path of [TuiPaths.appendPrompt, TuiPaths.executeCommand, TuiPaths.publish, TuiPaths.selectSession]) { - expect(legacy.paths[path].post?.responses?.[400]).toBeDefined() - expect(effect.paths[path].post?.responses?.[400]).toBeDefined() - } - }) - - test("serves TUI command and event routes through experimental Effect routes", async () => { - await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) - const headers = { "x-opencode-directory": tmp.path } - - await expectTrue(TuiPaths.appendPrompt, headers, { text: "hello" }) - await expectTrue(TuiPaths.openHelp, headers) - await expectTrue(TuiPaths.openSessions, headers) - await expectTrue(TuiPaths.openThemes, headers) - await expectTrue(TuiPaths.openModels, headers) - await expectTrue(TuiPaths.submitPrompt, headers) - await expectTrue(TuiPaths.clearPrompt, headers) - await expectTrue(TuiPaths.executeCommand, headers, { command: "agent_cycle" }) - await expectTrue(TuiPaths.showToast, headers, { message: "Saved", variant: "success" }) - await expectTrue(TuiPaths.publish, headers, { - type: "tui.prompt.append", - properties: { text: "from publish" }, - }) - - const missingSessionID = SessionID.descending() - const missing = await app().request(TuiPaths.selectSession, { - method: "POST", - headers: { ...headers, "content-type": "application/json" }, - body: JSON.stringify({ sessionID: missingSessionID }), - }) - expect(missing.status).toBe(404) - }) - - test("matches Hono missing selected session error body", async () => { - await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) - const headers = { "x-opencode-directory": tmp.path, "content-type": "application/json" } - const body = JSON.stringify({ sessionID: SessionID.descending() }) - - const hono = await app(false).request(TuiPaths.selectSession, { method: "POST", headers, body }) - const httpapi = await app().request(TuiPaths.selectSession, { method: "POST", headers, body }) - - expect(httpapi.status).toBe(hono.status) - expect(await httpapi.json()).toEqual(await hono.json()) - }) - - test("matches legacy unknown execute command behavior", async () => { - await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) - const headers = { "x-opencode-directory": tmp.path, "content-type": "application/json" } - const body = JSON.stringify({ command: "unknown_command" }) - - const legacyCommand = nextCommandExecute() - const legacy = await app(false).request(TuiPaths.executeCommand, { method: "POST", headers, body }) - expect(legacy.status).toBe(200) - expect(await legacy.json()).toBe(true) - - const effectCommand = nextCommandExecute() - const effect = await app().request(TuiPaths.executeCommand, { method: "POST", headers, body }) - expect(effect.status).toBe(200) - expect(await effect.json()).toBe(true) - - const legacyPublished = await legacyCommand - const effectPublished = await effectCommand - expect(effectPublished).toBe(legacyPublished) - expect(legacyPublished).toBeUndefined() - }) - - test("serves TUI control queue through experimental Effect routes", async () => { - await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) - const pending = callTui({ req: { json: async () => ({ value: 1 }), path: "/demo" } } as unknown as Context) - const headers = { "x-opencode-directory": tmp.path } - - const next = await app().request(TuiPaths.controlNext, { headers }) - expect(next.status).toBe(200) - expect(await next.json()).toEqual({ path: "/demo", body: { value: 1 } }) - - await expectTrue(TuiPaths.controlResponse, headers, { ok: true }) - expect(await pending).toEqual({ ok: true }) - }) -}) diff --git a/packages/opencode/test/server/httpapi-ui.test.ts b/packages/opencode/test/server/httpapi-ui.test.ts index 440aeaecb5..3cc8d3dce8 100644 --- a/packages/opencode/test/server/httpapi-ui.test.ts +++ b/packages/opencode/test/server/httpapi-ui.test.ts @@ -22,7 +22,6 @@ import { Server } from "../../src/server/server" void Log.init({ print: false }) const original = { - OPENCODE_EXPERIMENTAL_HTTPAPI: Flag.OPENCODE_EXPERIMENTAL_HTTPAPI, OPENCODE_DISABLE_EMBEDDED_WEB_UI: Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI, OPENCODE_SERVER_PASSWORD: Flag.OPENCODE_SERVER_PASSWORD, OPENCODE_SERVER_USERNAME: Flag.OPENCODE_SERVER_USERNAME, @@ -31,7 +30,6 @@ const original = { } afterEach(() => { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original.OPENCODE_EXPERIMENTAL_HTTPAPI Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI = original.OPENCODE_DISABLE_EMBEDDED_WEB_UI Flag.OPENCODE_SERVER_PASSWORD = original.OPENCODE_SERVER_PASSWORD Flag.OPENCODE_SERVER_USERNAME = original.OPENCODE_SERVER_USERNAME @@ -117,7 +115,6 @@ function httpClient(response: Response, onRequest?: (request: HttpClientRequest. describe("HttpApi UI fallback", () => { test("serves the web UI through the experimental backend", async () => { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI = true let proxiedUrl: string | undefined @@ -137,7 +134,6 @@ describe("HttpApi UI fallback", () => { }) test("strips upstream transfer encoding headers from proxied assets", async () => { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI = true let proxiedUrl: string | undefined @@ -189,7 +185,6 @@ describe("HttpApi UI fallback", () => { // forwarded through the proxy while the proxy itself re-frames the body, // causing browsers to fail with `ERR_INVALID_CHUNKED_ENCODING`. test("strips upstream transfer-encoding header from proxied assets", async () => { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI = true const response = await Effect.runPromise( @@ -232,7 +227,6 @@ describe("HttpApi UI fallback", () => { }) test("serves embedded UI assets when Bun can read them but access reports missing", async () => { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true let readPath: string | undefined const response = await Effect.runPromise( @@ -262,7 +256,6 @@ describe("HttpApi UI fallback", () => { }) test("allows embedded UI terminal wasm and theme preload CSP", async () => { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true const script = 'document.documentElement.dataset.theme = "dark"' const response = await Effect.runPromise( @@ -294,7 +287,6 @@ describe("HttpApi UI fallback", () => { }) test("keeps matched API routes ahead of the UI fallback", async () => { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true const response = await Server.Default().app.request("/session/nope") @@ -302,7 +294,6 @@ describe("HttpApi UI fallback", () => { }) test("requires server password for the web UI", async () => { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI = true const response = await uiApp({ password: "secret", username: "opencode" }).request("/") @@ -312,7 +303,6 @@ describe("HttpApi UI fallback", () => { }) test("accepts auth token for the web UI", async () => { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI = true const response = await uiApp({ @@ -326,7 +316,6 @@ describe("HttpApi UI fallback", () => { }) test("accepts basic auth for the web UI", async () => { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI = true const response = await uiApp({ password: "secret", username: "opencode" }).request("/", { @@ -342,7 +331,6 @@ describe("HttpApi UI fallback", () => { // server returning 401 breaks PWA install. These specific public assets // should bypass auth. test("serves the PWA manifest without auth even when a server password is set", async () => { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI = true for (const path of ["/site.webmanifest", "/web-app-manifest-192x192.png", "/web-app-manifest-512x512.png"]) { @@ -356,7 +344,6 @@ describe("HttpApi UI fallback", () => { }) test("allows web UI preflight without auth", async () => { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true const response = await app({ password: "secret", username: "opencode" }).request("/", { method: "OPTIONS", diff --git a/packages/opencode/test/server/httpapi-workspace.test.ts b/packages/opencode/test/server/httpapi-workspace.test.ts index 2e64081b8f..a2de1362fb 100644 --- a/packages/opencode/test/server/httpapi-workspace.test.ts +++ b/packages/opencode/test/server/httpapi-workspace.test.ts @@ -24,16 +24,14 @@ import { testEffect } from "../lib/effect" void Log.init({ print: false }) const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES -const originalHttpApi = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI const workspaceLayer = Workspace.defaultLayer.pipe( Layer.provide(InstanceStore.defaultLayer), Layer.provide(InstanceBootstrap.defaultLayer), ) const it = testEffect(Layer.mergeAll(NodeServices.layer, Project.defaultLayer, Session.defaultLayer, workspaceLayer)) -function request(path: string, directory: string, init: RequestInit = {}, httpApi = true) { +function request(path: string, directory: string, init: RequestInit = {}) { return Effect.promise(() => { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = httpApi const headers = new Headers(init.headers) headers.set("x-opencode-directory", directory) return Promise.resolve(Server.Default().app.request(path, { ...init, headers })) @@ -161,7 +159,6 @@ function eventStreamResponse() { afterEach(async () => { mock.restore() Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = originalHttpApi await disposeAllInstances() await resetDatabase() }) @@ -289,32 +286,6 @@ describe("workspace HttpApi", () => { }), ) - it.live("documents legacy Hono accepting the TUI payload shape", () => - Effect.gen(function* () { - Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true - const dir = yield* tmpdirScoped({ git: true }) - const project = yield* Project.use.fromDirectory(dir) - registerAdapter(project.project.id, "local-test", localAdapter(path.join(dir, ".workspace"))) - - const created = yield* request( - WorkspacePaths.list, - dir, - { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ type: "local-test", branch: null }), - }, - false, - ) - - expect(created.status).toBe(200) - expect((yield* Effect.promise(() => created.json())) as Workspace.Info).toMatchObject({ - type: "local-test", - name: "local-test", - }) - }), - ) - it.live("routes local workspace requests through the workspace target directory", () => Effect.gen(function* () { Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true diff --git a/packages/opencode/test/server/trace-attributes.test.ts b/packages/opencode/test/server/trace-attributes.test.ts deleted file mode 100644 index c6e8005a20..0000000000 --- a/packages/opencode/test/server/trace-attributes.test.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { describe, expect, test } from "bun:test" -import { paramToAttributeKey, requestAttributes } from "../../src/server/routes/instance/trace" - -function fakeContext(method: string, url: string, params: Record) { - return { - req: { - method, - url, - param: () => params, - }, - } -} - -describe("paramToAttributeKey", () => { - test("converts fooID to foo.id", () => { - expect(paramToAttributeKey("sessionID")).toBe("session.id") - expect(paramToAttributeKey("messageID")).toBe("message.id") - expect(paramToAttributeKey("partID")).toBe("part.id") - expect(paramToAttributeKey("projectID")).toBe("project.id") - expect(paramToAttributeKey("providerID")).toBe("provider.id") - expect(paramToAttributeKey("ptyID")).toBe("pty.id") - expect(paramToAttributeKey("permissionID")).toBe("permission.id") - expect(paramToAttributeKey("requestID")).toBe("request.id") - expect(paramToAttributeKey("workspaceID")).toBe("workspace.id") - }) - - test("namespaces non-ID params under opencode.", () => { - expect(paramToAttributeKey("name")).toBe("opencode.name") - expect(paramToAttributeKey("slug")).toBe("opencode.slug") - }) -}) - -describe("requestAttributes", () => { - test("includes http method and path", () => { - const attrs = requestAttributes(fakeContext("GET", "http://localhost/session", {})) - expect(attrs["http.method"]).toBe("GET") - expect(attrs["http.path"]).toBe("/session") - }) - - test("strips query string from path", () => { - const attrs = requestAttributes(fakeContext("GET", "http://localhost/file/search?query=foo&limit=10", {})) - expect(attrs["http.path"]).toBe("/file/search") - }) - - test("emits OTel-style .id for ID-shaped route params", () => { - const attrs = requestAttributes( - fakeContext("GET", "http://localhost/session/ses_abc/message/msg_def/part/prt_ghi", { - sessionID: "ses_abc", - messageID: "msg_def", - partID: "prt_ghi", - }), - ) - expect(attrs["session.id"]).toBe("ses_abc") - expect(attrs["message.id"]).toBe("msg_def") - expect(attrs["part.id"]).toBe("prt_ghi") - // No camelCase leftovers: - expect(attrs["opencode.sessionID"]).toBeUndefined() - expect(attrs["opencode.messageID"]).toBeUndefined() - expect(attrs["opencode.partID"]).toBeUndefined() - }) - - test("produces no param attributes when no params are matched", () => { - const attrs = requestAttributes(fakeContext("POST", "http://localhost/config", {})) - expect(Object.keys(attrs).filter((k) => k !== "http.method" && k !== "http.path")).toEqual([]) - }) - - test("namespaces non-ID params under opencode. (e.g. mcp :name)", () => { - const attrs = requestAttributes( - fakeContext("POST", "http://localhost/mcp/exa/connect", { - name: "exa", - }), - ) - expect(attrs["opencode.name"]).toBe("exa") - expect(attrs["name"]).toBeUndefined() - }) -}) diff --git a/packages/opencode/test/server/worktree-endpoint-repro.test.ts b/packages/opencode/test/server/worktree-endpoint-repro.test.ts index 768a261a00..e95d706d54 100644 --- a/packages/opencode/test/server/worktree-endpoint-repro.test.ts +++ b/packages/opencode/test/server/worktree-endpoint-repro.test.ts @@ -13,16 +13,13 @@ import { testEffect } from "../lib/effect" const stateLayer = Layer.effectDiscard( Effect.gen(function* () { const original = { - OPENCODE_EXPERIMENTAL_HTTPAPI: Flag.OPENCODE_EXPERIMENTAL_HTTPAPI, OPENCODE_EXPERIMENTAL_WORKSPACES: Flag.OPENCODE_EXPERIMENTAL_WORKSPACES, } - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true yield* Effect.addFinalizer(() => Effect.promise(async () => { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original.OPENCODE_EXPERIMENTAL_HTTPAPI Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = original.OPENCODE_EXPERIMENTAL_WORKSPACES await resetDatabase() }), diff --git a/packages/sdk/js/script/build.ts b/packages/sdk/js/script/build.ts index 946ad1402b..b3f74a1bf6 100755 --- a/packages/sdk/js/script/build.ts +++ b/packages/sdk/js/script/build.ts @@ -9,16 +9,9 @@ import path from "path" import { createClient } from "@hey-api/openapi-ts" -const openapiSource = process.env.OPENCODE_SDK_OPENAPI === "hono" ? "hono" : "httpapi" const opencode = path.resolve(dir, "../../opencode") -// `bun dev generate` now derives the spec from the Effect HttpApi contract by -// default; pass `--hono` to fall back to the legacy Hono spec for parity diffs. -if (openapiSource === "httpapi") { - await $`bun dev generate > ${dir}/openapi.json`.cwd(opencode) -} else { - await $`bun dev generate --hono > ${dir}/openapi.json`.cwd(opencode) -} +await $`bun dev generate > ${dir}/openapi.json`.cwd(opencode) await createClient({ input: "./openapi.json", From d01cb7f013b57fc5d10d03e895e148c73ddc2b4f Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sat, 9 May 2026 13:11:45 +0000 Subject: [PATCH 060/189] chore: generate --- packages/opencode/src/control-plane/types.ts | 5 +++-- packages/opencode/test/server/httpapi-config.test.ts | 1 - .../opencode/test/server/httpapi-exercise/routing.ts | 3 +-- .../opencode/test/server/httpapi-exercise/runner.ts | 10 +--------- .../test/server/httpapi-raw-route-auth.test.ts | 1 - packages/opencode/test/server/httpapi-session.test.ts | 6 +++--- packages/opencode/test/server/httpapi-ui.test.ts | 2 -- 7 files changed, 8 insertions(+), 20 deletions(-) diff --git a/packages/opencode/src/control-plane/types.ts b/packages/opencode/src/control-plane/types.ts index f5b1807462..ba5a778fac 100644 --- a/packages/opencode/src/control-plane/types.ts +++ b/packages/opencode/src/control-plane/types.ts @@ -14,8 +14,9 @@ export const WorkspaceInfo = Schema.Struct({ }).annotate({ identifier: "Workspace" }) export type WorkspaceInfo = DeepMutable> -export const WorkspaceListedInfo = Schema.Struct(Struct.omit(WorkspaceInfo.fields, ["id"])) - .annotate({ identifier: "WorkspaceListedInfo" }) +export const WorkspaceListedInfo = Schema.Struct(Struct.omit(WorkspaceInfo.fields, ["id"])).annotate({ + identifier: "WorkspaceListedInfo", +}) export type WorkspaceListedInfo = DeepMutable> export const WorkspaceAdapterEntry = Schema.Struct({ diff --git a/packages/opencode/test/server/httpapi-config.test.ts b/packages/opencode/test/server/httpapi-config.test.ts index fbbe29a9c2..3be04b57a3 100644 --- a/packages/opencode/test/server/httpapi-config.test.ts +++ b/packages/opencode/test/server/httpapi-config.test.ts @@ -8,7 +8,6 @@ import { waitGlobalBusEventPromise } from "./global-bus" void Log.init({ print: false }) - function app() { return Server.Default().app } diff --git a/packages/opencode/test/server/httpapi-exercise/routing.ts b/packages/opencode/test/server/httpapi-exercise/routing.ts index f1f9bddb4f..9e432af2e3 100644 --- a/packages/opencode/test/server/httpapi-exercise/routing.ts +++ b/packages/opencode/test/server/httpapi-exercise/routing.ts @@ -41,8 +41,7 @@ export function coverageResult(scenario: Scenario): Result { export function parseOptions(args: string[]): Options { const mode = option(args, "--mode") ?? "effect" - if (mode !== "effect" && mode !== "coverage" && mode !== "auth") - throw new Error(`invalid --mode ${mode}`) + if (mode !== "effect" && mode !== "coverage" && mode !== "auth") throw new Error(`invalid --mode ${mode}`) return { mode, include: option(args, "--include"), diff --git a/packages/opencode/test/server/httpapi-exercise/runner.ts b/packages/opencode/test/server/httpapi-exercise/runner.ts index 0263ee697b..2b3f720c84 100644 --- a/packages/opencode/test/server/httpapi-exercise/runner.ts +++ b/packages/opencode/test/server/httpapi-exercise/runner.ts @@ -8,15 +8,7 @@ import { MessageID, PartID } from "../../../src/session/schema" import { call, callAuthProbe } from "./backend" import { original } from "./environment" import { runtime } from "./runtime" -import type { - ActiveScenario, - Options, - ProjectOptions, - Result, - Scenario, - ScenarioContext, - SeededContext, -} from "./types" +import type { ActiveScenario, Options, ProjectOptions, Result, Scenario, ScenarioContext, SeededContext } from "./types" export function runScenario(options: Options) { return (scenario: Scenario) => { diff --git a/packages/opencode/test/server/httpapi-raw-route-auth.test.ts b/packages/opencode/test/server/httpapi-raw-route-auth.test.ts index 7e5757b1ea..b1d4af76b8 100644 --- a/packages/opencode/test/server/httpapi-raw-route-auth.test.ts +++ b/packages/opencode/test/server/httpapi-raw-route-auth.test.ts @@ -12,7 +12,6 @@ import * as Log from "@opencode-ai/core/util/log" void Log.init({ print: false }) - function app(input: { password?: string; username?: string }) { const handler = HttpRouter.toWebHandler( ExperimentalHttpApiServer.routes.pipe( diff --git a/packages/opencode/test/server/httpapi-session.test.ts b/packages/opencode/test/server/httpapi-session.test.ts index 6de704325b..96ddf8fcce 100644 --- a/packages/opencode/test/server/httpapi-session.test.ts +++ b/packages/opencode/test/server/httpapi-session.test.ts @@ -421,9 +421,9 @@ describe("session HttpApi", () => { directory: currentDir, }) const headers = { "x-opencode-directory": tmp.path } - const sessions = (yield* json(yield* request(`${SessionPaths.list}?${query}`, { headers }))).map( - (item) => item.id, - ) + const sessions = (yield* json( + yield* request(`${SessionPaths.list}?${query}`, { headers }), + )).map((item) => item.id) expect(sessions).toContain(pathSession.id) expect(sessions).not.toContain(pathlessSession.id) diff --git a/packages/opencode/test/server/httpapi-ui.test.ts b/packages/opencode/test/server/httpapi-ui.test.ts index 3cc8d3dce8..68ed2a3e25 100644 --- a/packages/opencode/test/server/httpapi-ui.test.ts +++ b/packages/opencode/test/server/httpapi-ui.test.ts @@ -287,7 +287,6 @@ describe("HttpApi UI fallback", () => { }) test("keeps matched API routes ahead of the UI fallback", async () => { - const response = await Server.Default().app.request("/session/nope") expect(response.status).toBe(404) @@ -344,7 +343,6 @@ describe("HttpApi UI fallback", () => { }) test("allows web UI preflight without auth", async () => { - const response = await app({ password: "secret", username: "opencode" }).request("/", { method: "OPTIONS", headers: { From 3afa622eab8c1009e627c06ca7cfe82b20b47e27 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sat, 9 May 2026 13:22:38 +0000 Subject: [PATCH 061/189] chore: update nix node_modules hashes --- nix/hashes.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/nix/hashes.json b/nix/hashes.json index 55d4fd038c..558264474a 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-ZqTxXSIWZmrNpdIQICbesrMpoL24blahhQzAGTzupAc=", - "aarch64-linux": "sha256-YdZAkfJXBCnqPcYS7VUbQqT9AkYnyu/ONVG5v78Lmdw=", - "aarch64-darwin": "sha256-3Fdu1le89F53UHuaiHanHP0CB4F72fK9p1d9PC47Wsg=", - "x86_64-darwin": "sha256-Tc4WppnKZPzYn/ICIv1vtx+NiNqAHAzd/ogTRUCQK18=" + "x86_64-linux": "sha256-LTo0ohJN5hBOubqFLVL45unVEIwBDkACNVv64k2nkq4=", + "aarch64-linux": "sha256-oYKY2UJRWG2fhufW4aGujX/Poou93023ZF2Fu7oyYOw=", + "aarch64-darwin": "sha256-618c9vqKN5I+no1nzylctAiWvqw7Bsa+bzSTNwXmSQA=", + "x86_64-darwin": "sha256-1ro3/gH0FC0TWXwWT+k675xR396GE98HpnBEeuD4t6k=" } } From b24a4e897e59d7cfdcdac364e49488acba39774b Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 9 May 2026 12:30:18 -0400 Subject: [PATCH 062/189] chore(server): clean up post-Hono-deletion scar tissue (#26542) --- packages/opencode/scripts/diff-sdk-types.sh | 52 --- packages/opencode/specs/effect/http-api.md | 406 ------------------ packages/opencode/src/config/config.ts | 6 +- .../src/server/routes/instance/httpapi/api.ts | 3 +- 4 files changed, 4 insertions(+), 463 deletions(-) delete mode 100755 packages/opencode/scripts/diff-sdk-types.sh delete mode 100644 packages/opencode/specs/effect/http-api.md diff --git a/packages/opencode/scripts/diff-sdk-types.sh b/packages/opencode/scripts/diff-sdk-types.sh deleted file mode 100755 index b27a31e8c3..0000000000 --- a/packages/opencode/scripts/diff-sdk-types.sh +++ /dev/null @@ -1,52 +0,0 @@ -#!/bin/bash -# Compare SDK types generated from Hono vs HttpApi specs. -# Sorts types alphabetically so only meaningful body differences show. -# -# Usage: ./scripts/diff-sdk-types.sh # full diff -# ./scripts/diff-sdk-types.sh --stat # summary only -set -euo pipefail - -DIR="$(cd "$(dirname "$0")/.." && pwd)" -SDK="$(cd "$DIR/../sdk/js" && pwd)" - -normalize() { - python3 -c " -import re, sys -content = open(sys.argv[1]).read() -blocks = re.split(r'(?=^export (?:type|function|const) )', content, flags=re.MULTILINE) -header, body = blocks[0], blocks[1:] -body.sort(key=lambda b: m.group(1) if (m := re.match(r'export \w+ (\w+)', b)) else '') -sys.stdout.write(header + ''.join(body)) -" "$1" -} - -echo "Generating Hono SDK..." >&2 -(cd "$SDK" && bun run script/build.ts >/dev/null 2>&1) -normalize "$SDK/src/v2/gen/types.gen.ts" > /tmp/sdk-types-hono.ts -git -C "$SDK" checkout -- src/ 2>/dev/null - -echo "Generating HttpApi SDK..." >&2 -(cd "$SDK" && OPENCODE_SDK_OPENAPI=httpapi bun run script/build.ts >/dev/null 2>&1) -normalize "$SDK/src/v2/gen/types.gen.ts" > /tmp/sdk-types-httpapi.ts -git -C "$SDK" checkout -- src/ 2>/dev/null - -echo "" >&2 -if [[ "${1:-}" == "--stat" ]]; then - diff_output=$(diff /tmp/sdk-types-hono.ts /tmp/sdk-types-httpapi.ts || true) - honly=$(printf "%s\n" "$diff_output" | grep -c '^< export type' || true) - aonly=$(printf "%s\n" "$diff_output" | grep -c '^> export type' || true) - total=$(printf "%s\n" "$diff_output" | wc -l | tr -d ' ') - echo "Hono-only: $honly types HttpApi-only: $aonly types Diff lines: $total" - echo "" - if [[ $honly -gt 0 ]]; then - echo "=== Hono-only types ===" - printf "%s\n" "$diff_output" | grep '^< export type' | sed 's/< export type //' | sed 's/[ =].*//' | sed 's/^/ /' - echo "" - fi - if [[ $aonly -gt 0 ]]; then - echo "=== HttpApi-only types ===" - printf "%s\n" "$diff_output" | grep '^> export type' | sed 's/> export type //' | sed 's/[ =].*//' | sed 's/^/ /' - fi -else - diff /tmp/sdk-types-hono.ts /tmp/sdk-types-httpapi.ts || true -fi diff --git a/packages/opencode/specs/effect/http-api.md b/packages/opencode/specs/effect/http-api.md deleted file mode 100644 index 3697559549..0000000000 --- a/packages/opencode/specs/effect/http-api.md +++ /dev/null @@ -1,406 +0,0 @@ -# HttpApi migration - -> Historical migration notes. PR #25667 deletes the Hono backend and removes the -> `OPENCODE_EXPERIMENTAL_HTTPAPI` backend selection path, so sections below that -> describe Hono as the default, SDK generation from `hono-openapi`, bridge parity, -> or dual-backend rollout are pre-deletion context rather than current guidance. - -Plan for replacing instance Hono route implementations with Effect `HttpApi` while preserving behavior, OpenAPI, and SDK output during the transition. - -## End State - -- JSON route contracts and handlers live in `src/server/routes/instance/httpapi/*`. -- Route modules own their `HttpApiGroup`, schemas, handlers, and route-level middleware. -- `httpapi/server.ts` only composes groups, instance lookup, observability, and the web handler bridge. -- Hono route implementations are deleted once their `HttpApi` replacements are default, tested, and represented in the SDK/OpenAPI pipeline. -- Streaming, SSE, and websocket routes move later through Effect HTTP primitives or another explicit replacement plan; they do not need to fit `HttpApi` if `HttpApi` is the wrong abstraction. - -## Current State - -- `OPENCODE_EXPERIMENTAL_HTTPAPI` selects the backend at server startup. Default is still `hono`. -- `server/backend.ts` picks one of `effect-httpapi` or `hono`; `server.ts` builds either a pure Effect `HttpApi` web handler or the legacy Hono app accordingly. The earlier in-Hono "bridge" model has been replaced by this fork-at-startup. -- Legacy Hono routes remain mounted for the `hono` backend and remain the source for `hono-openapi` SDK generation. -- An Effect `HttpApi` OpenAPI surface exists (`OpenApi.fromApi(PublicApi)` in `cli/cmd/generate.ts --httpapi`, `OPENCODE_SDK_OPENAPI=httpapi` in `packages/sdk/js/script/build.ts`) but is opt-in. The default SDK generation is still Hono. -- `httpapi/public.ts` carries the Hono-compat normalization for the Effect-generated OpenAPI surface (auth scheme strip, request-body required flag, optional `null` arms, `BadRequestError` / `NotFoundError` remap, `$ref` self-cycle fix, `auth_token` query injection). Today's Effect-generated SDK is not byte-identical to the Hono-generated SDK — see Phase 4. -- Auth is centrally configured for the Effect backend via Effect `Config` (`refactor: use Effect config for HttpApi authorization`, `Fix HttpApi raw route authorization`) rather than re-attached in each route module. -- Auth supports Basic auth and the legacy `auth_token` query parameter through `HttpApiSecurity.apiKey`. -- Instance context is provided by `httpapi/server.ts` using `directory`, `workspace`, and `x-opencode-directory`. -- `Observability.layer` is provided in the Effect route layer and deduplicated through the shared `memoMap`. -- CORS middleware is wired into both backends (`feat(httpapi): add CORS middleware to instance routes`). - -## Migration Rules - -- Preserve runtime behavior first. Semantic changes, new error behavior, or route shape changes need separate PRs. -- Migrate one route group, or one coherent subset of a route group, at a time. -- Reuse existing services. Do not re-architect service logic during HTTP boundary migration. -- Effect Schema owns route DTOs. Keep `.zod` only as compatibility for remaining Hono/OpenAPI surfaces. -- Regenerate the SDK after schema or OpenAPI-affecting changes and verify the diff is expected. -- Do not delete a Hono route until the SDK/OpenAPI pipeline no longer depends on its Hono `describeRoute` entry. - -## Route Slice Checklist - -Use this checklist for each small HttpApi migration PR: - -1. Read the legacy Hono route and copy behavior exactly, including default values, headers, operation IDs, response schemas, and status codes. -2. Put the new `HttpApiGroup`, route paths, DTO schemas, and handlers in `src/server/routes/instance/httpapi/*`. -3. Mount the new paths in `src/server/routes/instance/index.ts` only inside the `OPENCODE_EXPERIMENTAL_HTTPAPI` block. -4. Use `InstanceState.context` / `InstanceState.directory` inside HttpApi handlers instead of `Instance.directory`, `Instance.worktree`, or `Instance.project` ALS globals. -5. Reuse existing services directly. If a service returns plain objects, use `Schema.Struct`; use `Schema.Class` only when handlers return actual class instances. -6. Keep legacy Hono routes and `.zod` compatibility in place for SDK/OpenAPI generation. -7. Add tests that hit the Hono-mounted bridge via `InstanceRoutes`, not only the raw `HttpApi` web handler, when the route depends on auth or instance context. -8. Run `bun typecheck` from `packages/opencode`, relevant `bun run test:ci ...` tests from `packages/opencode`, and `./packages/sdk/js/script/build.ts` from the repo root. - -## Hono Deletion Checklist - -Use this checklist before deleting any Hono route implementation. A route being `bridged` is not enough. - -1. `HttpApi` parity is complete for the route path, method, auth behavior, query parameters, request body, response status, response headers, and error status. -2. The route is mounted by default, not only behind `OPENCODE_EXPERIMENTAL_HTTPAPI`. -3. If a fallback flag exists, tests cover both the default `HttpApi` path and the fallback Hono path until the fallback is removed. -4. OpenAPI generation uses the Effect `HttpApi` route as the source for that path. -5. Generated SDK output is unchanged from the Hono-generated contract, or the SDK diff is intentionally reviewed and accepted. -6. The legacy Hono `describeRoute`, validator, and handler for that path are removed. -7. Any duplicate Zod-only DTOs are deleted or kept only as `.zod` compatibility on the canonical Effect Schema. -8. Bridge tests exist for auth, instance selection, success response, and route-specific side effects. -9. Mutation routes prove persisted side effects and cleanup behavior in tests. If the mutation disposes/reloads the active instance, disposal happens through an explicit post-response lifecycle hook rather than inline handler teardown. -10. Streaming, SSE, websocket, and UI bridge routes have a specific non-Hono replacement plan. Do not force them through `HttpApi` if raw Effect HTTP is a better fit. - -Hono can be removed from the instance server only after all mounted Hono route groups meet this checklist and `server/routes/instance/index.ts` no longer depends on Hono routing for default behavior. - -## Experimental Read Slice Guidance - -For the experimental route group, port read-only JSON routes before mutations: - -- Good first batch: `GET /console`, `GET /console/orgs`, `GET /tool/ids`, `GET /resource`. -- Consider `GET /worktree` only if the handler uses `InstanceState.context` instead of `Instance.project`. -- Defer `POST /console/switch`, worktree create/remove/reset, and `GET /session` to separate PRs because they mutate state or have broader pagination/session behavior. -- Preserve response headers such as pagination cursors if a route is ported. -- If SDK generation changes, explain whether it is a semantic contract change or a generator-equivalent type normalization. - -## Schema Notes - -- Use `Schema.Struct(...).annotate({ identifier })` for named OpenAPI refs when handlers return plain objects. -- Use `Schema.Class` only when the handler returns real class instances or the constructor requirement is intentional. -- Keep nested anonymous shapes as `Schema.Struct` unless a named SDK type is useful. -- Avoid parallel hand-written Zod and Effect definitions for the same route boundary. - -## Phases - -### 1. Stabilize The Bridge - -Before porting more routes, cover the bridge behavior that every route depends on. - -- Add tests that hit the Hono-mounted `HttpApi` bridge, not just `HttpApiBuilder.layer` directly. -- Cover auth disabled, Basic auth success, `auth_token` success, missing credentials, and bad credentials. -- Cover `directory` and `x-opencode-directory` instance selection. -- Verify generated SDK output remains unchanged for non-SDK work. -- Fix or remove any implemented-but-unmounted `HttpApi` groups. - -### 2. Complete The Inventory - -Create a route inventory from the actual Hono registrations and classify each route. - -Statuses: - -- `bridged`: served through the `HttpApi` bridge when the flag is on. -- `implemented`: `HttpApi` group exists but is not mounted through Hono. -- `next`: good JSON candidate for near-term porting. -- `later`: portable, but needs schema/service cleanup first. -- `special`: SSE, websocket, streaming, or UI bridge behavior that likely needs raw Effect HTTP rather than `HttpApi`. - -### 3. Finish JSON Route Parity - -Port remaining JSON routes in small batches. - -Good near-term candidates: - -- top-level reads: `GET /path`, `GET /vcs`, `GET /vcs/diff`, `GET /command`, `GET /agent`, `GET /skill`, `GET /lsp`, `GET /formatter` -- simple mutations: `POST /instance/dispose` -- experimental JSON reads: console, tool, worktree list, resource list -- deferred JSON mutations: workspace/worktree create/remove/reset, file search, MCP auth flows - -Keep large or stateful groups for later: - -- `session` -- `sync` -- process-level experimental routes - -### 4. Move OpenAPI And SDK Generation - -Hono routes cannot be deleted while `hono-openapi` is the source of SDK generation. - -Status: the Effect `HttpApi` OpenAPI surface is **implemented and opt-in** (`bun dev generate --httpapi`, `OPENCODE_SDK_OPENAPI=httpapi`). Default SDK generation still uses Hono. `httpapi/public.ts` applies the Hono-compat normalization layer to the Effect output. Diff against the Hono-generated spec still shows real gaps that must be closed before the SDK can flip: - -- Branded-type `pattern` constraints on ID schemas are not propagated to the Effect output (~169 missing). -- Per-property `description` annotations are not propagated through `Schema.Struct` to the Effect output (~107 missing). -- `Event.*` and `SyncEvent.*` component names use dotted form in Hono and PascalCase in Effect (~50 differences, breaks SDK type names). -- Effect's component deduper emits numbered duplicates (`Session9`, `SyncEvent.session.updated.11`) that need a name-collision fix. -- Cosmetic-only diffs (`additionalProperties: false`, `const` vs `enum`, MAX_SAFE_INTEGER `maximum`, `propertyNames`) can be normalized in `public.ts` if they would otherwise change SDK output. - -Required before route deletion: - -- Close the diff above so Effect-generated SDK output matches the Hono-generated SDK output for every retained path. -- Keep operation IDs, schemas, status codes, and SDK type names stable unless the change is intentional. -- Flip `packages/sdk/js/script/build.ts` default to `httpapi` and regenerate. -- Compare generated SDK output against `dev` for every route group deletion. -- Remove Hono OpenAPI stubs only after Effect OpenAPI is the SDK source for those paths. - -V2 cleanup once SDK compatibility no longer needs the legacy Hono contract: - -- Remove `public.ts` compatibility transforms that hide honest `HttpApi` metadata, including auth `securitySchemes`, per-route `security`, and generated `401` responses. -- Stop remapping built-in `HttpApi` error schemas back to legacy Hono `BadRequestError` / `NotFoundError` components if V2 clients can consume the actual Effect error shape. -- Prefer the direct `HttpApi` OpenAPI output for request/response bodies and named component schemas instead of rewriting it to match Hono generator quirks. -- Keep schema fixes that describe the actual wire format, but delete transforms that only preserve legacy SDK type names or inline-vs-ref shape. -- Re-evaluate `auth_token` as an OpenAPI security scheme rather than a hand-injected query parameter once clients can consume the V2 spec. - -### 5. Make HttpApi Default For JSON Routes - -After JSON parity and SDK generation are covered: - -- Flip the bridge default for ported JSON routes. -- Keep a short-lived fallback flag for the old Hono implementation. -- Run the same tests against both the default and fallback path during rollout. -- Stop adding new Hono handlers for JSON routes once the default flips. - -### 6. Delete Hono Route Implementations - -Delete Hono routes group-by-group after each group meets the deletion criteria. - -Deletion criteria: - -- `HttpApi` route is mounted by default. -- Behavior is covered by bridge-level tests. -- OpenAPI/SDK generation comes from Effect for that path. -- SDK diff is zero or explicitly accepted. -- Legacy Hono route is no longer needed as a fallback. - -After deleting a group: - -- Remove its Hono route file or dead endpoints. -- Remove its `.route(...)` registration from `instance/index.ts`. -- Remove duplicate Zod-only route DTOs if Effect Schema now owns the type. -- Regenerate SDK and verify output. - -### 7. Replace Special Routes - -Special routes need explicit designs before Hono can disappear completely. - -- `event`: SSE -- `pty`: websocket -- `tui`: UI/control bridge behavior -- streaming `session` endpoints - -Use raw Effect HTTP routes where `HttpApi` does not fit. The goal is deleting Hono implementations, not forcing every transport shape through `HttpApi`. - -## Current Route Status - -| Area | Status | Notes | -| ------------------------- | ----------------- | -------------------------------------------------------------------------- | -| `question` | `bridged` | `GET /question`, reply, reject | -| `permission` | `bridged` | list and reply | -| `provider` | `bridged` | list, auth, OAuth authorize/callback | -| `config` | `bridged` | read, providers, update | -| `project` | `bridged` | list, current, git init, update | -| `file` | `bridged` partial | find text/file/symbol, list/content/status | -| `mcp` | `bridged` | status, add, OAuth, connect/disconnect | -| `workspace` | `bridged` | adapter/list/status/create/remove/session-restore | -| top-level instance routes | `bridged` | path, vcs, command, agent, skill, lsp, formatter, dispose | -| experimental JSON routes | `bridged` | console, tool, worktree list/mutations, global session list, resource list | -| `session` | `bridged` | read, lifecycle, prompt, message/part mutations, revert, permission reply | -| `sync` | `bridged` | start/replay/history | -| `event` | `bridged` | SSE via raw Effect HTTP | -| `pty` | `special` | websocket | -| `tui` | `special` | UI bridge | - -## Full Route Checklist - -This checklist tracks bridge parity only. Checked routes are available through the experimental `HttpApi` bridge; Hono deletion is tracked separately by the deletion checklist above. - -### Top-Level Instance Routes - -- [x] `POST /instance/dispose` - dispose active instance after response. -- [x] `GET /path` - current directory and worktree paths. -- [x] `GET /vcs` - current VCS status. -- [x] `GET /vcs/diff` - VCS diff summary. -- [x] `GET /command` - command catalog. -- [x] `GET /agent` - agent catalog. -- [x] `GET /skill` - skill catalog. -- [x] `GET /lsp` - LSP status. -- [x] `GET /formatter` - formatter status. - -### Config Routes - -- [x] `GET /config` - read config. -- [x] `PATCH /config` - update config and dispose active instance after response. -- [x] `GET /config/providers` - config provider summary. - -### Project Routes - -- [x] `GET /project` - list projects. -- [x] `GET /project/current` - current project. -- [x] `POST /project/git/init` - initialize git and reload active instance after response. -- [x] `PATCH /project/:projectID` - update project metadata. - -### Provider Routes - -- [x] `GET /provider` - list providers. -- [x] `GET /provider/auth` - list provider auth methods. -- [x] `POST /provider/:providerID/oauth/authorize` - start provider OAuth. -- [x] `POST /provider/:providerID/oauth/callback` - finish provider OAuth. - -### Question Routes - -- [x] `GET /question` - list questions. -- [x] `POST /question/:requestID/reply` - reply to question. -- [x] `POST /question/:requestID/reject` - reject question. - -### Permission Routes - -- [x] `GET /permission` - list permission requests. -- [x] `POST /permission/:requestID/reply` - reply to permission request. - -### File Routes - -- [x] `GET /find` - text search. -- [x] `GET /find/file` - file search. -- [x] `GET /find/symbol` - symbol search. -- [x] `GET /file` - list directory entries. -- [x] `GET /file/content` - read file content. -- [x] `GET /file/status` - file status. - -### MCP Routes - -- [x] `GET /mcp` - MCP status. -- [x] `POST /mcp` - add MCP server at runtime. -- [x] `POST /mcp/:name/auth` - start MCP OAuth. -- [x] `POST /mcp/:name/auth/callback` - finish MCP OAuth callback. -- [x] `POST /mcp/:name/auth/authenticate` - run MCP OAuth authenticate flow. -- [x] `DELETE /mcp/:name/auth` - remove MCP OAuth credentials. -- [x] `POST /mcp/:name/connect` - connect MCP server. -- [x] `POST /mcp/:name/disconnect` - disconnect MCP server. - -### Experimental Routes - -- [x] `GET /experimental/console` - active Console provider metadata. -- [x] `GET /experimental/console/orgs` - switchable Console orgs. -- [x] `POST /experimental/console/switch` - switch active Console org. -- [x] `GET /experimental/tool/ids` - tool IDs. -- [x] `GET /experimental/tool` - tools for provider/model. -- [x] `GET /experimental/worktree` - list worktrees. -- [x] `POST /experimental/worktree` - create worktree. -- [x] `DELETE /experimental/worktree` - remove worktree. -- [x] `POST /experimental/worktree/reset` - reset worktree. -- [x] `GET /experimental/session` - global session list. -- [x] `GET /experimental/resource` - MCP resources. - -### Workspace Routes - -- [x] `GET /experimental/workspace/adapter` - list workspace adapters. -- [x] `POST /experimental/workspace` - create workspace. -- [x] `GET /experimental/workspace` - list workspaces. -- [x] `GET /experimental/workspace/status` - workspace status. -- [x] `DELETE /experimental/workspace/:id` - remove workspace. -- [x] `POST /experimental/workspace/:id/session-restore` - restore session into workspace. - -### Sync Routes - -- [x] `POST /sync/start` - start workspace sync. -- [x] `POST /sync/replay` - replay sync events. -- [x] `POST /sync/history` - list sync event history. - -### Session Routes - -- [x] `GET /session` - list sessions. -- [x] `GET /session/status` - session status map. -- [x] `GET /session/:sessionID` - get session. -- [x] `GET /session/:sessionID/children` - get child sessions. -- [x] `GET /session/:sessionID/todo` - get session todos. -- [x] `POST /session` - create session. -- [x] `DELETE /session/:sessionID` - delete session. -- [x] `PATCH /session/:sessionID` - update session metadata. -- [x] `POST /session/:sessionID/init` - run project init command. -- [x] `POST /session/:sessionID/fork` - fork session. -- [x] `POST /session/:sessionID/abort` - abort session. -- [x] `POST /session/:sessionID/share` - share session. -- [x] `GET /session/:sessionID/diff` - session diff. -- [x] `DELETE /session/:sessionID/share` - unshare session. -- [x] `POST /session/:sessionID/summarize` - summarize session. -- [x] `GET /session/:sessionID/message` - list session messages. -- [x] `GET /session/:sessionID/message/:messageID` - get message. -- [x] `DELETE /session/:sessionID/message/:messageID` - delete message. -- [x] `DELETE /session/:sessionID/message/:messageID/part/:partID` - delete part. -- [x] `PATCH /session/:sessionID/message/:messageID/part/:partID` - update part. -- [x] `POST /session/:sessionID/message` - prompt with streaming response. -- [x] `POST /session/:sessionID/prompt_async` - async prompt. -- [x] `POST /session/:sessionID/command` - run command. -- [x] `POST /session/:sessionID/shell` - run shell command. -- [x] `POST /session/:sessionID/revert` - revert message. -- [x] `POST /session/:sessionID/unrevert` - restore reverted messages. -- [x] `POST /session/:sessionID/permissions/:permissionID` - deprecated permission response route. - -### Event Routes - -- [x] `GET /event` - SSE event stream via raw Effect HTTP. - -### PTY Routes - -- [x] `GET /pty` - list PTY sessions. -- [x] `POST /pty` - create PTY session. -- [x] `GET /pty/:ptyID` - get PTY session. -- [x] `PUT /pty/:ptyID` - update PTY session. -- [x] `DELETE /pty/:ptyID` - remove PTY session. -- [x] `GET /pty/:ptyID/connect` - PTY websocket; replace with raw Effect HTTP/websocket support. - -### TUI Routes - -- [x] `POST /tui/append-prompt` - append prompt. -- [x] `POST /tui/open-help` - open help. -- [x] `POST /tui/open-sessions` - open sessions. -- [x] `POST /tui/open-themes` - open themes. -- [x] `POST /tui/open-models` - open models. -- [x] `POST /tui/submit-prompt` - submit prompt. -- [x] `POST /tui/clear-prompt` - clear prompt. -- [x] `POST /tui/execute-command` - execute command. -- [x] `POST /tui/show-toast` - show toast. -- [x] `POST /tui/publish` - publish TUI event. -- [x] `POST /tui/select-session` - select session. -- [x] `GET /tui/control/next` - get next TUI request. -- [x] `POST /tui/control/response` - submit TUI control response. - -## Remaining PR Plan - -Prefer smaller PRs from here so route behavior and SDK/OpenAPI fallout stays reviewable. - -1. [x] Bridge `PATCH /project/:projectID`. -2. [x] Bridge MCP add/connect/disconnect routes. -3. [x] Bridge MCP OAuth routes: start, callback, authenticate, remove. -4. [x] Bridge experimental console switch and tool list routes. -5. [x] Bridge experimental global session list. -6. [x] Bridge workspace create/remove/session-restore routes. -7. [x] Bridge sync start/replay/history routes. -8. [x] Bridge session read routes: list, status, get, children, todo, diff, messages. -9. [x] Bridge session lifecycle mutation routes: create, delete, update, fork, abort. -10. [x] Bridge remaining session mutation and prompt routes. -11. [ ] Replace event SSE with non-Hono Effect HTTP. The Effect backend has a raw Effect HTTP `httpapi/event.ts`; the Hono backend still uses `hono/streaming` `streamSSE`. Either port Hono `/event` to raw Effect HTTP for the fallback window, or skip and delete it together with Hono in step 15. -12. [x] Replace pty websocket/control routes with non-Hono Effect HTTP for the Effect backend. Hono `pty.ts` remains in the Hono backend. -13. [x] Replace tui bridge routes or explicitly isolate them behind a non-Hono compatibility layer for the Effect backend. Hono `tui.ts` remains in the Hono backend. -14. [ ] Switch OpenAPI/SDK generation to Effect routes and compare SDK output. Effect path is implemented and opt-in via `--httpapi` / `OPENCODE_SDK_OPENAPI=httpapi`. Close the schema-shape gaps in `public.ts` (branded `pattern`, per-property `description`, `Event.*` / `SyncEvent.*` naming, dedup collisions), then flip `packages/sdk/js/script/build.ts` default. -15. [ ] Flip `backend.ts` default from `hono` to `effect-httpapi`, keep `OPENCODE_EXPERIMENTAL_HTTPAPI` (or its inverse) as a short fallback flag, then delete replaced Hono route files. - -## Checklist - -- [x] Add first `HttpApi` JSON route slices. -- [x] Bridge selected `HttpApi` routes behind `OPENCODE_EXPERIMENTAL_HTTPAPI`. (Now backend-fork-at-startup rather than in-Hono path mounting.) -- [x] Reuse existing Effect services in handlers. -- [x] Provide auth, instance lookup, and observability in the Effect route layer. -- [x] Centralize auth via Effect `Config` for the Effect backend. -- [x] Support `auth_token` as a query security scheme. -- [x] Add bridge-level auth and instance tests. -- [x] Complete exact Hono route inventory. -- [x] Resolve implemented-but-unmounted route groups. -- [x] Port remaining top-level JSON reads. -- [x] Implement Effect `HttpApi` OpenAPI generation behind `--httpapi` / `OPENCODE_SDK_OPENAPI=httpapi`. -- [ ] Close Effect-vs-Hono OpenAPI schema-shape gaps and flip the SDK generator default. -- [ ] Flip the runtime backend default from `hono` to `effect-httpapi`, with a short fallback flag. -- [ ] Delete replaced Hono route implementations. -- [ ] Replace SSE/websocket/streaming Hono routes with non-Hono implementations (or remove with the rest of Hono). diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index ebf6ecc591..5b7aa16ff1 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -121,12 +121,12 @@ const LogLevelRef = Schema.Literals(["DEBUG", "INFO", "WARN", "ERROR"]).annotate }) // The Effect Schema is the canonical source of truth. The `.zod` compatibility -// surface is derived so existing Hono validators keep working without a parallel -// Zod definition. +// surface is derived from it so plugin/SDK Zod consumers keep working without +// a parallel hand-maintained Zod definition. // // The walker emits `z.object({...})` which is non-strict by default. Config // historically uses `.strict()` (additionalProperties: false in openapi.json), -// so layer that on after derivation. Re-apply the Config ref afterward +// so layer that on after derivation. Re-apply the Config ref afterward // since `.strict()` strips the walker's meta annotation. export const Info = Schema.Struct({ $schema: Schema.optional(Schema.String).annotate({ diff --git a/packages/opencode/src/server/routes/instance/httpapi/api.ts b/packages/opencode/src/server/routes/instance/httpapi/api.ts index 9b7af481cf..bdd917e4e8 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/api.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/api.ts @@ -22,8 +22,7 @@ import { WorkspaceApi } from "./groups/workspace" import { V2Api } from "./groups/v2" import { Authorization } from "./middleware/authorization" -// SSE event schemas built from the same BusEvent/SyncEvent registries that -// the Hono spec uses, so both specs emit identical Event/SyncEvent components. +// SSE event schemas built from the BusEvent/SyncEvent registries. const EventSchema = Schema.Union(BusEvent.effectPayloads()).annotate({ identifier: "Event" }) const SyncEventSchemas = SyncEvent.effectPayloads() From 092bc674a5664dd09ee5a5c9e2ce36672431116b Mon Sep 17 00:00:00 2001 From: Polo123456789 <57022222+Polo123456789@users.noreply.github.com> Date: Sat, 9 May 2026 10:31:46 -0600 Subject: [PATCH 063/189] fix(sidebar): fix logic and missleading message #26469 (#26470) --- .../opencode/src/cli/cmd/tui/feature-plugins/sidebar/lsp.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/lsp.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/lsp.tsx index 5127ca0e4b..ce10d7d54a 100644 --- a/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/lsp.tsx +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/lsp.tsx @@ -8,7 +8,7 @@ function View(props: { api: TuiPluginApi }) { const [open, setOpen] = createSignal(true) const theme = () => props.api.theme.current const list = createMemo(() => props.api.state.lsp()) - const off = createMemo(() => props.api.state.config.lsp === false) + const off = createMemo(() => !props.api.state.config.lsp) return ( @@ -23,7 +23,7 @@ function View(props: { api: TuiPluginApi }) { - {off() ? "LSPs have been disabled in settings" : "LSPs will activate as files are read"} + {off() ? "LSPs are disabled" : "LSPs will activate as files are read"} From 347526e9fdd9d81e7130c493809b5c82f4924240 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sat, 9 May 2026 16:32:52 +0000 Subject: [PATCH 064/189] chore: generate --- .../opencode/src/cli/cmd/tui/feature-plugins/sidebar/lsp.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/lsp.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/lsp.tsx index ce10d7d54a..f640fbee90 100644 --- a/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/lsp.tsx +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/lsp.tsx @@ -22,9 +22,7 @@ function View(props: { api: TuiPluginApi }) { - - {off() ? "LSPs are disabled" : "LSPs will activate as files are read"} - + {off() ? "LSPs are disabled" : "LSPs will activate as files are read"} {(item) => ( From 817cb076a8075314644aa330ce1598cb31c468f3 Mon Sep 17 00:00:00 2001 From: Steffen Deusch Date: Sat, 9 May 2026 18:45:44 +0200 Subject: [PATCH 065/189] fix(acp): include tool image attachments in updates (#25128) --- packages/opencode/src/acp/agent.ts | 130 +++++++------ .../test/acp/event-subscription.test.ts | 179 +++++++++++++++++- 2 files changed, 246 insertions(+), 63 deletions(-) diff --git a/packages/opencode/src/acp/agent.ts b/packages/opencode/src/acp/agent.ts index 1d941c6b92..867b830cf2 100644 --- a/packages/opencode/src/acp/agent.ts +++ b/packages/opencode/src/acp/agent.ts @@ -347,33 +347,7 @@ export class Agent implements ACPAgent { this.toolStarts.delete(part.callID) this.shellSnapshots.delete(part.callID) const kind = toToolKind(part.tool) - const content: ToolCallContent[] = [ - { - type: "content", - content: { - type: "text", - text: part.state.output, - }, - }, - ] - - if (kind === "edit") { - const input = part.state.input - const filePath = typeof input["filePath"] === "string" ? input["filePath"] : "" - const oldText = typeof input["oldString"] === "string" ? input["oldString"] : "" - const newText = - typeof input["newString"] === "string" - ? input["newString"] - : typeof input["content"] === "string" - ? input["content"] - : "" - content.push({ - type: "diff", - path: filePath, - oldText, - newText, - }) - } + const content = completedToolContent(part, kind) if (part.tool === "todowrite") { const parsedTodos = decodeTodos(part.state.output) @@ -413,10 +387,7 @@ export class Agent implements ACPAgent { content, title: part.state.title, rawInput: part.state.input, - rawOutput: { - output: part.state.output, - metadata: part.state.metadata, - }, + rawOutput: completedToolRawOutput(part), }, }) .catch((error) => { @@ -860,33 +831,7 @@ export class Agent implements ACPAgent { this.toolStarts.delete(part.callID) this.shellSnapshots.delete(part.callID) const kind = toToolKind(part.tool) - const content: ToolCallContent[] = [ - { - type: "content", - content: { - type: "text", - text: part.state.output, - }, - }, - ] - - if (kind === "edit") { - const input = part.state.input - const filePath = typeof input["filePath"] === "string" ? input["filePath"] : "" - const oldText = typeof input["oldString"] === "string" ? input["oldString"] : "" - const newText = - typeof input["newString"] === "string" - ? input["newString"] - : typeof input["content"] === "string" - ? input["content"] - : "" - content.push({ - type: "diff", - path: filePath, - oldText, - newText, - }) - } + const content = completedToolContent(part, kind) if (part.tool === "todowrite") { const parsedTodos = decodeTodos(part.state.output) @@ -926,10 +871,7 @@ export class Agent implements ACPAgent { content, title: part.state.title, rawInput: part.state.input, - rawOutput: { - output: part.state.output, - metadata: part.state.metadata, - }, + rawOutput: completedToolRawOutput(part), }, }) .catch((err) => { @@ -1655,6 +1597,70 @@ function toLocations(toolName: string, input: Record): { path: stri } } +function completedToolContent(part: ToolPart, kind: ToolKind): ToolCallContent[] { + if (part.state.status !== "completed") return [] + + const content: ToolCallContent[] = [ + { + type: "content", + content: { + type: "text", + text: part.state.output, + }, + }, + ] + + if (kind === "edit") { + const input = part.state.input + const filePath = typeof input["filePath"] === "string" ? input["filePath"] : "" + const oldText = typeof input["oldString"] === "string" ? input["oldString"] : "" + const newText = + typeof input["newString"] === "string" + ? input["newString"] + : typeof input["content"] === "string" + ? input["content"] + : "" + content.push({ + type: "diff", + path: filePath, + oldText, + newText, + }) + } + + content.push(...imageContents(part.state.attachments ?? [])) + return content +} + +function completedToolRawOutput(part: ToolPart) { + if (part.state.status !== "completed") return {} + return { + output: part.state.output, + metadata: part.state.metadata, + ...(part.state.attachments?.length ? { attachments: part.state.attachments } : {}), + } +} + +function imageContents(attachments: Array<{ mime: string; url: string }>): ToolCallContent[] { + return attachments.flatMap((attachment): ToolCallContent[] => { + const match = attachment.url.match(/^data:([^;,]+)(?:;[^,]*)*;base64,(.*)$/) + const mime = match?.[1] ?? attachment.mime + if (!mime.startsWith("image/")) return [] + const data = match?.[2] + if (data === undefined) return [] + return [ + { + type: "content" as const, + content: { + type: "image" as const, + mimeType: mime, + data, + }, + }, + ] + }) +} + async function defaultModel(config: ACPConfig, cwd?: string): Promise<{ providerID: ProviderID; modelID: ModelID }> { const sdk = config.sdk const configured = config.defaultModel diff --git a/packages/opencode/test/acp/event-subscription.test.ts b/packages/opencode/test/acp/event-subscription.test.ts index 2722757ab9..d0fb2e994d 100644 --- a/packages/opencode/test/acp/event-subscription.test.ts +++ b/packages/opencode/test/acp/event-subscription.test.ts @@ -1,7 +1,13 @@ import { describe, expect, test } from "bun:test" import { ACP } from "../../src/acp/agent" import type { AgentSideConnection } from "@agentclientprotocol/sdk" -import type { Event, EventMessagePartUpdated, ToolStatePending, ToolStateRunning } from "@opencode-ai/sdk/v2" +import type { + Event, + EventMessagePartUpdated, + ToolStateCompleted, + ToolStatePending, + ToolStateRunning, +} from "@opencode-ai/sdk/v2" import { Instance } from "../../src/project/instance" import { WithInstance } from "../../src/project/with-instance" import { tmpdir } from "../fixture/fixture" @@ -36,6 +42,14 @@ function isToolCallUpdate( return update.sessionUpdate === "tool_call_update" } +function completedToolUpdate(sessionUpdates: SessionUpdateParams[], sessionId: string, callID: string) { + return sessionUpdates + .filter((u) => u.sessionId === sessionId) + .map((u) => u.update) + .filter(isToolCallUpdate) + .find((u) => u.toolCallId === callID && u.status === "completed") +} + function toolEvent( sessionId: string, cwd: string, @@ -78,6 +92,45 @@ function toolEvent( return { directory: cwd, payload } } +function completedToolEvent( + sessionId: string, + cwd: string, + opts: { + callID: string + tool: string + input: Record + output: string + attachments?: ToolStateCompleted["attachments"] + }, +): GlobalEventEnvelope { + const state: ToolStateCompleted = { + status: "completed", + input: opts.input, + output: opts.output, + title: opts.tool, + metadata: {}, + time: { start: Date.now() - 1, end: Date.now() }, + ...(opts.attachments && { attachments: opts.attachments }), + } + const payload: EventMessagePartUpdated = { + type: "message.part.updated", + properties: { + sessionID: sessionId, + time: Date.now(), + part: { + id: `part_${opts.callID}`, + sessionID: sessionId, + messageID: `msg_${opts.callID}`, + type: "tool", + callID: opts.callID, + tool: opts.tool, + state, + }, + }, + } + return { directory: cwd, payload } +} + function createEventStream() { const queue: GlobalEventEnvelope[] = [] const waiters: Array<(value: GlobalEventEnvelope | undefined) => void> = [] @@ -616,6 +669,130 @@ describe("acp.agent event subscription", () => { }) }) + test("emits image attachments as ACP tool content blocks on live completed tool updates", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const { agent, controller, sessionUpdates, stop } = createFakeAgent() + const cwd = "/tmp/opencode-acp-test" + const sessionId = await agent.newSession({ cwd, mcpServers: [] } as any).then((x) => x.sessionId) + const data = Buffer.from("image-data").toString("base64") + + controller.push( + completedToolEvent(sessionId, cwd, { + callID: "call_image", + tool: "read", + input: { filePath: "/tmp/image.png" }, + output: "Image read successfully", + attachments: [ + { + id: "part_image", + sessionID: sessionId, + messageID: "msg_image", + type: "file", + mime: "image/png", + filename: "image.png", + url: `data:image/png;base64,${data}`, + }, + { + id: "part_text", + sessionID: sessionId, + messageID: "msg_image", + type: "file", + mime: "text/plain", + filename: "note.txt", + url: "data:text/plain;base64,Zm9v", + }, + ], + }), + ) + await new Promise((r) => setTimeout(r, 20)) + + const update = completedToolUpdate(sessionUpdates, sessionId, "call_image") + expect(update?.content).toContainEqual({ + type: "content", + content: { type: "text", text: "Image read successfully" }, + }) + expect(update?.content).toContainEqual({ + type: "content", + content: { type: "image", mimeType: "image/png", data }, + }) + expect(update?.content?.some((item) => item.type === "content" && item.content.type === "resource")).toBe(false) + expect((update?.rawOutput as { attachments?: unknown[] } | undefined)?.attachments?.length).toBe(2) + + stop() + }, + }) + }) + + test("replays completed tool image attachments as ACP tool content blocks", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const { agent, sessionUpdates, stop, sdk } = createFakeAgent() + const cwd = "/tmp/opencode-acp-test" + const sessionId = await agent.newSession({ cwd, mcpServers: [] } as any).then((x) => x.sessionId) + const data = Buffer.from("replay-image").toString("base64") + + sdk.session.messages = async () => ({ + data: [ + { + info: { + role: "assistant", + sessionID: sessionId, + }, + parts: [ + { + id: "part_replay", + sessionID: sessionId, + messageID: "msg_replay", + type: "tool", + callID: "call_replay_image", + tool: "webfetch", + state: { + status: "completed", + input: { url: "https://example.com/image.png" }, + output: "Image fetched successfully", + title: "webfetch", + metadata: {}, + time: { start: Date.now() - 1, end: Date.now() }, + attachments: [ + { + id: "part_replay_image", + sessionID: sessionId, + messageID: "msg_replay", + type: "file", + mime: "image/jpeg", + filename: "image.jpg", + url: `data:image/jpeg;base64,${data}`, + }, + ], + }, + }, + ], + }, + ], + }) + + await agent.loadSession({ sessionId, cwd, mcpServers: [] } as any) + + const update = completedToolUpdate(sessionUpdates, sessionId, "call_replay_image") + expect(update?.content).toContainEqual({ + type: "content", + content: { type: "text", text: "Image fetched successfully" }, + }) + expect(update?.content).toContainEqual({ + type: "content", + content: { type: "image", mimeType: "image/jpeg", data }, + }) + + stop() + }, + }) + }) + test("does not emit duplicate synthetic pending after replayed running tool", async () => { await using tmp = await tmpdir() await WithInstance.provide({ From 780bbb0f3bb8616150b4981d1f7cbdeec2aab6f4 Mon Sep 17 00:00:00 2001 From: opencode Date: Sat, 9 May 2026 16:54:43 +0000 Subject: [PATCH 066/189] sync release versions for v1.14.42 --- bun.lock | 34 +++++++++++++------------- packages/app/package.json | 2 +- packages/console/app/package.json | 2 +- packages/console/core/package.json | 2 +- packages/console/function/package.json | 2 +- packages/console/mail/package.json | 2 +- packages/core/package.json | 2 +- packages/desktop/package.json | 2 +- packages/enterprise/package.json | 2 +- packages/extensions/zed/extension.toml | 12 ++++----- packages/function/package.json | 2 +- packages/http-recorder/package.json | 2 +- packages/llm/package.json | 2 +- packages/opencode/package.json | 2 +- packages/plugin/package.json | 2 +- packages/sdk/js/package.json | 2 +- packages/slack/package.json | 2 +- packages/ui/package.json | 2 +- packages/web/package.json | 2 +- sdks/vscode/package.json | 2 +- 20 files changed, 41 insertions(+), 41 deletions(-) diff --git a/bun.lock b/bun.lock index 3f22ce5b47..2d216eed60 100644 --- a/bun.lock +++ b/bun.lock @@ -29,7 +29,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "1.14.41", + "version": "1.14.42", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/core": "workspace:*", @@ -85,7 +85,7 @@ }, "packages/console/app": { "name": "@opencode-ai/console-app", - "version": "1.14.41", + "version": "1.14.42", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@ibm/plex": "6.4.1", @@ -120,7 +120,7 @@ }, "packages/console/core": { "name": "@opencode-ai/console-core", - "version": "1.14.41", + "version": "1.14.42", "dependencies": { "@aws-sdk/client-sts": "3.782.0", "@jsx-email/render": "1.1.1", @@ -147,7 +147,7 @@ }, "packages/console/function": { "name": "@opencode-ai/console-function", - "version": "1.14.41", + "version": "1.14.42", "dependencies": { "@ai-sdk/anthropic": "3.0.64", "@ai-sdk/openai": "3.0.48", @@ -171,7 +171,7 @@ }, "packages/console/mail": { "name": "@opencode-ai/console-mail", - "version": "1.14.41", + "version": "1.14.42", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", @@ -195,7 +195,7 @@ }, "packages/core": { "name": "@opencode-ai/core", - "version": "1.14.41", + "version": "1.14.42", "bin": { "opencode": "./bin/opencode", }, @@ -229,7 +229,7 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "1.14.41", + "version": "1.14.42", "dependencies": { "drizzle-orm": "catalog:", "effect": "catalog:", @@ -283,7 +283,7 @@ }, "packages/enterprise": { "name": "@opencode-ai/enterprise", - "version": "1.14.41", + "version": "1.14.42", "dependencies": { "@opencode-ai/core": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -313,7 +313,7 @@ }, "packages/function": { "name": "@opencode-ai/function", - "version": "1.14.41", + "version": "1.14.42", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "catalog:", @@ -329,7 +329,7 @@ }, "packages/http-recorder": { "name": "@opencode-ai/http-recorder", - "version": "0.0.0", + "version": "1.14.42", "dependencies": { "@effect/platform-node": "catalog:", "effect": "catalog:", @@ -342,7 +342,7 @@ }, "packages/llm": { "name": "@opencode-ai/llm", - "version": "1.14.25", + "version": "1.14.42", "dependencies": { "@smithy/eventstream-codec": "4.2.14", "@smithy/util-utf8": "4.2.2", @@ -360,7 +360,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.14.41", + "version": "1.14.42", "bin": { "opencode": "./bin/opencode", }, @@ -495,7 +495,7 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.14.41", + "version": "1.14.42", "dependencies": { "@opencode-ai/sdk": "workspace:*", "effect": "catalog:", @@ -533,7 +533,7 @@ }, "packages/sdk/js": { "name": "@opencode-ai/sdk", - "version": "1.14.41", + "version": "1.14.42", "dependencies": { "cross-spawn": "catalog:", }, @@ -548,7 +548,7 @@ }, "packages/slack": { "name": "@opencode-ai/slack", - "version": "1.14.41", + "version": "1.14.42", "dependencies": { "@opencode-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -583,7 +583,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.14.41", + "version": "1.14.42", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/core": "workspace:*", @@ -632,7 +632,7 @@ }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.14.41", + "version": "1.14.42", "dependencies": { "@astrojs/cloudflare": "12.6.3", "@astrojs/markdown-remark": "6.3.1", diff --git a/packages/app/package.json b/packages/app/package.json index 600c011b6b..8a6cd1a554 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "1.14.41", + "version": "1.14.42", "description": "", "type": "module", "exports": { diff --git a/packages/console/app/package.json b/packages/console/app/package.json index b31488f155..56bdac4e97 100644 --- a/packages/console/app/package.json +++ b/packages/console/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-app", - "version": "1.14.41", + "version": "1.14.42", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/console/core/package.json b/packages/console/core/package.json index 4ca29eb4c7..f49cd43d7b 100644 --- a/packages/console/core/package.json +++ b/packages/console/core/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/console-core", - "version": "1.14.41", + "version": "1.14.42", "private": true, "type": "module", "license": "MIT", diff --git a/packages/console/function/package.json b/packages/console/function/package.json index 7e1d77d7dc..14f04d7c57 100644 --- a/packages/console/function/package.json +++ b/packages/console/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-function", - "version": "1.14.41", + "version": "1.14.42", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/console/mail/package.json b/packages/console/mail/package.json index 34ddd073f0..e62e1b4a5f 100644 --- a/packages/console/mail/package.json +++ b/packages/console/mail/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-mail", - "version": "1.14.41", + "version": "1.14.42", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", diff --git a/packages/core/package.json b/packages/core/package.json index 995ab18ee5..ceb227b2f1 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.14.41", + "version": "1.14.42", "name": "@opencode-ai/core", "type": "module", "license": "MIT", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 49e35c5db8..0b149d8064 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop", "private": true, - "version": "1.14.41", + "version": "1.14.42", "type": "module", "license": "MIT", "homepage": "https://opencode.ai", diff --git a/packages/enterprise/package.json b/packages/enterprise/package.json index 7f964f511a..9d682c736f 100644 --- a/packages/enterprise/package.json +++ b/packages/enterprise/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/enterprise", - "version": "1.14.41", + "version": "1.14.42", "private": true, "type": "module", "license": "MIT", diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml index 8b4850c885..427f651815 100644 --- a/packages/extensions/zed/extension.toml +++ b/packages/extensions/zed/extension.toml @@ -1,7 +1,7 @@ id = "opencode" name = "OpenCode" description = "The open source coding agent." -version = "1.14.41" +version = "1.14.42" schema_version = 1 authors = ["Anomaly"] repository = "https://github.com/anomalyco/opencode" @@ -11,26 +11,26 @@ name = "OpenCode" icon = "./icons/opencode.svg" [agent_servers.opencode.targets.darwin-aarch64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.41/opencode-darwin-arm64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.42/opencode-darwin-arm64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.darwin-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.41/opencode-darwin-x64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.42/opencode-darwin-x64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-aarch64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.41/opencode-linux-arm64.tar.gz" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.42/opencode-linux-arm64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.41/opencode-linux-x64.tar.gz" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.42/opencode-linux-x64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.windows-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.41/opencode-windows-x64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.42/opencode-windows-x64.zip" cmd = "./opencode.exe" args = ["acp"] diff --git a/packages/function/package.json b/packages/function/package.json index 70812ab10a..b05c7b0126 100644 --- a/packages/function/package.json +++ b/packages/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/function", - "version": "1.14.41", + "version": "1.14.42", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/http-recorder/package.json b/packages/http-recorder/package.json index ee4865b475..b2726b339c 100644 --- a/packages/http-recorder/package.json +++ b/packages/http-recorder/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "0.0.0", + "version": "1.14.42", "name": "@opencode-ai/http-recorder", "type": "module", "license": "MIT", diff --git a/packages/llm/package.json b/packages/llm/package.json index 3a616f4093..97b7818fe5 100644 --- a/packages/llm/package.json +++ b/packages/llm/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.14.25", + "version": "1.14.42", "name": "@opencode-ai/llm", "type": "module", "license": "MIT", diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 3126914416..c90ae26930 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.14.41", + "version": "1.14.42", "name": "opencode", "type": "module", "license": "MIT", diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 91afa3c883..79e1d5b5e5 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/plugin", - "version": "1.14.41", + "version": "1.14.42", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index 2959cba2dd..c3d491bde0 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/sdk", - "version": "1.14.41", + "version": "1.14.42", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/slack/package.json b/packages/slack/package.json index 34175d66a2..ea3a6adcfc 100644 --- a/packages/slack/package.json +++ b/packages/slack/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/slack", - "version": "1.14.41", + "version": "1.14.42", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/ui/package.json b/packages/ui/package.json index fc065be9ef..d626fc221b 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/ui", - "version": "1.14.41", + "version": "1.14.42", "type": "module", "license": "MIT", "exports": { diff --git a/packages/web/package.json b/packages/web/package.json index 252c81a295..49dd1bcd4f 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -2,7 +2,7 @@ "name": "@opencode-ai/web", "type": "module", "license": "MIT", - "version": "1.14.41", + "version": "1.14.42", "scripts": { "dev": "astro dev", "dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev", diff --git a/sdks/vscode/package.json b/sdks/vscode/package.json index 3eaca42fb7..b1d14c48e5 100644 --- a/sdks/vscode/package.json +++ b/sdks/vscode/package.json @@ -2,7 +2,7 @@ "name": "opencode", "displayName": "opencode", "description": "opencode for VS Code", - "version": "1.14.41", + "version": "1.14.42", "publisher": "sst-dev", "repository": { "type": "git", From b1cd25de3d24e0494c513355721048644dd804d9 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Sat, 9 May 2026 12:08:00 -0500 Subject: [PATCH 067/189] ignore: fix typerrs on dev (#26544) --- packages/opencode/test/acp/event-subscription.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/opencode/test/acp/event-subscription.test.ts b/packages/opencode/test/acp/event-subscription.test.ts index d0fb2e994d..791b5c578f 100644 --- a/packages/opencode/test/acp/event-subscription.test.ts +++ b/packages/opencode/test/acp/event-subscription.test.ts @@ -8,7 +8,6 @@ import type { ToolStatePending, ToolStateRunning, } from "@opencode-ai/sdk/v2" -import { Instance } from "../../src/project/instance" import { WithInstance } from "../../src/project/with-instance" import { tmpdir } from "../fixture/fixture" @@ -113,6 +112,7 @@ function completedToolEvent( ...(opts.attachments && { attachments: opts.attachments }), } const payload: EventMessagePartUpdated = { + id: `evt_${opts.callID}`, type: "message.part.updated", properties: { sessionID: sessionId, @@ -671,7 +671,7 @@ describe("acp.agent event subscription", () => { test("emits image attachments as ACP tool content blocks on live completed tool updates", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const { agent, controller, sessionUpdates, stop } = createFakeAgent() @@ -728,7 +728,7 @@ describe("acp.agent event subscription", () => { test("replays completed tool image attachments as ACP tool content blocks", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const { agent, sessionUpdates, stop, sdk } = createFakeAgent() From 27fa297a42cc7c25c6b34823f22d2f16e43256d6 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 9 May 2026 13:40:46 -0400 Subject: [PATCH 068/189] fix(server): keep provider lists JSON-safe (#26550) --- packages/opencode/src/provider/provider.ts | 12 ++- .../instance/httpapi/handlers/config.ts | 2 +- .../instance/httpapi/handlers/provider.ts | 2 +- .../instance/httpapi/middleware/error.ts | 26 ------ .../test/server/httpapi-provider.test.ts | 88 +++++++++++++++++++ 5 files changed, 101 insertions(+), 29 deletions(-) diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 4013dcee36..d9b7fac2fa 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -935,6 +935,16 @@ export const ConfigProvidersResult = Schema.Struct({ }).pipe(withStatics((s) => ({ zod: zod(s) }))) export type ConfigProvidersResult = Types.DeepMutable> +export function toPublicInfo(provider: Info): Info { + return JSON.parse( + JSON.stringify(provider, (_, value) => { + if (typeof value === "function" || typeof value === "symbol" || value === undefined) return undefined + if (typeof value === "bigint") return value.toString() + return value + }), + ) +} + export function defaultModelIDs }>(providers: Record) { return mapValues(providers, (item) => sort(Object.values(item.models))[0].id) } @@ -1299,7 +1309,7 @@ const layer: Layer.Layer< const options = yield* Effect.promise(() => plugin.auth!.loader!( () => bridge.promise(auth.get(providerID).pipe(Effect.orDie)) as any, - database[plugin.auth!.provider], + toPublicInfo(database[plugin.auth!.provider]), ), ) const opts = options ?? {} diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/config.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/config.ts index 753ba03138..3d0e8a06c0 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/config.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/config.ts @@ -24,7 +24,7 @@ export const configHandlers = HttpApiBuilder.group(InstanceHttpApi, "config", (h const providers = Effect.fn("ConfigHttpApi.providers")(function* () { const providers = yield* providerSvc.list() return { - providers: Object.values(providers), + providers: Object.values(providers).map(Provider.toPublicInfo), default: Provider.defaultModelIDs(providers), } }) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/provider.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/provider.ts index 15049fcc55..7027e666ca 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/provider.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/provider.ts @@ -30,7 +30,7 @@ export const providerHandlers = HttpApiBuilder.group(InstanceHttpApi, "provider" connected, ) return { - all: Object.values(providers), + all: Object.values(providers).map(Provider.toPublicInfo), default: Provider.defaultModelIDs(providers), connected: Object.keys(connected), } diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/error.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/error.ts index 5a018f16da..6f3c33a647 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/middleware/error.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/error.ts @@ -6,39 +6,13 @@ import { NamedError } from "@opencode-ai/core/util/error" import * as Log from "@opencode-ai/core/util/log" import { Cause, Effect } from "effect" import { HttpRouter, HttpServerError, HttpServerRespondable, HttpServerResponse } from "effect/unstable/http" -import { HttpApiError } from "effect/unstable/httpapi" -import { HttpApiSchemaError } from "effect/unstable/httpapi/HttpApiError" const log = Log.create({ service: "server" }) -function badRequestResponse() { - return HttpServerResponse.jsonUnsafe( - { - data: {}, - errors: [], - success: false, - }, - { status: 400 }, - ) -} - -function normalizeEmptyBadRequest(response: HttpServerResponse.HttpServerResponse) { - if (response.status !== 400 || response.body._tag !== "Empty") return response - return badRequestResponse() -} - // Keep typed HttpApi failures on their declared error path; this boundary only replaces defect-only empty 500s. export const errorLayer = HttpRouter.middleware<{ handles: unknown }>()((effect) => effect.pipe( - Effect.catch((error) => { - if (error instanceof HttpApiError.BadRequest) return Effect.succeed(badRequestResponse()) - return Effect.fail(error) - }), - Effect.map(normalizeEmptyBadRequest), Effect.catchCause((cause) => { - const schemaError = cause.reasons.filter(Cause.isDieReason).find((reason) => HttpApiSchemaError.is(reason.defect)) - if (schemaError) return Effect.succeed(badRequestResponse()) - const defect = cause.reasons.filter(Cause.isDieReason).find((reason) => { if (HttpServerResponse.isHttpServerResponse(reason.defect)) return false if (HttpServerError.isHttpServerError(reason.defect)) return false diff --git a/packages/opencode/test/server/httpapi-provider.test.ts b/packages/opencode/test/server/httpapi-provider.test.ts index 12262e30ed..d44ff4cd60 100644 --- a/packages/opencode/test/server/httpapi-provider.test.ts +++ b/packages/opencode/test/server/httpapi-provider.test.ts @@ -21,6 +21,22 @@ function app() { return Server.Default().app } +function providerListHasFetch(list: unknown) { + if (!Array.isArray(list)) return false + return list.some((item: unknown) => { + if (typeof item !== "object" || item === null || !("id" in item) || !("options" in item)) return false + if (item.id !== "google") return false + if (typeof item.options !== "object" || item.options === null) return false + return "fetch" in item.options + }) +} + +function hasProviderWithFetch(input: unknown, key: "all" | "providers") { + if (typeof input !== "object" || input === null) return false + if (key === "all") return "all" in input && providerListHasFetch(input.all) + return "providers" in input && providerListHasFetch(input.providers) +} + function requestAuthorize(input: { app: ReturnType providerID: string @@ -76,6 +92,39 @@ function writeProviderAuthPlugin(dir: string) { }) } +function writeFunctionOptionsPlugin(dir: string) { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + const path = yield* Path.Path + + yield* fs.makeDirectory(path.join(dir, ".opencode", "plugin"), { recursive: true }) + yield* fs.writeFileString( + path.join(dir, ".opencode", "plugin", "provider-function-options.ts"), + [ + "export default {", + ' id: "test.provider-function-options",', + " server: async () => ({", + " auth: {", + ' provider: "google",', + " loader: async (_getAuth, provider) => {", + " for (const model of Object.values(provider.models ?? {})) {", + " model.cost = { input: 0, output: 0 }", + " }", + " return {", + ' apiKey: "",', + " fetch: async (input, init) => fetch(input, init),", + " }", + " },", + " methods: [{ type: 'api', label: 'API key' }],", + " },", + " }),", + "}", + "", + ].join("\n"), + ) + }) +} + function withProviderProject(self: (dir: string) => Effect.Effect) { return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem @@ -136,4 +185,43 @@ describe("provider HttpApi", () => { }), ), ) + + it.live("serves provider lists when auth loaders add runtime fetch options", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + const path = yield* Path.Path + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "opencode-test-" }) + const previous = process.env.OPENCODE_AUTH_CONTENT + + yield* fs.writeFileString( + path.join(dir, "opencode.json"), + JSON.stringify({ $schema: "https://opencode.ai/config.json", formatter: false, lsp: false }), + ) + yield* writeFunctionOptionsPlugin(dir) + yield* Effect.sync(() => { + process.env.OPENCODE_AUTH_CONTENT = JSON.stringify({ + google: { type: "oauth", refresh: "dummy", access: "dummy", expires: 9999999999999 }, + }) + }) + yield* Effect.addFinalizer(() => + Effect.sync(() => { + if (previous === undefined) delete process.env.OPENCODE_AUTH_CONTENT + if (previous !== undefined) process.env.OPENCODE_AUTH_CONTENT = previous + }), + ) + const headers = { "x-opencode-directory": dir } + const providerResponse = yield* Effect.promise(() => Promise.resolve(app().request("/provider", { headers }))) + const configResponse = yield* Effect.promise(() => + Promise.resolve(app().request("/config/providers", { headers })), + ) + + expect(providerResponse.status).toBe(200) + expect(configResponse.status).toBe(200) + + const providerBody = yield* Effect.promise(() => providerResponse.json()) + const configBody = yield* Effect.promise(() => configResponse.json()) + expect(hasProviderWithFetch(providerBody, "all")).toBe(false) + expect(hasProviderWithFetch(configBody, "providers")).toBe(false) + }), + ) }) From e22144806f74d998188259bdeea2eb3400b61db0 Mon Sep 17 00:00:00 2001 From: opencode Date: Sat, 9 May 2026 18:06:16 +0000 Subject: [PATCH 069/189] sync release versions for v1.14.43 --- bun.lock | 34 +++++++++++++------------- packages/app/package.json | 2 +- packages/console/app/package.json | 2 +- packages/console/core/package.json | 2 +- packages/console/function/package.json | 2 +- packages/console/mail/package.json | 2 +- packages/core/package.json | 2 +- packages/desktop/package.json | 2 +- packages/enterprise/package.json | 2 +- packages/extensions/zed/extension.toml | 12 ++++----- packages/function/package.json | 2 +- packages/http-recorder/package.json | 2 +- packages/llm/package.json | 2 +- packages/opencode/package.json | 2 +- packages/plugin/package.json | 2 +- packages/sdk/js/package.json | 2 +- packages/slack/package.json | 2 +- packages/ui/package.json | 2 +- packages/web/package.json | 2 +- sdks/vscode/package.json | 2 +- 20 files changed, 41 insertions(+), 41 deletions(-) diff --git a/bun.lock b/bun.lock index 2d216eed60..ab87d3b029 100644 --- a/bun.lock +++ b/bun.lock @@ -29,7 +29,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "1.14.42", + "version": "1.14.43", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/core": "workspace:*", @@ -85,7 +85,7 @@ }, "packages/console/app": { "name": "@opencode-ai/console-app", - "version": "1.14.42", + "version": "1.14.43", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@ibm/plex": "6.4.1", @@ -120,7 +120,7 @@ }, "packages/console/core": { "name": "@opencode-ai/console-core", - "version": "1.14.42", + "version": "1.14.43", "dependencies": { "@aws-sdk/client-sts": "3.782.0", "@jsx-email/render": "1.1.1", @@ -147,7 +147,7 @@ }, "packages/console/function": { "name": "@opencode-ai/console-function", - "version": "1.14.42", + "version": "1.14.43", "dependencies": { "@ai-sdk/anthropic": "3.0.64", "@ai-sdk/openai": "3.0.48", @@ -171,7 +171,7 @@ }, "packages/console/mail": { "name": "@opencode-ai/console-mail", - "version": "1.14.42", + "version": "1.14.43", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", @@ -195,7 +195,7 @@ }, "packages/core": { "name": "@opencode-ai/core", - "version": "1.14.42", + "version": "1.14.43", "bin": { "opencode": "./bin/opencode", }, @@ -229,7 +229,7 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "1.14.42", + "version": "1.14.43", "dependencies": { "drizzle-orm": "catalog:", "effect": "catalog:", @@ -283,7 +283,7 @@ }, "packages/enterprise": { "name": "@opencode-ai/enterprise", - "version": "1.14.42", + "version": "1.14.43", "dependencies": { "@opencode-ai/core": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -313,7 +313,7 @@ }, "packages/function": { "name": "@opencode-ai/function", - "version": "1.14.42", + "version": "1.14.43", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "catalog:", @@ -329,7 +329,7 @@ }, "packages/http-recorder": { "name": "@opencode-ai/http-recorder", - "version": "1.14.42", + "version": "1.14.43", "dependencies": { "@effect/platform-node": "catalog:", "effect": "catalog:", @@ -342,7 +342,7 @@ }, "packages/llm": { "name": "@opencode-ai/llm", - "version": "1.14.42", + "version": "1.14.43", "dependencies": { "@smithy/eventstream-codec": "4.2.14", "@smithy/util-utf8": "4.2.2", @@ -360,7 +360,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.14.42", + "version": "1.14.43", "bin": { "opencode": "./bin/opencode", }, @@ -495,7 +495,7 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.14.42", + "version": "1.14.43", "dependencies": { "@opencode-ai/sdk": "workspace:*", "effect": "catalog:", @@ -533,7 +533,7 @@ }, "packages/sdk/js": { "name": "@opencode-ai/sdk", - "version": "1.14.42", + "version": "1.14.43", "dependencies": { "cross-spawn": "catalog:", }, @@ -548,7 +548,7 @@ }, "packages/slack": { "name": "@opencode-ai/slack", - "version": "1.14.42", + "version": "1.14.43", "dependencies": { "@opencode-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -583,7 +583,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.14.42", + "version": "1.14.43", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/core": "workspace:*", @@ -632,7 +632,7 @@ }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.14.42", + "version": "1.14.43", "dependencies": { "@astrojs/cloudflare": "12.6.3", "@astrojs/markdown-remark": "6.3.1", diff --git a/packages/app/package.json b/packages/app/package.json index 8a6cd1a554..2150e0694f 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "1.14.42", + "version": "1.14.43", "description": "", "type": "module", "exports": { diff --git a/packages/console/app/package.json b/packages/console/app/package.json index 56bdac4e97..aa6e859d71 100644 --- a/packages/console/app/package.json +++ b/packages/console/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-app", - "version": "1.14.42", + "version": "1.14.43", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/console/core/package.json b/packages/console/core/package.json index f49cd43d7b..b41240fe57 100644 --- a/packages/console/core/package.json +++ b/packages/console/core/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/console-core", - "version": "1.14.42", + "version": "1.14.43", "private": true, "type": "module", "license": "MIT", diff --git a/packages/console/function/package.json b/packages/console/function/package.json index 14f04d7c57..b7c3aa2a98 100644 --- a/packages/console/function/package.json +++ b/packages/console/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-function", - "version": "1.14.42", + "version": "1.14.43", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/console/mail/package.json b/packages/console/mail/package.json index e62e1b4a5f..6ebaf2daad 100644 --- a/packages/console/mail/package.json +++ b/packages/console/mail/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-mail", - "version": "1.14.42", + "version": "1.14.43", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", diff --git a/packages/core/package.json b/packages/core/package.json index ceb227b2f1..1f843e40d3 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.14.42", + "version": "1.14.43", "name": "@opencode-ai/core", "type": "module", "license": "MIT", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 0b149d8064..f051bd50d0 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop", "private": true, - "version": "1.14.42", + "version": "1.14.43", "type": "module", "license": "MIT", "homepage": "https://opencode.ai", diff --git a/packages/enterprise/package.json b/packages/enterprise/package.json index 9d682c736f..7f33ad4e1c 100644 --- a/packages/enterprise/package.json +++ b/packages/enterprise/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/enterprise", - "version": "1.14.42", + "version": "1.14.43", "private": true, "type": "module", "license": "MIT", diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml index 427f651815..8af387c4bf 100644 --- a/packages/extensions/zed/extension.toml +++ b/packages/extensions/zed/extension.toml @@ -1,7 +1,7 @@ id = "opencode" name = "OpenCode" description = "The open source coding agent." -version = "1.14.42" +version = "1.14.43" schema_version = 1 authors = ["Anomaly"] repository = "https://github.com/anomalyco/opencode" @@ -11,26 +11,26 @@ name = "OpenCode" icon = "./icons/opencode.svg" [agent_servers.opencode.targets.darwin-aarch64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.42/opencode-darwin-arm64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.43/opencode-darwin-arm64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.darwin-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.42/opencode-darwin-x64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.43/opencode-darwin-x64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-aarch64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.42/opencode-linux-arm64.tar.gz" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.43/opencode-linux-arm64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.42/opencode-linux-x64.tar.gz" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.43/opencode-linux-x64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.windows-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.42/opencode-windows-x64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.43/opencode-windows-x64.zip" cmd = "./opencode.exe" args = ["acp"] diff --git a/packages/function/package.json b/packages/function/package.json index b05c7b0126..6c15ccc306 100644 --- a/packages/function/package.json +++ b/packages/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/function", - "version": "1.14.42", + "version": "1.14.43", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/http-recorder/package.json b/packages/http-recorder/package.json index b2726b339c..b8c0d58832 100644 --- a/packages/http-recorder/package.json +++ b/packages/http-recorder/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.14.42", + "version": "1.14.43", "name": "@opencode-ai/http-recorder", "type": "module", "license": "MIT", diff --git a/packages/llm/package.json b/packages/llm/package.json index 97b7818fe5..d3ac5357e0 100644 --- a/packages/llm/package.json +++ b/packages/llm/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.14.42", + "version": "1.14.43", "name": "@opencode-ai/llm", "type": "module", "license": "MIT", diff --git a/packages/opencode/package.json b/packages/opencode/package.json index c90ae26930..9a9f53f57c 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.14.42", + "version": "1.14.43", "name": "opencode", "type": "module", "license": "MIT", diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 79e1d5b5e5..9344f0488a 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/plugin", - "version": "1.14.42", + "version": "1.14.43", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index c3d491bde0..d6443f3b2b 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/sdk", - "version": "1.14.42", + "version": "1.14.43", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/slack/package.json b/packages/slack/package.json index ea3a6adcfc..92aedbe063 100644 --- a/packages/slack/package.json +++ b/packages/slack/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/slack", - "version": "1.14.42", + "version": "1.14.43", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/ui/package.json b/packages/ui/package.json index d626fc221b..325916d456 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/ui", - "version": "1.14.42", + "version": "1.14.43", "type": "module", "license": "MIT", "exports": { diff --git a/packages/web/package.json b/packages/web/package.json index 49dd1bcd4f..92f690f250 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -2,7 +2,7 @@ "name": "@opencode-ai/web", "type": "module", "license": "MIT", - "version": "1.14.42", + "version": "1.14.43", "scripts": { "dev": "astro dev", "dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev", diff --git a/sdks/vscode/package.json b/sdks/vscode/package.json index b1d14c48e5..cb8db945ea 100644 --- a/sdks/vscode/package.json +++ b/sdks/vscode/package.json @@ -2,7 +2,7 @@ "name": "opencode", "displayName": "opencode", "description": "opencode for VS Code", - "version": "1.14.42", + "version": "1.14.43", "publisher": "sst-dev", "repository": { "type": "git", From 57efec44290b41e95511870f50d598905c50781f Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 9 May 2026 14:18:55 -0400 Subject: [PATCH 070/189] fix(storage): default workspace time migration (#26556) --- .../migration.sql | 2 +- .../storage/workspace-time-migration.test.ts | 43 +++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) create mode 100644 packages/opencode/test/storage/workspace-time-migration.test.ts diff --git a/packages/opencode/migration/20260507164347_add_workspace_time/migration.sql b/packages/opencode/migration/20260507164347_add_workspace_time/migration.sql index bdeed6bce1..c865526a88 100644 --- a/packages/opencode/migration/20260507164347_add_workspace_time/migration.sql +++ b/packages/opencode/migration/20260507164347_add_workspace_time/migration.sql @@ -1 +1 @@ -ALTER TABLE `workspace` ADD `time_used` integer NOT NULL; \ No newline at end of file +ALTER TABLE `workspace` ADD `time_used` integer NOT NULL DEFAULT 0; diff --git a/packages/opencode/test/storage/workspace-time-migration.test.ts b/packages/opencode/test/storage/workspace-time-migration.test.ts new file mode 100644 index 0000000000..2d30646976 --- /dev/null +++ b/packages/opencode/test/storage/workspace-time-migration.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, test } from "bun:test" +import { Database } from "bun:sqlite" +import { drizzle } from "drizzle-orm/bun-sqlite" +import { migrate } from "drizzle-orm/bun-sqlite/migrator" +import { readFileSync, readdirSync } from "fs" +import path from "path" + +const target = "20260507164347_add_workspace_time" + +function migrations() { + return readdirSync(path.join(import.meta.dirname, "../../migration"), { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => ({ + name: entry.name, + timestamp: Number(entry.name.split("_")[0]), + sql: readFileSync(path.join(import.meta.dirname, "../../migration", entry.name, "migration.sql"), "utf-8"), + })) + .sort((a, b) => a.timestamp - b.timestamp) +} + +describe("workspace time migration", () => { + test("migrates existing workspace rows", () => { + const sqlite = new Database(":memory:") + const db = drizzle({ client: sqlite }) + const entries = migrations() + const index = entries.findIndex((entry) => entry.name === target) + + expect(index).toBeGreaterThan(0) + + migrate(db, entries.slice(0, index)) + sqlite.run( + "INSERT INTO project (id, worktree, vcs, name, time_created, time_updated, sandboxes) VALUES (?, ?, ?, ?, ?, ?, ?)", + ["project_1", "/tmp/project", "git", "project", 1, 1, "[]"], + ) + sqlite.run( + "INSERT INTO workspace (id, type, name, branch, directory, extra, project_id) VALUES (?, ?, ?, ?, ?, ?, ?)", + ["workspace_1", "local", "main", "main", "/tmp/project", null, "project_1"], + ) + + expect(() => migrate(db, entries.slice(index))).not.toThrow() + expect(sqlite.query("SELECT time_used FROM workspace WHERE id = ?").get("workspace_1")).toEqual({ time_used: 0 }) + }) +}) From ebe9dcf27ef9366a8a94f0984db0cc3c2622539e Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 9 May 2026 14:39:25 -0400 Subject: [PATCH 071/189] fix(server): return validation error bodies --- .../src/server/routes/instance/httpapi/api.ts | 3 +++ .../httpapi/middleware/schema-error.ts | 21 +++++++++++++++++++ .../server/routes/instance/httpapi/server.ts | 3 +++ 3 files changed, 27 insertions(+) create mode 100644 packages/opencode/src/server/routes/instance/httpapi/middleware/schema-error.ts diff --git a/packages/opencode/src/server/routes/instance/httpapi/api.ts b/packages/opencode/src/server/routes/instance/httpapi/api.ts index bdd917e4e8..4c6e46a455 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/api.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/api.ts @@ -21,6 +21,7 @@ import { TuiApi } from "./groups/tui" import { WorkspaceApi } from "./groups/workspace" import { V2Api } from "./groups/v2" import { Authorization } from "./middleware/authorization" +import { SchemaErrorMiddleware } from "./middleware/schema-error" // SSE event schemas built from the BusEvent/SyncEvent registries. const EventSchema = Schema.Union(BusEvent.effectPayloads()).annotate({ identifier: "Event" }) @@ -29,6 +30,7 @@ const SyncEventSchemas = SyncEvent.effectPayloads() export const RootHttpApi = HttpApi.make("opencode-root") .addHttpApi(ControlApi) .addHttpApi(GlobalApi) + .middleware(SchemaErrorMiddleware) .middleware(Authorization) export const InstanceHttpApi = HttpApi.make("opencode-instance") @@ -47,6 +49,7 @@ export const InstanceHttpApi = HttpApi.make("opencode-instance") .addHttpApi(V2Api) .addHttpApi(TuiApi) .addHttpApi(WorkspaceApi) + .middleware(SchemaErrorMiddleware) export const OpenCodeHttpApi = HttpApi.make("opencode") .addHttpApi(RootHttpApi) diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/schema-error.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/schema-error.ts new file mode 100644 index 0000000000..9eecde3e64 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/schema-error.ts @@ -0,0 +1,21 @@ +import { Effect } from "effect" +import { HttpServerResponse } from "effect/unstable/http" +import { HttpApiMiddleware } from "effect/unstable/httpapi" + +export class SchemaErrorMiddleware extends HttpApiMiddleware.Service()( + "@opencode/ExperimentalHttpApiSchemaError", +) {} + +export const schemaErrorLayer = HttpApiMiddleware.layerSchemaErrorTransform(SchemaErrorMiddleware, (error) => { + if (error.kind === "Body") return Effect.fail(error) + return Effect.succeed( + HttpServerResponse.jsonUnsafe( + { + data: {}, + errors: [{ kind: error.kind, message: error.cause.message }], + success: false, + }, + { status: 400 }, + ), + ) +}) diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index 495497ecb4..a64a9b9083 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -84,6 +84,7 @@ import { compressionLayer } from "./middleware/compression" import { corsVaryFix } from "./middleware/cors-vary" import { errorLayer } from "./middleware/error" import { fenceLayer } from "./middleware/fence" +import { schemaErrorLayer } from "./middleware/schema-error" export const context = Context.makeUnsafe(new Map()) @@ -114,6 +115,7 @@ const authOnlyRouterLayer = authorizationRouterMiddleware.layer.pipe(Layer.provi const httpApiAuthLayer = authorizationLayer.pipe(Layer.provide(ServerAuth.Config.defaultLayer)) const rootApiRoutes = HttpApiBuilder.layer(RootHttpApi).pipe( Layer.provide([controlHandlers, globalHandlers]), + Layer.provide(schemaErrorLayer), Layer.provide(httpApiAuthLayer), ) const instanceRouterLayer = authorizationRouterMiddleware @@ -142,6 +144,7 @@ const instanceApiRoutes = HttpApiBuilder.layer(InstanceHttpApi).pipe( tuiHandlers, workspaceHandlers, ]), + Layer.provide(schemaErrorLayer), ) const rawInstanceRoutes = Layer.mergeAll(ptyConnectRoute).pipe(Layer.provide(instanceRouterLayer)) From 6fea0178eb44ad7d6ccb0eccabb241bb82b36916 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 9 May 2026 14:50:45 -0400 Subject: [PATCH 072/189] fix(server): defer validation error body change --- .../src/server/routes/instance/httpapi/api.ts | 3 --- .../httpapi/middleware/schema-error.ts | 21 ------------------- .../server/routes/instance/httpapi/server.ts | 3 --- .../opencode/test/server/httpapi-sync.test.ts | 2 +- 4 files changed, 1 insertion(+), 28 deletions(-) delete mode 100644 packages/opencode/src/server/routes/instance/httpapi/middleware/schema-error.ts diff --git a/packages/opencode/src/server/routes/instance/httpapi/api.ts b/packages/opencode/src/server/routes/instance/httpapi/api.ts index 4c6e46a455..bdd917e4e8 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/api.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/api.ts @@ -21,7 +21,6 @@ import { TuiApi } from "./groups/tui" import { WorkspaceApi } from "./groups/workspace" import { V2Api } from "./groups/v2" import { Authorization } from "./middleware/authorization" -import { SchemaErrorMiddleware } from "./middleware/schema-error" // SSE event schemas built from the BusEvent/SyncEvent registries. const EventSchema = Schema.Union(BusEvent.effectPayloads()).annotate({ identifier: "Event" }) @@ -30,7 +29,6 @@ const SyncEventSchemas = SyncEvent.effectPayloads() export const RootHttpApi = HttpApi.make("opencode-root") .addHttpApi(ControlApi) .addHttpApi(GlobalApi) - .middleware(SchemaErrorMiddleware) .middleware(Authorization) export const InstanceHttpApi = HttpApi.make("opencode-instance") @@ -49,7 +47,6 @@ export const InstanceHttpApi = HttpApi.make("opencode-instance") .addHttpApi(V2Api) .addHttpApi(TuiApi) .addHttpApi(WorkspaceApi) - .middleware(SchemaErrorMiddleware) export const OpenCodeHttpApi = HttpApi.make("opencode") .addHttpApi(RootHttpApi) diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/schema-error.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/schema-error.ts deleted file mode 100644 index 9eecde3e64..0000000000 --- a/packages/opencode/src/server/routes/instance/httpapi/middleware/schema-error.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { Effect } from "effect" -import { HttpServerResponse } from "effect/unstable/http" -import { HttpApiMiddleware } from "effect/unstable/httpapi" - -export class SchemaErrorMiddleware extends HttpApiMiddleware.Service()( - "@opencode/ExperimentalHttpApiSchemaError", -) {} - -export const schemaErrorLayer = HttpApiMiddleware.layerSchemaErrorTransform(SchemaErrorMiddleware, (error) => { - if (error.kind === "Body") return Effect.fail(error) - return Effect.succeed( - HttpServerResponse.jsonUnsafe( - { - data: {}, - errors: [{ kind: error.kind, message: error.cause.message }], - success: false, - }, - { status: 400 }, - ), - ) -}) diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index a64a9b9083..495497ecb4 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -84,7 +84,6 @@ import { compressionLayer } from "./middleware/compression" import { corsVaryFix } from "./middleware/cors-vary" import { errorLayer } from "./middleware/error" import { fenceLayer } from "./middleware/fence" -import { schemaErrorLayer } from "./middleware/schema-error" export const context = Context.makeUnsafe(new Map()) @@ -115,7 +114,6 @@ const authOnlyRouterLayer = authorizationRouterMiddleware.layer.pipe(Layer.provi const httpApiAuthLayer = authorizationLayer.pipe(Layer.provide(ServerAuth.Config.defaultLayer)) const rootApiRoutes = HttpApiBuilder.layer(RootHttpApi).pipe( Layer.provide([controlHandlers, globalHandlers]), - Layer.provide(schemaErrorLayer), Layer.provide(httpApiAuthLayer), ) const instanceRouterLayer = authorizationRouterMiddleware @@ -144,7 +142,6 @@ const instanceApiRoutes = HttpApiBuilder.layer(InstanceHttpApi).pipe( tuiHandlers, workspaceHandlers, ]), - Layer.provide(schemaErrorLayer), ) const rawInstanceRoutes = Layer.mergeAll(ptyConnectRoute).pipe(Layer.provide(instanceRouterLayer)) diff --git a/packages/opencode/test/server/httpapi-sync.test.ts b/packages/opencode/test/server/httpapi-sync.test.ts index c5ee637842..cd626c28f4 100644 --- a/packages/opencode/test/server/httpapi-sync.test.ts +++ b/packages/opencode/test/server/httpapi-sync.test.ts @@ -122,7 +122,7 @@ describe("sync HttpApi", () => { } }) - test("returns structured validation errors", async () => { + test.todo("returns structured validation errors", async () => { await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) const response = await ExperimentalHttpApiServer.webHandler().handler( new Request(`http://localhost${SyncPaths.history}`, { From 19abadaf276675cd64d5a747f8e495fede47a42a Mon Sep 17 00:00:00 2001 From: opencode Date: Sat, 9 May 2026 19:33:26 +0000 Subject: [PATCH 073/189] sync release versions for v1.14.44 --- bun.lock | 34 +++++++++++++------------- packages/app/package.json | 2 +- packages/console/app/package.json | 2 +- packages/console/core/package.json | 2 +- packages/console/function/package.json | 2 +- packages/console/mail/package.json | 2 +- packages/core/package.json | 2 +- packages/desktop/package.json | 2 +- packages/enterprise/package.json | 2 +- packages/extensions/zed/extension.toml | 12 ++++----- packages/function/package.json | 2 +- packages/http-recorder/package.json | 2 +- packages/llm/package.json | 2 +- packages/opencode/package.json | 2 +- packages/plugin/package.json | 2 +- packages/sdk/js/package.json | 2 +- packages/slack/package.json | 2 +- packages/ui/package.json | 2 +- packages/web/package.json | 2 +- sdks/vscode/package.json | 2 +- 20 files changed, 41 insertions(+), 41 deletions(-) diff --git a/bun.lock b/bun.lock index ab87d3b029..17b0caccd1 100644 --- a/bun.lock +++ b/bun.lock @@ -29,7 +29,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "1.14.43", + "version": "1.14.44", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/core": "workspace:*", @@ -85,7 +85,7 @@ }, "packages/console/app": { "name": "@opencode-ai/console-app", - "version": "1.14.43", + "version": "1.14.44", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@ibm/plex": "6.4.1", @@ -120,7 +120,7 @@ }, "packages/console/core": { "name": "@opencode-ai/console-core", - "version": "1.14.43", + "version": "1.14.44", "dependencies": { "@aws-sdk/client-sts": "3.782.0", "@jsx-email/render": "1.1.1", @@ -147,7 +147,7 @@ }, "packages/console/function": { "name": "@opencode-ai/console-function", - "version": "1.14.43", + "version": "1.14.44", "dependencies": { "@ai-sdk/anthropic": "3.0.64", "@ai-sdk/openai": "3.0.48", @@ -171,7 +171,7 @@ }, "packages/console/mail": { "name": "@opencode-ai/console-mail", - "version": "1.14.43", + "version": "1.14.44", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", @@ -195,7 +195,7 @@ }, "packages/core": { "name": "@opencode-ai/core", - "version": "1.14.43", + "version": "1.14.44", "bin": { "opencode": "./bin/opencode", }, @@ -229,7 +229,7 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "1.14.43", + "version": "1.14.44", "dependencies": { "drizzle-orm": "catalog:", "effect": "catalog:", @@ -283,7 +283,7 @@ }, "packages/enterprise": { "name": "@opencode-ai/enterprise", - "version": "1.14.43", + "version": "1.14.44", "dependencies": { "@opencode-ai/core": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -313,7 +313,7 @@ }, "packages/function": { "name": "@opencode-ai/function", - "version": "1.14.43", + "version": "1.14.44", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "catalog:", @@ -329,7 +329,7 @@ }, "packages/http-recorder": { "name": "@opencode-ai/http-recorder", - "version": "1.14.43", + "version": "1.14.44", "dependencies": { "@effect/platform-node": "catalog:", "effect": "catalog:", @@ -342,7 +342,7 @@ }, "packages/llm": { "name": "@opencode-ai/llm", - "version": "1.14.43", + "version": "1.14.44", "dependencies": { "@smithy/eventstream-codec": "4.2.14", "@smithy/util-utf8": "4.2.2", @@ -360,7 +360,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.14.43", + "version": "1.14.44", "bin": { "opencode": "./bin/opencode", }, @@ -495,7 +495,7 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.14.43", + "version": "1.14.44", "dependencies": { "@opencode-ai/sdk": "workspace:*", "effect": "catalog:", @@ -533,7 +533,7 @@ }, "packages/sdk/js": { "name": "@opencode-ai/sdk", - "version": "1.14.43", + "version": "1.14.44", "dependencies": { "cross-spawn": "catalog:", }, @@ -548,7 +548,7 @@ }, "packages/slack": { "name": "@opencode-ai/slack", - "version": "1.14.43", + "version": "1.14.44", "dependencies": { "@opencode-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -583,7 +583,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.14.43", + "version": "1.14.44", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/core": "workspace:*", @@ -632,7 +632,7 @@ }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.14.43", + "version": "1.14.44", "dependencies": { "@astrojs/cloudflare": "12.6.3", "@astrojs/markdown-remark": "6.3.1", diff --git a/packages/app/package.json b/packages/app/package.json index 2150e0694f..0b18298ba6 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "1.14.43", + "version": "1.14.44", "description": "", "type": "module", "exports": { diff --git a/packages/console/app/package.json b/packages/console/app/package.json index aa6e859d71..8ce88d4948 100644 --- a/packages/console/app/package.json +++ b/packages/console/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-app", - "version": "1.14.43", + "version": "1.14.44", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/console/core/package.json b/packages/console/core/package.json index b41240fe57..9365539302 100644 --- a/packages/console/core/package.json +++ b/packages/console/core/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/console-core", - "version": "1.14.43", + "version": "1.14.44", "private": true, "type": "module", "license": "MIT", diff --git a/packages/console/function/package.json b/packages/console/function/package.json index b7c3aa2a98..3e1daabd52 100644 --- a/packages/console/function/package.json +++ b/packages/console/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-function", - "version": "1.14.43", + "version": "1.14.44", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/console/mail/package.json b/packages/console/mail/package.json index 6ebaf2daad..127a352de5 100644 --- a/packages/console/mail/package.json +++ b/packages/console/mail/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-mail", - "version": "1.14.43", + "version": "1.14.44", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", diff --git a/packages/core/package.json b/packages/core/package.json index 1f843e40d3..fc7a8f84d1 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.14.43", + "version": "1.14.44", "name": "@opencode-ai/core", "type": "module", "license": "MIT", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index f051bd50d0..355db03fe4 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop", "private": true, - "version": "1.14.43", + "version": "1.14.44", "type": "module", "license": "MIT", "homepage": "https://opencode.ai", diff --git a/packages/enterprise/package.json b/packages/enterprise/package.json index 7f33ad4e1c..ecf6d05191 100644 --- a/packages/enterprise/package.json +++ b/packages/enterprise/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/enterprise", - "version": "1.14.43", + "version": "1.14.44", "private": true, "type": "module", "license": "MIT", diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml index 8af387c4bf..8dcd84b37f 100644 --- a/packages/extensions/zed/extension.toml +++ b/packages/extensions/zed/extension.toml @@ -1,7 +1,7 @@ id = "opencode" name = "OpenCode" description = "The open source coding agent." -version = "1.14.43" +version = "1.14.44" schema_version = 1 authors = ["Anomaly"] repository = "https://github.com/anomalyco/opencode" @@ -11,26 +11,26 @@ name = "OpenCode" icon = "./icons/opencode.svg" [agent_servers.opencode.targets.darwin-aarch64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.43/opencode-darwin-arm64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.44/opencode-darwin-arm64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.darwin-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.43/opencode-darwin-x64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.44/opencode-darwin-x64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-aarch64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.43/opencode-linux-arm64.tar.gz" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.44/opencode-linux-arm64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.43/opencode-linux-x64.tar.gz" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.44/opencode-linux-x64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.windows-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.43/opencode-windows-x64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.44/opencode-windows-x64.zip" cmd = "./opencode.exe" args = ["acp"] diff --git a/packages/function/package.json b/packages/function/package.json index 6c15ccc306..6f871cb8c6 100644 --- a/packages/function/package.json +++ b/packages/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/function", - "version": "1.14.43", + "version": "1.14.44", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/http-recorder/package.json b/packages/http-recorder/package.json index b8c0d58832..f4874d763a 100644 --- a/packages/http-recorder/package.json +++ b/packages/http-recorder/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.14.43", + "version": "1.14.44", "name": "@opencode-ai/http-recorder", "type": "module", "license": "MIT", diff --git a/packages/llm/package.json b/packages/llm/package.json index d3ac5357e0..e983f19e80 100644 --- a/packages/llm/package.json +++ b/packages/llm/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.14.43", + "version": "1.14.44", "name": "@opencode-ai/llm", "type": "module", "license": "MIT", diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 9a9f53f57c..cd58a663f8 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.14.43", + "version": "1.14.44", "name": "opencode", "type": "module", "license": "MIT", diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 9344f0488a..10f9b6014d 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/plugin", - "version": "1.14.43", + "version": "1.14.44", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index d6443f3b2b..10b306ea31 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/sdk", - "version": "1.14.43", + "version": "1.14.44", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/slack/package.json b/packages/slack/package.json index 92aedbe063..813df5b04e 100644 --- a/packages/slack/package.json +++ b/packages/slack/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/slack", - "version": "1.14.43", + "version": "1.14.44", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/ui/package.json b/packages/ui/package.json index 325916d456..7c92c13107 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/ui", - "version": "1.14.43", + "version": "1.14.44", "type": "module", "license": "MIT", "exports": { diff --git a/packages/web/package.json b/packages/web/package.json index 92f690f250..6b923e2331 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -2,7 +2,7 @@ "name": "@opencode-ai/web", "type": "module", "license": "MIT", - "version": "1.14.43", + "version": "1.14.44", "scripts": { "dev": "astro dev", "dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev", diff --git a/sdks/vscode/package.json b/sdks/vscode/package.json index cb8db945ea..b39d872b51 100644 --- a/sdks/vscode/package.json +++ b/sdks/vscode/package.json @@ -2,7 +2,7 @@ "name": "opencode", "displayName": "opencode", "description": "opencode for VS Code", - "version": "1.14.43", + "version": "1.14.44", "publisher": "sst-dev", "repository": { "type": "git", From 5e49029e70567148ebf9b86f2b71cc7721d58c78 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 9 May 2026 15:47:07 -0400 Subject: [PATCH 074/189] fix(provider): isolate plugin model mutations (#26561) Co-authored-by: Developer --- packages/opencode/src/provider/provider.ts | 2 +- .../test/server/httpapi-provider.test.ts | 97 +++++++++++++++++++ 2 files changed, 98 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index d9b7fac2fa..4184f96b2c 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -1162,7 +1162,7 @@ const layer: Layer.Layer< const pluginAuth = yield* auth.get(providerID).pipe(Effect.orDie) provider.models = yield* Effect.promise(async () => { - const next = await models(provider, { auth: pluginAuth }) + const next = await models(toPublicInfo(provider), { auth: pluginAuth }) return Object.fromEntries( Object.entries(next).map(([id, model]) => [ id, diff --git a/packages/opencode/test/server/httpapi-provider.test.ts b/packages/opencode/test/server/httpapi-provider.test.ts index d44ff4cd60..68db6663d2 100644 --- a/packages/opencode/test/server/httpapi-provider.test.ts +++ b/packages/opencode/test/server/httpapi-provider.test.ts @@ -37,6 +37,38 @@ function hasProviderWithFetch(input: unknown, key: "all" | "providers") { return "providers" in input && providerListHasFetch(input.providers) } +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} + +function providerList(input: unknown, key: "all" | "providers") { + if (!isRecord(input)) return [] + if (!Array.isArray(input[key])) return [] + return input[key] +} + +function providerByID(input: unknown, key: "all" | "providers", id: string) { + return providerList(input, key).find((provider) => isRecord(provider) && provider.id === id) +} + +function hasNonZeroModelCost(input: unknown, key: "all" | "providers", id: string) { + const provider = providerByID(input, key, id) + if (!isRecord(provider) || !isRecord(provider.models)) return false + return Object.values(provider.models).some((model) => { + if (!isRecord(model) || !isRecord(model.cost) || !isRecord(model.cost.cache)) return false + return [model.cost.input, model.cost.output, model.cost.cache.read, model.cost.cache.write].some( + (cost) => typeof cost === "number" && cost > 0, + ) + }) +} + +function hasProviderMutationMarker(input: unknown, key: "all" | "providers", id: string) { + const provider = providerByID(input, key, id) + if (!isRecord(provider)) return false + if (provider.name === "mutated-provider") return true + return isRecord(provider.options) && provider.options.mutatedByPlugin === true +} + function requestAuthorize(input: { app: ReturnType providerID: string @@ -125,6 +157,40 @@ function writeFunctionOptionsPlugin(dir: string) { }) } +function writeProviderModelsMutationPlugin(dir: string) { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + const path = yield* Path.Path + + yield* fs.makeDirectory(path.join(dir, ".opencode", "plugin"), { recursive: true }) + yield* fs.writeFileString( + path.join(dir, ".opencode", "plugin", "provider-models-mutation.ts"), + [ + "export default {", + ' id: "test.provider-models-mutation",', + " server: async () => ({", + " provider: {", + ' id: "google",', + " models: async (provider) => {", + " const models = Object.fromEntries(", + " Object.entries(provider.models ?? {}).map(([id, model]) => [id, { ...model }]),", + " )", + ' provider.name = "mutated-provider"', + " provider.options = { ...provider.options, mutatedByPlugin: true }", + " for (const model of Object.values(provider.models ?? {})) {", + " model.cost = { input: 0, output: 0 }", + " }", + " return models", + " },", + " },", + " }),", + "}", + "", + ].join("\n"), + ) + }) +} + function withProviderProject(self: (dir: string) => Effect.Effect) { return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem @@ -222,6 +288,37 @@ describe("provider HttpApi", () => { const configBody = yield* Effect.promise(() => configResponse.json()) expect(hasProviderWithFetch(providerBody, "all")).toBe(false) expect(hasProviderWithFetch(configBody, "providers")).toBe(false) + expect(hasNonZeroModelCost(providerBody, "all", "google")).toBe(true) + expect(hasNonZeroModelCost(configBody, "providers", "google")).toBe(true) + }), + ) + + it.live("keeps provider.models hook input mutations out of provider state", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + const path = yield* Path.Path + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "opencode-test-" }) + + yield* fs.writeFileString( + path.join(dir, "opencode.json"), + JSON.stringify({ $schema: "https://opencode.ai/config.json", formatter: false, lsp: false }), + ) + yield* writeProviderModelsMutationPlugin(dir) + + const headers = { "x-opencode-directory": dir } + const providerResponse = yield* Effect.promise(() => Promise.resolve(app().request("/provider", { headers }))) + const configResponse = yield* Effect.promise(() => + Promise.resolve(app().request("/config/providers", { headers })), + ) + + expect(providerResponse.status).toBe(200) + expect(configResponse.status).toBe(200) + + const providerBody = yield* Effect.promise(() => providerResponse.json()) + const configBody = yield* Effect.promise(() => configResponse.json()) + expect(hasProviderMutationMarker(providerBody, "all", "google")).toBe(false) + expect(hasProviderMutationMarker(configBody, "providers", "google")).toBe(false) + expect(hasNonZeroModelCost(providerBody, "all", "google")).toBe(true) }), ) }) From dcdbdb218fafc4ce2d0495f90bf022a08fc51d80 Mon Sep 17 00:00:00 2001 From: Dax Date: Sat, 9 May 2026 15:51:09 -0400 Subject: [PATCH 075/189] Move schema utilities into core (#26565) --- packages/{opencode/src/util => core/src}/effect-zod.ts | 0 packages/{opencode/src/util => core/src}/schema.ts | 0 packages/opencode/specs/effect/migration.md | 4 ++-- packages/opencode/specs/effect/schema.md | 6 +++--- packages/opencode/src/agent/agent.ts | 4 ++-- packages/opencode/src/auth/index.ts | 4 ++-- packages/opencode/src/cli/cmd/tui/event.ts | 2 +- packages/opencode/src/command/index.ts | 4 ++-- packages/opencode/src/config/agent.ts | 4 ++-- packages/opencode/src/config/command.ts | 4 ++-- packages/opencode/src/config/config.ts | 6 +++--- packages/opencode/src/config/console-state.ts | 4 ++-- packages/opencode/src/config/formatter.ts | 4 ++-- packages/opencode/src/config/layout.ts | 4 ++-- packages/opencode/src/config/lsp.ts | 4 ++-- packages/opencode/src/config/mcp.ts | 4 ++-- packages/opencode/src/config/model-id.ts | 4 ++-- packages/opencode/src/config/parse.ts | 2 +- packages/opencode/src/config/permission.ts | 4 ++-- packages/opencode/src/config/plugin.ts | 4 ++-- packages/opencode/src/config/provider.ts | 4 ++-- packages/opencode/src/config/reference.ts | 4 ++-- packages/opencode/src/config/server.ts | 4 ++-- packages/opencode/src/config/skills.ts | 4 ++-- packages/opencode/src/control-plane/schema.ts | 4 ++-- packages/opencode/src/control-plane/types.ts | 2 +- packages/opencode/src/file/index.ts | 4 ++-- packages/opencode/src/file/ripgrep.ts | 4 ++-- packages/opencode/src/format/index.ts | 4 ++-- packages/opencode/src/lsp/lsp.ts | 4 ++-- packages/opencode/src/mcp/index.ts | 4 ++-- packages/opencode/src/permission/index.ts | 4 ++-- packages/opencode/src/permission/schema.ts | 4 ++-- packages/opencode/src/project/project.ts | 4 ++-- packages/opencode/src/project/schema.ts | 4 ++-- packages/opencode/src/project/vcs.ts | 4 ++-- packages/opencode/src/provider/auth.ts | 4 ++-- packages/opencode/src/provider/provider.ts | 4 ++-- packages/opencode/src/provider/schema.ts | 4 ++-- packages/opencode/src/pty/index.ts | 4 ++-- packages/opencode/src/pty/schema.ts | 4 ++-- packages/opencode/src/pty/ticket.ts | 2 +- packages/opencode/src/question/index.ts | 4 ++-- packages/opencode/src/question/schema.ts | 4 ++-- .../server/routes/instance/httpapi/groups/experimental.ts | 2 +- .../src/server/routes/instance/httpapi/groups/sync.ts | 2 +- .../server/routes/instance/httpapi/handlers/experimental.ts | 2 +- packages/opencode/src/session/message-v2.ts | 4 ++-- packages/opencode/src/session/message.ts | 4 ++-- packages/opencode/src/session/prompt.ts | 6 +++--- packages/opencode/src/session/revert.ts | 4 ++-- packages/opencode/src/session/schema.ts | 4 ++-- packages/opencode/src/session/session.ts | 4 ++-- packages/opencode/src/session/status.ts | 4 ++-- packages/opencode/src/session/summary.ts | 4 ++-- packages/opencode/src/session/todo.ts | 4 ++-- packages/opencode/src/skill/index.ts | 4 ++-- packages/opencode/src/snapshot/index.ts | 4 ++-- packages/opencode/src/storage/storage.ts | 2 +- packages/opencode/src/sync/index.ts | 2 +- packages/opencode/src/sync/schema.ts | 4 ++-- packages/opencode/src/tool/read.ts | 2 +- packages/opencode/src/tool/registry.ts | 2 +- packages/opencode/src/tool/schema.ts | 4 ++-- packages/opencode/src/tool/shell/prompt.ts | 2 +- packages/opencode/src/util/named-schema-error.ts | 2 +- packages/opencode/src/v2/auth.ts | 2 +- packages/opencode/src/v2/event.ts | 2 +- packages/opencode/src/v2/model.ts | 2 +- packages/opencode/src/v2/session-event.ts | 2 +- packages/opencode/src/v2/session.ts | 2 +- packages/opencode/test/tool/parameters.test.ts | 2 +- packages/opencode/test/util/effect-zod.test.ts | 2 +- 73 files changed, 125 insertions(+), 125 deletions(-) rename packages/{opencode/src/util => core/src}/effect-zod.ts (100%) rename packages/{opencode/src/util => core/src}/schema.ts (100%) diff --git a/packages/opencode/src/util/effect-zod.ts b/packages/core/src/effect-zod.ts similarity index 100% rename from packages/opencode/src/util/effect-zod.ts rename to packages/core/src/effect-zod.ts diff --git a/packages/opencode/src/util/schema.ts b/packages/core/src/schema.ts similarity index 100% rename from packages/opencode/src/util/schema.ts rename to packages/core/src/schema.ts diff --git a/packages/opencode/specs/effect/migration.md b/packages/opencode/specs/effect/migration.md index 947eef5a15..01af9da6ce 100644 --- a/packages/opencode/specs/effect/migration.md +++ b/packages/opencode/specs/effect/migration.md @@ -59,10 +59,10 @@ Rules: ## Schema → Zod interop -When a service uses Effect Schema internally but needs Zod schemas for the HTTP layer, derive Zod from Schema using the `zod()` helper from `@/util/effect-zod`: +When a service uses Effect Schema internally but needs Zod schemas for the HTTP layer, derive Zod from Schema using the `zod()` helper from `@opencode-ai/core/effect-zod`: ```ts -import { zod } from "@/util/effect-zod" +import { zod } from "@opencode-ai/core/effect-zod" export const ZodInfo = zod(Info) // derives z.ZodType from Schema.Union ``` diff --git a/packages/opencode/specs/effect/schema.md b/packages/opencode/specs/effect/schema.md index e755457e61..e20605c3bc 100644 --- a/packages/opencode/specs/effect/schema.md +++ b/packages/opencode/specs/effect/schema.md @@ -8,7 +8,7 @@ Zod-first definitions to Effect Schema with Zod compatibility shims. Use Effect Schema as the source of truth for domain models, IDs, inputs, outputs, and typed errors. Keep Zod available at existing HTTP, tool, and compatibility boundaries by exposing a `.zod` static derived from the Effect -schema via `@/util/effect-zod`. +schema via `@opencode-ai/core/effect-zod`. The long-term driver is `specs/effect/http-api.md` — once the HTTP server moves to `@effect/platform`, every Schema-first DTO can flow through @@ -97,7 +97,7 @@ creating a parallel schema source of truth. ## Escape hatches -The walker in `@/util/effect-zod` exposes two explicit escape hatches for +The walker in `@opencode-ai/core/effect-zod` exposes two explicit escape hatches for cases the pure-Schema path cannot express. Each one stays in the codebase only as long as its upstream or local dependency requires it — inline comments document when each can be deleted. @@ -389,7 +389,7 @@ piecewise. ## Notes -- Use `@/util/effect-zod` for all Schema → Zod conversion. +- Use `@opencode-ai/core/effect-zod` for all Schema → Zod conversion. - Prefer one canonical schema definition. Avoid maintaining parallel Zod and Effect definitions for the same domain type. - Keep the migration incremental. Converting the domain model first is more diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index 8584682412..a5876ac347 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -24,8 +24,8 @@ import { Effect, Context, Layer, Schema } from "effect" import { InstanceState } from "@/effect/instance-state" import * as Option from "effect/Option" import * as OtelTracer from "@effect/opentelemetry/Tracer" -import { zod } from "@/util/effect-zod" -import { withStatics, type DeepMutable } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { withStatics, type DeepMutable } from "@opencode-ai/core/schema" type ReferenceEntry = NonNullable[string] type ResolvedReference = { kind: "git"; repository: string; branch?: string } | { kind: "local"; path: string } diff --git a/packages/opencode/src/auth/index.ts b/packages/opencode/src/auth/index.ts index 3d6a0d91d0..f7c6319357 100644 --- a/packages/opencode/src/auth/index.ts +++ b/packages/opencode/src/auth/index.ts @@ -1,7 +1,7 @@ import path from "path" import { Effect, Layer, Record, Result, Schema, Context } from "effect" -import { zod } from "@/util/effect-zod" -import { NonNegativeInt } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { NonNegativeInt } from "@opencode-ai/core/schema" import { Global } from "@opencode-ai/core/global" import { AppFileSystem } from "@opencode-ai/core/filesystem" diff --git a/packages/opencode/src/cli/cmd/tui/event.ts b/packages/opencode/src/cli/cmd/tui/event.ts index fbe5ce7f9f..bebb1fc6aa 100644 --- a/packages/opencode/src/cli/cmd/tui/event.ts +++ b/packages/opencode/src/cli/cmd/tui/event.ts @@ -1,6 +1,6 @@ import { BusEvent } from "@/bus/bus-event" import { SessionID } from "@/session/schema" -import { PositiveInt } from "@/util/schema" +import { PositiveInt } from "@opencode-ai/core/schema" import { Effect, Schema } from "effect" const DEFAULT_TOAST_DURATION = 5000 diff --git a/packages/opencode/src/command/index.ts b/packages/opencode/src/command/index.ts index 140d2b8a7a..e26c4068b1 100644 --- a/packages/opencode/src/command/index.ts +++ b/packages/opencode/src/command/index.ts @@ -5,8 +5,8 @@ import type { InstanceContext } from "@/project/instance" import { SessionID, MessageID } from "@/session/schema" import { Effect, Layer, Context, Schema } from "effect" import z from "zod" -import { zod, ZodOverride } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { zod, ZodOverride } from "@opencode-ai/core/effect-zod" +import { withStatics } from "@opencode-ai/core/schema" import { Config } from "@/config/config" import { MCP } from "../mcp" import { Skill } from "../skill" diff --git a/packages/opencode/src/config/agent.ts b/packages/opencode/src/config/agent.ts index e72f658728..94c8d8fe00 100644 --- a/packages/opencode/src/config/agent.ts +++ b/packages/opencode/src/config/agent.ts @@ -2,8 +2,8 @@ export * as ConfigAgent from "./agent" import { Exit, Schema, SchemaGetter } from "effect" import { Bus } from "@/bus" -import { zod } from "@/util/effect-zod" -import { PositiveInt, withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { PositiveInt, withStatics } from "@opencode-ai/core/schema" import * as Log from "@opencode-ai/core/util/log" import { NamedError } from "@opencode-ai/core/util/error" import { Glob } from "@opencode-ai/core/util/glob" diff --git a/packages/opencode/src/config/command.ts b/packages/opencode/src/config/command.ts index 4d0fec6872..c611f3c198 100644 --- a/packages/opencode/src/config/command.ts +++ b/packages/opencode/src/config/command.ts @@ -5,8 +5,8 @@ import { Schema } from "effect" import { NamedError } from "@opencode-ai/core/util/error" import { Glob } from "@opencode-ai/core/util/glob" import { Bus } from "@/bus" -import { zod } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { withStatics } from "@opencode-ai/core/schema" import { configEntryNameFromPath } from "./entry-name" import { InvalidError } from "./error" import * as ConfigMarkdown from "./markdown" diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 5b7aa16ff1..073001e191 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -22,8 +22,8 @@ import { InstanceState } from "@/effect/instance-state" import { Context, Duration, Effect, Exit, Fiber, Layer, Option, Schema } from "effect" import { EffectFlock } from "@opencode-ai/core/util/effect-flock" import { containsPath } from "../project/instance-context" -import { zod } from "@/util/effect-zod" -import { NonNegativeInt, PositiveInt, withStatics, type DeepMutable } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { NonNegativeInt, PositiveInt, withStatics, type DeepMutable } from "@opencode-ai/core/schema" import { ConfigAgent } from "./agent" import { ConfigCommand } from "./command" import { ConfigFormatter } from "./formatter" @@ -307,7 +307,7 @@ export const Info = Schema.Struct({ })), ) -// Uses the shared `DeepMutable` from `@/util/schema`. See the definition +// Uses the shared `DeepMutable` from `@opencode-ai/core/schema`. See the definition // there for why the local variant is needed over `Types.DeepMutable` from // effect-smol (the upstream version collapses `unknown` to `{}`). export type Info = DeepMutable> & { diff --git a/packages/opencode/src/config/console-state.ts b/packages/opencode/src/config/console-state.ts index 0d4f20df91..485e334167 100644 --- a/packages/opencode/src/config/console-state.ts +++ b/packages/opencode/src/config/console-state.ts @@ -1,6 +1,6 @@ import { Schema } from "effect" -import { zod } from "@/util/effect-zod" -import { NonNegativeInt } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { NonNegativeInt } from "@opencode-ai/core/schema" export class ConsoleState extends Schema.Class("ConsoleState")({ consoleManagedProviders: Schema.mutable(Schema.Array(Schema.String)), diff --git a/packages/opencode/src/config/formatter.ts b/packages/opencode/src/config/formatter.ts index 8c1f09a247..222a750057 100644 --- a/packages/opencode/src/config/formatter.ts +++ b/packages/opencode/src/config/formatter.ts @@ -1,8 +1,8 @@ export * as ConfigFormatter from "./formatter" import { Schema } from "effect" -import { zod } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { withStatics } from "@opencode-ai/core/schema" export const Entry = Schema.Struct({ disabled: Schema.optional(Schema.Boolean), diff --git a/packages/opencode/src/config/layout.ts b/packages/opencode/src/config/layout.ts index 49c34b6639..a5299ea955 100644 --- a/packages/opencode/src/config/layout.ts +++ b/packages/opencode/src/config/layout.ts @@ -1,6 +1,6 @@ import { Schema } from "effect" -import { zod } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { withStatics } from "@opencode-ai/core/schema" export const Layout = Schema.Literals(["auto", "stretch"]) .annotate({ identifier: "LayoutConfig" }) diff --git a/packages/opencode/src/config/lsp.ts b/packages/opencode/src/config/lsp.ts index 1cf93177e4..accfbee3b2 100644 --- a/packages/opencode/src/config/lsp.ts +++ b/packages/opencode/src/config/lsp.ts @@ -1,8 +1,8 @@ export * as ConfigLSP from "./lsp" import { Schema } from "effect" -import { zod } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { withStatics } from "@opencode-ai/core/schema" import * as LSPServer from "../lsp/server" export const Disabled = Schema.Struct({ diff --git a/packages/opencode/src/config/mcp.ts b/packages/opencode/src/config/mcp.ts index fc31ba356f..bb4fd88f04 100644 --- a/packages/opencode/src/config/mcp.ts +++ b/packages/opencode/src/config/mcp.ts @@ -1,6 +1,6 @@ import { Schema } from "effect" -import { zod } from "@/util/effect-zod" -import { PositiveInt, withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { PositiveInt, withStatics } from "@opencode-ai/core/schema" export const Local = Schema.Struct({ type: Schema.Literal("local").annotate({ description: "Type of MCP server connection" }), diff --git a/packages/opencode/src/config/model-id.ts b/packages/opencode/src/config/model-id.ts index 3ad9e035ce..26fa2e0b34 100644 --- a/packages/opencode/src/config/model-id.ts +++ b/packages/opencode/src/config/model-id.ts @@ -1,7 +1,7 @@ import { Schema } from "effect" import z from "zod" -import { zod, ZodOverride } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { zod, ZodOverride } from "@opencode-ai/core/effect-zod" +import { withStatics } from "@opencode-ai/core/schema" // The original Zod schema carried an external $ref pointing at the models.dev // JSON schema. That external reference is not a named SDK component — it is a diff --git a/packages/opencode/src/config/parse.ts b/packages/opencode/src/config/parse.ts index 9351047894..f964ed4e15 100644 --- a/packages/opencode/src/config/parse.ts +++ b/packages/opencode/src/config/parse.ts @@ -3,7 +3,7 @@ export * as ConfigParse from "./parse" import { type ParseError as JsoncParseError, parse as parseJsoncImpl, printParseErrorCode } from "jsonc-parser" import { Cause, Exit, Schema as EffectSchema, SchemaIssue } from "effect" import z from "zod" -import type { DeepMutable } from "@/util/schema" +import type { DeepMutable } from "@opencode-ai/core/schema" import { InvalidError, JsonError } from "./error" type ZodSchema = z.ZodType diff --git a/packages/opencode/src/config/permission.ts b/packages/opencode/src/config/permission.ts index 5326b007bd..8c5f854996 100644 --- a/packages/opencode/src/config/permission.ts +++ b/packages/opencode/src/config/permission.ts @@ -1,7 +1,7 @@ export * as ConfigPermission from "./permission" import { Schema, SchemaGetter } from "effect" -import { zod } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { withStatics } from "@opencode-ai/core/schema" export const Action = Schema.Literals(["ask", "allow", "deny"]) .annotate({ identifier: "PermissionActionConfig" }) diff --git a/packages/opencode/src/config/plugin.ts b/packages/opencode/src/config/plugin.ts index 9667dbb59a..b1e3ec6f42 100644 --- a/packages/opencode/src/config/plugin.ts +++ b/packages/opencode/src/config/plugin.ts @@ -2,8 +2,8 @@ import { Glob } from "@opencode-ai/core/util/glob" import { Schema } from "effect" import { pathToFileURL } from "url" import { isPathPluginSpec, parsePluginSpecifier, resolvePathPluginTarget } from "@/plugin/shared" -import { zod } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { withStatics } from "@opencode-ai/core/schema" import path from "path" export const Options = Schema.Record(Schema.String, Schema.Unknown).pipe(withStatics((s) => ({ zod: zod(s) }))) diff --git a/packages/opencode/src/config/provider.ts b/packages/opencode/src/config/provider.ts index 7821bca5a9..239a0164d8 100644 --- a/packages/opencode/src/config/provider.ts +++ b/packages/opencode/src/config/provider.ts @@ -1,6 +1,6 @@ import { Schema } from "effect" -import { zod } from "@/util/effect-zod" -import { PositiveInt, withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { PositiveInt, withStatics } from "@opencode-ai/core/schema" export const Model = Schema.Struct({ id: Schema.optional(Schema.String), diff --git a/packages/opencode/src/config/reference.ts b/packages/opencode/src/config/reference.ts index eea3d998c1..36a8faff7e 100644 --- a/packages/opencode/src/config/reference.ts +++ b/packages/opencode/src/config/reference.ts @@ -1,8 +1,8 @@ export * as ConfigReference from "./reference" import { Schema } from "effect" -import { zod } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { withStatics } from "@opencode-ai/core/schema" const Git = Schema.Struct({ repository: Schema.String.annotate({ diff --git a/packages/opencode/src/config/server.ts b/packages/opencode/src/config/server.ts index 3f13698269..159ba0ce5a 100644 --- a/packages/opencode/src/config/server.ts +++ b/packages/opencode/src/config/server.ts @@ -1,6 +1,6 @@ import { Schema } from "effect" -import { zod } from "@/util/effect-zod" -import { PositiveInt, withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { PositiveInt, withStatics } from "@opencode-ai/core/schema" export const Server = Schema.Struct({ port: Schema.optional(PositiveInt).annotate({ diff --git a/packages/opencode/src/config/skills.ts b/packages/opencode/src/config/skills.ts index f29d854f50..f707e922ee 100644 --- a/packages/opencode/src/config/skills.ts +++ b/packages/opencode/src/config/skills.ts @@ -1,6 +1,6 @@ import { Schema } from "effect" -import { zod } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { withStatics } from "@opencode-ai/core/schema" export const Info = Schema.Struct({ paths: Schema.optional(Schema.Array(Schema.String)).annotate({ diff --git a/packages/opencode/src/control-plane/schema.ts b/packages/opencode/src/control-plane/schema.ts index 5a0850a249..dd4c325490 100644 --- a/packages/opencode/src/control-plane/schema.ts +++ b/packages/opencode/src/control-plane/schema.ts @@ -1,8 +1,8 @@ import { Schema } from "effect" import { Identifier } from "@/id/id" -import { zod, ZodOverride } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { zod, ZodOverride } from "@opencode-ai/core/effect-zod" +import { withStatics } from "@opencode-ai/core/schema" const workspaceIdSchema = Schema.String.annotate({ [ZodOverride]: Identifier.schema("workspace") }).pipe( Schema.brand("WorkspaceID"), diff --git a/packages/opencode/src/control-plane/types.ts b/packages/opencode/src/control-plane/types.ts index ba5a778fac..e78d728e04 100644 --- a/packages/opencode/src/control-plane/types.ts +++ b/packages/opencode/src/control-plane/types.ts @@ -1,7 +1,7 @@ import { Schema, Struct } from "effect" import { ProjectID } from "@/project/schema" import { WorkspaceID } from "./schema" -import type { DeepMutable } from "@/util/schema" +import type { DeepMutable } from "@opencode-ai/core/schema" export const WorkspaceInfo = Schema.Struct({ id: WorkspaceID, diff --git a/packages/opencode/src/file/index.ts b/packages/opencode/src/file/index.ts index 4dd6a3ae7a..52f2b8486d 100644 --- a/packages/opencode/src/file/index.ts +++ b/packages/opencode/src/file/index.ts @@ -14,8 +14,8 @@ import { containsPath } from "../project/instance-context" import * as Log from "@opencode-ai/core/util/log" import { Protected } from "./protected" import { Ripgrep } from "./ripgrep" -import { zod } from "@/util/effect-zod" -import { NonNegativeInt, type DeepMutable, withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { NonNegativeInt, type DeepMutable, withStatics } from "@opencode-ai/core/schema" export const Info = Schema.Struct({ path: Schema.String, diff --git a/packages/opencode/src/file/ripgrep.ts b/packages/opencode/src/file/ripgrep.ts index 27fd5f2323..8459dd9ac1 100644 --- a/packages/opencode/src/file/ripgrep.ts +++ b/packages/opencode/src/file/ripgrep.ts @@ -11,8 +11,8 @@ import { Global } from "@opencode-ai/core/global" import * as Log from "@opencode-ai/core/util/log" import { sanitizedProcessEnv } from "@opencode-ai/core/util/opencode-process" import { which } from "@/util/which" -import { zod } from "@/util/effect-zod" -import { NonNegativeInt, withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { NonNegativeInt, withStatics } from "@opencode-ai/core/schema" const log = Log.create({ service: "ripgrep" }) const VERSION = "15.1.0" diff --git a/packages/opencode/src/format/index.ts b/packages/opencode/src/format/index.ts index a61eb7be29..c9ab433f11 100644 --- a/packages/opencode/src/format/index.ts +++ b/packages/opencode/src/format/index.ts @@ -7,8 +7,8 @@ import { mergeDeep } from "remeda" import { Config } from "@/config/config" import * as Log from "@opencode-ai/core/util/log" import * as Formatter from "./formatter" -import { zod } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { withStatics } from "@opencode-ai/core/schema" const log = Log.create({ service: "format" }) diff --git a/packages/opencode/src/lsp/lsp.ts b/packages/opencode/src/lsp/lsp.ts index 5110eccbf8..a647dc099f 100644 --- a/packages/opencode/src/lsp/lsp.ts +++ b/packages/opencode/src/lsp/lsp.ts @@ -13,8 +13,8 @@ import { spawn as lspspawn } from "./launch" import { Effect, Layer, Context, Schema } from "effect" import { InstanceState } from "@/effect/instance-state" import { containsPath } from "@/project/instance-context" -import { NonNegativeInt, withStatics } from "@/util/schema" -import { zod, ZodOverride } from "@/util/effect-zod" +import { NonNegativeInt, withStatics } from "@opencode-ai/core/schema" +import { zod, ZodOverride } from "@opencode-ai/core/effect-zod" const log = Log.create({ service: "lsp" }) diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index fe71802388..20e8c912e1 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -30,8 +30,8 @@ import { EffectBridge } from "@/effect/bridge" import { InstanceState } from "@/effect/instance-state" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" -import { zod as effectZod } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { zod as effectZod } from "@opencode-ai/core/effect-zod" +import { withStatics } from "@opencode-ai/core/schema" const log = Log.create({ service: "mcp" }) const DEFAULT_TIMEOUT = 30_000 diff --git a/packages/opencode/src/permission/index.ts b/packages/opencode/src/permission/index.ts index d93670709e..f4bd2e2cc1 100644 --- a/packages/opencode/src/permission/index.ts +++ b/packages/opencode/src/permission/index.ts @@ -7,9 +7,9 @@ import { MessageID, SessionID } from "@/session/schema" import { PermissionTable } from "@/session/session.sql" import { Database } from "@/storage/db" import { eq } from "drizzle-orm" -import { zod } from "@/util/effect-zod" +import { zod } from "@opencode-ai/core/effect-zod" import * as Log from "@opencode-ai/core/util/log" -import { withStatics } from "@/util/schema" +import { withStatics } from "@opencode-ai/core/schema" import { Wildcard } from "@/util/wildcard" import { Deferred, Effect, Layer, Schema, Context } from "effect" import os from "os" diff --git a/packages/opencode/src/permission/schema.ts b/packages/opencode/src/permission/schema.ts index e8bdb2ea20..725030935d 100644 --- a/packages/opencode/src/permission/schema.ts +++ b/packages/opencode/src/permission/schema.ts @@ -1,8 +1,8 @@ import { Schema } from "effect" import { Identifier } from "@/id/id" -import { zod, ZodOverride } from "@/util/effect-zod" -import { Newtype } from "@/util/schema" +import { zod, ZodOverride } from "@opencode-ai/core/effect-zod" +import { Newtype } from "@opencode-ai/core/schema" export class PermissionID extends Newtype()( "PermissionID", diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts index a2c1a097b1..25feb657c1 100644 --- a/packages/opencode/src/project/project.ts +++ b/packages/opencode/src/project/project.ts @@ -18,8 +18,8 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import { NodePath } from "@effect/platform-node" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" -import { zod } from "@/util/effect-zod" -import { NonNegativeInt, optionalOmitUndefined, withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { NonNegativeInt, optionalOmitUndefined, withStatics } from "@opencode-ai/core/schema" import { serviceUse } from "@/effect/service-use" const log = Log.create({ service: "project" }) diff --git a/packages/opencode/src/project/schema.ts b/packages/opencode/src/project/schema.ts index 7708b8de1e..c6cff94fde 100644 --- a/packages/opencode/src/project/schema.ts +++ b/packages/opencode/src/project/schema.ts @@ -1,7 +1,7 @@ import { Schema } from "effect" -import { zod } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { withStatics } from "@opencode-ai/core/schema" const projectIdSchema = Schema.String.pipe(Schema.brand("ProjectID")) diff --git a/packages/opencode/src/project/vcs.ts b/packages/opencode/src/project/vcs.ts index 02173453db..28050e86f7 100644 --- a/packages/opencode/src/project/vcs.ts +++ b/packages/opencode/src/project/vcs.ts @@ -6,8 +6,8 @@ import { InstanceState } from "@/effect/instance-state" import { FileWatcher } from "@/file/watcher" import { Git } from "@/git" import * as Log from "@opencode-ai/core/util/log" -import { zod, zodObject } from "@/util/effect-zod" -import { NonNegativeInt, withStatics } from "@/util/schema" +import { zod, zodObject } from "@opencode-ai/core/effect-zod" +import { NonNegativeInt, withStatics } from "@opencode-ai/core/schema" const log = Log.create({ service: "vcs" }) const PATCH_CONTEXT_LINES = 2_147_483_647 diff --git a/packages/opencode/src/provider/auth.ts b/packages/opencode/src/provider/auth.ts index 9b2ca33c31..135df6fecf 100644 --- a/packages/opencode/src/provider/auth.ts +++ b/packages/opencode/src/provider/auth.ts @@ -1,9 +1,9 @@ import type { AuthOAuthResult, Hooks } from "@opencode-ai/plugin" import { Auth } from "@/auth" import { InstanceState } from "@/effect/instance-state" -import { zod } from "@/util/effect-zod" +import { zod } from "@opencode-ai/core/effect-zod" import { namedSchemaError } from "@/util/named-schema-error" -import { optionalOmitUndefined, withStatics } from "@/util/schema" +import { optionalOmitUndefined, withStatics } from "@opencode-ai/core/schema" import { Plugin } from "../plugin" import { ProviderID } from "./schema" import { Array as Arr, Effect, Layer, Record, Result, Context, Schema } from "effect" diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 4184f96b2c..18974ea646 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -13,7 +13,7 @@ import { Auth } from "../auth" import { Env } from "../env" import { InstallationVersion } from "@opencode-ai/core/installation/version" import { Flag } from "@opencode-ai/core/flag/flag" -import { zod } from "@/util/effect-zod" +import { zod } from "@opencode-ai/core/effect-zod" import { namedSchemaError } from "@/util/named-schema-error" import { iife } from "@/util/iife" import { Global } from "@opencode-ai/core/global" @@ -24,7 +24,7 @@ import { EffectBridge } from "@/effect/bridge" import { InstanceState } from "@/effect/instance-state" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { isRecord } from "@/util/record" -import { optionalOmitUndefined, withStatics } from "@/util/schema" +import { optionalOmitUndefined, withStatics } from "@opencode-ai/core/schema" import * as ProviderTransform from "./transform" import { ModelID, ProviderID } from "./schema" diff --git a/packages/opencode/src/provider/schema.ts b/packages/opencode/src/provider/schema.ts index ea3cac3424..757b70f3ff 100644 --- a/packages/opencode/src/provider/schema.ts +++ b/packages/opencode/src/provider/schema.ts @@ -1,7 +1,7 @@ import { Schema } from "effect" -import { zod } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { withStatics } from "@opencode-ai/core/schema" const providerIdSchema = Schema.String.pipe(Schema.brand("ProviderID")) diff --git a/packages/opencode/src/pty/index.ts b/packages/opencode/src/pty/index.ts index ade4b5d02e..85e0840cb7 100644 --- a/packages/opencode/src/pty/index.ts +++ b/packages/opencode/src/pty/index.ts @@ -10,8 +10,8 @@ import type { Proc } from "#pty" import * as Log from "@opencode-ai/core/util/log" import { PtyID } from "./schema" import { Effect, Layer, Context, Schema, Types } from "effect" -import { zod } from "@/util/effect-zod" -import { NonNegativeInt, PositiveInt, withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { NonNegativeInt, PositiveInt, withStatics } from "@opencode-ai/core/schema" const log = Log.create({ service: "pty" }) diff --git a/packages/opencode/src/pty/schema.ts b/packages/opencode/src/pty/schema.ts index 6b4d779f26..0f1d6996df 100644 --- a/packages/opencode/src/pty/schema.ts +++ b/packages/opencode/src/pty/schema.ts @@ -1,8 +1,8 @@ import { Schema } from "effect" import { Identifier } from "@/id/id" -import { zod, ZodOverride } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { zod, ZodOverride } from "@opencode-ai/core/effect-zod" +import { withStatics } from "@opencode-ai/core/schema" const ptyIdSchema = Schema.String.annotate({ [ZodOverride]: Identifier.schema("pty") }).pipe(Schema.brand("PtyID")) diff --git a/packages/opencode/src/pty/ticket.ts b/packages/opencode/src/pty/ticket.ts index b5e5747c51..0978e52083 100644 --- a/packages/opencode/src/pty/ticket.ts +++ b/packages/opencode/src/pty/ticket.ts @@ -3,7 +3,7 @@ export * as PtyTicket from "./ticket" import { WorkspaceID } from "@/control-plane/schema" import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref" import { PtyID } from "@/pty/schema" -import { PositiveInt } from "@/util/schema" +import { PositiveInt } from "@opencode-ai/core/schema" import { Cache, Context, Duration, Effect, Layer, Schema } from "effect" const DEFAULT_TTL = Duration.seconds(60) diff --git a/packages/opencode/src/question/index.ts b/packages/opencode/src/question/index.ts index d52f353da9..c041462ad4 100644 --- a/packages/opencode/src/question/index.ts +++ b/packages/opencode/src/question/index.ts @@ -3,9 +3,9 @@ import { Bus } from "@/bus" import { BusEvent } from "@/bus/bus-event" import { InstanceState } from "@/effect/instance-state" import { SessionID, MessageID } from "@/session/schema" -import { zod } from "@/util/effect-zod" +import { zod } from "@opencode-ai/core/effect-zod" import * as Log from "@opencode-ai/core/util/log" -import { withStatics } from "@/util/schema" +import { withStatics } from "@opencode-ai/core/schema" import { QuestionID } from "./schema" const log = Log.create({ service: "question" }) diff --git a/packages/opencode/src/question/schema.ts b/packages/opencode/src/question/schema.ts index 7dade9cdfa..c18eca3e23 100644 --- a/packages/opencode/src/question/schema.ts +++ b/packages/opencode/src/question/schema.ts @@ -1,8 +1,8 @@ import { Schema } from "effect" import { Identifier } from "@/id/id" -import { zod, ZodOverride } from "@/util/effect-zod" -import { Newtype } from "@/util/schema" +import { zod, ZodOverride } from "@opencode-ai/core/effect-zod" +import { Newtype } from "@opencode-ai/core/schema" export class QuestionID extends Newtype()( "QuestionID", diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts index 3488d2616c..0c11c6c472 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts @@ -3,7 +3,7 @@ import { MCP } from "@/mcp" import { ProviderID, ModelID } from "@/provider/schema" import { Session } from "@/session/session" import { Worktree } from "@/worktree" -import { NonNegativeInt } from "@/util/schema" +import { NonNegativeInt } from "@opencode-ai/core/schema" import { Schema, SchemaGetter } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "../middleware/authorization" diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/sync.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/sync.ts index 442e656554..1603c29c4c 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/sync.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/sync.ts @@ -1,4 +1,4 @@ -import { NonNegativeInt } from "@/util/schema" +import { NonNegativeInt } from "@opencode-ai/core/schema" import { SessionID } from "@/session/schema" import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts index fa91b44117..55272fc2f2 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts @@ -6,7 +6,7 @@ import { MCP } from "@/mcp" import { Project } from "@/project/project" import { Session } from "@/session/session" import { ToolRegistry } from "@/tool/registry" -import * as EffectZod from "@/util/effect-zod" +import * as EffectZod from "@opencode-ai/core/effect-zod" import { Worktree } from "@/worktree" import { Effect, Option } from "effect" import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse" diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 85f6124505..23a01a1704 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -23,8 +23,8 @@ import type { SystemError } from "bun" import type { Provider } from "@/provider/provider" import { ModelID, ProviderID } from "@/provider/schema" import { Effect, Schema, Types } from "effect" -import { zod, ZodOverride } from "@/util/effect-zod" -import { NonNegativeInt, withStatics } from "@/util/schema" +import { zod, ZodOverride } from "@opencode-ai/core/effect-zod" +import { NonNegativeInt, withStatics } from "@opencode-ai/core/schema" import { namedSchemaError } from "@/util/named-schema-error" import * as EffectLogger from "@opencode-ai/core/effect/logger" diff --git a/packages/opencode/src/session/message.ts b/packages/opencode/src/session/message.ts index 9d67c48686..12952a87b9 100644 --- a/packages/opencode/src/session/message.ts +++ b/packages/opencode/src/session/message.ts @@ -1,8 +1,8 @@ import { Schema } from "effect" import { SessionID } from "./schema" import { ModelID, ProviderID } from "../provider/schema" -import { zod } from "@/util/effect-zod" -import { NonNegativeInt, withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { NonNegativeInt, withStatics } from "@opencode-ai/core/schema" import { namedSchemaError } from "@/util/named-schema-error" export const OutputLengthError = namedSchemaError("MessageOutputLengthError", {}) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index fef8c43836..1d5e4055c8 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1,6 +1,6 @@ import path from "path" import os from "os" -import * as EffectZod from "@/util/effect-zod" +import * as EffectZod from "@opencode-ai/core/effect-zod" import { SessionID, MessageID, PartID } from "./schema" import { MessageV2 } from "./message-v2" import * as Log from "@opencode-ai/core/util/log" @@ -46,8 +46,8 @@ import { Truncate } from "@/tool/truncate" import { decodeDataUrl } from "@/util/data-url" import { Process } from "@/util/process" import { Cause, Effect, Exit, Latch, Layer, Option, Scope, Context, Schema, Types } from "effect" -import { zod } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { withStatics } from "@opencode-ai/core/schema" import * as EffectLogger from "@opencode-ai/core/effect/logger" import { InstanceState } from "@/effect/instance-state" import { TaskTool, type TaskPromptOps } from "@/tool/task" diff --git a/packages/opencode/src/session/revert.ts b/packages/opencode/src/session/revert.ts index abf7c3441f..12c81180eb 100644 --- a/packages/opencode/src/session/revert.ts +++ b/packages/opencode/src/session/revert.ts @@ -4,8 +4,8 @@ import { Snapshot } from "../snapshot" import { Storage } from "@/storage/storage" import { SyncEvent } from "../sync" import * as Log from "@opencode-ai/core/util/log" -import { zod } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { withStatics } from "@opencode-ai/core/schema" import * as Session from "./session" import { MessageV2 } from "./message-v2" import { SessionID, MessageID, PartID } from "./schema" diff --git a/packages/opencode/src/session/schema.ts b/packages/opencode/src/session/schema.ts index 487cbcd34a..d0e6cd4cb7 100644 --- a/packages/opencode/src/session/schema.ts +++ b/packages/opencode/src/session/schema.ts @@ -1,8 +1,8 @@ import { Schema } from "effect" import { Identifier } from "@/id/id" -import { zod, ZodOverride } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { zod, ZodOverride } from "@opencode-ai/core/effect-zod" +import { withStatics } from "@opencode-ai/core/schema" export const SessionID = Schema.String.annotate({ [ZodOverride]: Identifier.schema("session") }).pipe( Schema.brand("SessionID"), diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index 5c938ff693..510d82a731 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -37,8 +37,8 @@ import type { Provider } from "@/provider/provider" import { Permission } from "@/permission" import { Global } from "@opencode-ai/core/global" import { Effect, Layer, Option, Context, Schema, Types } from "effect" -import { zod } from "@/util/effect-zod" -import { NonNegativeInt, optionalOmitUndefined, withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { NonNegativeInt, optionalOmitUndefined, withStatics } from "@opencode-ai/core/schema" const log = Log.create({ service: "session" }) diff --git a/packages/opencode/src/session/status.ts b/packages/opencode/src/session/status.ts index abe51bcd61..1dd36ec53a 100644 --- a/packages/opencode/src/session/status.ts +++ b/packages/opencode/src/session/status.ts @@ -2,8 +2,8 @@ import { BusEvent } from "@/bus/bus-event" import { Bus } from "@/bus" import { InstanceState } from "@/effect/instance-state" import { SessionID } from "./schema" -import { zod } from "@/util/effect-zod" -import { NonNegativeInt, withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { NonNegativeInt, withStatics } from "@opencode-ai/core/schema" import { Effect, Layer, Context, Schema } from "effect" import z from "zod" diff --git a/packages/opencode/src/session/summary.ts b/packages/opencode/src/session/summary.ts index f1709d5a2f..d5e52b91e9 100644 --- a/packages/opencode/src/session/summary.ts +++ b/packages/opencode/src/session/summary.ts @@ -2,8 +2,8 @@ import { Effect, Layer, Context, Schema } from "effect" import { Bus } from "@/bus" import { Snapshot } from "@/snapshot" import { Storage } from "@/storage/storage" -import { zod } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { withStatics } from "@opencode-ai/core/schema" import * as Session from "./session" import { MessageV2 } from "./message-v2" import { SessionID, MessageID } from "./schema" diff --git a/packages/opencode/src/session/todo.ts b/packages/opencode/src/session/todo.ts index 32a8370464..9b7daf7f0c 100644 --- a/packages/opencode/src/session/todo.ts +++ b/packages/opencode/src/session/todo.ts @@ -1,8 +1,8 @@ import { BusEvent } from "@/bus/bus-event" import { Bus } from "@/bus" import { SessionID } from "./schema" -import { zod } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { withStatics } from "@opencode-ai/core/schema" import { Effect, Layer, Context, Schema } from "effect" import z from "zod" import { Database } from "@/storage/db" diff --git a/packages/opencode/src/skill/index.ts b/packages/opencode/src/skill/index.ts index 696ab887a7..3000406ebc 100644 --- a/packages/opencode/src/skill/index.ts +++ b/packages/opencode/src/skill/index.ts @@ -2,8 +2,8 @@ import path from "path" import { pathToFileURL } from "url" import z from "zod" import { Effect, Layer, Context, Schema } from "effect" -import { zod } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { withStatics } from "@opencode-ai/core/schema" import { NamedError } from "@opencode-ai/core/util/error" import type { Agent } from "@/agent/agent" import { Bus } from "@/bus" diff --git a/packages/opencode/src/snapshot/index.ts b/packages/opencode/src/snapshot/index.ts index ea30f5afc7..6acd74763e 100644 --- a/packages/opencode/src/snapshot/index.ts +++ b/packages/opencode/src/snapshot/index.ts @@ -10,8 +10,8 @@ import { Hash } from "@opencode-ai/core/util/hash" import { Config } from "@/config/config" import { Global } from "@opencode-ai/core/global" import * as Log from "@opencode-ai/core/util/log" -import { NonNegativeInt, withStatics } from "@/util/schema" -import { zod } from "@/util/effect-zod" +import { NonNegativeInt, withStatics } from "@opencode-ai/core/schema" +import { zod } from "@opencode-ai/core/effect-zod" export const Patch = Schema.Struct({ hash: Schema.String, diff --git a/packages/opencode/src/storage/storage.ts b/packages/opencode/src/storage/storage.ts index 5b2df1e899..bc4d8b8f17 100644 --- a/packages/opencode/src/storage/storage.ts +++ b/packages/opencode/src/storage/storage.ts @@ -5,7 +5,7 @@ import { NamedError } from "@opencode-ai/core/util/error" import z from "zod" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Effect, Exit, Layer, Option, RcMap, Schema, Context, TxReentrantLock } from "effect" -import { NonNegativeInt } from "@/util/schema" +import { NonNegativeInt } from "@opencode-ai/core/schema" import { Git } from "@/git" const log = Log.create({ service: "storage" }) diff --git a/packages/opencode/src/sync/index.ts b/packages/opencode/src/sync/index.ts index 05d9727891..931f5e84c8 100644 --- a/packages/opencode/src/sync/index.ts +++ b/packages/opencode/src/sync/index.ts @@ -9,7 +9,7 @@ import type { WorkspaceID } from "@/control-plane/schema" import { EventID } from "./schema" import { Flag } from "@opencode-ai/core/flag/flag" import { Context, Effect, Layer, Schema as EffectSchema } from "effect" -import type { DeepMutable } from "@/util/schema" +import type { DeepMutable } from "@opencode-ai/core/schema" import { makeRuntime } from "@/effect/run-service" import { serviceUse } from "@/effect/service-use" import { InstanceState } from "@/effect/instance-state" diff --git a/packages/opencode/src/sync/schema.ts b/packages/opencode/src/sync/schema.ts index e714b86ae0..e4e2e75b73 100644 --- a/packages/opencode/src/sync/schema.ts +++ b/packages/opencode/src/sync/schema.ts @@ -1,8 +1,8 @@ import { Schema } from "effect" import { Identifier } from "@/id/id" -import { zod, ZodOverride } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { zod, ZodOverride } from "@opencode-ai/core/effect-zod" +import { withStatics } from "@opencode-ai/core/schema" export const EventID = Schema.String.annotate({ [ZodOverride]: Identifier.schema("event") }).pipe( Schema.brand("EventID"), diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts index bf01fc7d2d..4b5ba5efed 100644 --- a/packages/opencode/src/tool/read.ts +++ b/packages/opencode/src/tool/read.ts @@ -1,5 +1,5 @@ import { Effect, Option, Schema, Scope } from "effect" -import { NonNegativeInt } from "@/util/schema" +import { NonNegativeInt } from "@opencode-ai/core/schema" import { createReadStream } from "fs" import * as path from "path" import { createInterface } from "readline" diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index c8a91c1de1..2a5a64033d 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -17,7 +17,7 @@ import { Config } from "@/config/config" import { type ToolContext as PluginToolContext, type ToolDefinition } from "@opencode-ai/plugin" import { Schema } from "effect" import z from "zod" -import { ZodOverride } from "@/util/effect-zod" +import { ZodOverride } from "@opencode-ai/core/effect-zod" import { Plugin } from "../plugin" import { Provider } from "@/provider/provider" import { ProviderID, type ModelID } from "../provider/schema" diff --git a/packages/opencode/src/tool/schema.ts b/packages/opencode/src/tool/schema.ts index 9ce7bece2b..b6c263a4ce 100644 --- a/packages/opencode/src/tool/schema.ts +++ b/packages/opencode/src/tool/schema.ts @@ -1,8 +1,8 @@ import { Schema } from "effect" import { Identifier } from "@/id/id" -import { zod, ZodOverride } from "@/util/effect-zod" -import { withStatics } from "@/util/schema" +import { zod, ZodOverride } from "@opencode-ai/core/effect-zod" +import { withStatics } from "@opencode-ai/core/schema" const toolIdSchema = Schema.String.annotate({ [ZodOverride]: Identifier.schema("tool") }).pipe(Schema.brand("ToolID")) diff --git a/packages/opencode/src/tool/shell/prompt.ts b/packages/opencode/src/tool/shell/prompt.ts index 45c637863a..f26e364b61 100644 --- a/packages/opencode/src/tool/shell/prompt.ts +++ b/packages/opencode/src/tool/shell/prompt.ts @@ -1,6 +1,6 @@ import { Schema } from "effect" import DESCRIPTION from "./shell.txt" -import { PositiveInt } from "@/util/schema" +import { PositiveInt } from "@opencode-ai/core/schema" import { Global } from "@opencode-ai/core/global" import { ShellID } from "./id" diff --git a/packages/opencode/src/util/named-schema-error.ts b/packages/opencode/src/util/named-schema-error.ts index d9b92a23cc..d87e1dcdb5 100644 --- a/packages/opencode/src/util/named-schema-error.ts +++ b/packages/opencode/src/util/named-schema-error.ts @@ -1,6 +1,6 @@ import { Schema } from "effect" import z from "zod" -import { zod } from "@/util/effect-zod" +import { zod } from "@opencode-ai/core/effect-zod" /** * Create a Schema-backed NamedError-shaped class. diff --git a/packages/opencode/src/v2/auth.ts b/packages/opencode/src/v2/auth.ts index 1cc443974d..0ac6223a66 100644 --- a/packages/opencode/src/v2/auth.ts +++ b/packages/opencode/src/v2/auth.ts @@ -1,7 +1,7 @@ import path from "path" import { Effect, Layer, Option, Schema, Context, SynchronizedRef } from "effect" import { Identifier } from "@opencode-ai/core/util/identifier" -import { NonNegativeInt, withStatics } from "@/util/schema" +import { NonNegativeInt, withStatics } from "@opencode-ai/core/schema" import { Global } from "@opencode-ai/core/global" import { AppFileSystem } from "@opencode-ai/core/filesystem" diff --git a/packages/opencode/src/v2/event.ts b/packages/opencode/src/v2/event.ts index fde8d4326f..8d0562eba3 100644 --- a/packages/opencode/src/v2/event.ts +++ b/packages/opencode/src/v2/event.ts @@ -1,6 +1,6 @@ import { Identifier } from "@/id/id" import { SyncEvent } from "@/sync" -import { withStatics } from "@/util/schema" +import { withStatics } from "@opencode-ai/core/schema" import { Flag } from "@opencode-ai/core/flag/flag" import * as Schema from "effect/Schema" diff --git a/packages/opencode/src/v2/model.ts b/packages/opencode/src/v2/model.ts index db66199a59..a3ee882107 100644 --- a/packages/opencode/src/v2/model.ts +++ b/packages/opencode/src/v2/model.ts @@ -1,4 +1,4 @@ -import { withStatics } from "@/util/schema" +import { withStatics } from "@opencode-ai/core/schema" import { Array, Context, Effect, HashMap, Layer, Option, Order, pipe, Schema } from "effect" import { DateTimeUtcFromMillis } from "effect/Schema" diff --git a/packages/opencode/src/v2/session-event.ts b/packages/opencode/src/v2/session-event.ts index 7c768bd551..f439b1f841 100644 --- a/packages/opencode/src/v2/session-event.ts +++ b/packages/opencode/src/v2/session-event.ts @@ -1,5 +1,5 @@ import { SessionID } from "@/session/schema" -import { NonNegativeInt } from "@/util/schema" +import { NonNegativeInt } from "@opencode-ai/core/schema" import { EventV2 } from "./event" import { FileAttachment, Prompt } from "./session-prompt" import { Schema } from "effect" diff --git a/packages/opencode/src/v2/session.ts b/packages/opencode/src/v2/session.ts index bb86f039b2..b3da6009f6 100644 --- a/packages/opencode/src/v2/session.ts +++ b/packages/opencode/src/v2/session.ts @@ -10,7 +10,7 @@ import { EventV2 } from "./event" import { ProjectID } from "@/project/schema" import { SessionEvent } from "./session-event" import { V2Schema } from "./schema" -import { optionalOmitUndefined } from "@/util/schema" +import { optionalOmitUndefined } from "@opencode-ai/core/schema" import { Modelv2 } from "./model" export const Delivery = Schema.Literals(["immediate", "deferred"]).annotate({ diff --git a/packages/opencode/test/tool/parameters.test.ts b/packages/opencode/test/tool/parameters.test.ts index 9f6a0617ed..17af7b983e 100644 --- a/packages/opencode/test/tool/parameters.test.ts +++ b/packages/opencode/test/tool/parameters.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test" import { Result, Schema } from "effect" -import { toJsonSchema } from "../../src/util/effect-zod" +import { toJsonSchema } from "@opencode-ai/core/effect-zod" // Each tool exports its parameters schema at module scope so this test can // import them without running the tool's Effect-based init. The JSON Schema diff --git a/packages/opencode/test/util/effect-zod.test.ts b/packages/opencode/test/util/effect-zod.test.ts index 70cd8f0e64..ab3923d8e0 100644 --- a/packages/opencode/test/util/effect-zod.test.ts +++ b/packages/opencode/test/util/effect-zod.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test" import { Effect, Schema, SchemaGetter } from "effect" import z from "zod" -import { zod, ZodOverride } from "../../src/util/effect-zod" +import { zod, ZodOverride } from "@opencode-ai/core/effect-zod" function json(schema: z.ZodTypeAny) { const { $schema: _, ...rest } = z.toJSONSchema(schema) From 9a8b54fe622566e1b65401bb3e03fd4798dddef2 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Sat, 9 May 2026 21:56:49 +0200 Subject: [PATCH 076/189] Plugin command API shim (#26564) --- .../opencode/specs/v2/tui-command-shim.md | 67 +++++++++++ .../opencode/src/cli/cmd/tui/plugin/api.tsx | 3 + .../src/cli/cmd/tui/plugin/command-shim.ts | 109 ++++++++++++++++++ .../src/cli/cmd/tui/plugin/runtime.ts | 3 + packages/plugin/src/tui.ts | 43 +++++++ 5 files changed, 225 insertions(+) create mode 100644 packages/opencode/specs/v2/tui-command-shim.md create mode 100644 packages/opencode/src/cli/cmd/tui/plugin/command-shim.ts diff --git a/packages/opencode/specs/v2/tui-command-shim.md b/packages/opencode/specs/v2/tui-command-shim.md new file mode 100644 index 0000000000..5afade2a96 --- /dev/null +++ b/packages/opencode/specs/v2/tui-command-shim.md @@ -0,0 +1,67 @@ +# TUI Command Shim Removal + +Problem: + +- v1 keeps a deprecated `api.command` TUI plugin shim so older plugins do not fail during initialization +- v2 should expose only the keymap command API +- tests and fixtures should not encode legacy command behavior as expected behavior + +## Remove Public Types + +In `packages/plugin/src/tui.ts`, remove: + +- `TuiCommand` +- `TuiCommandApi` +- `TuiPluginApi.command` + +Keep `api.keymap` as the only TUI command registration and execution surface. + +## Remove Runtime Shim + +Delete `packages/opencode/src/cli/cmd/tui/plugin/command-shim.ts`. + +In `packages/opencode/src/cli/cmd/tui/plugin/api.tsx`, remove: + +- the `createCommandShim` import +- the `command: createCommandShim(...)` field from `createTuiApi(...)` + +In `packages/opencode/src/cli/cmd/tui/plugin/runtime.ts`, remove: + +- the `createCommandShim` import +- the `command: createCommandShim(...)` field from `pluginApi(...)` + +## Migration Target + +Plugin authors should replace old calls with keymap calls: + +```ts +api.keymap.registerLayer({ + commands: [ + { + name: "plugin.command", + title: "Plugin Command", + namespace: "palette", + slashName: "plugin", + run() { + api.ui.dialog.clear() + }, + }, + ], + bindings: [{ key: "ctrl+shift+p", cmd: "plugin.command" }], +}) +``` + +Direct replacements: + +- `api.command.register(cb)` -> `api.keymap.registerLayer({ commands, bindings })` +- `api.command.trigger(name)` -> `api.keymap.dispatchCommand(name)` +- `api.command.show()` -> `api.keymap.dispatchCommand("command.palette.show")` +- `onSelect(dialog)` -> use `api.ui.dialog` from the plugin API closure + +## Verification + +After removal, run from package directories: + +- `bun typecheck` in `packages/plugin` +- `bun typecheck` in `packages/opencode` +- TUI plugin loader tests in `packages/opencode` if runtime plugin API wiring changed diff --git a/packages/opencode/src/cli/cmd/tui/plugin/api.tsx b/packages/opencode/src/cli/cmd/tui/plugin/api.tsx index 7b7ce0bbb5..6015150ec2 100644 --- a/packages/opencode/src/cli/cmd/tui/plugin/api.tsx +++ b/packages/opencode/src/cli/cmd/tui/plugin/api.tsx @@ -17,6 +17,7 @@ import { Slot as HostSlot } from "./slots" import type { useToast } from "../ui/toast" import { InstallationVersion } from "@opencode-ai/core/installation/version" import * as Keymap from "../keymap" +import { createCommandShim } from "./command-shim" type RouteEntry = { key: symbol @@ -200,6 +201,8 @@ export function createTuiApi(input: Input): TuiPluginApi { } return { app: appApi(), + // Keep deprecated `api.command` working for v1 plugins; remove in v2. + command: createCommandShim(input.keymap, input.dialog, input.tuiConfig.keybinds), keys: { formatSequence(parts) { return Keymap.formatKeySequence(parts, input.tuiConfig) diff --git a/packages/opencode/src/cli/cmd/tui/plugin/command-shim.ts b/packages/opencode/src/cli/cmd/tui/plugin/command-shim.ts new file mode 100644 index 0000000000..61eb833fe7 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/plugin/command-shim.ts @@ -0,0 +1,109 @@ +// Legacy `api.command` bridge for v1 plugins; remove in v2. +import type { TuiCommand, TuiPluginApi } from "@opencode-ai/plugin/tui" +import { TuiKeybind } from "../config/keybind" +import type { DialogContext } from "../ui/dialog" + +const COMMAND_PALETTE_SHOW = "command.palette.show" +const warned = new Set() + +type Warn = (api: string, replacement: string) => void +type LegacyDialog = TuiPluginApi["ui"]["dialog"] +type CommandShimDialog = DialogContext | LegacyDialog +type LegacyKeybinds = TuiPluginApi["tuiConfig"]["keybinds"] + +function warnCommandShim(api: string, replacement: string) { + // Warn v1 plugins about deprecated `api.command`; remove this shim path in v2. + console.warn("[tui.plugin] deprecated TUI plugin API", { api, replacement }) +} + +function createCommandShimDialog(dialog: CommandShimDialog): LegacyDialog { + if (!("stack" in dialog)) return dialog + return { + replace(render, onClose) { + dialog.replace(render, onClose) + }, + clear() { + dialog.clear() + }, + setSize(size) { + dialog.setSize(size) + }, + get size() { + return dialog.size + }, + get depth() { + return dialog.stack.length + }, + get open() { + return dialog.stack.length > 0 + }, + } +} + +function warnOnce(api: string, replacement: string, warn: Warn) { + if (warned.has(api)) return + warned.add(api) + warn(api, replacement) +} + +function toCommand(item: TuiCommand, dialog: LegacyDialog) { + return { + namespace: "palette", + name: item.value, + title: item.title, + desc: item.description, + category: item.category, + suggested: item.suggested, + hidden: item.hidden, + enabled: item.enabled, + slashName: item.slash?.name, + slashAliases: item.slash?.aliases, + run() { + return item.onSelect?.(dialog) + }, + } +} + +function toBindings(commands: TuiCommand[], keybinds: LegacyKeybinds) { + return commands.flatMap((item) => + item.keybind + ? keybinds.has(TuiKeybind.CommandMap[item.keybind as keyof typeof TuiKeybind.CommandMap] ?? item.keybind) + ? keybinds + .get(TuiKeybind.CommandMap[item.keybind as keyof typeof TuiKeybind.CommandMap] ?? item.keybind) + .map((binding) => ({ ...binding, cmd: item.value, desc: binding.desc ?? item.title })) + : [ + { + key: item.keybind, + cmd: item.value, + desc: item.title, + }, + ] + : [], + ) +} + +export function createCommandShim( + keymap: TuiPluginApi["keymap"], + dialog: CommandShimDialog, + keybinds: LegacyKeybinds, +): TuiPluginApi["command"] { + const shimDialog = createCommandShimDialog(dialog) + return { + register(cb) { + warnOnce("api.command.register", "api.keymap.registerLayer({ commands, bindings })", warnCommandShim) + const commands = cb() + return keymap.registerLayer({ + commands: commands.map((item) => toCommand(item, shimDialog)), + bindings: toBindings(commands, keybinds), + }) + }, + trigger(value) { + warnOnce("api.command.trigger", "api.keymap.dispatchCommand(name)", warnCommandShim) + keymap.dispatchCommand(value) + }, + show() { + warnOnce("api.command.show", `api.keymap.dispatchCommand("${COMMAND_PALETTE_SHOW}")`, warnCommandShim) + keymap.dispatchCommand(COMMAND_PALETTE_SHOW) + }, + } +} diff --git a/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts b/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts index 91ccaaaa01..64961b20f7 100644 --- a/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts +++ b/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts @@ -39,6 +39,7 @@ import { INTERNAL_TUI_PLUGINS, type InternalTuiPlugin } from "./internal" import { setupSlots, Slot as View } from "./slots" import type { HostPluginApi, HostSlots } from "./slots" import { ConfigPlugin } from "@/config/plugin" +import { createCommandShim } from "./command-shim" ensureRuntimePluginSupport({ additional: keymapRuntimeModules }) @@ -576,6 +577,8 @@ function pluginApi(runtime: RuntimeState, plugin: PluginEntry, scope: PluginScop return { app: api.app, + // Keep deprecated `api.command` working for v1 plugins; remove in v2. + command: createCommandShim(keymap, api.ui.dialog, api.tuiConfig.keybinds), keys: api.keys, keymap, route, diff --git a/packages/plugin/src/tui.ts b/packages/plugin/src/tui.ts index 13bc17f66b..851b0476e5 100644 --- a/packages/plugin/src/tui.ts +++ b/packages/plugin/src/tui.ts @@ -77,6 +77,42 @@ export type TuiKeys = { export type TuiKeymap = Keymap +/** + * Legacy `api.command` shape kept so v1 plugins can initialize. Remove in v2. + * + * @deprecated Use `api.keymap.registerLayer({ commands, bindings })` instead. + */ +export type TuiCommand = { + title: string + value: string + description?: string + category?: string + keybind?: string + suggested?: boolean + hidden?: boolean + enabled?: boolean + slash?: { + name: string + aliases?: string[] + } + onSelect?: (dialog?: TuiDialogStack) => void | Promise +} + +/** + * Legacy `api.command` API kept so v1 plugins can initialize. Remove in v2. + * + * @deprecated Use `api.keymap.registerLayer`, `api.keymap.dispatchCommand`, and + * `api.keymap.dispatchCommand("command.palette.show")` instead. + */ +export type TuiCommandApi = { + /** @deprecated Use `api.keymap.registerLayer({ commands, bindings })` instead. */ + register: (cb: () => TuiCommand[]) => () => void + /** @deprecated Use `api.keymap.dispatchCommand(name)` instead. */ + trigger: (value: string) => void + /** @deprecated Use `api.keymap.dispatchCommand("command.palette.show")` instead. */ + show: () => void +} + export type TuiDialogProps = { size?: "medium" | "large" | "xlarge" onClose: () => void @@ -461,6 +497,13 @@ export type TuiWorkspace = { export type TuiPluginApi = { app: TuiApp + /** + * Legacy `api.command` API kept so v1 plugins can initialize. Remove in v2. + * + * @deprecated Use `api.keymap.registerLayer`, `api.keymap.dispatchCommand`, and + * `api.keymap.dispatchCommand("command.palette.show")` instead. + */ + command?: TuiCommandApi keys: TuiKeys keymap: TuiKeymap route: { From 6c2dfd2f52099d952cfe78613d55370b9fb9fefd Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 9 May 2026 15:58:03 -0400 Subject: [PATCH 077/189] fix(tui): guard messages.data in session.sync against undefined (#26566) Co-authored-by: Developer --- .../opencode/src/cli/cmd/tui/context/sync.tsx | 6 +- .../test/cli/cmd/tui/sync-fixture.tsx | 120 +++++++++++++++++ .../cmd/tui/sync-undefined-messages.test.tsx | 47 +++++++ .../opencode/test/cli/cmd/tui/sync.test.tsx | 121 +----------------- 4 files changed, 172 insertions(+), 122 deletions(-) create mode 100644 packages/opencode/test/cli/cmd/tui/sync-fixture.tsx create mode 100644 packages/opencode/test/cli/cmd/tui/sync-undefined-messages.test.tsx diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index 24609dd81e..e4c2824d41 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -526,10 +526,12 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ if (match.found) draft.session[match.index] = session.data! if (!match.found) draft.session.splice(match.index, 0, session.data!) draft.todo[sessionID] = todo.data ?? [] - draft.message[sessionID] = messages.data!.map((x) => x.info) - for (const message of messages.data!) { + const infos: typeof draft.message[string] = [] + for (const message of messages.data ?? []) { + infos.push(message.info) draft.part[message.info.id] = message.parts } + draft.message[sessionID] = infos draft.session_diff[sessionID] = diff.data ?? [] }), ) diff --git a/packages/opencode/test/cli/cmd/tui/sync-fixture.tsx b/packages/opencode/test/cli/cmd/tui/sync-fixture.tsx new file mode 100644 index 0000000000..d9ecdbe9d5 --- /dev/null +++ b/packages/opencode/test/cli/cmd/tui/sync-fixture.tsx @@ -0,0 +1,120 @@ +/** @jsxImportSource @opentui/solid */ +import { testRender } from "@opentui/solid" +import { onMount } from "solid-js" +import { ArgsProvider } from "../../../../src/cli/cmd/tui/context/args" +import { ExitProvider } from "../../../../src/cli/cmd/tui/context/exit" +import { KVProvider, useKV } from "../../../../src/cli/cmd/tui/context/kv" +import { ProjectProvider } from "../../../../src/cli/cmd/tui/context/project" +import { SDKProvider, type EventSource } from "../../../../src/cli/cmd/tui/context/sdk" +import { SyncProvider, useSync } from "../../../../src/cli/cmd/tui/context/sync" + +export const worktree = "/tmp/opencode" +export const directory = `${worktree}/packages/opencode` + +export async function wait(fn: () => boolean, timeout = 2000) { + const start = Date.now() + while (!fn()) { + if (Date.now() - start > timeout) throw new Error("timed out waiting for condition") + await Bun.sleep(10) + } +} + +export function json(data: unknown, init?: ResponseInit) { + return new Response(JSON.stringify(data), { + ...init, + headers: { "content-type": "application/json", ...(init?.headers ?? {}) }, + }) +} + +export function eventSource(): EventSource { + return { subscribe: async () => () => {} } +} + +type FetchHandler = (url: URL) => Response | Promise | undefined + +export function createFetch(override?: FetchHandler) { + const session = [] as URL[] + const fetch = (async (input: RequestInfo | URL) => { + const url = new URL(input instanceof Request ? input.url : String(input)) + if (url.pathname === "/session") session.push(url) + + const overridden = await override?.(url) + if (overridden) return overridden + + switch (url.pathname) { + case "/agent": + case "/command": + case "/experimental/workspace": + case "/experimental/workspace/status": + case "/formatter": + case "/lsp": + return json([]) + case "/config": + case "/experimental/resource": + case "/mcp": + case "/provider/auth": + case "/session/status": + return json({}) + case "/config/providers": + return json({ providers: {}, default: {} }) + case "/experimental/console": + return json({ consoleManagedProviders: [], switchableOrgCount: 0 }) + case "/path": + return json({ home: "", state: "", config: "", worktree, directory }) + case "/project/current": + return json({ id: "proj_test" }) + case "/provider": + return json({ all: [], default: {}, connected: [] }) + case "/session": + return json([]) + case "/vcs": + return json({ branch: "main" }) + } + + throw new Error(`unexpected request: ${url.pathname}`) + }) as typeof globalThis.fetch + + return { fetch, session } +} + +type Ctx = { kv: ReturnType; sync: ReturnType } + +export async function mount(override?: FetchHandler) { + const calls = createFetch(override) + let sync!: ReturnType + let kv!: ReturnType + let done!: () => void + const ready = new Promise((resolve) => { + done = resolve + }) + + function Probe() { + const ctx: Ctx = { kv: useKV(), sync: useSync() } + onMount(() => { + sync = ctx.sync + kv = ctx.kv + done() + }) + return + } + + const app = await testRender(() => ( + + + + + + + + + + + + + + )) + + await ready + await wait(() => sync.status === "complete") + return { app, kv, sync, session: calls.session } +} diff --git a/packages/opencode/test/cli/cmd/tui/sync-undefined-messages.test.tsx b/packages/opencode/test/cli/cmd/tui/sync-undefined-messages.test.tsx new file mode 100644 index 0000000000..5fb7ece94d --- /dev/null +++ b/packages/opencode/test/cli/cmd/tui/sync-undefined-messages.test.tsx @@ -0,0 +1,47 @@ +/** @jsxImportSource @opentui/solid */ +/** + * Reproducer for #26560 — TUI crashes with + * `TypeError: undefined is not an object (evaluating 'f.data.map')` + * when entering a session whose messages endpoint returns a non-2xx. + * The failure path is `sync.tsx#sync.session.sync` reading + * `messages.data!` while the SDK leaves `data` undefined on error. + */ +import { describe, expect, test } from "bun:test" +import { Global } from "@opencode-ai/core/global" +import { tmpdir } from "../../../fixture/fixture" +import { directory, json, mount } from "./sync-fixture" + +const sessionID = "ses_undef" + +describe("tui sync (#26560)", () => { + test("entering a session whose messages endpoint errors does not crash sync", async () => { + const previous = Global.Path.state + await using tmp = await tmpdir() + Global.Path.state = tmp.path + await Bun.write(`${tmp.path}/kv.json`, "{}") + + const sessionPayload = { + id: sessionID, + title: "broken", + time: { created: 0, updated: 0 }, + version: "1.14.42", + directory, + project_id: "proj_test", + } + const { app, sync } = await mount((url) => { + if (url.pathname === `/session/${sessionID}`) return json(sessionPayload) + if (url.pathname === `/session/${sessionID}/messages`) return json({}, { status: 500 }) + if (url.pathname === `/session/${sessionID}/todo`) return json([]) + if (url.pathname === `/session/${sessionID}/diff`) return json([]) + if (url.pathname === "/session") return json([sessionPayload]) + return undefined + }) + + try { + await expect(sync.session.sync(sessionID)).resolves.toBeUndefined() + } finally { + app.renderer.destroy() + Global.Path.state = previous + } + }) +}) diff --git a/packages/opencode/test/cli/cmd/tui/sync.test.tsx b/packages/opencode/test/cli/cmd/tui/sync.test.tsx index 993484d3ca..f67257f6ce 100644 --- a/packages/opencode/test/cli/cmd/tui/sync.test.tsx +++ b/packages/opencode/test/cli/cmd/tui/sync.test.tsx @@ -1,127 +1,8 @@ /** @jsxImportSource @opentui/solid */ import { describe, expect, test } from "bun:test" -import { testRender } from "@opentui/solid" -import { onMount } from "solid-js" import { Global } from "@opencode-ai/core/global" -import { ArgsProvider } from "../../../../src/cli/cmd/tui/context/args" -import { ExitProvider } from "../../../../src/cli/cmd/tui/context/exit" -import { KVProvider, useKV } from "../../../../src/cli/cmd/tui/context/kv" -import { ProjectProvider } from "../../../../src/cli/cmd/tui/context/project" -import { SDKProvider, type EventSource } from "../../../../src/cli/cmd/tui/context/sdk" -import { SyncProvider, useSync } from "../../../../src/cli/cmd/tui/context/sync" import { tmpdir } from "../../../fixture/fixture" - -const worktree = "/tmp/opencode" -const directory = `${worktree}/packages/opencode` - -async function wait(fn: () => boolean, timeout = 2000) { - const start = Date.now() - while (!fn()) { - if (Date.now() - start > timeout) throw new Error("timed out waiting for condition") - await Bun.sleep(10) - } -} - -function json(data: unknown) { - return new Response(JSON.stringify(data), { - headers: { "content-type": "application/json" }, - }) -} - -function eventSource(): EventSource { - return { - subscribe: async () => () => {}, - } -} - -function createFetch() { - const session = [] as URL[] - const fetch = (async (input: RequestInfo | URL) => { - const url = new URL(input instanceof Request ? input.url : String(input)) - if (url.pathname === "/session") session.push(url) - - switch (url.pathname) { - case "/agent": - case "/command": - case "/experimental/workspace": - case "/experimental/workspace/status": - case "/formatter": - case "/lsp": - return json([]) - case "/config": - case "/experimental/resource": - case "/mcp": - case "/provider/auth": - case "/session/status": - return json({}) - case "/config/providers": - return json({ providers: {}, default: {} }) - case "/experimental/console": - return json({ consoleManagedProviders: [], switchableOrgCount: 0 }) - case "/path": - return json({ home: "", state: "", config: "", worktree, directory }) - case "/project/current": - return json({ id: "proj_test" }) - case "/provider": - return json({ all: [], default: {}, connected: [] }) - case "/session": - return json([]) - case "/vcs": - return json({ branch: "main" }) - } - - throw new Error(`unexpected request: ${url.pathname}`) - }) as typeof globalThis.fetch - - return { fetch, session } -} - -async function mount() { - const calls = createFetch() - let sync!: ReturnType - let kv!: ReturnType - let done!: () => void - const ready = new Promise((resolve) => { - done = resolve - }) - - const app = await testRender(() => ( - - - - - - - { - sync = ctx.sync - kv = ctx.kv - done() - }} - /> - - - - - - - )) - - await ready - await wait(() => sync.status === "complete") - return { app, kv, sync, session: calls.session } -} - -function Probe(props: { onReady: (ctx: { kv: ReturnType; sync: ReturnType }) => void }) { - const kv = useKV() - const sync = useSync() - - onMount(() => { - props.onReady({ kv, sync }) - }) - - return -} +import { mount } from "./sync-fixture" describe("tui sync", () => { test("refresh scopes sessions by default and lists project sessions when disabled", async () => { From ad79f3e0cf3780db924c03c5c203ccefff695237 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sat, 9 May 2026 19:59:00 +0000 Subject: [PATCH 078/189] chore: generate --- packages/opencode/src/cli/cmd/tui/context/sync.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index e4c2824d41..25ae4e2736 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -526,7 +526,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ if (match.found) draft.session[match.index] = session.data! if (!match.found) draft.session.splice(match.index, 0, session.data!) draft.todo[sessionID] = todo.data ?? [] - const infos: typeof draft.message[string] = [] + const infos: (typeof draft.message)[string] = [] for (const message of messages.data ?? []) { infos.push(message.info) draft.part[message.info.id] = message.parts From 77da433e0aacaa991123723b8c5f79c6464eb98b Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 9 May 2026 16:02:35 -0400 Subject: [PATCH 079/189] fix(session): accept routing params in message list (#26569) Co-authored-by: Developer --- .../routes/instance/httpapi/groups/session.ts | 7 +++++- .../test/server/session-messages.test.ts | 25 ++++++++++++++++++- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts index 967cc80206..5dd8f8fabc 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts @@ -25,8 +25,12 @@ const QueryBoolean = Schema.Literals(["true", "false"]).pipe( encode: SchemaGetter.transform((value) => (value ? "true" : "false")), }), ) -export const ListQuery = Schema.Struct({ +const WorkspaceRoutingQuery = { directory: Schema.optional(Schema.String), + workspace: Schema.optional(Schema.String), +} +export const ListQuery = Schema.Struct({ + ...WorkspaceRoutingQuery, scope: Schema.optional(Schema.Literals(["project"])), path: Schema.optional(Schema.String), roots: Schema.optional(QueryBoolean), @@ -36,6 +40,7 @@ export const ListQuery = Schema.Struct({ }) export const DiffQuery = Schema.Struct(Struct.omit(SessionSummary.DiffInput.fields, ["sessionID"])) export const MessagesQuery = Schema.Struct({ + ...WorkspaceRoutingQuery, limit: Schema.optional(Schema.NumberFromString.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(0))), before: Schema.optional(Schema.String), }) diff --git a/packages/opencode/test/server/session-messages.test.ts b/packages/opencode/test/server/session-messages.test.ts index e3c5e83136..f5ee5bdcb0 100644 --- a/packages/opencode/test/server/session-messages.test.ts +++ b/packages/opencode/test/server/session-messages.test.ts @@ -1,6 +1,5 @@ import { afterEach, describe, expect, test } from "bun:test" import { Effect } from "effect" -import { Instance } from "../../src/project/instance" import { WithInstance } from "../../src/project/with-instance" import { Server } from "../../src/server/server" import { Session as SessionNs } from "@/session/session" @@ -165,4 +164,28 @@ describe("session messages endpoint", () => { }), ) }) + + test("accepts directory query used by workspace routing", async () => { + await using tmp = await tmpdir({ git: true }) + await withoutWatcher(() => + WithInstance.provide({ + directory: tmp.path, + fn: async () => { + const session = await svc.create({}) + await fill(session.id, 1) + const app = Server.Default().app + + const res = await app.request( + `/session/${session.id}/message?limit=80&directory=${encodeURIComponent(tmp.path)}`, + ) + expect(res.status).toBe(200) + const body = await res.json() + expect(Array.isArray(body)).toBe(true) + expect(body).toHaveLength(1) + + await svc.remove(session.id) + }, + }), + ) + }) }) From 8602937a37b9a9c8f1bf4b4ea7b52a2f95f6646b Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 9 May 2026 16:19:06 -0400 Subject: [PATCH 080/189] test(session): cover workspace-routed messages (#26576) --- packages/opencode/test/server/httpapi-session.test.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/opencode/test/server/httpapi-session.test.ts b/packages/opencode/test/server/httpapi-session.test.ts index 96ddf8fcce..d646b35fcb 100644 --- a/packages/opencode/test/server/httpapi-session.test.ts +++ b/packages/opencode/test/server/httpapi-session.test.ts @@ -364,8 +364,15 @@ describe("session HttpApi", () => { headers: { "x-opencode-directory": tmp.path, "content-type": "application/json" }, body: JSON.stringify({ title: "workspace session" }), }) + const messages = yield* request( + `${pathFor(SessionPaths.messages, { sessionID: created.id })}?workspace=${workspace.id}`, + { + headers: { "x-opencode-directory": tmp.path }, + }, + ) expect(created).toMatchObject({ id: created.id, workspaceID: workspace.id }) + expect(messages.status).toBe(200) expect( yield* Effect.sync(() => Database.use((db) => From d62442bb5d12642b24610260b67437e4cdd9fd83 Mon Sep 17 00:00:00 2001 From: OpeOginni <107570612+OpeOginni@users.noreply.github.com> Date: Sat, 9 May 2026 22:23:28 +0200 Subject: [PATCH 081/189] fix(sessions): allow optional patch field in diff for migrated sessions (#26574) --- packages/opencode/src/cli/cmd/export.ts | 5 +++-- packages/opencode/src/snapshot/index.ts | 2 +- packages/sdk/js/src/v2/gen/types.gen.ts | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/cli/cmd/export.ts b/packages/opencode/src/cli/cmd/export.ts index bf73ce941e..6a64d0e057 100644 --- a/packages/opencode/src/cli/cmd/export.ts +++ b/packages/opencode/src/cli/cmd/export.ts @@ -23,11 +23,12 @@ function span(id: string, value: { value: string; start: number; end: number }) } } -function diff(kind: string, diffs: { file: string; patch: string }[] | undefined) { +function diff(kind: string, diffs: { file: string; patch?: string }[] | undefined) { return diffs?.map((item, i) => ({ ...item, file: redact(`${kind}-file`, String(i), item.file), - patch: redact(`${kind}-patch`, String(i), item.patch), + patch: + item.patch === undefined ? undefined : redact(`${kind}-patch`, String(i), item.patch), })) } diff --git a/packages/opencode/src/snapshot/index.ts b/packages/opencode/src/snapshot/index.ts index 6acd74763e..05d234e9b8 100644 --- a/packages/opencode/src/snapshot/index.ts +++ b/packages/opencode/src/snapshot/index.ts @@ -21,7 +21,7 @@ export type Patch = typeof Patch.Type export const FileDiff = Schema.Struct({ file: Schema.String, - patch: Schema.String, + patch: Schema.optional(Schema.String), additions: NonNegativeInt, deletions: NonNegativeInt, status: Schema.optional(Schema.Literals(["added", "deleted", "modified"])), diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 220278b8c2..6a0ba21027 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -120,7 +120,7 @@ export type PermissionRequest = { export type SnapshotFileDiff = { file: string - patch: string + patch?: string additions: number deletions: number status?: "added" | "deleted" | "modified" From 480aa8b23b430875f28c78574743f4f82c0f2f85 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sat, 9 May 2026 20:24:28 +0000 Subject: [PATCH 082/189] chore: generate --- packages/opencode/src/cli/cmd/export.ts | 3 +-- packages/sdk/openapi.json | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/cli/cmd/export.ts b/packages/opencode/src/cli/cmd/export.ts index 6a64d0e057..d1f9cd8d7d 100644 --- a/packages/opencode/src/cli/cmd/export.ts +++ b/packages/opencode/src/cli/cmd/export.ts @@ -27,8 +27,7 @@ function diff(kind: string, diffs: { file: string; patch?: string }[] | undefine return diffs?.map((item, i) => ({ ...item, file: redact(`${kind}-file`, String(i), item.file), - patch: - item.patch === undefined ? undefined : redact(`${kind}-patch`, String(i), item.patch), + patch: item.patch === undefined ? undefined : redact(`${kind}-patch`, String(i), item.patch), })) } diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index e19ead9b32..127d48a58c 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -9120,7 +9120,7 @@ "enum": ["added", "deleted", "modified"] } }, - "required": ["file", "patch", "additions", "deletions"], + "required": ["file", "additions", "deletions"], "additionalProperties": false }, "ProviderAuthError": { From 805af011c9d74668a58160399d38e8fb446ebdaf Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 9 May 2026 16:30:31 -0400 Subject: [PATCH 083/189] test(session): regression test for #26574 + mirror loosening on Vcs.FileDiff (#26578) Co-authored-by: Developer --- packages/opencode/src/project/vcs.ts | 5 +- packages/opencode/src/snapshot/index.ts | 3 + .../server/session-diff-missing-patch.test.ts | 79 +++++++++++++++++++ 3 files changed, 86 insertions(+), 1 deletion(-) create mode 100644 packages/opencode/test/server/session-diff-missing-patch.test.ts diff --git a/packages/opencode/src/project/vcs.ts b/packages/opencode/src/project/vcs.ts index 28050e86f7..21ee882c41 100644 --- a/packages/opencode/src/project/vcs.ts +++ b/packages/opencode/src/project/vcs.ts @@ -230,7 +230,10 @@ export type Info = Schema.Schema.Type export const FileDiff = Schema.Struct({ file: Schema.String, - patch: Schema.String, + // Mirrors Snapshot.FileDiff (see #26574). The current producer always + // populates patch, but loosening matches the sibling schema so a + // future code path that omits it can't crash /instance/vcs/diff. + patch: Schema.optional(Schema.String), additions: NonNegativeInt, deletions: NonNegativeInt, status: Schema.optional(Schema.Literals(["added", "deleted", "modified"])), diff --git a/packages/opencode/src/snapshot/index.ts b/packages/opencode/src/snapshot/index.ts index 05d234e9b8..8c8fd9156a 100644 --- a/packages/opencode/src/snapshot/index.ts +++ b/packages/opencode/src/snapshot/index.ts @@ -21,6 +21,9 @@ export type Patch = typeof Patch.Type export const FileDiff = Schema.Struct({ file: Schema.String, + // Optional because legacy/imported `summary_diffs` on disk may omit + // the patch text (see #26574). Required-Schema rejected the whole + // /session//diff response and broke session loading on Desktop. patch: Schema.optional(Schema.String), additions: NonNegativeInt, deletions: NonNegativeInt, diff --git a/packages/opencode/test/server/session-diff-missing-patch.test.ts b/packages/opencode/test/server/session-diff-missing-patch.test.ts new file mode 100644 index 0000000000..e85632dbf2 --- /dev/null +++ b/packages/opencode/test/server/session-diff-missing-patch.test.ts @@ -0,0 +1,79 @@ +/** + * Regression test for the same bug class as #26574 (sibling of #26566 and + * #26553). The Desktop app calls GET /session//diff; before #26574 + * the response was Schema-encoded against `Snapshot.FileDiff` with + * `patch: Schema.String` (required), so any session whose stored + * `summary_diffs` had a row without `patch` returned HTTP 400 and the + * session never loaded. + * + * This test inserts a session row with a missing-patch diff entry and + * asserts that GET /session//diff returns 200 with the row intact. + */ +import { afterEach, describe, expect } from "bun:test" +import { Effect } from "effect" +import { Server } from "@/server/server" +import { SessionPaths } from "@/server/routes/instance/httpapi/groups/session" +import { Session } from "@/session/session" +import { Storage } from "@/storage/storage" +import { WithInstance } from "@/project/with-instance" +import { resetDatabase } from "../fixture/db" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" +import { it } from "../lib/effect" +import * as Log from "@opencode-ai/core/util/log" + +void Log.init({ print: false }) + +afterEach(async () => { + await disposeAllInstances() + await resetDatabase() +}) + +function pathFor(template: string, params: Record) { + return Object.entries(params).reduce((result, [key, value]) => result.replace(`:${key}`, value), template) +} + +describe("session diff with missing patch (#26574)", () => { + it.live("GET /session//diff returns 200 when summary_diffs row has no patch", () => + Effect.gen(function* () { + const tmp = yield* Effect.acquireRelease( + Effect.promise(() => tmpdir({ git: true, config: { formatter: false, lsp: false } })), + (t) => Effect.promise(() => t[Symbol.asyncDispose]()), + ) + + yield* Effect.promise(() => + WithInstance.provide({ + directory: tmp.path, + fn: async () => { + const session = await Effect.runPromise( + Effect.provide(Session.Service.use((s) => s.create({ title: "missing-patch" })), Session.defaultLayer), + ) + + // Mimic legacy/imported on-disk shape: a diff entry with no + // `patch` text. Pre-fix the typed response encoder rejects + // this and returns 400. + await Effect.runPromise( + Effect.provide( + Storage.Service.use((s) => + s.write(["session_diff", session.id], [{ file: "legacy.txt", additions: 1, deletions: 0 }]), + ), + Storage.defaultLayer, + ), + ) + + const headers = { "x-opencode-directory": tmp.path } + const response = await Server.Default().app.request( + pathFor(SessionPaths.diff, { sessionID: session.id }), + { headers }, + ) + expect(response.status).toBe(200) + const body = (await response.json()) as Array<{ file: string; patch?: string; additions: number }> + expect(body).toHaveLength(1) + expect(body[0]?.file).toBe("legacy.txt") + expect(body[0]?.additions).toBe(1) + expect(body[0]?.patch).toBeUndefined() + }, + }), + ) + }), + ) +}) From 5fa5d876fce2536c79c4492c71d64dd4e4755e7d Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sat, 9 May 2026 20:31:32 +0000 Subject: [PATCH 084/189] chore: generate --- .../test/server/session-diff-missing-patch.test.ts | 12 +++++++----- packages/sdk/js/src/v2/gen/types.gen.ts | 2 +- packages/sdk/openapi.json | 2 +- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/packages/opencode/test/server/session-diff-missing-patch.test.ts b/packages/opencode/test/server/session-diff-missing-patch.test.ts index e85632dbf2..5f27a4e2fd 100644 --- a/packages/opencode/test/server/session-diff-missing-patch.test.ts +++ b/packages/opencode/test/server/session-diff-missing-patch.test.ts @@ -45,7 +45,10 @@ describe("session diff with missing patch (#26574)", () => { directory: tmp.path, fn: async () => { const session = await Effect.runPromise( - Effect.provide(Session.Service.use((s) => s.create({ title: "missing-patch" })), Session.defaultLayer), + Effect.provide( + Session.Service.use((s) => s.create({ title: "missing-patch" })), + Session.defaultLayer, + ), ) // Mimic legacy/imported on-disk shape: a diff entry with no @@ -61,10 +64,9 @@ describe("session diff with missing patch (#26574)", () => { ) const headers = { "x-opencode-directory": tmp.path } - const response = await Server.Default().app.request( - pathFor(SessionPaths.diff, { sessionID: session.id }), - { headers }, - ) + const response = await Server.Default().app.request(pathFor(SessionPaths.diff, { sessionID: session.id }), { + headers, + }) expect(response.status).toBe(200) const body = (await response.json()) as Array<{ file: string; patch?: string; additions: number }> expect(body).toHaveLength(1) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 6a0ba21027..5a79ae2661 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1520,7 +1520,7 @@ export type VcsFileStatus = { export type VcsFileDiff = { file: string - patch: string + patch?: string additions: number deletions: number status?: "added" | "deleted" | "modified" diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 127d48a58c..32c9a0922a 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -13039,7 +13039,7 @@ "enum": ["added", "deleted", "modified"] } }, - "required": ["file", "patch", "additions", "deletions"], + "required": ["file", "additions", "deletions"], "additionalProperties": false }, "VcsApplyError": { From d373c562f2fd34ea38924a18161faa88e46592ab Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 9 May 2026 16:44:24 -0400 Subject: [PATCH 085/189] fix(session): accept legacy summary diffs (#26579) Co-authored-by: Developer --- .../src/pages/session/session-side-panel.tsx | 11 +++++-- packages/opencode/src/cli/cmd/export.ts | 4 +-- .../opencode/src/cli/cmd/tui/plugin/api.tsx | 4 ++- packages/opencode/src/session/summary.ts | 1 + packages/opencode/src/snapshot/index.ts | 6 ++-- .../test/server/httpapi-session.test.ts | 30 +++++++++++++++++++ .../test/session/schema-decoding.test.ts | 20 +++++++++++++ .../test/session/snapshot-tool-race.test.ts | 2 +- packages/sdk/js/src/v2/gen/types.gen.ts | 2 +- packages/ui/src/components/session-diff.ts | 3 +- packages/ui/src/components/session-review.tsx | 9 ++++-- packages/ui/src/components/session-turn.tsx | 11 +++++-- 12 files changed, 88 insertions(+), 15 deletions(-) diff --git a/packages/app/src/pages/session/session-side-panel.tsx b/packages/app/src/pages/session/session-side-panel.tsx index 99197f0a70..66f5269bf9 100644 --- a/packages/app/src/pages/session/session-side-panel.tsx +++ b/packages/app/src/pages/session/session-side-panel.tsx @@ -28,6 +28,12 @@ import { createOpenSessionFileTab, createSessionTabs, getTabReorderIndex, type S import { setSessionHandoff } from "@/pages/session/handoff" import { useSessionLayout } from "@/pages/session/session-layout" +type RenderDiff = (SnapshotFileDiff & { file: string }) | VcsFileDiff + +function renderDiff(value: SnapshotFileDiff | VcsFileDiff): value is RenderDiff { + return typeof value.file === "string" +} + export function SessionSidePanel(props: { canReview: () => boolean diffs: () => (SnapshotFileDiff | VcsFileDiff)[] @@ -70,7 +76,8 @@ export function SessionSidePanel(props: { }) const treeWidth = createMemo(() => (fileOpen() ? `${layout.fileTree.width()}px` : "0px")) - const diffFiles = createMemo(() => props.diffs().map((d) => d.file)) + const diffs = createMemo(() => props.diffs().filter(renderDiff)) + const diffFiles = createMemo(() => diffs().map((d) => d.file)) const kinds = createMemo(() => { const merge = (a: "add" | "del" | "mix" | undefined, b: "add" | "del" | "mix") => { if (!a) return b @@ -81,7 +88,7 @@ export function SessionSidePanel(props: { const normalize = (p: string) => p.replaceAll("\\\\", "/").replace(/\/+$/, "") const out = new Map() - for (const diff of props.diffs()) { + for (const diff of diffs()) { const file = normalize(diff.file) const kind = diff.status === "added" ? "add" : diff.status === "deleted" ? "del" : "mix" diff --git a/packages/opencode/src/cli/cmd/export.ts b/packages/opencode/src/cli/cmd/export.ts index d1f9cd8d7d..9eb1faffea 100644 --- a/packages/opencode/src/cli/cmd/export.ts +++ b/packages/opencode/src/cli/cmd/export.ts @@ -23,10 +23,10 @@ function span(id: string, value: { value: string; start: number; end: number }) } } -function diff(kind: string, diffs: { file: string; patch?: string }[] | undefined) { +function diff(kind: string, diffs: { file?: string; patch?: string }[] | undefined) { return diffs?.map((item, i) => ({ ...item, - file: redact(`${kind}-file`, String(i), item.file), + file: item.file === undefined ? undefined : redact(`${kind}-file`, String(i), item.file), patch: item.patch === undefined ? undefined : redact(`${kind}-patch`, String(i), item.patch), })) } diff --git a/packages/opencode/src/cli/cmd/tui/plugin/api.tsx b/packages/opencode/src/cli/cmd/tui/plugin/api.tsx index 6015150ec2..54059f4a2d 100644 --- a/packages/opencode/src/cli/cmd/tui/plugin/api.tsx +++ b/packages/opencode/src/cli/cmd/tui/plugin/api.tsx @@ -148,7 +148,9 @@ function stateApi(sync: ReturnType): TuiPluginApi["state"] { return sync.data.session.length }, diff(sessionID) { - return sync.data.session_diff[sessionID] ?? [] + return (sync.data.session_diff[sessionID] ?? []).flatMap((item) => + item.file === undefined ? [] : [{ ...item, file: item.file }], + ) }, todo(sessionID) { return sync.data.todo[sessionID] ?? [] diff --git a/packages/opencode/src/session/summary.ts b/packages/opencode/src/session/summary.ts index d5e52b91e9..e39bd85e9a 100644 --- a/packages/opencode/src/session/summary.ts +++ b/packages/opencode/src/session/summary.ts @@ -134,6 +134,7 @@ export const layer = Layer.effect( .read(["session_diff", input.sessionID]) .pipe(Effect.catch(() => Effect.succeed([] as Snapshot.FileDiff[]))) const next = diffs.map((item) => { + if (item.file === undefined) return item const file = unquoteGitPath(item.file) if (file === item.file) return item return { ...item, file } diff --git a/packages/opencode/src/snapshot/index.ts b/packages/opencode/src/snapshot/index.ts index 8c8fd9156a..f54794a8a9 100644 --- a/packages/opencode/src/snapshot/index.ts +++ b/packages/opencode/src/snapshot/index.ts @@ -20,10 +20,10 @@ export const Patch = Schema.Struct({ export type Patch = typeof Patch.Type export const FileDiff = Schema.Struct({ - file: Schema.String, // Optional because legacy/imported `summary_diffs` on disk may omit - // the patch text (see #26574). Required-Schema rejected the whole - // /session//diff response and broke session loading on Desktop. + // file details and patch text. Required Schema rejected the whole + // session response and broke session loading on Desktop. + file: Schema.optional(Schema.String), patch: Schema.optional(Schema.String), additions: NonNegativeInt, deletions: NonNegativeInt, diff --git a/packages/opencode/test/server/httpapi-session.test.ts b/packages/opencode/test/server/httpapi-session.test.ts index d646b35fcb..24c845183d 100644 --- a/packages/opencode/test/server/httpapi-session.test.ts +++ b/packages/opencode/test/server/httpapi-session.test.ts @@ -297,6 +297,36 @@ describe("session HttpApi", () => { ), ) + it.live( + "serves sessions with migrated summary diffs missing file details", + withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) => + Effect.gen(function* () { + const session = yield* createSession(tmp.path, { title: "legacy diff" }) + yield* Effect.sync(() => + Database.use((db) => + db + .update(SessionTable) + .set({ + summary_additions: 1, + summary_deletions: 0, + summary_files: 1, + summary_diffs: [{ additions: 1, deletions: 0 }], + }) + .where(eq(SessionTable.id, session.id)) + .run(), + ), + ) + + const response = yield* request(pathFor(SessionPaths.get, { sessionID: session.id }), { + headers: { "x-opencode-directory": tmp.path }, + }) + + expect(response.status).toBe(200) + expect((yield* json(response)).summary?.diffs).toEqual([{ additions: 1, deletions: 0 }]) + }), + ), + ) + it.live( "serves lifecycle mutation routes", withTmp({ git: true, config: { formatter: false, lsp: false, share: "disabled" } }, (tmp) => diff --git a/packages/opencode/test/session/schema-decoding.test.ts b/packages/opencode/test/session/schema-decoding.test.ts index bee2184e5b..e9628ce49f 100644 --- a/packages/opencode/test/session/schema-decoding.test.ts +++ b/packages/opencode/test/session/schema-decoding.test.ts @@ -83,6 +83,26 @@ describe("Session.Info", () => { expect(Session.Info.zod.parse(input)).toEqual(input) }) + test("accepts migrated summary diffs without file details", () => { + const input = { + id: sessionID, + slug: "legacy-diff", + projectID, + directory: "/tmp/proj", + title: "Legacy diff", + version: "0.1.0", + summary: { + additions: 1, + deletions: 0, + files: 1, + diffs: [{ additions: 1, deletions: 0 }], + }, + time: { created: 1, updated: 2 }, + } + expect(decode(input)).toEqual(input) + expect(Session.Info.zod.parse(input)).toEqual(input) + }) + test("rejects unbranded session id", () => { const bad = { id: "not-a-session-id" } as unknown expect(() => decode(bad)).toThrow() diff --git a/packages/opencode/test/session/snapshot-tool-race.test.ts b/packages/opencode/test/session/snapshot-tool-race.test.ts index 82b88a72fd..671f62145c 100644 --- a/packages/opencode/test/session/snapshot-tool-race.test.ts +++ b/packages/opencode/test/session/snapshot-tool-race.test.ts @@ -238,7 +238,7 @@ it.live("tool execution produces non-empty session diff (snapshot race)", () => expect(tool?.state.status).toBe("completed") // Poll for diff — summarize() is fire-and-forget - let diff: Array<{ file: string }> = [] + let diff: Array<{ file?: string }> = [] for (let i = 0; i < 50; i++) { diff = yield* summary.diff({ sessionID: session.id }) if (diff.length > 0) break diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 5a79ae2661..0d8ab61179 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -119,7 +119,7 @@ export type PermissionRequest = { } export type SnapshotFileDiff = { - file: string + file?: string patch?: string additions: number deletions: number diff --git a/packages/ui/src/components/session-diff.ts b/packages/ui/src/components/session-diff.ts index bd6bed88d8..60dcffd83d 100644 --- a/packages/ui/src/components/session-diff.ts +++ b/packages/ui/src/components/session-diff.ts @@ -12,7 +12,8 @@ type LegacyDiff = { status?: "added" | "deleted" | "modified" } -type ReviewDiff = SnapshotFileDiff | VcsFileDiff | LegacyDiff +type SnapshotDiff = SnapshotFileDiff & { file: string } +type ReviewDiff = SnapshotDiff | VcsFileDiff | LegacyDiff export type ViewDiff = { file: string diff --git a/packages/ui/src/components/session-review.tsx b/packages/ui/src/components/session-review.tsx index 949402f439..1089587ee1 100644 --- a/packages/ui/src/components/session-review.tsx +++ b/packages/ui/src/components/session-review.tsx @@ -62,7 +62,12 @@ export type SessionReviewCommentActions = { export type SessionReviewFocus = { file: string; id: string } -type ReviewDiff = (SnapshotFileDiff | VcsFileDiff) & { preloaded?: PreloadMultiFileDiffResult } +type RawReviewDiff = (SnapshotFileDiff | VcsFileDiff) & { + preloaded?: PreloadMultiFileDiffResult +} +type ReviewDiff = ((SnapshotFileDiff & { file: string }) | VcsFileDiff) & { + preloaded?: PreloadMultiFileDiffResult +} type Item = ViewDiff & { preloaded?: PreloadMultiFileDiffResult } function diff(value: unknown): value is ReviewDiff { @@ -108,7 +113,7 @@ export interface SessionReviewProps { classList?: Record classes?: { root?: string; header?: string; container?: string } actions?: JSX.Element - diffs: ReviewDiff[] + diffs: RawReviewDiff[] onViewFile?: (file: string) => void readFile?: (path: string) => Promise lineCommentMention?: LineCommentEditorProps["mention"] diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index b35f718ef0..a39b9a7f64 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -90,6 +90,12 @@ function list(value: T[] | undefined | null, fallback: T[]) { return fallback } +type SummaryDiff = SnapshotFileDiff & { file: string } + +function summaryDiff(value: SnapshotFileDiff): value is SummaryDiff { + return typeof value.file === "string" +} + const hidden = new Set(["todowrite"]) function partState(part: PartType, showReasoningSummaries: boolean) { @@ -169,7 +175,7 @@ export function SessionTurn( const emptyMessages: MessageType[] = [] const emptyParts: PartType[] = [] const emptyAssistant: AssistantMessage[] = [] - const emptyDiffs: SnapshotFileDiff[] = [] + const emptyDiffs: SummaryDiff[] = [] const idle = { type: "idle" as const } const allMessages = createMemo(() => props.messages ?? list(data.store.message?.[props.sessionID], emptyMessages)) @@ -238,7 +244,8 @@ export function SessionTurn( const seen = new Set() return files - .reduceRight((result, diff) => { + .reduceRight((result, diff) => { + if (!summaryDiff(diff)) return result if (seen.has(diff.file)) return result seen.add(diff.file) result.push(diff) From c61ab518865638192f091ee9b915a427ceaf37ec Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sat, 9 May 2026 20:45:29 +0000 Subject: [PATCH 086/189] chore: generate --- packages/sdk/openapi.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 32c9a0922a..270749dc1a 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -9120,7 +9120,7 @@ "enum": ["added", "deleted", "modified"] } }, - "required": ["file", "additions", "deletions"], + "required": ["additions", "deletions"], "additionalProperties": false }, "ProviderAuthError": { From 43b51f09d095aadb3d851fde51031369be30d23f Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 9 May 2026 16:50:30 -0400 Subject: [PATCH 087/189] fix(httpapi): align runtime query schemas with workspace routing params (#26581) Co-authored-by: Developer --- .../instance/httpapi/groups/experimental.ts | 5 +- .../routes/instance/httpapi/groups/file.ts | 6 +- .../instance/httpapi/groups/instance.ts | 3 +- .../routes/instance/httpapi/groups/pty.ts | 7 +- .../routes/instance/httpapi/groups/session.ts | 15 +- .../httpapi/middleware/workspace-routing.ts | 12 +- .../server/httpapi-query-schema-drift.test.ts | 134 ++++++++++++++++++ 7 files changed, 167 insertions(+), 15 deletions(-) create mode 100644 packages/opencode/test/server/httpapi-query-schema-drift.test.ts diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts index 0c11c6c472..d2f3c65ad2 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts @@ -8,7 +8,7 @@ import { Schema, SchemaGetter } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "../middleware/authorization" import { InstanceContextMiddleware } from "../middleware/instance-context" -import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" +import { WorkspaceRoutingMiddleware, WorkspaceRoutingQueryFields } from "../middleware/workspace-routing" import { described } from "./metadata" const ConsoleStateResponse = Schema.Struct({ @@ -43,6 +43,7 @@ const ToolListItem = Schema.Struct({ }).annotate({ identifier: "ToolListItem" }) const ToolList = Schema.Array(ToolListItem).annotate({ identifier: "ToolList" }) export const ToolListQuery = Schema.Struct({ + ...WorkspaceRoutingQueryFields, provider: ProviderID, model: ModelID, }) @@ -55,7 +56,7 @@ const QueryBoolean = Schema.Literals(["true", "false"]).pipe( ) const WorktreeList = Schema.Array(Schema.String) export const SessionListQuery = Schema.Struct({ - directory: Schema.optional(Schema.String), + ...WorkspaceRoutingQueryFields, roots: Schema.optional(QueryBoolean), start: Schema.optional(Schema.NumberFromString), cursor: Schema.optional(Schema.NumberFromString), diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/file.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/file.ts index b950adb383..fe0b0b617c 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/file.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/file.ts @@ -5,18 +5,21 @@ import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "../middleware/authorization" import { InstanceContextMiddleware } from "../middleware/instance-context" -import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" +import { WorkspaceRoutingMiddleware, WorkspaceRoutingQueryFields } from "../middleware/workspace-routing" import { described } from "./metadata" export const FileQuery = Schema.Struct({ + ...WorkspaceRoutingQueryFields, path: Schema.String, }) export const FindTextQuery = Schema.Struct({ + ...WorkspaceRoutingQueryFields, pattern: Schema.String, }) export const FindFileQuery = Schema.Struct({ + ...WorkspaceRoutingQueryFields, query: Schema.String, dirs: Schema.optional(Schema.Literals(["true", "false"])), type: Schema.optional(Schema.Literals(["file", "directory"])), @@ -26,6 +29,7 @@ export const FindFileQuery = Schema.Struct({ }) export const FindSymbolQuery = Schema.Struct({ + ...WorkspaceRoutingQueryFields, query: Schema.String, }) diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/instance.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/instance.ts index f2b0504a05..5c45cd5c15 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/instance.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/instance.ts @@ -8,7 +8,7 @@ import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "../middleware/authorization" import { InstanceContextMiddleware } from "../middleware/instance-context" -import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" +import { WorkspaceRoutingMiddleware, WorkspaceRoutingQueryFields } from "../middleware/workspace-routing" import { described } from "./metadata" const PathInfo = Schema.Struct({ @@ -20,6 +20,7 @@ const PathInfo = Schema.Struct({ }).annotate({ identifier: "Path" }) export const VcsDiffQuery = Schema.Struct({ + ...WorkspaceRoutingQueryFields, mode: Vcs.Mode, }) diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/pty.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/pty.ts index ad513e0ad4..17f5890f1e 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/pty.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/pty.ts @@ -5,13 +5,16 @@ import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "../middleware/authorization" import { InstanceContextMiddleware } from "../middleware/instance-context" -import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" +import { WorkspaceRoutingMiddleware, WorkspaceRoutingQueryFields } from "../middleware/workspace-routing" import { ApiNotFoundError } from "../errors" import { described } from "./metadata" const root = "/pty" export const Params = Schema.Struct({ ptyID: PtyID }) -export const CursorQuery = Schema.Struct({ cursor: Schema.optional(Schema.String) }) +export const CursorQuery = Schema.Struct({ + ...WorkspaceRoutingQueryFields, + cursor: Schema.optional(Schema.String), +}) export const ShellItem = Schema.Struct({ path: Schema.String, name: Schema.String, diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts index 5dd8f8fabc..f1dc3697b9 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts @@ -14,7 +14,7 @@ import { Schema, SchemaGetter, Struct } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "../middleware/authorization" import { InstanceContextMiddleware } from "../middleware/instance-context" -import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" +import { WorkspaceRoutingMiddleware, WorkspaceRoutingQueryFields } from "../middleware/workspace-routing" import { ApiNotFoundError } from "../errors" import { described } from "./metadata" @@ -25,12 +25,8 @@ const QueryBoolean = Schema.Literals(["true", "false"]).pipe( encode: SchemaGetter.transform((value) => (value ? "true" : "false")), }), ) -const WorkspaceRoutingQuery = { - directory: Schema.optional(Schema.String), - workspace: Schema.optional(Schema.String), -} export const ListQuery = Schema.Struct({ - ...WorkspaceRoutingQuery, + ...WorkspaceRoutingQueryFields, scope: Schema.optional(Schema.Literals(["project"])), path: Schema.optional(Schema.String), roots: Schema.optional(QueryBoolean), @@ -38,9 +34,12 @@ export const ListQuery = Schema.Struct({ search: Schema.optional(Schema.String), limit: Schema.optional(Schema.NumberFromString), }) -export const DiffQuery = Schema.Struct(Struct.omit(SessionSummary.DiffInput.fields, ["sessionID"])) +export const DiffQuery = Schema.Struct({ + ...WorkspaceRoutingQueryFields, + ...Struct.omit(SessionSummary.DiffInput.fields, ["sessionID"]), +}) export const MessagesQuery = Schema.Struct({ - ...WorkspaceRoutingQuery, + ...WorkspaceRoutingQueryFields, limit: Schema.optional(Schema.NumberFromString.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(0))), before: Schema.optional(Schema.String), }) diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts index 8ec9f74860..fd5d534bbc 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts @@ -9,11 +9,21 @@ import * as Fence from "@/server/shared/fence" import { getWorkspaceRouteSessionID, isLocalWorkspaceRoute, workspaceProxyURL } from "@/server/shared/workspace-routing" import { NotFoundError } from "@/storage/storage" import { Flag } from "@opencode-ai/core/flag/flag" -import { Context, Data, Effect, Layer } from "effect" +import { Context, Data, Effect, Layer, Schema } from "effect" import { HttpClient, HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" import { HttpApiMiddleware } from "effect/unstable/httpapi" import * as Socket from "effect/unstable/socket/Socket" +// Query fields this middleware reads from the URL. Spread into every +// endpoint query schema in groups that apply WorkspaceRoutingMiddleware, +// otherwise HttpApi rejects requests carrying these params with 400. +// HttpApiMiddleware in effect-smol cannot declare query params today — +// remove this once upstream supports middleware-declared query schemas. +export const WorkspaceRoutingQueryFields = { + directory: Schema.optional(Schema.String), + workspace: Schema.optional(Schema.String), +} + type RemoteTarget = Extract type RequestPlan = Data.TaggedEnum<{ diff --git a/packages/opencode/test/server/httpapi-query-schema-drift.test.ts b/packages/opencode/test/server/httpapi-query-schema-drift.test.ts new file mode 100644 index 0000000000..68daeca1e9 --- /dev/null +++ b/packages/opencode/test/server/httpapi-query-schema-drift.test.ts @@ -0,0 +1,134 @@ +import { afterEach, describe, expect } from "bun:test" +import { Effect } from "effect" +import { Flag } from "@opencode-ai/core/flag/flag" +import { Server } from "../../src/server/server" +import { SessionID } from "../../src/session/schema" +import { resetDatabase } from "../fixture/db" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" +import { it } from "../lib/effect" + +const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES + +function app() { + return Server.Default().app +} + +function request(url: string, init?: RequestInit) { + return Effect.promise(async () => app().request(url, init)) +} + +function withTmp( + options: Parameters[0], + fn: (tmp: Awaited>) => Effect.Effect, +) { + return Effect.acquireRelease( + Effect.promise(() => tmpdir(options)), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ).pipe(Effect.flatMap(fn)) +} + +afterEach(async () => { + Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces + await disposeAllInstances() + await resetDatabase() +}) + +// Regression for the "OpenAPI advertises ?directory&workspace, runtime +// rejects them" drift class. Each affected route must accept both params +// without 400. +describe("httpapi query schema drift", () => { + const routingParams = (dir: string) => + `directory=${encodeURIComponent(dir)}&workspace=${encodeURIComponent("ws_test")}` + + const expectNotSchemaRejection = (status: number, url: string) => { + expect(status, `route ${url} 400'd, query schema is missing routing fields`).not.toBe(400) + } + + it.live( + "session list accepts directory and workspace", + withTmp({ config: { formatter: false, lsp: false } }, (tmp) => + Effect.gen(function* () { + const url = `/session?${routingParams(tmp.path)}` + const response = yield* request(url) + expectNotSchemaRejection(response.status, url) + }), + ), + ) + + it.live( + "session messages accepts directory and workspace", + withTmp({ config: { formatter: false, lsp: false } }, (tmp) => + Effect.gen(function* () { + const url = `/session/${SessionID.descending()}/message?limit=80&${routingParams(tmp.path)}` + const response = yield* request(url) + expectNotSchemaRejection(response.status, url) + }), + ), + ) + + it.live( + "file find/file accepts directory and workspace", + withTmp({ config: { formatter: false, lsp: false } }, (tmp) => + Effect.gen(function* () { + const url = `/find/file?query=foo&${routingParams(tmp.path)}` + const response = yield* request(url) + expectNotSchemaRejection(response.status, url) + }), + ), + ) + + it.live( + "file find/text accepts directory and workspace", + withTmp({ config: { formatter: false, lsp: false } }, (tmp) => + Effect.gen(function* () { + const url = `/find?pattern=foo&${routingParams(tmp.path)}` + const response = yield* request(url) + expectNotSchemaRejection(response.status, url) + }), + ), + ) + + it.live( + "file read accepts directory and workspace", + withTmp({ config: { formatter: false, lsp: false } }, (tmp) => + Effect.gen(function* () { + const url = `/file?path=foo&${routingParams(tmp.path)}` + const response = yield* request(url) + expectNotSchemaRejection(response.status, url) + }), + ), + ) + + it.live( + "experimental session list accepts directory and workspace", + withTmp({ config: { formatter: false, lsp: false } }, (tmp) => + Effect.gen(function* () { + const url = `/experimental/session?${routingParams(tmp.path)}` + const response = yield* request(url) + expectNotSchemaRejection(response.status, url) + }), + ), + ) + + it.live( + "experimental tool list accepts directory and workspace", + withTmp({ config: { formatter: false, lsp: false } }, (tmp) => + Effect.gen(function* () { + const url = `/experimental/tool?provider=anthropic&model=claude&${routingParams(tmp.path)}` + const response = yield* request(url) + expectNotSchemaRejection(response.status, url) + }), + ), + ) + + it.live( + "vcs diff accepts directory and workspace", + withTmp({ config: { formatter: false, lsp: false } }, (tmp) => + Effect.gen(function* () { + const url = `/vcs/diff?mode=working&${routingParams(tmp.path)}` + const response = yield* request(url) + expectNotSchemaRejection(response.status, url) + }), + ), + ) +}) From bd1029b19fd7e360359c0bc3b5f5161fe769fb7e Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 9 May 2026 18:46:10 -0400 Subject: [PATCH 088/189] test(server): cover HttpApi context inheritance Adds regression coverage for request context inheritance in promptAsync and explicit context provisioning in stream bodies. --- .../httpapi-promptasync-context.test.ts | 191 ++++++++++++++++++ 1 file changed, 191 insertions(+) create mode 100644 packages/opencode/test/server/httpapi-promptasync-context.test.ts diff --git a/packages/opencode/test/server/httpapi-promptasync-context.test.ts b/packages/opencode/test/server/httpapi-promptasync-context.test.ts new file mode 100644 index 0000000000..106e549686 --- /dev/null +++ b/packages/opencode/test/server/httpapi-promptasync-context.test.ts @@ -0,0 +1,191 @@ +// Regression coverage for issue #26526's claim that promptAsync's +// Effect.forkIn loses the request's InstanceRef/WorkspaceRef. It does not — +// forkIn preserves Context.Reference values via standard fiber inheritance. +// +// The companion claim that the streaming prompt handler "captures and +// provides" those services is true and load-bearing: Stream.fromEffect's +// body runs detached from the request fiber's context, so the explicit +// Effect.provideService calls there are required, not defensive duplication. + +import { NodeHttpServer, NodeServices } from "@effect/platform-node" +import { Flag } from "@opencode-ai/core/flag/flag" +import { describe, expect } from "bun:test" +import { Deferred, Effect, Layer, Scope } from "effect" +import * as Stream from "effect/Stream" +import { HttpClient, HttpRouter, HttpServerResponse } from "effect/unstable/http" +import * as Socket from "effect/unstable/socket/Socket" +import { mkdir } from "node:fs/promises" +import { registerAdapter } from "../../src/control-plane/adapters" +import type { WorkspaceAdapter } from "../../src/control-plane/types" +import { Workspace } from "../../src/control-plane/workspace" +import { InstanceRef, WorkspaceRef } from "../../src/effect/instance-ref" +import { InstanceBootstrap } from "../../src/project/bootstrap" +import { InstanceLayer } from "../../src/project/instance-layer" +import { InstanceStore } from "../../src/project/instance-store" +import { Project } from "../../src/project/project" +import { instanceRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/instance-context" +import { workspaceRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/workspace-routing" +import { resetDatabase } from "../fixture/db" +import { disposeAllInstances, tmpdirScoped } from "../fixture/fixture" +import { testEffect } from "../lib/effect" + +const testStateLayer = Layer.effectDiscard( + Effect.gen(function* () { + const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES + yield* Effect.promise(() => resetDatabase()) + Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true + yield* Effect.addFinalizer(() => + Effect.promise(async () => { + Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces + await disposeAllInstances() + await resetDatabase() + }), + ) + }), +) + +const workspaceLayer = Workspace.defaultLayer.pipe( + Layer.provide(InstanceStore.defaultLayer), + Layer.provide(InstanceBootstrap.defaultLayer), +) + +const it = testEffect( + Layer.mergeAll( + testStateLayer, + NodeHttpServer.layerTest, + NodeServices.layer, + InstanceLayer.layer, + Project.defaultLayer, + workspaceLayer, + ), +) + +const instanceContextTestLayer = instanceRouterMiddleware + .combine(workspaceRouterMiddleware) + .layer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal)) + +const localAdapter = (directory: string): WorkspaceAdapter => ({ + name: "Local Test", + description: "Create a local test workspace", + configure: (info) => ({ ...info, name: "local-test", directory }), + create: async () => { + await mkdir(directory, { recursive: true }) + }, + async remove() {}, + target: () => ({ type: "local" as const, directory }), +}) + +const setupWorkspace = (kind: string) => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + yield* Project.use.fromDirectory(dir) + const projectID = yield* Project.Service.use((svc) => + svc.fromDirectory(dir).pipe(Effect.map((p) => p.project.id)), + ) + registerAdapter(projectID, kind, localAdapter(dir)) + const workspace = yield* Workspace.Service.use((svc) => + svc.create({ type: kind, branch: null, extra: null, projectID }), + ) + return { dir, workspace } + }) + +type Capture = { directory?: string; workspaceID?: string } + +const captureInstance = Effect.gen(function* () { + const instance = yield* InstanceRef + const workspaceID = yield* WorkspaceRef + return { directory: instance?.directory, workspaceID } satisfies Capture +}) + +describe("HttpApi handler context inheritance", () => { + // Mirrors handlers/session.ts:281 promptAsync. The forked fiber inherits + // the request's Context — including InstanceRef and WorkspaceRef provided + // by InstanceContextMiddleware — without any explicit re-provide. + it.live("Effect.forkIn preserves InstanceRef/WorkspaceRef across the fork", () => + Effect.gen(function* () { + const { dir, workspace } = yield* setupWorkspace("local-fork") + const capture = yield* Deferred.make() + + yield* HttpRouter.add( + "POST", + "/fork-probe", + Effect.gen(function* () { + const scope = yield* Scope.Scope + yield* Effect.gen(function* () { + yield* Deferred.succeed(capture, yield* captureInstance) + }).pipe(Effect.forkIn(scope, { startImmediately: true })) + return HttpServerResponse.empty({ status: 204 }) + }), + ).pipe(Layer.provide(instanceContextTestLayer), HttpRouter.serve, Layer.build) + + const response = yield* HttpClient.post( + `/fork-probe?directory=${encodeURIComponent(dir)}&workspace=${encodeURIComponent(workspace.id)}`, + ) + expect(response.status).toBe(204) + + const observed = yield* Deferred.await(capture).pipe(Effect.timeout("2 seconds")) + expect(observed.directory).toBe(dir) + expect(observed.workspaceID).toBe(workspace.id) + }), + ) + + // Mirrors handlers/session.ts:255 prompt — the streaming handler reads + // InstanceRef/WorkspaceRef in the request fiber and re-provides them to + // the Stream.fromEffect body. This test locks in why the explicit + // provides are required: without them the stream body sees undefined. + it.live("Stream.fromEffect body needs explicit provides — inheritance does not carry through", () => + Effect.gen(function* () { + const { dir, workspace } = yield* setupWorkspace("local-stream") + const withoutCapture = yield* Deferred.make() + const withCapture = yield* Deferred.make() + + yield* HttpRouter.add( + "POST", + "/stream-probe-without", + Effect.gen(function* () { + return HttpServerResponse.stream( + Stream.fromEffect( + Effect.gen(function* () { + yield* Deferred.succeed(withoutCapture, yield* captureInstance) + return "" + }), + ).pipe(Stream.encodeText), + { contentType: "application/json" }, + ) + }), + ).pipe(Layer.provide(instanceContextTestLayer), HttpRouter.serve, Layer.build) + + yield* HttpRouter.add( + "POST", + "/stream-probe-with", + Effect.gen(function* () { + const instance = yield* InstanceRef + const workspaceID = yield* WorkspaceRef + return HttpServerResponse.stream( + Stream.fromEffect( + Effect.gen(function* () { + yield* Deferred.succeed(withCapture, yield* captureInstance) + return "" + }).pipe(Effect.provideService(InstanceRef, instance), Effect.provideService(WorkspaceRef, workspaceID)), + ).pipe(Stream.encodeText), + { contentType: "application/json" }, + ) + }), + ).pipe(Layer.provide(instanceContextTestLayer), HttpRouter.serve, Layer.build) + + const queryString = `directory=${encodeURIComponent(dir)}&workspace=${encodeURIComponent(workspace.id)}` + const responseWithout = yield* HttpClient.post(`/stream-probe-without?${queryString}`) + yield* responseWithout.text + const responseWith = yield* HttpClient.post(`/stream-probe-with?${queryString}`) + yield* responseWith.text + + const without = yield* Deferred.await(withoutCapture).pipe(Effect.timeout("2 seconds")) + expect(without.directory).toBeUndefined() + expect(without.workspaceID).toBeUndefined() + + const withProvide = yield* Deferred.await(withCapture).pipe(Effect.timeout("2 seconds")) + expect(withProvide.directory).toBe(dir) + expect(withProvide.workspaceID).toBe(workspace.id) + }), + ) +}) From ba9e4b67ed2f14c1043f483b070be58889c8714a Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 9 May 2026 18:46:29 -0400 Subject: [PATCH 089/189] fix(tool/read): match permission patterns against worktree-relative path Read permission checks now use the same worktree-relative path basis as edit/write/apply_patch, so configured patterns apply consistently. --- packages/opencode/src/tool/read.ts | 2 +- packages/opencode/test/tool/read.test.ts | 15 ++++++++++++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts index 4b5ba5efed..7ade166c5f 100644 --- a/packages/opencode/src/tool/read.ts +++ b/packages/opencode/src/tool/read.ts @@ -178,7 +178,7 @@ export const ReadTool = Tool.define( yield* ctx.ask({ permission: "read", - patterns: [filepath], + patterns: [path.relative(instance.worktree, filepath)], always: ["*"], metadata: {}, }) diff --git a/packages/opencode/test/tool/read.test.ts b/packages/opencode/test/tool/read.test.ts index 695d96ec2f..969364bad9 100644 --- a/packages/opencode/test/tool/read.test.ts +++ b/packages/opencode/test/tool/read.test.ts @@ -155,11 +155,24 @@ describe("tool.read external_directory permission", () => { yield* exec(dir, { filePath: alt }, next) const read = items.find((item) => item.permission === "read") expect(read).toBeDefined() - expect(read!.patterns).toEqual([full(target)]) + expect(read!.patterns).toEqual([path.relative(dir, full(target))]) }), ) } + it.live("uses worktree-relative path for read permission so user rules match like edit/write", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + yield* put(path.join(dir, "src", "secret.ts"), "shh") + + const { items, next } = asks() + yield* exec(dir, { filePath: path.join(dir, "src", "secret.ts") }, next) + const read = items.find((item) => item.permission === "read") + expect(read).toBeDefined() + expect(read!.patterns).toEqual([path.join("src", "secret.ts")]) + }), + ) + it.live("asks for directory-scoped external_directory permission when reading external directory", () => Effect.gen(function* () { const outer = yield* tmpdirScoped() From 11363170ca81b07e3f44e1bb4f316e8de05de7d0 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 9 May 2026 18:46:43 -0400 Subject: [PATCH 090/189] fix(sdk): wrap thrown error bodies in Error SDK throwOnError paths now convert structured response bodies into real Error instances while preserving the original body and status in cause. --- .../opencode/test/server/httpapi-sdk.test.ts | 10 ++- .../test/server/sdk-error-shape.test.ts | 74 +++++++++++++++++++ packages/sdk/js/src/client.ts | 2 + packages/sdk/js/src/error-interceptor.ts | 51 +++++++++++++ packages/sdk/js/src/v2/client.ts | 21 +----- 5 files changed, 138 insertions(+), 20 deletions(-) create mode 100644 packages/opencode/test/server/sdk-error-shape.test.ts create mode 100644 packages/sdk/js/src/error-interceptor.ts diff --git a/packages/opencode/test/server/httpapi-sdk.test.ts b/packages/opencode/test/server/httpapi-sdk.test.ts index 8a179a4dcc..0201f98c25 100644 --- a/packages/opencode/test/server/httpapi-sdk.test.ts +++ b/packages/opencode/test/server/httpapi-sdk.test.ts @@ -394,8 +394,16 @@ describe("HttpApi SDK", () => { const missing = yield* capture(() => sdk.session.get({ sessionID })) const thrown = yield* captureThrown(() => sdk.session.get({ sessionID }, { throwOnError: true })) + // Result-tuple path: error body is preserved as-is so existing + // consumers reading `result.error.name` / `JSON.stringify(error)` + // keep working byte-for-byte. expect(missing.error).toEqual(expected) - expect(thrown).toEqual(expected) + // throwOnError path: SDK wraps the body in a real Error with the + // server's message, with the original parsed body preserved under + // `.cause.body`. + expect(thrown).toBeInstanceOf(Error) + expect((thrown as Error).message).toBe(expected.data.message) + expect(((thrown as Error).cause as { body: unknown }).body).toEqual(expected) return { status: missing.status, error: missing.error, diff --git a/packages/opencode/test/server/sdk-error-shape.test.ts b/packages/opencode/test/server/sdk-error-shape.test.ts new file mode 100644 index 0000000000..30eedc9adb --- /dev/null +++ b/packages/opencode/test/server/sdk-error-shape.test.ts @@ -0,0 +1,74 @@ +/** + * Regression tests for the SDK error shape — the v2 SDK's `throwOnError: true` + * path used to throw raw values (empty strings or POJOs from JSON-decoded + * error bodies). The TUI catches those and `e.message`/`e.stack` are + * undefined, so users see `[object Object]` or a blank crash. + * + * Both cases must throw a real `Error` instance with a non-empty `.message` + * extracted from the response body, plus `.status` and `.body` attached. + */ +import { afterEach, describe, expect, test } from "bun:test" +import { createOpencodeClient } from "@opencode-ai/sdk/v2" +import { Server } from "../../src/server/server" +import * as Log from "@opencode-ai/core/util/log" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" +import { resetDatabase } from "../fixture/db" + +void Log.init({ print: false }) + +afterEach(async () => { + await disposeAllInstances() + await resetDatabase() +}) + +function client(directory: string) { + return createOpencodeClient({ + baseUrl: "http://test", + directory, + fetch: ((req: Request) => Server.Default().app.fetch(req)) as unknown as typeof fetch, + }) +} + +describe("v2 SDK error shape", () => { + test("404 with NamedError body throws a real Error carrying the server message", async () => { + await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) + const sdk = client(tmp.path) + + let caught: unknown + try { + await sdk.session.get({ sessionID: "ses_no_such" }, { throwOnError: true }) + } catch (e) { + caught = e + } + + expect(caught).toBeInstanceOf(Error) + const err = caught as Error + const cause = err.cause as { body?: any; status?: number } + expect(err.message).toContain("Session not found") + expect(cause.status).toBe(404) + expect(cause.body).toMatchObject({ + name: "NotFoundError", + data: { message: expect.stringContaining("Session not found") }, + }) + }) + + test("400 with empty body throws a real Error naming the status", async () => { + await using tmp = await tmpdir({ config: { formatter: false, lsp: false } }) + const sdk = client(tmp.path) + + let caught: unknown + try { + // POST /sync/history with `aggregate: -1` triggers schema validation + // that returns an empty 400 body (verified via plan-mode probe). + await sdk.sync.history.list({ aggregate: -1 } as any, { throwOnError: true }) + } catch (e) { + caught = e + } + + expect(caught).toBeInstanceOf(Error) + const err = caught as Error + const cause = err.cause as { status?: number } + expect(err.message.length).toBeGreaterThan(0) + expect(cause.status).toBe(400) + }) +}) diff --git a/packages/sdk/js/src/client.ts b/packages/sdk/js/src/client.ts index 05f4638252..5cf071e7b7 100644 --- a/packages/sdk/js/src/client.ts +++ b/packages/sdk/js/src/client.ts @@ -3,6 +3,7 @@ export * from "./gen/types.gen.js" import { createClient } from "./gen/client/client.gen.js" import { type Config } from "./gen/client/types.gen.js" import { OpencodeClient } from "./gen/sdk.gen.js" +import { wrapClientError } from "./error-interceptor.js" export { type Config as OpencodeClientConfig, OpencodeClient } function pick(value: string | null, fallback?: string) { @@ -51,5 +52,6 @@ export function createOpencodeClient(config?: Config & { directory?: string }) { const client = createClient(config) client.interceptors.request.use((request) => rewrite(request, config?.directory)) + client.interceptors.error.use(wrapClientError) return new OpencodeClient({ client }) } diff --git a/packages/sdk/js/src/error-interceptor.ts b/packages/sdk/js/src/error-interceptor.ts new file mode 100644 index 0000000000..26407ecfc9 --- /dev/null +++ b/packages/sdk/js/src/error-interceptor.ts @@ -0,0 +1,51 @@ +/** + * Wrap whatever the generated client decoded from a non-2xx error body + * into a real `Error` so downstream formatters (TUI, plugins) get a + * useful `.message` instead of `[object Object]` or blank. The original + * parsed body and status live under `.cause` for callers that need + * structured fields. + * + * Only fires when the caller used `{ throwOnError: true }`. Callers that + * read `result.error` directly (the result-tuple path) get the parsed + * body unchanged so existing field-level reads (`.error.name`, + * `JSON.stringify(error)`, etc.) are byte-for-byte identical to before. + */ +export function wrapClientError( + error: unknown, + response: Response | undefined, + request: Request | undefined, + opts: { throwOnError?: boolean } | undefined, +): unknown { + if (!opts?.throwOnError) return error + if (error instanceof Error) return error + + // NamedError-shaped responses (the common case for opencode 4xx) come + // through as POJOs — extract a useful message first, then wrap. + if (typeof error === "object" && error !== null && Object.keys(error).length > 0) { + const obj = error as { data?: { message?: unknown }; message?: unknown; name?: unknown } + const message = + (typeof obj.data?.message === "string" && obj.data.message) || + (typeof obj.message === "string" && obj.message) || + (typeof obj.name === "string" && obj.name) || + describe(request, response) + return new Error(message, { cause: { body: error, status: response?.status } }) + } + + if (typeof error === "string" && error.length > 0) { + return new Error(error, { cause: { body: error, status: response?.status } }) + } + + // Empty body / network failure / undefined / null / empty object. + const reason = response ? "(empty response body)" : "network error (no response)" + return new Error(`opencode server ${describe(request, response)}: ${reason}`, { + cause: { body: error, status: response?.status }, + }) +} + +function describe(request: Request | undefined, response: Response | undefined) { + const method = request?.method ?? "?" + const url = request?.url ?? "?" + const status = response?.status + const statusText = response?.statusText + return `${method} ${url}${status ? " → " + status : ""}${statusText ? " " + statusText : ""}` +} diff --git a/packages/sdk/js/src/v2/client.ts b/packages/sdk/js/src/v2/client.ts index 8b49e7f101..1c8afc0d64 100644 --- a/packages/sdk/js/src/v2/client.ts +++ b/packages/sdk/js/src/v2/client.ts @@ -3,6 +3,7 @@ export * from "./gen/types.gen.js" import { createClient } from "./gen/client/client.gen.js" import { type Config } from "./gen/client/types.gen.js" import { OpencodeClient } from "./gen/sdk.gen.js" +import { wrapClientError } from "../error-interceptor.js" export { type Config as OpencodeClientConfig, OpencodeClient } function pick(value: string | null, fallback?: string, encode?: (value: string) => string) { @@ -84,24 +85,6 @@ export function createOpencodeClient(config?: Config & { directory?: string; exp return response }) - // The generated client falls back to throwing a literal `{}` when the server - // responds with an empty / unparseable error body, which surfaces as a bare - // `{}` in TUI / CLI error output. Wrap ONLY that case in a real Error so - // downstream formatters get a useful message — but pass through any parsed - // JSON error body unchanged so existing consumers can still inspect fields. - client.interceptors.error.use((error, response, request) => { - const isEmpty = - error === undefined || - error === null || - error === "" || - (typeof error === "object" && !(error instanceof Error) && Object.keys(error).length === 0) - if (!isEmpty) return error - const method = request?.method ?? "?" - const url = request?.url ?? "?" - if (!response) return new Error(`opencode server ${method} ${url}: network error (no response)`) - const status = response.status - const statusText = response.statusText ? " " + response.statusText : "" - return new Error(`opencode server ${method} ${url} → ${status}${statusText}: (empty response body)`) - }) + client.interceptors.error.use(wrapClientError) return new OpencodeClient({ client }) } From 29b5b2478703805afc11d8e278fb3427bce3fe54 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 9 May 2026 18:46:52 -0400 Subject: [PATCH 091/189] fix(tui): aggregate bootstrap request failures TUI bootstrap now reports all parallel fetch failures together instead of losing sibling failures after the first rejection. --- .../cli/cmd/tui/context/aggregate-failures.ts | 34 +++++++++++ .../opencode/src/cli/cmd/tui/context/sync.tsx | 26 +++++--- .../cli/cmd/tui/aggregate-failures.test.ts | 59 +++++++++++++++++++ 3 files changed, 111 insertions(+), 8 deletions(-) create mode 100644 packages/opencode/src/cli/cmd/tui/context/aggregate-failures.ts create mode 100644 packages/opencode/test/cli/cmd/tui/aggregate-failures.test.ts diff --git a/packages/opencode/src/cli/cmd/tui/context/aggregate-failures.ts b/packages/opencode/src/cli/cmd/tui/context/aggregate-failures.ts new file mode 100644 index 0000000000..63b3fb4487 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/context/aggregate-failures.ts @@ -0,0 +1,34 @@ +/** + * Aggregate Promise.allSettled results into a single Error that names every + * failed endpoint, or return null when all fulfilled. Used at TUI bootstrap + * boundaries so a single 4xx doesn't drown its parallel siblings as + * unhandled rejections — every failure surfaces in one labeled message. + */ +export type LabeledSettled = { + name: string + result: PromiseSettledResult +} + +export function aggregateFailures(labeled: LabeledSettled[]): Error | null { + const failed = labeled.filter( + (x): x is { name: string; result: PromiseRejectedResult } => x.result.status === "rejected", + ) + if (failed.length === 0) return null + + const reasons = failed.map((f) => `${f.name}: ${reasonMessage(f.result.reason)}`).join("; ") + const summary = `${failed.length} of ${labeled.length} requests failed: ${reasons}` + const err = new Error(summary) + err.cause = { failures: failed.map((f) => ({ name: f.name, reason: f.result.reason })) } + return err +} + +function reasonMessage(reason: unknown): string { + if (reason instanceof Error) return reason.message + if (typeof reason === "string") return reason + if (reason && typeof reason === "object") { + const obj = reason as { message?: unknown; name?: unknown } + if (typeof obj.message === "string") return obj.message + if (typeof obj.name === "string") return obj.name + } + return String(reason) +} diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index 25ae4e2736..afdb05d838 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -32,6 +32,7 @@ import * as Log from "@opencode-ai/core/util/log" import { emptyConsoleState, type ConsoleState } from "@/config/console-state" import path from "path" import { useKV } from "./kv" +import { aggregateFailures } from "./aggregate-failures" export const { use: useSync, provider: SyncProvider } = createSimpleContext({ name: "Sync", @@ -391,16 +392,25 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ .catch(() => emptyConsoleState) const agentsPromise = sdk.client.app.agents({ workspace }, { throwOnError: true }) const configPromise = sdk.client.config.get({ workspace }, { throwOnError: true }) - const blockingRequests: Promise[] = [ - providersPromise, - providerListPromise, - agentsPromise, - configPromise, - projectPromise, - ...(args.continue ? [sessionListPromise] : []), + const blockingRequests: { name: string; promise: Promise }[] = [ + { name: "config.providers", promise: providersPromise }, + { name: "provider.list", promise: providerListPromise }, + { name: "app.agents", promise: agentsPromise }, + { name: "config.get", promise: configPromise }, + { name: "project.sync", promise: projectPromise }, + ...(args.continue ? [{ name: "session.list", promise: sessionListPromise }] : []), ] - await Promise.all(blockingRequests) + await Promise.allSettled(blockingRequests.map((r) => r.promise)) + .then((settled) => { + // Surface every failed endpoint in one labeled message instead of + // letting the first rejection drown its siblings as unhandled + // rejections. + const failure = aggregateFailures( + blockingRequests.map((r, i) => ({ name: r.name, result: settled[i] })), + ) + if (failure) throw failure + }) .then(async () => { const providersResponse = providersPromise.then((x) => x.data!) const providerListResponse = providerListPromise.then((x) => x.data!) diff --git a/packages/opencode/test/cli/cmd/tui/aggregate-failures.test.ts b/packages/opencode/test/cli/cmd/tui/aggregate-failures.test.ts new file mode 100644 index 0000000000..ae591e24fd --- /dev/null +++ b/packages/opencode/test/cli/cmd/tui/aggregate-failures.test.ts @@ -0,0 +1,59 @@ +/** + * Regression test for the TUI bootstrap aggregation helper. Replaces the + * pre-fix Promise.all behavior where the first rejection drowned every + * sibling endpoint's failure as an unhandled rejection. + */ +import { describe, expect, test } from "bun:test" +import { aggregateFailures } from "@/cli/cmd/tui/context/aggregate-failures" + +describe("aggregateFailures", () => { + test("returns null when every result is fulfilled", () => { + expect( + aggregateFailures([ + { name: "config", result: { status: "fulfilled", value: 1 } }, + { name: "providers", result: { status: "fulfilled", value: 2 } }, + ]), + ).toBeNull() + }) + + test("names the failed endpoint when one rejects", () => { + const err = aggregateFailures([ + { name: "config", result: { status: "fulfilled", value: 1 } }, + { + name: "providers", + result: { status: "rejected", reason: new Error("Service unavailable") }, + }, + ]) + expect(err).toBeInstanceOf(Error) + expect(err!.message).toContain("1 of 2") + expect(err!.message).toContain("providers: Service unavailable") + }) + + test("names every failed endpoint when multiple reject", () => { + const err = aggregateFailures([ + { name: "config", result: { status: "rejected", reason: new Error("400 Bad Request") } }, + { name: "providers", result: { status: "fulfilled", value: 1 } }, + { name: "agents", result: { status: "rejected", reason: { message: "boom" } } }, + ]) + expect(err).toBeInstanceOf(Error) + expect(err!.message).toContain("2 of 3") + expect(err!.message).toContain("config: 400 Bad Request") + expect(err!.message).toContain("agents: boom") + }) + + test("attaches structured failure list under .cause", () => { + const reason = new Error("nope") + const err = aggregateFailures([ + { name: "providers", result: { status: "rejected", reason } }, + ]) + const cause = err!.cause as { failures: Array<{ name: string; reason: unknown }> } + expect(cause.failures).toEqual([{ name: "providers", reason }]) + }) + + test("falls back to String() for opaque reasons", () => { + const err = aggregateFailures([ + { name: "x", result: { status: "rejected", reason: 42 } }, + ]) + expect(err!.message).toContain("x: 42") + }) +}) From 818b56dbd0a73e6906b8c62dd9ca9293a13d0bfa Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sat, 9 May 2026 22:47:54 +0000 Subject: [PATCH 092/189] chore: generate --- packages/opencode/src/cli/cmd/tui/context/sync.tsx | 4 +--- .../opencode/test/cli/cmd/tui/aggregate-failures.test.ts | 8 ++------ .../test/server/httpapi-promptasync-context.test.ts | 4 +--- 3 files changed, 4 insertions(+), 12 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index afdb05d838..0d4cb2e6e2 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -406,9 +406,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ // Surface every failed endpoint in one labeled message instead of // letting the first rejection drown its siblings as unhandled // rejections. - const failure = aggregateFailures( - blockingRequests.map((r, i) => ({ name: r.name, result: settled[i] })), - ) + const failure = aggregateFailures(blockingRequests.map((r, i) => ({ name: r.name, result: settled[i] }))) if (failure) throw failure }) .then(async () => { diff --git a/packages/opencode/test/cli/cmd/tui/aggregate-failures.test.ts b/packages/opencode/test/cli/cmd/tui/aggregate-failures.test.ts index ae591e24fd..c9b3551d9a 100644 --- a/packages/opencode/test/cli/cmd/tui/aggregate-failures.test.ts +++ b/packages/opencode/test/cli/cmd/tui/aggregate-failures.test.ts @@ -43,17 +43,13 @@ describe("aggregateFailures", () => { test("attaches structured failure list under .cause", () => { const reason = new Error("nope") - const err = aggregateFailures([ - { name: "providers", result: { status: "rejected", reason } }, - ]) + const err = aggregateFailures([{ name: "providers", result: { status: "rejected", reason } }]) const cause = err!.cause as { failures: Array<{ name: string; reason: unknown }> } expect(cause.failures).toEqual([{ name: "providers", reason }]) }) test("falls back to String() for opaque reasons", () => { - const err = aggregateFailures([ - { name: "x", result: { status: "rejected", reason: 42 } }, - ]) + const err = aggregateFailures([{ name: "x", result: { status: "rejected", reason: 42 } }]) expect(err!.message).toContain("x: 42") }) }) diff --git a/packages/opencode/test/server/httpapi-promptasync-context.test.ts b/packages/opencode/test/server/httpapi-promptasync-context.test.ts index 106e549686..a7a66ff4f6 100644 --- a/packages/opencode/test/server/httpapi-promptasync-context.test.ts +++ b/packages/opencode/test/server/httpapi-promptasync-context.test.ts @@ -79,9 +79,7 @@ const setupWorkspace = (kind: string) => Effect.gen(function* () { const dir = yield* tmpdirScoped({ git: true }) yield* Project.use.fromDirectory(dir) - const projectID = yield* Project.Service.use((svc) => - svc.fromDirectory(dir).pipe(Effect.map((p) => p.project.id)), - ) + const projectID = yield* Project.Service.use((svc) => svc.fromDirectory(dir).pipe(Effect.map((p) => p.project.id))) registerAdapter(projectID, kind, localAdapter(dir)) const workspace = yield* Workspace.Service.use((svc) => svc.create({ type: kind, branch: null, extra: null, projectID }), From 00c324829528130b54fd630df2418bd406620c83 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 9 May 2026 18:59:05 -0400 Subject: [PATCH 093/189] fix(config): allow active provider model status (#26592) Co-authored-by: Developer --- packages/opencode/src/config/provider.ts | 2 +- packages/opencode/src/provider/models.ts | 2 +- .../test/server/httpapi-config.test.ts | 37 +++++++++++++++++++ packages/sdk/js/src/gen/types.gen.ts | 4 +- packages/sdk/js/src/v2/gen/types.gen.ts | 2 +- packages/sdk/openapi.json | 2 +- 6 files changed, 43 insertions(+), 6 deletions(-) diff --git a/packages/opencode/src/config/provider.ts b/packages/opencode/src/config/provider.ts index 239a0164d8..e501227ae5 100644 --- a/packages/opencode/src/config/provider.ts +++ b/packages/opencode/src/config/provider.ts @@ -49,7 +49,7 @@ export const Model = Schema.Struct({ }), ), experimental: Schema.optional(Schema.Boolean), - status: Schema.optional(Schema.Literals(["alpha", "beta", "deprecated"])), + status: Schema.optional(Schema.Literals(["alpha", "beta", "deprecated", "active"])), provider: Schema.optional( Schema.Struct({ npm: Schema.optional(Schema.String), api: Schema.optional(Schema.String) }), ), diff --git a/packages/opencode/src/provider/models.ts b/packages/opencode/src/provider/models.ts index 77e217eb7f..a2b6ee2a79 100644 --- a/packages/opencode/src/provider/models.ts +++ b/packages/opencode/src/provider/models.ts @@ -71,7 +71,7 @@ export const Model = Schema.Struct({ ), }), ), - status: Schema.optional(Schema.Literals(["alpha", "beta", "deprecated"])), + status: Schema.optional(Schema.Literals(["alpha", "beta", "deprecated", "active"])), provider: Schema.optional( Schema.Struct({ npm: Schema.optional(Schema.String), api: Schema.optional(Schema.String) }), ), diff --git a/packages/opencode/test/server/httpapi-config.test.ts b/packages/opencode/test/server/httpapi-config.test.ts index 3be04b57a3..509a067d08 100644 --- a/packages/opencode/test/server/httpapi-config.test.ts +++ b/packages/opencode/test/server/httpapi-config.test.ts @@ -47,4 +47,41 @@ describe("config HttpApi", () => { lsp: false, }) }) + + test("serves config with active provider model status", async () => { + await using tmp = await tmpdir({ + config: { + formatter: false, + lsp: false, + provider: { + omniroute: { + models: { + "gpt-4o": { + status: "active", + }, + }, + }, + }, + }, + }) + + const response = await app().request("/config", { + headers: { + "x-opencode-directory": tmp.path, + }, + }) + + expect(response.status).toBe(200) + expect(await response.json()).toMatchObject({ + provider: { + omniroute: { + models: { + "gpt-4o": { + status: "active", + }, + }, + }, + }, + }) + }) }) diff --git a/packages/sdk/js/src/gen/types.gen.ts b/packages/sdk/js/src/gen/types.gen.ts index 8eefe5bfe9..62e1b8fe8d 100644 --- a/packages/sdk/js/src/gen/types.gen.ts +++ b/packages/sdk/js/src/gen/types.gen.ts @@ -1065,7 +1065,7 @@ export type ProviderConfig = { output: Array<"text" | "audio" | "image" | "video" | "pdf"> } experimental?: boolean - status?: "alpha" | "beta" | "deprecated" + status?: "alpha" | "beta" | "deprecated" | "active" options?: { [key: string]: unknown } @@ -3012,7 +3012,7 @@ export type ProviderListResponses = { output: Array<"text" | "audio" | "image" | "video" | "pdf"> } experimental?: boolean - status?: "alpha" | "beta" | "deprecated" + status?: "alpha" | "beta" | "deprecated" | "active" options: { [key: string]: unknown } diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 0d8ab61179..186128242f 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1060,7 +1060,7 @@ export type ProviderConfig = { output: Array<"text" | "audio" | "image" | "video" | "pdf"> } experimental?: boolean - status?: "alpha" | "beta" | "deprecated" + status?: "alpha" | "beta" | "deprecated" | "active" provider?: { npm?: string api?: string diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 270749dc1a..3717f57ec0 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -11725,7 +11725,7 @@ }, "status": { "type": "string", - "enum": ["alpha", "beta", "deprecated"] + "enum": ["alpha", "beta", "deprecated", "active"] }, "provider": { "type": "object", From 6849f968253bef5b7cbf7d8a94e8a11c2fe89b14 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 9 May 2026 19:20:31 -0400 Subject: [PATCH 094/189] refactor(provider): share model status schema (#26595) Co-authored-by: Developer --- packages/opencode/src/config/provider.ts | 3 +- .../opencode/src/provider/model-status.ts | 9 +++ packages/opencode/src/provider/models.ts | 3 +- packages/opencode/src/provider/provider.ts | 3 +- packages/opencode/src/v2/model.ts | 3 +- .../test/provider/model-status.test.ts | 61 +++++++++++++++++++ 6 files changed, 78 insertions(+), 4 deletions(-) create mode 100644 packages/opencode/src/provider/model-status.ts create mode 100644 packages/opencode/test/provider/model-status.test.ts diff --git a/packages/opencode/src/config/provider.ts b/packages/opencode/src/config/provider.ts index e501227ae5..af9aac6964 100644 --- a/packages/opencode/src/config/provider.ts +++ b/packages/opencode/src/config/provider.ts @@ -1,6 +1,7 @@ import { Schema } from "effect" import { zod } from "@opencode-ai/core/effect-zod" import { PositiveInt, withStatics } from "@opencode-ai/core/schema" +import { ModelStatus } from "@/provider/model-status" export const Model = Schema.Struct({ id: Schema.optional(Schema.String), @@ -49,7 +50,7 @@ export const Model = Schema.Struct({ }), ), experimental: Schema.optional(Schema.Boolean), - status: Schema.optional(Schema.Literals(["alpha", "beta", "deprecated", "active"])), + status: Schema.optional(ModelStatus), provider: Schema.optional( Schema.Struct({ npm: Schema.optional(Schema.String), api: Schema.optional(Schema.String) }), ), diff --git a/packages/opencode/src/provider/model-status.ts b/packages/opencode/src/provider/model-status.ts new file mode 100644 index 0000000000..468b59ce39 --- /dev/null +++ b/packages/opencode/src/provider/model-status.ts @@ -0,0 +1,9 @@ +import { Schema } from "effect" + +export const CatalogModelStatus = Schema.Literals(["alpha", "beta", "deprecated"]) +export type CatalogModelStatus = typeof CatalogModelStatus.Type + +export const ModelStatus = Schema.Literals(["alpha", "beta", "deprecated", "active"]) +export type ModelStatus = typeof ModelStatus.Type + +export * as ProviderModelStatus from "./model-status" diff --git a/packages/opencode/src/provider/models.ts b/packages/opencode/src/provider/models.ts index a2b6ee2a79..fb240e4cf1 100644 --- a/packages/opencode/src/provider/models.ts +++ b/packages/opencode/src/provider/models.ts @@ -8,6 +8,7 @@ import { Flock } from "@opencode-ai/core/util/flock" import { Hash } from "@opencode-ai/core/util/hash" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { withTransientReadRetry } from "@/util/effect-http-client" +import { CatalogModelStatus } from "./model-status" const Cost = Schema.Struct({ input: Schema.Finite, @@ -71,7 +72,7 @@ export const Model = Schema.Struct({ ), }), ), - status: Schema.optional(Schema.Literals(["alpha", "beta", "deprecated", "active"])), + status: Schema.optional(CatalogModelStatus), provider: Schema.optional( Schema.Struct({ npm: Schema.optional(Schema.String), api: Schema.optional(Schema.String) }), ), diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 18974ea646..c27b69b6a2 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -28,6 +28,7 @@ import { optionalOmitUndefined, withStatics } from "@opencode-ai/core/schema" import * as ProviderTransform from "./transform" import { ModelID, ProviderID } from "./schema" +import { ModelStatus } from "./model-status" const log = Log.create({ service: "provider" }) @@ -897,7 +898,7 @@ export const Model = Schema.Struct({ capabilities: ProviderCapabilities, cost: ProviderCost, limit: ProviderLimit, - status: Schema.Literals(["alpha", "beta", "deprecated", "active"]), + status: ModelStatus, options: Schema.Record(Schema.String, Schema.Any), headers: Schema.Record(Schema.String, Schema.String), release_date: Schema.String, diff --git a/packages/opencode/src/v2/model.ts b/packages/opencode/src/v2/model.ts index a3ee882107..56357ab400 100644 --- a/packages/opencode/src/v2/model.ts +++ b/packages/opencode/src/v2/model.ts @@ -1,4 +1,5 @@ import { withStatics } from "@opencode-ai/core/schema" +import { ModelStatus } from "@/provider/model-status" import { Array, Context, Effect, HashMap, Layer, Option, Order, pipe, Schema } from "effect" import { DateTimeUtcFromMillis } from "effect/Schema" @@ -114,7 +115,7 @@ export class Info extends Schema.Class("Model.Info")({ released: DateTimeUtcFromMillis, }), cost: Cost.pipe(Schema.Array), - status: Schema.Literals(["alpha", "beta", "deprecated", "active"]), + status: ModelStatus, limit: Schema.Struct({ context: Schema.Int, input: Schema.Int.pipe(Schema.optional), diff --git a/packages/opencode/test/provider/model-status.test.ts b/packages/opencode/test/provider/model-status.test.ts new file mode 100644 index 0000000000..e6fa645e71 --- /dev/null +++ b/packages/opencode/test/provider/model-status.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, test } from "bun:test" +import { Schema } from "effect" +import { ConfigProvider } from "@/config/provider" +import { CatalogModelStatus, ModelStatus } from "@/provider/model-status" +import { ModelsDev } from "@/provider/models" +import { Provider } from "@/provider/provider" + +describe("provider model status schemas", () => { + test("keeps catalog status separate from normalized provider status", () => { + expect(Schema.decodeUnknownSync(CatalogModelStatus)("deprecated")).toBe("deprecated") + expect(() => Schema.decodeUnknownSync(CatalogModelStatus)("active")).toThrow() + expect(Schema.decodeUnknownSync(ModelStatus)("active")).toBe("active") + }) + + test("accepts active status across public provider schemas", () => { + expect(Schema.decodeUnknownSync(ConfigProvider.Model)({ status: "active" }).status).toBe("active") + expect( + Schema.decodeUnknownSync(ModelsDev.Model)({ + id: "test-model", + name: "Test Model", + release_date: "2026-01-01", + attachment: false, + reasoning: false, + temperature: true, + tool_call: true, + limit: { context: 128000, output: 8192 }, + }).status, + ).toBeUndefined() + expect( + Schema.decodeUnknownSync(Provider.Model)({ + id: "test-model", + providerID: "test-provider", + api: { + id: "test-model", + url: "", + npm: "@ai-sdk/openai-compatible", + }, + name: "Test Model", + capabilities: { + temperature: true, + reasoning: false, + attachment: false, + toolcall: true, + input: { text: true, audio: false, image: false, video: false, pdf: false }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + interleaved: false, + }, + cost: { + input: 0, + output: 0, + cache: { read: 0, write: 0 }, + }, + limit: { context: 128000, output: 8192 }, + status: "active", + options: {}, + headers: {}, + release_date: "2026-01-01", + }).status, + ).toBe("active") + }) +}) From b8ca71d309349147c160105d9c171b850b138f2e Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 9 May 2026 19:51:55 -0400 Subject: [PATCH 095/189] fix(task): subagent inherits parent agent's deny rules (Plan Mode security bypass) (#26597) Co-authored-by: Developer --- .../src/agent/subagent-permissions.ts | 33 ++++ packages/opencode/src/tool/task.ts | 37 ++--- .../agent/plan-mode-subagent-bypass.test.ts | 141 ++++++++++++++++++ 3 files changed, 185 insertions(+), 26 deletions(-) create mode 100644 packages/opencode/src/agent/subagent-permissions.ts create mode 100644 packages/opencode/test/agent/plan-mode-subagent-bypass.test.ts diff --git a/packages/opencode/src/agent/subagent-permissions.ts b/packages/opencode/src/agent/subagent-permissions.ts new file mode 100644 index 0000000000..1174ec31ad --- /dev/null +++ b/packages/opencode/src/agent/subagent-permissions.ts @@ -0,0 +1,33 @@ +import type { Permission } from "../permission" +import type { Agent } from "./agent" + +/** + * Build the `permission` ruleset for a subagent's session when it's spawned + * via the task tool. Combines: + * + * 1. The parent **agent's** deny rules — Plan Mode and other agent-level + * restrictions live on the agent ruleset, not on the session, so a + * subagent that only inherited the parent SESSION's permission would + * silently bypass them. (#26514) + * 2. The parent **session's** deny rules and external_directory rules — + * same forwarding the original code already did. + * 3. Default `todowrite` and `task` denies if the subagent's own ruleset + * doesn't already permit them. + */ +export function deriveSubagentSessionPermission(input: { + parentSessionPermission: Permission.Ruleset + parentAgent: Agent.Info | undefined + subagent: Agent.Info +}): Permission.Ruleset { + const canTask = input.subagent.permission.some((rule) => rule.permission === "task") + const canTodo = input.subagent.permission.some((rule) => rule.permission === "todowrite") + const parentAgentDenies = input.parentAgent?.permission.filter((rule) => rule.action === "deny") ?? [] + return [ + ...parentAgentDenies, + ...input.parentSessionPermission.filter( + (rule) => rule.permission === "external_directory" || rule.action === "deny", + ), + ...(canTodo ? [] : [{ permission: "todowrite" as const, pattern: "*" as const, action: "deny" as const }]), + ...(canTask ? [] : [{ permission: "task" as const, pattern: "*" as const, action: "deny" as const }]), + ] +} diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index 22e4e5671c..c4d5bf7f4a 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -4,6 +4,7 @@ import { Session } from "@/session/session" import { SessionID, MessageID } from "../session/schema" import { MessageV2 } from "../session/message-v2" import { Agent } from "../agent/agent" +import { deriveSubagentSessionPermission } from "../agent/subagent-permissions" import type { SessionPrompt } from "../session/prompt" import { Config } from "@/config/config" import { Effect, Exit, Schema } from "effect" @@ -58,41 +59,25 @@ export const TaskTool = Tool.define( return yield* Effect.fail(new Error(`Unknown agent type: ${params.subagent_type} is not a valid agent type`)) } - const canTask = next.permission.some((rule) => rule.permission === id) - const canTodo = next.permission.some((rule) => rule.permission === "todowrite") - const taskID = params.task_id const session = taskID ? yield* sessions.get(SessionID.make(taskID)).pipe(Effect.catchCause(() => Effect.succeed(undefined))) : undefined const parent = yield* sessions.get(ctx.sessionID) + const parentAgent = parent.agent + ? yield* agent.get(parent.agent).pipe(Effect.catchCause(() => Effect.succeed(undefined))) + : undefined const nextSession = session ?? (yield* sessions.create({ parentID: ctx.sessionID, title: params.description + ` (@${next.name} subagent)`, permission: [ - ...(parent.permission ?? []).filter( - (rule) => rule.permission === "external_directory" || rule.action === "deny", - ), - ...(canTodo - ? [] - : [ - { - permission: "todowrite" as const, - pattern: "*" as const, - action: "deny" as const, - }, - ]), - ...(canTask - ? [] - : [ - { - permission: id, - pattern: "*" as const, - action: "deny" as const, - }, - ]), + ...deriveSubagentSessionPermission({ + parentSessionPermission: parent.permission ?? [], + parentAgent, + subagent: next, + }), ...(cfg.experimental?.primary_tools?.map((item) => ({ pattern: "*", action: "allow" as const, @@ -144,8 +129,8 @@ export const TaskTool = Tool.define( }, agent: next.name, tools: { - ...(canTodo ? {} : { todowrite: false }), - ...(canTask ? {} : { task: false }), + ...(next.permission.some((rule) => rule.permission === "todowrite") ? {} : { todowrite: false }), + ...(next.permission.some((rule) => rule.permission === id) ? {} : { task: false }), ...Object.fromEntries((cfg.experimental?.primary_tools ?? []).map((item) => [item, false])), }, parts, diff --git a/packages/opencode/test/agent/plan-mode-subagent-bypass.test.ts b/packages/opencode/test/agent/plan-mode-subagent-bypass.test.ts new file mode 100644 index 0000000000..5ba6b54834 --- /dev/null +++ b/packages/opencode/test/agent/plan-mode-subagent-bypass.test.ts @@ -0,0 +1,141 @@ +/** + * Reproducer for opencode issue #26514: + * + * In Plan Mode (the `plan` agent), the main agent's edit/write tools are + * blocked by the plan agent's permission ruleset (`edit: { "*": "deny" }`). + * However, when the plan agent spawns a subagent via the `task` tool, the + * subagent retains full file modification capabilities — a security bypass. + * + * This test replicates the permission ruleset that would govern a + * `general` subagent when launched from a `plan` parent session, mirroring + * the logic in `src/tool/task.ts` (filtered parent permissions ++ runtime + * subagent agent permissions, evaluated as in `session/prompt.ts`). + * + * The expected (secure) behavior is that the subagent inherits the plan + * mode read-only restriction and `edit`/`write` resolve to `deny`. On + * origin/dev this assertion fails because the parent **agent** permissions + * are not propagated to the subagent — only the parent **session** + * permissions are passed through, and Plan Mode's restrictions live on the + * agent, not the session. + */ +import { test, expect, afterEach } from "bun:test" +import { Effect } from "effect" +import { disposeAllInstances, provideInstance, tmpdir } from "../fixture/fixture" +import { WithInstance } from "../../src/project/with-instance" +import { Agent } from "../../src/agent/agent" +import { deriveSubagentSessionPermission } from "../../src/agent/subagent-permissions" +import { Permission } from "../../src/permission" + +afterEach(async () => { + await disposeAllInstances() +}) + +function load(dir: string, fn: (svc: Agent.Interface) => Effect.Effect) { + return Effect.runPromise(provideInstance(dir)(Agent.Service.use(fn)).pipe(Effect.provide(Agent.defaultLayer))) +} + +// `deriveSubagentSessionPermission` is imported from production. The test +// exercises the actual helper that task.ts uses to build the subagent's +// session permission, so any regression in that helper trips this test. + +test("[#26514] subagent spawned from plan mode inherits read-only restriction (edit denied)", async () => { + await using tmp = await tmpdir() + await WithInstance.provide({ + directory: tmp.path, + fn: async () => { + const planAgent = await load(tmp.path, (svc) => svc.get("plan")) + const generalAgent = await load(tmp.path, (svc) => svc.get("general")) + + expect(planAgent).toBeDefined() + expect(generalAgent).toBeDefined() + // Sanity: the plan agent itself blocks edit. (Note: `write` and + // `apply_patch` route through the `edit` permission at the runtime + // tool layer — see Permission.disabled / EDIT_TOOLS.) + expect(Permission.evaluate("edit", "/some/file.ts", planAgent!.permission).action).toBe("deny") + + // Simulate the plan-mode parent session: in real flow the plan + // session's `permission` field is empty (Plan Mode lives on the agent + // ruleset, not the session). So we pass [] through as the parent + // session permission, exactly like the actual code path. + const parentSessionPermission: Permission.Ruleset = [] + + const subagentSessionPermission = deriveSubagentSessionPermission({ + parentSessionPermission, + parentAgent: planAgent, + subagent: generalAgent!, + }) + + // Mirror the runtime evaluation in session/prompt.ts (~line 410, 639): + // ruleset: Permission.merge(agent.permission, session.permission ?? []) + const effective = Permission.merge(generalAgent!.permission, subagentSessionPermission) + + expect(Permission.evaluate("edit", "/some/file.ts", effective).action).toBe("deny") + expect(Permission.evaluate("edit", "/another/path/index.tsx", effective).action).toBe("deny") + }, + }) +}) + +test("[#26514] explore subagent launched from plan mode also stays read-only", async () => { + // Sibling check: even though `explore` is intrinsically read-only, the + // bug surface is the same. Including this case to document that the fix + // should propagate the parent **agent** permissions, not just deny edit + // when the subagent happens to already deny it. + await using tmp = await tmpdir() + await WithInstance.provide({ + directory: tmp.path, + fn: async () => { + const planAgent = await load(tmp.path, (svc) => svc.get("plan")) + const explore = await load(tmp.path, (svc) => svc.get("explore")) + expect(planAgent).toBeDefined() + expect(explore).toBeDefined() + + const parentSessionPermission: Permission.Ruleset = [] + const subagentSessionPermission = deriveSubagentSessionPermission({ + parentSessionPermission, + parentAgent: planAgent, + subagent: explore!, + }) + const effective = Permission.merge(explore!.permission, subagentSessionPermission) + + // Already deny — sanity check. + expect(Permission.evaluate("edit", "/x.ts", effective).action).toBe("deny") + }, + }) +}) + +test("[#26514] custom user subagent launched from plan mode bypasses Plan Mode read-only", async () => { + // The most damaging case: a user-defined subagent with default + // permissions (allow-by-default, like `general`). The subagent must NOT + // be able to edit when the parent agent is `plan`. + await using tmp = await tmpdir({ + config: { + agent: { + my_subagent: { + description: "A user-defined subagent", + mode: "subagent", + }, + }, + }, + }) + await WithInstance.provide({ + directory: tmp.path, + fn: async () => { + const planAgent = await load(tmp.path, (svc) => svc.get("plan")) + const my = await load(tmp.path, (svc) => svc.get("my_subagent")) + expect(planAgent).toBeDefined() + expect(my).toBeDefined() + + const parentSessionPermission: Permission.Ruleset = [] + const subagentSessionPermission = deriveSubagentSessionPermission({ + parentSessionPermission, + parentAgent: planAgent, + subagent: my!, + }) + const effective = Permission.merge(my!.permission, subagentSessionPermission) + + // BUG: on origin/dev edit resolves to "allow" because the plan + // agent's `edit: deny *` rule never reaches the subagent. + expect(Permission.evaluate("edit", "/some/file.ts", effective).action).toBe("deny") + }, + }) +}) From 7a79f3a5ea8967f8e23b7b7fff5a15b9ac650bf0 Mon Sep 17 00:00:00 2001 From: opencode Date: Sun, 10 May 2026 00:07:24 +0000 Subject: [PATCH 096/189] sync release versions for v1.14.45 --- bun.lock | 34 +++++++++++++------------- packages/app/package.json | 2 +- packages/console/app/package.json | 2 +- packages/console/core/package.json | 2 +- packages/console/function/package.json | 2 +- packages/console/mail/package.json | 2 +- packages/core/package.json | 2 +- packages/desktop/package.json | 2 +- packages/enterprise/package.json | 2 +- packages/extensions/zed/extension.toml | 12 ++++----- packages/function/package.json | 2 +- packages/http-recorder/package.json | 2 +- packages/llm/package.json | 2 +- packages/opencode/package.json | 2 +- packages/plugin/package.json | 2 +- packages/sdk/js/package.json | 2 +- packages/slack/package.json | 2 +- packages/ui/package.json | 2 +- packages/web/package.json | 2 +- sdks/vscode/package.json | 2 +- 20 files changed, 41 insertions(+), 41 deletions(-) diff --git a/bun.lock b/bun.lock index 17b0caccd1..22ad0279a7 100644 --- a/bun.lock +++ b/bun.lock @@ -29,7 +29,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "1.14.44", + "version": "1.14.45", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/core": "workspace:*", @@ -85,7 +85,7 @@ }, "packages/console/app": { "name": "@opencode-ai/console-app", - "version": "1.14.44", + "version": "1.14.45", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@ibm/plex": "6.4.1", @@ -120,7 +120,7 @@ }, "packages/console/core": { "name": "@opencode-ai/console-core", - "version": "1.14.44", + "version": "1.14.45", "dependencies": { "@aws-sdk/client-sts": "3.782.0", "@jsx-email/render": "1.1.1", @@ -147,7 +147,7 @@ }, "packages/console/function": { "name": "@opencode-ai/console-function", - "version": "1.14.44", + "version": "1.14.45", "dependencies": { "@ai-sdk/anthropic": "3.0.64", "@ai-sdk/openai": "3.0.48", @@ -171,7 +171,7 @@ }, "packages/console/mail": { "name": "@opencode-ai/console-mail", - "version": "1.14.44", + "version": "1.14.45", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", @@ -195,7 +195,7 @@ }, "packages/core": { "name": "@opencode-ai/core", - "version": "1.14.44", + "version": "1.14.45", "bin": { "opencode": "./bin/opencode", }, @@ -229,7 +229,7 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "1.14.44", + "version": "1.14.45", "dependencies": { "drizzle-orm": "catalog:", "effect": "catalog:", @@ -283,7 +283,7 @@ }, "packages/enterprise": { "name": "@opencode-ai/enterprise", - "version": "1.14.44", + "version": "1.14.45", "dependencies": { "@opencode-ai/core": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -313,7 +313,7 @@ }, "packages/function": { "name": "@opencode-ai/function", - "version": "1.14.44", + "version": "1.14.45", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "catalog:", @@ -329,7 +329,7 @@ }, "packages/http-recorder": { "name": "@opencode-ai/http-recorder", - "version": "1.14.44", + "version": "1.14.45", "dependencies": { "@effect/platform-node": "catalog:", "effect": "catalog:", @@ -342,7 +342,7 @@ }, "packages/llm": { "name": "@opencode-ai/llm", - "version": "1.14.44", + "version": "1.14.45", "dependencies": { "@smithy/eventstream-codec": "4.2.14", "@smithy/util-utf8": "4.2.2", @@ -360,7 +360,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.14.44", + "version": "1.14.45", "bin": { "opencode": "./bin/opencode", }, @@ -495,7 +495,7 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.14.44", + "version": "1.14.45", "dependencies": { "@opencode-ai/sdk": "workspace:*", "effect": "catalog:", @@ -533,7 +533,7 @@ }, "packages/sdk/js": { "name": "@opencode-ai/sdk", - "version": "1.14.44", + "version": "1.14.45", "dependencies": { "cross-spawn": "catalog:", }, @@ -548,7 +548,7 @@ }, "packages/slack": { "name": "@opencode-ai/slack", - "version": "1.14.44", + "version": "1.14.45", "dependencies": { "@opencode-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -583,7 +583,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.14.44", + "version": "1.14.45", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/core": "workspace:*", @@ -632,7 +632,7 @@ }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.14.44", + "version": "1.14.45", "dependencies": { "@astrojs/cloudflare": "12.6.3", "@astrojs/markdown-remark": "6.3.1", diff --git a/packages/app/package.json b/packages/app/package.json index 0b18298ba6..16d1f6b10d 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "1.14.44", + "version": "1.14.45", "description": "", "type": "module", "exports": { diff --git a/packages/console/app/package.json b/packages/console/app/package.json index 8ce88d4948..1ec8d79a66 100644 --- a/packages/console/app/package.json +++ b/packages/console/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-app", - "version": "1.14.44", + "version": "1.14.45", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/console/core/package.json b/packages/console/core/package.json index 9365539302..2c7f23d6df 100644 --- a/packages/console/core/package.json +++ b/packages/console/core/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/console-core", - "version": "1.14.44", + "version": "1.14.45", "private": true, "type": "module", "license": "MIT", diff --git a/packages/console/function/package.json b/packages/console/function/package.json index 3e1daabd52..a5f01a32ab 100644 --- a/packages/console/function/package.json +++ b/packages/console/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-function", - "version": "1.14.44", + "version": "1.14.45", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/console/mail/package.json b/packages/console/mail/package.json index 127a352de5..c522a5f283 100644 --- a/packages/console/mail/package.json +++ b/packages/console/mail/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-mail", - "version": "1.14.44", + "version": "1.14.45", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", diff --git a/packages/core/package.json b/packages/core/package.json index fc7a8f84d1..a68a1d3f7a 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.14.44", + "version": "1.14.45", "name": "@opencode-ai/core", "type": "module", "license": "MIT", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 355db03fe4..1d3f9b961b 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop", "private": true, - "version": "1.14.44", + "version": "1.14.45", "type": "module", "license": "MIT", "homepage": "https://opencode.ai", diff --git a/packages/enterprise/package.json b/packages/enterprise/package.json index ecf6d05191..7c7cc1ed24 100644 --- a/packages/enterprise/package.json +++ b/packages/enterprise/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/enterprise", - "version": "1.14.44", + "version": "1.14.45", "private": true, "type": "module", "license": "MIT", diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml index 8dcd84b37f..7d84cd3730 100644 --- a/packages/extensions/zed/extension.toml +++ b/packages/extensions/zed/extension.toml @@ -1,7 +1,7 @@ id = "opencode" name = "OpenCode" description = "The open source coding agent." -version = "1.14.44" +version = "1.14.45" schema_version = 1 authors = ["Anomaly"] repository = "https://github.com/anomalyco/opencode" @@ -11,26 +11,26 @@ name = "OpenCode" icon = "./icons/opencode.svg" [agent_servers.opencode.targets.darwin-aarch64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.44/opencode-darwin-arm64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.45/opencode-darwin-arm64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.darwin-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.44/opencode-darwin-x64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.45/opencode-darwin-x64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-aarch64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.44/opencode-linux-arm64.tar.gz" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.45/opencode-linux-arm64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.44/opencode-linux-x64.tar.gz" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.45/opencode-linux-x64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.windows-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.44/opencode-windows-x64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.45/opencode-windows-x64.zip" cmd = "./opencode.exe" args = ["acp"] diff --git a/packages/function/package.json b/packages/function/package.json index 6f871cb8c6..731e814501 100644 --- a/packages/function/package.json +++ b/packages/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/function", - "version": "1.14.44", + "version": "1.14.45", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/http-recorder/package.json b/packages/http-recorder/package.json index f4874d763a..ff23648540 100644 --- a/packages/http-recorder/package.json +++ b/packages/http-recorder/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.14.44", + "version": "1.14.45", "name": "@opencode-ai/http-recorder", "type": "module", "license": "MIT", diff --git a/packages/llm/package.json b/packages/llm/package.json index e983f19e80..4d2c2fdfbf 100644 --- a/packages/llm/package.json +++ b/packages/llm/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.14.44", + "version": "1.14.45", "name": "@opencode-ai/llm", "type": "module", "license": "MIT", diff --git a/packages/opencode/package.json b/packages/opencode/package.json index cd58a663f8..f55b75c0d2 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.14.44", + "version": "1.14.45", "name": "opencode", "type": "module", "license": "MIT", diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 10f9b6014d..5e7deabcdf 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/plugin", - "version": "1.14.44", + "version": "1.14.45", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index 10b306ea31..bd881db451 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/sdk", - "version": "1.14.44", + "version": "1.14.45", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/slack/package.json b/packages/slack/package.json index 813df5b04e..c30e9c3b54 100644 --- a/packages/slack/package.json +++ b/packages/slack/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/slack", - "version": "1.14.44", + "version": "1.14.45", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/ui/package.json b/packages/ui/package.json index 7c92c13107..75a8509400 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/ui", - "version": "1.14.44", + "version": "1.14.45", "type": "module", "license": "MIT", "exports": { diff --git a/packages/web/package.json b/packages/web/package.json index 6b923e2331..47f1d91e85 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -2,7 +2,7 @@ "name": "@opencode-ai/web", "type": "module", "license": "MIT", - "version": "1.14.44", + "version": "1.14.45", "scripts": { "dev": "astro dev", "dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev", diff --git a/sdks/vscode/package.json b/sdks/vscode/package.json index b39d872b51..f04148b881 100644 --- a/sdks/vscode/package.json +++ b/sdks/vscode/package.json @@ -2,7 +2,7 @@ "name": "opencode", "displayName": "opencode", "description": "opencode for VS Code", - "version": "1.14.44", + "version": "1.14.45", "publisher": "sst-dev", "repository": { "type": "git", From e30d8173c1a01cb3ad1b37b964f9f8ec60d81f9b Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 9 May 2026 21:12:34 -0400 Subject: [PATCH 097/189] Fix OpenAPI workspace query drift (#26609) --- .../specs/openapi-translation-cleanup.md | 204 ++++++++++++++++++ .../server/routes/instance/httpapi/event.ts | 2 + .../routes/instance/httpapi/groups/config.ts | 5 +- .../instance/httpapi/groups/experimental.ts | 15 +- .../routes/instance/httpapi/groups/file.ts | 7 +- .../instance/httpapi/groups/instance.ts | 17 +- .../routes/instance/httpapi/groups/mcp.ts | 10 +- .../instance/httpapi/groups/permission.ts | 4 +- .../routes/instance/httpapi/groups/project.ts | 6 +- .../instance/httpapi/groups/provider.ts | 6 +- .../routes/instance/httpapi/groups/pty.ts | 14 +- .../instance/httpapi/groups/question.ts | 5 +- .../routes/instance/httpapi/groups/session.ts | 30 ++- .../routes/instance/httpapi/groups/sync.ts | 6 +- .../routes/instance/httpapi/groups/tui.ts | 15 +- .../instance/httpapi/groups/v2/message.ts | 55 ++--- .../instance/httpapi/groups/v2/session.ts | 88 +++----- .../instance/httpapi/groups/workspace.ts | 9 +- .../instance/httpapi/handlers/v2/message.ts | 1 + .../instance/httpapi/handlers/v2/session.ts | 27 +++ .../httpapi/middleware/workspace-routing.ts | 2 + .../server/routes/instance/httpapi/public.ts | 31 +-- .../test/server/httpapi-exercise/index.ts | 109 ++++++++++ .../server/httpapi-query-schema-drift.test.ts | 94 ++++++++ packages/sdk/js/src/v2/gen/sdk.gen.ts | 20 ++ packages/sdk/js/src/v2/gen/types.gen.ts | 182 +++++++++------- 26 files changed, 752 insertions(+), 212 deletions(-) create mode 100644 packages/opencode/specs/openapi-translation-cleanup.md diff --git a/packages/opencode/specs/openapi-translation-cleanup.md b/packages/opencode/specs/openapi-translation-cleanup.md new file mode 100644 index 0000000000..abf8fb2c4c --- /dev/null +++ b/packages/opencode/specs/openapi-translation-cleanup.md @@ -0,0 +1,204 @@ +# OpenAPI Translation Cleanup Plan + +## Goal + +Trim `packages/opencode/src/server/routes/instance/httpapi/public.ts` until OpenAPI generation is mostly a direct projection of the `HttpApi` route declarations, without breaking the generated SDK surface. + +The main failure mode to eliminate is spec-only behavior: anything that appears in `/doc` or the SDK but is not accepted by runtime `HttpApi` validation. + +## Current Culprit + +`public.ts` exports `PublicApi` with a large `OpenApi.annotations({ transform })` hook. That hook rewrites the generated spec for legacy SDK compatibility. + +The highest-risk rewrite is `InstanceQueryParameters`, which injected `directory` and `workspace` into every instance route in OpenAPI even when the runtime query schema did not accept them. This caused the SDK and `/doc` to advertise calls that could fail with `400` at runtime. + +## Non-Negotiables + +- Do not break the generated JavaScript SDK without an explicit versioned migration plan. +- Runtime route schemas are the source of truth for accepted params, payloads, and responses. +- `/doc`, generated SDK types, and runtime validation must agree for every endpoint. +- Prefer endpoint or schema annotations over post-generation spec surgery. +- Remove one category of rewrite at a time, with focused compatibility checks. + +## PR Checklist + +Status legend: `[x]` done locally, `[~]` in progress locally, `[ ]` not started. + +Current combined PR scope: + +- `[x]` PR 1 drift tests: added OpenAPI/runtime query assertions and a negative fixture in `test/server/httpapi-query-schema-drift.test.ts`. +- `[x]` PR 2 injection removal: removed broad `directory` / `workspace` post-generation injection from `public.ts` and replaced it with explicit runtime query schemas on affected routes. +- `[ ]` PR 3+ cleanup: leave query override, path pattern, error shape, auth, and component-shape rewrites for later PRs. + +### PR 1: Add OpenAPI/Runtime Query Drift Tests + +- `[x]` Add or extend `packages/opencode/test/server/httpapi-query-schema-drift.test.ts`. +- `[x]` Import `OpenApi.fromApi` and `PublicApi`. +- `[x]` Generate the public spec in-process with `OpenApi.fromApi(PublicApi)`. +- `[x]` Add a route inventory for the existing runtime reproducers: `session`, `file`, `experimental`, and `instance` routes. +- `[x]` For each inventory entry, assert every OpenAPI query parameter is declared by the runtime query schema. +- `[x]` Add a negative regression fixture that fails on spec-only `directory` / `workspace` params. +- `[x]` Keep this part test-only. + +Verification: + +- `[x]` `bun test --timeout 5000 test/server/httpapi-query-schema-drift.test.ts` from `packages/opencode`. +- `[x]` `bun typecheck` from `packages/opencode`. + +### PR 2: Delete Spec-Only Workspace Query Injection + +- `[x]` Edit `packages/opencode/src/server/routes/instance/httpapi/public.ts`. +- `[x]` Delete `InstanceQueryParameters`. +- `[x]` Delete the `isInstanceRoute` constant. +- `[x]` Delete the branch that prepends `directory` and `workspace` to every instance operation. +- `[x]` Keep `normalizeParameter(param, route)` for parameters that are actually produced by `HttpApi`. +- `[x]` Add `WorkspaceRoutingQuery` / `WorkspaceRoutingQueryFields` to runtime query schemas for affected routes. +- `[x]` Regenerate SDK and inspect diff. Result: no `directory` / `workspace` request-param removals; generated SDK diff is declaration ordering only. + +Notes: + +- Added `WorkspaceRoutingQuery` in `middleware/workspace-routing.ts` as the canonical runtime schema for middleware-consumed query params. +- Replaced v2 union-query schemas with plain struct query schemas so `OpenApi.fromApi` emits their query params directly. This intentionally exposes the beta `/api/session` pagination/filter params in the SDK; cursor mutual-exclusion rules now live in the handlers, while `directory` / `workspace` remain allowed with cursors for routing. + +Expected code shape: + +```ts +for (const param of operation.parameters ?? []) normalizeParameter(param, `${method.toUpperCase()} ${path}`) +``` + +Verification: + +- `[x]` `bun test --timeout 5000 test/server/httpapi-query-schema-drift.test.ts` from `packages/opencode`. +- `[x]` `bun dev generate > /tmp/opencode-openapi.json` from `packages/opencode`. +- `[x]` `./packages/sdk/js/script/build.ts` from repo root. +- `[x]` Inspect SDK diff for removed `directory` / `workspace` params. Result: none after explicit runtime schemas; v2 list/message now also expose their existing beta pagination/filter query params in the SDK. +- `[x]` `bun typecheck` from `packages/opencode`. + +### PR 3: Replace Broad Query Type Override Sets With Route-Level Helpers + +- Edit `packages/opencode/src/server/routes/instance/httpapi/public.ts`. +- Remove broad name-based assumptions from `QueryNumberParameters` and `QueryBooleanParameters` one field at a time. +- Add shared query schema helpers near route group code if needed, for example in `groups/metadata.ts` or a new `groups/query.ts`. +- Prefer route declarations like `Schema.NumberFromString.check(...)` and boolean string decoders like the existing `QueryBoolean` in `groups/session.ts`. +- Keep only route-specific `QueryParameterSchemas` entries when SDK compatibility requires a public encoded type that Effect OpenAPI cannot emit yet. + +Concrete first targets: + +- Replace `roots` / `archived` reliance on `QueryBooleanParameters` with explicit route schema helpers. +- Replace `start` / `cursor` / `limit` reliance on `QueryNumberParameters` with explicit route schema constraints where missing. +- Keep `GET /find/file limit`, `GET /session/{sessionID}/diff messageID`, and `GET /session/{sessionID}/message limit` overrides until their route schemas generate identical SDK types directly. + +Verification: + +- Focused HTTP tests for changed query fields. +- `bun dev generate > /tmp/opencode-openapi.json` from `packages/opencode`. +- `./packages/sdk/js/script/build.ts` from repo root. +- Inspect generated SDK request param types before deleting each override. +- `bun typecheck` from `packages/opencode`. + +### PR 4: Move Path Parameter Patterns Into ID Schemas + +- Audit `PathParameterSchemas` and `pathParameterSchema()` in `public.ts`. +- Check source schemas in files like `packages/opencode/src/session/schema.ts`, `packages/opencode/src/permission/schema.ts`, and pty schema definitions. +- Add or fix `ZodOverride` / OpenAPI-compatible annotations on branded ID schemas so generated path params include the same patterns without `public.ts` overrides. +- Delete one path override only after generated OpenAPI is unchanged for that param. + +Concrete first targets: + +- `sessionID` +- `messageID` +- `partID` +- `permissionID` +- `ptyID` + +Leave ambiguous route-local `id` overrides for workspace routes until they are renamed or explicitly typed in endpoint params. + +Verification: + +- `bun dev generate > /tmp/opencode-openapi.json` from `packages/opencode`. +- `./packages/sdk/js/script/build.ts` from repo root. +- Inspect generated path param types and patterns. +- `bun typecheck` from `packages/opencode`. + +### PR 5: Replace Built-In Error Rewrites With Declared API Errors + +- Edit route group files under `packages/opencode/src/server/routes/instance/httpapi/groups/`. +- Replace SDK-visible `HttpApiError.BadRequest` / `HttpApiError.NotFound` with explicit error schemas from `packages/opencode/src/server/routes/instance/httpapi/errors.ts` or add new ones there. +- Update handlers to fail with the declared API errors at the boundary. +- Remove matching cases from `normalizeLegacyErrorResponses()` only after generated OpenAPI remains SDK-compatible. +- Do this group by group, starting with one small route group. + +Concrete first targets: + +- `groups/config.ts` `PATCH /config` bad request. +- `groups/session.ts` endpoints that already translate domain not-found errors. +- `groups/file.ts` if any handler currently relies on built-in error shape. + +Verification: + +- Focused HTTP tests asserting response body shape for changed error paths. +- `bun dev generate > /tmp/opencode-openapi.json` from `packages/opencode`. +- `./packages/sdk/js/script/build.ts` from repo root. +- Inspect SDK error union diff. +- `bun typecheck` from `packages/opencode`. + +### PR 6: Remove Auth/Security Spec Rewrites If SDK Can Tolerate It + +- Audit `delete operation.security`, `delete operation.responses?.["401"]`, and `delete spec.components?.securitySchemes` in `public.ts`. +- Decide whether SDK should expose auth in generated operation metadata. +- If preserving no-auth SDK surface is required, leave this rewrite and document it as intentional compatibility code. +- If removing it, update SDK generation expectations and docs in the same PR. + +Verification: + +- `./packages/sdk/js/script/build.ts` from repo root. +- Inspect generated client call signatures and error unions. +- Do not merge if auth churn changes normal SDK call ergonomics unintentionally. + +### PR 7: Tackle Component Shape Rewrites One At A Time + +- Audit these in `public.ts`: `normalizeComponentNames`, `collapseDuplicateComponents`, `applyLegacySchemaOverrides`, `normalizeComponentDescriptions`, `stripOptionalNull`, `fixSelfReferencingComponents`. +- For each rewrite, make a tiny PR that removes or narrows only that rewrite. +- If generated SDK type names churn broadly, stop and either keep the rewrite or fix `effect-smol` generation first. + +Concrete first targets: + +- Delete cosmetic `normalizeComponentDescriptions` if SDK output does not change materially. +- Narrow `applyLegacySchemaOverrides` entries that correspond to schemas already fixed at the source. +- Keep `stripOptionalNull` until there is an explicit SDK migration plan, because it likely affects many optional fields. + +Verification: + +- `bun dev generate > /tmp/opencode-openapi.json` from `packages/opencode`. +- `./packages/sdk/js/script/build.ts` from repo root. +- Inspect generated SDK type-name and optionality diffs. + +## Upstream Middleware Query Support + +Long-term, `WorkspaceRoutingMiddleware` should declare the query fields it reads once, and `HttpApi` should use that declaration for both runtime validation and OpenAPI generation. + +Target in `effect-smol`: + +- Extend `HttpApiMiddleware.Service` config with optional query schema support, or add a dedicated middleware query annotation. +- Make runtime request decoding include middleware query schemas. +- Make `OpenApi.fromApi` emit middleware query params for endpoints using that middleware. + +Once available, remove `WorkspaceRoutingQueryFields` spreads from route groups and declare `directory` / `workspace` only on `WorkspaceRoutingMiddleware`. + +## Suggested PR Order + +1. Add drift detection tests only. +2. Remove `InstanceQueryParameters` spec injection; rely on `WorkspaceRoutingQueryFields` already present in runtime schemas. +3. Convert query type overrides into route/schema-level helpers where possible. +4. Convert path parameter overrides into schema annotations or upstream fixes. +5. Replace built-in error response rewrites with explicit declared API errors by route group. +6. Tackle component naming/nullability rewrites only after SDK compatibility snapshots are stable. + +## Verification Checklist Per PR + +- Focused HTTP tests for changed routes. +- OpenAPI drift tests. +- `bun dev generate > /tmp/opencode-openapi.json` from `packages/opencode`. +- `./packages/sdk/js/script/build.ts` from repo root. +- Inspect generated SDK diff for public API churn. +- `bun typecheck` from `packages/opencode`. diff --git a/packages/opencode/src/server/routes/instance/httpapi/event.ts b/packages/opencode/src/server/routes/instance/httpapi/event.ts index a5c328ac0e..8113c76f51 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/event.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/event.ts @@ -5,6 +5,7 @@ import * as Stream from "effect/Stream" import { HttpServerResponse } from "effect/unstable/http" import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" import * as Sse from "effect/unstable/encoding/Sse" +import { WorkspaceRoutingQuery } from "./middleware/workspace-routing" const log = Log.create({ service: "server" }) @@ -16,6 +17,7 @@ export const EventApi = HttpApi.make("event").add( HttpApiGroup.make("event") .add( HttpApiEndpoint.get("subscribe", EventPaths.event, { + query: WorkspaceRoutingQuery, success: Schema.String.pipe(HttpApiSchema.asText({ contentType: "text/event-stream" })), }).annotateMerge( OpenApi.annotations({ diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/config.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/config.ts index fa77785a9b..a86845beff 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/config.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/config.ts @@ -3,7 +3,7 @@ import { Provider } from "@/provider/provider" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "../middleware/authorization" import { InstanceContextMiddleware } from "../middleware/instance-context" -import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" +import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing" import { described } from "./metadata" const root = "/config" @@ -13,6 +13,7 @@ export const ConfigApi = HttpApi.make("config") HttpApiGroup.make("config") .add( HttpApiEndpoint.get("get", root, { + query: WorkspaceRoutingQuery, success: described(Config.Info, "Get config info"), }).annotateMerge( OpenApi.annotations({ @@ -22,6 +23,7 @@ export const ConfigApi = HttpApi.make("config") }), ), HttpApiEndpoint.patch("update", root, { + query: WorkspaceRoutingQuery, payload: Config.Info, success: described(Config.Info, "Successfully updated config"), error: HttpApiError.BadRequest, @@ -33,6 +35,7 @@ export const ConfigApi = HttpApi.make("config") }), ), HttpApiEndpoint.get("providers", `${root}/providers`, { + query: WorkspaceRoutingQuery, success: described(Provider.ConfigProvidersResult, "List of providers"), }).annotateMerge( OpenApi.annotations({ diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts index d2f3c65ad2..411e7398f8 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts @@ -8,7 +8,11 @@ import { Schema, SchemaGetter } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "../middleware/authorization" import { InstanceContextMiddleware } from "../middleware/instance-context" -import { WorkspaceRoutingMiddleware, WorkspaceRoutingQueryFields } from "../middleware/workspace-routing" +import { + WorkspaceRoutingMiddleware, + WorkspaceRoutingQuery, + WorkspaceRoutingQueryFields, +} from "../middleware/workspace-routing" import { described } from "./metadata" const ConsoleStateResponse = Schema.Struct({ @@ -82,6 +86,7 @@ export const ExperimentalApi = HttpApi.make("experimental") HttpApiGroup.make("experimental") .add( HttpApiEndpoint.get("console", ExperimentalPaths.console, { + query: WorkspaceRoutingQuery, success: described(ConsoleStateResponse, "Active Console provider metadata"), error: HttpApiError.InternalServerError, }).annotateMerge( @@ -92,6 +97,7 @@ export const ExperimentalApi = HttpApi.make("experimental") }), ), HttpApiEndpoint.get("consoleOrgs", ExperimentalPaths.consoleOrgs, { + query: WorkspaceRoutingQuery, success: described(ConsoleOrgList, "Switchable Console orgs"), error: HttpApiError.InternalServerError, }).annotateMerge( @@ -102,6 +108,7 @@ export const ExperimentalApi = HttpApi.make("experimental") }), ), HttpApiEndpoint.post("consoleSwitch", ExperimentalPaths.consoleSwitch, { + query: WorkspaceRoutingQuery, payload: ConsoleSwitchPayload, success: described(Schema.Boolean, "Switch success"), error: HttpApiError.BadRequest, @@ -125,6 +132,7 @@ export const ExperimentalApi = HttpApi.make("experimental") }), ), HttpApiEndpoint.get("toolIDs", ExperimentalPaths.toolIDs, { + query: WorkspaceRoutingQuery, success: described(ToolIDs, "Tool IDs"), error: HttpApiError.BadRequest, }).annotateMerge( @@ -136,6 +144,7 @@ export const ExperimentalApi = HttpApi.make("experimental") }), ), HttpApiEndpoint.get("worktree", ExperimentalPaths.worktree, { + query: WorkspaceRoutingQuery, success: described(WorktreeList, "List of worktree directories"), }).annotateMerge( OpenApi.annotations({ @@ -145,6 +154,7 @@ export const ExperimentalApi = HttpApi.make("experimental") }), ), HttpApiEndpoint.post("worktreeCreate", ExperimentalPaths.worktree, { + query: WorkspaceRoutingQuery, payload: Schema.optional(Worktree.CreateInput), success: described(Worktree.Info, "Worktree created"), error: HttpApiError.BadRequest, @@ -156,6 +166,7 @@ export const ExperimentalApi = HttpApi.make("experimental") }), ), HttpApiEndpoint.delete("worktreeRemove", ExperimentalPaths.worktree, { + query: WorkspaceRoutingQuery, payload: Worktree.RemoveInput, success: described(Schema.Boolean, "Worktree removed"), error: HttpApiError.BadRequest, @@ -167,6 +178,7 @@ export const ExperimentalApi = HttpApi.make("experimental") }), ), HttpApiEndpoint.post("worktreeReset", ExperimentalPaths.worktreeReset, { + query: WorkspaceRoutingQuery, payload: Worktree.ResetInput, success: described(Schema.Boolean, "Worktree reset"), error: HttpApiError.BadRequest, @@ -189,6 +201,7 @@ export const ExperimentalApi = HttpApi.make("experimental") }), ), HttpApiEndpoint.get("resource", ExperimentalPaths.resource, { + query: WorkspaceRoutingQuery, success: described(Schema.Record(Schema.String, MCP.Resource), "MCP resources"), }).annotateMerge( OpenApi.annotations({ diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/file.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/file.ts index fe0b0b617c..c636e583d7 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/file.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/file.ts @@ -5,7 +5,11 @@ import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "../middleware/authorization" import { InstanceContextMiddleware } from "../middleware/instance-context" -import { WorkspaceRoutingMiddleware, WorkspaceRoutingQueryFields } from "../middleware/workspace-routing" +import { + WorkspaceRoutingMiddleware, + WorkspaceRoutingQuery, + WorkspaceRoutingQueryFields, +} from "../middleware/workspace-routing" import { described } from "./metadata" export const FileQuery = Schema.Struct({ @@ -97,6 +101,7 @@ export const FileApi = HttpApi.make("file") }), ), HttpApiEndpoint.get("status", FilePaths.status, { + query: WorkspaceRoutingQuery, success: described(Schema.Array(File.Info), "File status"), }).annotateMerge( OpenApi.annotations({ diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/instance.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/instance.ts index 5c45cd5c15..ea8db35035 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/instance.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/instance.ts @@ -8,7 +8,11 @@ import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "../middleware/authorization" import { InstanceContextMiddleware } from "../middleware/instance-context" -import { WorkspaceRoutingMiddleware, WorkspaceRoutingQueryFields } from "../middleware/workspace-routing" +import { + WorkspaceRoutingMiddleware, + WorkspaceRoutingQuery, + WorkspaceRoutingQueryFields, +} from "../middleware/workspace-routing" import { described } from "./metadata" const PathInfo = Schema.Struct({ @@ -55,6 +59,7 @@ export const InstanceApi = HttpApi.make("instance") HttpApiGroup.make("instance") .add( HttpApiEndpoint.post("dispose", InstancePaths.dispose, { + query: WorkspaceRoutingQuery, success: described(Schema.Boolean, "Instance disposed"), }).annotateMerge( OpenApi.annotations({ @@ -64,6 +69,7 @@ export const InstanceApi = HttpApi.make("instance") }), ), HttpApiEndpoint.get("path", InstancePaths.path, { + query: WorkspaceRoutingQuery, success: PathInfo, }).annotateMerge( OpenApi.annotations({ @@ -74,6 +80,7 @@ export const InstanceApi = HttpApi.make("instance") }), ), HttpApiEndpoint.get("vcs", InstancePaths.vcs, { + query: WorkspaceRoutingQuery, success: described(Vcs.Info, "VCS info"), }).annotateMerge( OpenApi.annotations({ @@ -84,6 +91,7 @@ export const InstanceApi = HttpApi.make("instance") }), ), HttpApiEndpoint.get("vcsStatus", InstancePaths.vcsStatus, { + query: WorkspaceRoutingQuery, success: described(Schema.Array(Vcs.FileStatus), "VCS status"), }).annotateMerge( OpenApi.annotations({ @@ -103,6 +111,7 @@ export const InstanceApi = HttpApi.make("instance") }), ), HttpApiEndpoint.get("vcsDiffRaw", InstancePaths.vcsDiffRaw, { + query: WorkspaceRoutingQuery, success: described( Schema.String.pipe(HttpApiSchema.asText({ contentType: "text/x-diff; charset=utf-8" })), "Raw VCS diff", @@ -115,6 +124,7 @@ export const InstanceApi = HttpApi.make("instance") }), ), HttpApiEndpoint.post("vcsApply", InstancePaths.vcsApply, { + query: WorkspaceRoutingQuery, payload: Vcs.ApplyInput, success: described(Vcs.ApplyResult, "VCS patch applied"), error: ApiVcsApplyError, @@ -126,6 +136,7 @@ export const InstanceApi = HttpApi.make("instance") }), ), HttpApiEndpoint.get("command", InstancePaths.command, { + query: WorkspaceRoutingQuery, success: described(Schema.Array(Command.Info), "List of commands"), }).annotateMerge( OpenApi.annotations({ @@ -135,6 +146,7 @@ export const InstanceApi = HttpApi.make("instance") }), ), HttpApiEndpoint.get("agent", InstancePaths.agent, { + query: WorkspaceRoutingQuery, success: described(Schema.Array(Agent.Info), "List of agents"), }).annotateMerge( OpenApi.annotations({ @@ -144,6 +156,7 @@ export const InstanceApi = HttpApi.make("instance") }), ), HttpApiEndpoint.get("skill", InstancePaths.skill, { + query: WorkspaceRoutingQuery, success: described(Schema.Array(Skill.Info), "List of skills"), }).annotateMerge( OpenApi.annotations({ @@ -153,6 +166,7 @@ export const InstanceApi = HttpApi.make("instance") }), ), HttpApiEndpoint.get("lsp", InstancePaths.lsp, { + query: WorkspaceRoutingQuery, success: described(Schema.Array(LSP.Status), "LSP server status"), }).annotateMerge( OpenApi.annotations({ @@ -162,6 +176,7 @@ export const InstanceApi = HttpApi.make("instance") }), ), HttpApiEndpoint.get("formatter", InstancePaths.formatter, { + query: WorkspaceRoutingQuery, success: described(Schema.Array(Format.Status), "Formatter status"), }).annotateMerge( OpenApi.annotations({ diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/mcp.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/mcp.ts index b30714c196..c7ed4a9b95 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/mcp.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/mcp.ts @@ -4,7 +4,7 @@ import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "../middleware/authorization" import { InstanceContextMiddleware } from "../middleware/instance-context" -import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" +import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing" import { described } from "./metadata" export const AddPayload = Schema.Struct({ @@ -42,6 +42,7 @@ export const McpApi = HttpApi.make("mcp") HttpApiGroup.make("mcp") .add( HttpApiEndpoint.get("status", McpPaths.status, { + query: WorkspaceRoutingQuery, success: described(Schema.Record(Schema.String, MCP.Status), "MCP server status"), }).annotateMerge( OpenApi.annotations({ @@ -51,6 +52,7 @@ export const McpApi = HttpApi.make("mcp") }), ), HttpApiEndpoint.post("add", McpPaths.status, { + query: WorkspaceRoutingQuery, payload: AddPayload, success: described(StatusMap, "MCP server added successfully"), error: HttpApiError.BadRequest, @@ -63,6 +65,7 @@ export const McpApi = HttpApi.make("mcp") ), HttpApiEndpoint.post("authStart", McpPaths.auth, { params: { name: Schema.String }, + query: WorkspaceRoutingQuery, success: described(AuthStartResponse, "OAuth flow started"), error: [UnsupportedOAuthError, HttpApiError.NotFound], }).annotateMerge( @@ -74,6 +77,7 @@ export const McpApi = HttpApi.make("mcp") ), HttpApiEndpoint.post("authCallback", McpPaths.authCallback, { params: { name: Schema.String }, + query: WorkspaceRoutingQuery, payload: AuthCallbackPayload, success: described(MCP.Status, "OAuth authentication completed"), error: [HttpApiError.BadRequest, HttpApiError.NotFound], @@ -87,6 +91,7 @@ export const McpApi = HttpApi.make("mcp") ), HttpApiEndpoint.post("authAuthenticate", McpPaths.authAuthenticate, { params: { name: Schema.String }, + query: WorkspaceRoutingQuery, success: described(MCP.Status, "OAuth authentication completed"), error: [UnsupportedOAuthError, HttpApiError.NotFound], }).annotateMerge( @@ -98,6 +103,7 @@ export const McpApi = HttpApi.make("mcp") ), HttpApiEndpoint.delete("authRemove", McpPaths.auth, { params: { name: Schema.String }, + query: WorkspaceRoutingQuery, success: described(AuthRemoveResponse, "OAuth credentials removed"), error: HttpApiError.NotFound, }).annotateMerge( @@ -109,6 +115,7 @@ export const McpApi = HttpApi.make("mcp") ), HttpApiEndpoint.post("connect", McpPaths.connect, { params: { name: Schema.String }, + query: WorkspaceRoutingQuery, success: described(Schema.Boolean, "MCP server connected successfully"), }).annotateMerge( OpenApi.annotations({ @@ -118,6 +125,7 @@ export const McpApi = HttpApi.make("mcp") ), HttpApiEndpoint.post("disconnect", McpPaths.disconnect, { params: { name: Schema.String }, + query: WorkspaceRoutingQuery, success: described(Schema.Boolean, "MCP server disconnected successfully"), }).annotateMerge( OpenApi.annotations({ diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/permission.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/permission.ts index 22c4d6f6d3..5326596d39 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/permission.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/permission.ts @@ -4,7 +4,7 @@ import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "../middleware/authorization" import { InstanceContextMiddleware } from "../middleware/instance-context" -import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" +import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing" import { described } from "./metadata" const root = "/permission" @@ -18,6 +18,7 @@ export const PermissionApi = HttpApi.make("permission") HttpApiGroup.make("permission") .add( HttpApiEndpoint.get("list", root, { + query: WorkspaceRoutingQuery, success: described(Schema.Array(Permission.Request), "List of pending permissions"), }).annotateMerge( OpenApi.annotations({ @@ -28,6 +29,7 @@ export const PermissionApi = HttpApi.make("permission") ), HttpApiEndpoint.post("reply", `${root}/:requestID/reply`, { params: { requestID: PermissionID }, + query: WorkspaceRoutingQuery, payload: ReplyPayload, success: described(Schema.Boolean, "Permission processed successfully"), error: [HttpApiError.BadRequest, HttpApiError.NotFound], diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/project.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/project.ts index 1a2084547d..f95199eb01 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/project.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/project.ts @@ -4,7 +4,7 @@ import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "../middleware/authorization" import { InstanceContextMiddleware } from "../middleware/instance-context" -import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" +import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing" import { described } from "./metadata" const root = "/project" @@ -19,6 +19,7 @@ export const ProjectApi = HttpApi.make("project") HttpApiGroup.make("project") .add( HttpApiEndpoint.get("list", root, { + query: WorkspaceRoutingQuery, success: described(Schema.Array(Project.Info), "List of projects"), }).annotateMerge( OpenApi.annotations({ @@ -28,6 +29,7 @@ export const ProjectApi = HttpApi.make("project") }), ), HttpApiEndpoint.get("current", `${root}/current`, { + query: WorkspaceRoutingQuery, success: described(Project.Info, "Current project information"), }).annotateMerge( OpenApi.annotations({ @@ -37,6 +39,7 @@ export const ProjectApi = HttpApi.make("project") }), ), HttpApiEndpoint.post("initGit", `${root}/git/init`, { + query: WorkspaceRoutingQuery, success: described(Project.Info, "Project information after git initialization"), }).annotateMerge( OpenApi.annotations({ @@ -47,6 +50,7 @@ export const ProjectApi = HttpApi.make("project") ), HttpApiEndpoint.patch("update", `${root}/:projectID`, { params: { projectID: ProjectID }, + query: WorkspaceRoutingQuery, payload: UpdatePayload, success: described(Project.Info, "Updated project information"), error: [HttpApiError.BadRequest, HttpApiError.NotFound], diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/provider.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/provider.ts index 4a9bbffc54..49792898df 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/provider.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/provider.ts @@ -5,7 +5,7 @@ import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "../middleware/authorization" import { InstanceContextMiddleware } from "../middleware/instance-context" -import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" +import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing" import { described } from "./metadata" const root = "/provider" @@ -15,6 +15,7 @@ export const ProviderApi = HttpApi.make("provider") HttpApiGroup.make("provider") .add( HttpApiEndpoint.get("list", root, { + query: WorkspaceRoutingQuery, success: described(Provider.ListResult, "List of providers"), }).annotateMerge( OpenApi.annotations({ @@ -24,6 +25,7 @@ export const ProviderApi = HttpApi.make("provider") }), ), HttpApiEndpoint.get("auth", `${root}/auth`, { + query: WorkspaceRoutingQuery, success: described(ProviderAuth.Methods, "Provider auth methods"), }).annotateMerge( OpenApi.annotations({ @@ -34,6 +36,7 @@ export const ProviderApi = HttpApi.make("provider") ), HttpApiEndpoint.post("authorize", `${root}/:providerID/oauth/authorize`, { params: { providerID: ProviderID }, + query: WorkspaceRoutingQuery, payload: ProviderAuth.AuthorizeInput, success: described(Schema.UndefinedOr(ProviderAuth.Authorization), "Authorization URL and method"), error: HttpApiError.BadRequest, @@ -46,6 +49,7 @@ export const ProviderApi = HttpApi.make("provider") ), HttpApiEndpoint.post("callback", `${root}/:providerID/oauth/callback`, { params: { providerID: ProviderID }, + query: WorkspaceRoutingQuery, payload: ProviderAuth.CallbackInput, success: described(Schema.Boolean, "OAuth callback processed successfully"), error: HttpApiError.BadRequest, diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/pty.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/pty.ts index 17f5890f1e..1391d2a919 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/pty.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/pty.ts @@ -5,7 +5,11 @@ import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "../middleware/authorization" import { InstanceContextMiddleware } from "../middleware/instance-context" -import { WorkspaceRoutingMiddleware, WorkspaceRoutingQueryFields } from "../middleware/workspace-routing" +import { + WorkspaceRoutingMiddleware, + WorkspaceRoutingQuery, + WorkspaceRoutingQueryFields, +} from "../middleware/workspace-routing" import { ApiNotFoundError } from "../errors" import { described } from "./metadata" @@ -37,6 +41,7 @@ export const PtyApi = HttpApi.make("pty") HttpApiGroup.make("pty") .add( HttpApiEndpoint.get("shells", PtyPaths.shells, { + query: WorkspaceRoutingQuery, success: described(Schema.Array(ShellItem), "List of shells"), }).annotateMerge( OpenApi.annotations({ @@ -46,6 +51,7 @@ export const PtyApi = HttpApi.make("pty") }), ), HttpApiEndpoint.get("list", PtyPaths.list, { + query: WorkspaceRoutingQuery, success: described(Schema.Array(Pty.Info), "List of sessions"), }).annotateMerge( OpenApi.annotations({ @@ -55,6 +61,7 @@ export const PtyApi = HttpApi.make("pty") }), ), HttpApiEndpoint.post("create", PtyPaths.create, { + query: WorkspaceRoutingQuery, payload: Pty.CreateInput, success: described(Pty.Info, "Created session"), error: HttpApiError.BadRequest, @@ -67,6 +74,7 @@ export const PtyApi = HttpApi.make("pty") ), HttpApiEndpoint.get("get", PtyPaths.get, { params: { ptyID: PtyID }, + query: WorkspaceRoutingQuery, success: described(Pty.Info, "Session info"), error: ApiNotFoundError, }).annotateMerge( @@ -78,6 +86,7 @@ export const PtyApi = HttpApi.make("pty") ), HttpApiEndpoint.put("update", PtyPaths.update, { params: { ptyID: PtyID }, + query: WorkspaceRoutingQuery, payload: Pty.UpdateInput, success: described(Pty.Info, "Updated session"), error: [HttpApiError.BadRequest, ApiNotFoundError], @@ -90,6 +99,7 @@ export const PtyApi = HttpApi.make("pty") ), HttpApiEndpoint.delete("remove", PtyPaths.remove, { params: { ptyID: PtyID }, + query: WorkspaceRoutingQuery, success: described(Schema.Boolean, "Session removed"), error: ApiNotFoundError, }).annotateMerge( @@ -101,6 +111,7 @@ export const PtyApi = HttpApi.make("pty") ), HttpApiEndpoint.post("connectToken", PtyPaths.connectToken, { params: { ptyID: PtyID }, + query: WorkspaceRoutingQuery, success: described(PtyTicket.ConnectToken, "WebSocket connect token"), error: [HttpApiError.Forbidden, ApiNotFoundError], }).annotateMerge( @@ -129,6 +140,7 @@ export const PtyConnectApi = HttpApi.make("pty-connect").add( .add( HttpApiEndpoint.get("connect", PtyPaths.connect, { params: Params, + query: WorkspaceRoutingQuery, success: described(Schema.Boolean, "Connected session"), error: [HttpApiError.Forbidden, HttpApiError.NotFound], }).annotateMerge( diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/question.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/question.ts index de2d4fca8e..35cd3314b5 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/question.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/question.ts @@ -4,7 +4,7 @@ import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "../middleware/authorization" import { InstanceContextMiddleware } from "../middleware/instance-context" -import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" +import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing" import { described } from "./metadata" const root = "/question" @@ -19,6 +19,7 @@ export const QuestionApi = HttpApi.make("question") HttpApiGroup.make("question") .add( HttpApiEndpoint.get("list", root, { + query: WorkspaceRoutingQuery, success: described(Schema.Array(Question.Request), "List of pending questions"), }).annotateMerge( OpenApi.annotations({ @@ -29,6 +30,7 @@ export const QuestionApi = HttpApi.make("question") ), HttpApiEndpoint.post("reply", `${root}/:requestID/reply`, { params: { requestID: QuestionID }, + query: WorkspaceRoutingQuery, payload: ReplyPayload, success: described(Schema.Boolean, "Question answered successfully"), error: [HttpApiError.BadRequest, HttpApiError.NotFound], @@ -41,6 +43,7 @@ export const QuestionApi = HttpApi.make("question") ), HttpApiEndpoint.post("reject", `${root}/:requestID/reject`, { params: { requestID: QuestionID }, + query: WorkspaceRoutingQuery, success: described(Schema.Boolean, "Question rejected successfully"), error: [HttpApiError.BadRequest, HttpApiError.NotFound], }).annotateMerge( diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts index f1dc3697b9..4a11db09a9 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts @@ -14,7 +14,11 @@ import { Schema, SchemaGetter, Struct } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "../middleware/authorization" import { InstanceContextMiddleware } from "../middleware/instance-context" -import { WorkspaceRoutingMiddleware, WorkspaceRoutingQueryFields } from "../middleware/workspace-routing" +import { + WorkspaceRoutingMiddleware, + WorkspaceRoutingQuery, + WorkspaceRoutingQueryFields, +} from "../middleware/workspace-routing" import { ApiNotFoundError } from "../errors" import { described } from "./metadata" @@ -116,6 +120,7 @@ export const SessionApi = HttpApi.make("session") }), ), HttpApiEndpoint.get("status", SessionPaths.status, { + query: WorkspaceRoutingQuery, success: described(StatusMap, "Get session status"), error: HttpApiError.BadRequest, }).annotateMerge( @@ -127,6 +132,7 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.get("get", SessionPaths.get, { params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, success: described(Session.Info, "Get session"), error: [HttpApiError.BadRequest, ApiNotFoundError], }).annotateMerge( @@ -138,6 +144,7 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.get("children", SessionPaths.children, { params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, success: described(Schema.Array(Session.Info), "List of children"), error: [HttpApiError.BadRequest, HttpApiError.NotFound], }).annotateMerge( @@ -149,6 +156,7 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.get("todo", SessionPaths.todo, { params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, success: described(Schema.Array(Todo.Info), "Todo list"), error: [HttpApiError.BadRequest, HttpApiError.NotFound], }).annotateMerge( @@ -183,6 +191,7 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.get("message", SessionPaths.message, { params: { sessionID: SessionID, messageID: MessageID }, + query: WorkspaceRoutingQuery, success: described(MessageV2.WithParts, "Message"), error: [HttpApiError.BadRequest, ApiNotFoundError], }).annotateMerge( @@ -193,6 +202,7 @@ export const SessionApi = HttpApi.make("session") }), ), HttpApiEndpoint.post("create", SessionPaths.create, { + query: WorkspaceRoutingQuery, payload: [HttpApiSchema.NoContent, Session.CreateInput], success: described(Session.Info, "Successfully created session"), error: HttpApiError.BadRequest, @@ -205,6 +215,7 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.delete("remove", SessionPaths.remove, { params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, success: described(Schema.Boolean, "Successfully deleted session"), error: [HttpApiError.BadRequest, ApiNotFoundError], }).annotateMerge( @@ -216,6 +227,7 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.patch("update", SessionPaths.update, { params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, payload: UpdatePayload, success: described(Session.Info, "Successfully updated session"), error: [HttpApiError.BadRequest, ApiNotFoundError], @@ -228,6 +240,7 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.post("fork", SessionPaths.fork, { params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, payload: ForkPayload, success: described(Session.Info, "200"), error: ApiNotFoundError, @@ -240,6 +253,7 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.post("abort", SessionPaths.abort, { params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, success: described(Schema.Boolean, "Aborted session"), error: [HttpApiError.BadRequest, HttpApiError.NotFound], }).annotateMerge( @@ -251,6 +265,7 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.post("init", SessionPaths.init, { params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, payload: InitPayload, success: described(Schema.Boolean, "200"), error: [HttpApiError.BadRequest, HttpApiError.NotFound], @@ -264,6 +279,7 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.post("share", SessionPaths.share, { params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, success: described(Session.Info, "Successfully shared session"), error: [HttpApiError.InternalServerError, ApiNotFoundError], }).annotateMerge( @@ -275,6 +291,7 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.delete("unshare", SessionPaths.share, { params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, success: described(Session.Info, "Successfully unshared session"), error: [HttpApiError.InternalServerError, ApiNotFoundError], }).annotateMerge( @@ -286,6 +303,7 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.post("summarize", SessionPaths.summarize, { params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, payload: SummarizePayload, success: described(Schema.Boolean, "Summarized session"), error: [HttpApiError.BadRequest, ApiNotFoundError], @@ -298,6 +316,7 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.post("prompt", SessionPaths.prompt, { params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, payload: PromptPayload, success: described(MessageV2.WithParts, "Created message"), error: [HttpApiError.BadRequest, HttpApiError.NotFound], @@ -310,6 +329,7 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.post("promptAsync", SessionPaths.promptAsync, { params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, payload: PromptPayload, success: described(HttpApiSchema.NoContent, "Prompt accepted"), error: [HttpApiError.BadRequest, HttpApiError.NotFound], @@ -323,6 +343,7 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.post("command", SessionPaths.command, { params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, payload: CommandPayload, success: described(MessageV2.WithParts, "Created message"), error: [HttpApiError.BadRequest, HttpApiError.NotFound], @@ -335,6 +356,7 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.post("shell", SessionPaths.shell, { params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, payload: ShellPayload, success: described(MessageV2.WithParts, "Created message"), error: [HttpApiError.BadRequest, HttpApiError.NotFound], @@ -347,6 +369,7 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.post("revert", SessionPaths.revert, { params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, payload: RevertPayload, success: described(Session.Info, "Updated session"), error: [HttpApiError.BadRequest, HttpApiError.NotFound], @@ -360,6 +383,7 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.post("unrevert", SessionPaths.unrevert, { params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, success: described(Session.Info, "Updated session"), error: [HttpApiError.BadRequest, HttpApiError.NotFound], }).annotateMerge( @@ -371,6 +395,7 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.post("permissionRespond", SessionPaths.permissions, { params: { sessionID: SessionID, permissionID: PermissionID }, + query: WorkspaceRoutingQuery, payload: PermissionResponsePayload, success: described(Schema.Boolean, "Permission processed successfully"), error: [HttpApiError.BadRequest, HttpApiError.NotFound], @@ -384,6 +409,7 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.delete("deleteMessage", SessionPaths.deleteMessage, { params: { sessionID: SessionID, messageID: MessageID }, + query: WorkspaceRoutingQuery, success: described(Schema.Boolean, "Successfully deleted message"), error: [HttpApiError.BadRequest, HttpApiError.NotFound], }).annotateMerge( @@ -396,6 +422,7 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.delete("deletePart", SessionPaths.deletePart, { params: { sessionID: SessionID, messageID: MessageID, partID: PartID }, + query: WorkspaceRoutingQuery, success: described(Schema.Boolean, "Successfully deleted part"), error: [HttpApiError.BadRequest, HttpApiError.NotFound], }).annotateMerge( @@ -406,6 +433,7 @@ export const SessionApi = HttpApi.make("session") ), HttpApiEndpoint.patch("updatePart", SessionPaths.updatePart, { params: { sessionID: SessionID, messageID: MessageID, partID: PartID }, + query: WorkspaceRoutingQuery, payload: MessageV2.Part, success: described(MessageV2.Part, "Successfully updated part"), error: [HttpApiError.BadRequest, HttpApiError.NotFound], diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/sync.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/sync.ts index 1603c29c4c..38a93240eb 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/sync.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/sync.ts @@ -4,7 +4,7 @@ import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "../middleware/authorization" import { InstanceContextMiddleware } from "../middleware/instance-context" -import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" +import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing" import { described } from "./metadata" const root = "/sync" @@ -46,6 +46,7 @@ export const SyncApi = HttpApi.make("sync") HttpApiGroup.make("sync") .add( HttpApiEndpoint.post("start", SyncPaths.start, { + query: WorkspaceRoutingQuery, success: described(Schema.Boolean, "Workspace sync started"), }).annotateMerge( OpenApi.annotations({ @@ -55,6 +56,7 @@ export const SyncApi = HttpApi.make("sync") }), ), HttpApiEndpoint.post("replay", SyncPaths.replay, { + query: WorkspaceRoutingQuery, payload: ReplayPayload, success: described(ReplayResponse, "Replayed sync events"), error: HttpApiError.BadRequest, @@ -66,6 +68,7 @@ export const SyncApi = HttpApi.make("sync") }), ), HttpApiEndpoint.post("steal", SyncPaths.steal, { + query: WorkspaceRoutingQuery, payload: SessionPayload, success: described(SessionPayload, "Session stolen into workspace"), error: HttpApiError.BadRequest, @@ -77,6 +80,7 @@ export const SyncApi = HttpApi.make("sync") }), ), HttpApiEndpoint.post("history", SyncPaths.history, { + query: WorkspaceRoutingQuery, payload: HistoryPayload, success: described(Schema.Array(HistoryEvent), "Sync events"), error: HttpApiError.BadRequest, diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/tui.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/tui.ts index 3676fc1964..3cf3de5b8e 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/tui.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/tui.ts @@ -4,7 +4,7 @@ import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "../middleware/authorization" import { InstanceContextMiddleware } from "../middleware/instance-context" -import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" +import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing" import { ApiNotFoundError } from "../errors" import { described } from "./metadata" @@ -54,6 +54,7 @@ export const TuiApi = HttpApi.make("tui") HttpApiGroup.make("tui") .add( HttpApiEndpoint.post("appendPrompt", TuiPaths.appendPrompt, { + query: WorkspaceRoutingQuery, payload: TuiEvent.PromptAppend.properties, success: described(Schema.Boolean, "Prompt processed successfully"), error: HttpApiError.BadRequest, @@ -65,6 +66,7 @@ export const TuiApi = HttpApi.make("tui") }), ), HttpApiEndpoint.post("openHelp", TuiPaths.openHelp, { + query: WorkspaceRoutingQuery, success: described(Schema.Boolean, "Help dialog opened successfully"), }).annotateMerge( OpenApi.annotations({ @@ -74,6 +76,7 @@ export const TuiApi = HttpApi.make("tui") }), ), HttpApiEndpoint.post("openSessions", TuiPaths.openSessions, { + query: WorkspaceRoutingQuery, success: described(Schema.Boolean, "Session dialog opened successfully"), }).annotateMerge( OpenApi.annotations({ @@ -83,6 +86,7 @@ export const TuiApi = HttpApi.make("tui") }), ), HttpApiEndpoint.post("openThemes", TuiPaths.openThemes, { + query: WorkspaceRoutingQuery, success: described(Schema.Boolean, "Theme dialog opened successfully"), }).annotateMerge( OpenApi.annotations({ @@ -92,6 +96,7 @@ export const TuiApi = HttpApi.make("tui") }), ), HttpApiEndpoint.post("openModels", TuiPaths.openModels, { + query: WorkspaceRoutingQuery, success: described(Schema.Boolean, "Model dialog opened successfully"), }).annotateMerge( OpenApi.annotations({ @@ -101,6 +106,7 @@ export const TuiApi = HttpApi.make("tui") }), ), HttpApiEndpoint.post("submitPrompt", TuiPaths.submitPrompt, { + query: WorkspaceRoutingQuery, success: described(Schema.Boolean, "Prompt submitted successfully"), }).annotateMerge( OpenApi.annotations({ @@ -110,6 +116,7 @@ export const TuiApi = HttpApi.make("tui") }), ), HttpApiEndpoint.post("clearPrompt", TuiPaths.clearPrompt, { + query: WorkspaceRoutingQuery, success: described(Schema.Boolean, "Prompt cleared successfully"), }).annotateMerge( OpenApi.annotations({ @@ -119,6 +126,7 @@ export const TuiApi = HttpApi.make("tui") }), ), HttpApiEndpoint.post("executeCommand", TuiPaths.executeCommand, { + query: WorkspaceRoutingQuery, payload: CommandPayload, success: described(Schema.Boolean, "Command executed successfully"), error: HttpApiError.BadRequest, @@ -130,6 +138,7 @@ export const TuiApi = HttpApi.make("tui") }), ), HttpApiEndpoint.post("showToast", TuiPaths.showToast, { + query: WorkspaceRoutingQuery, payload: TuiEvent.ToastShow.properties, success: described(Schema.Boolean, "Toast notification shown successfully"), }).annotateMerge( @@ -140,6 +149,7 @@ export const TuiApi = HttpApi.make("tui") }), ), HttpApiEndpoint.post("publish", TuiPaths.publish, { + query: WorkspaceRoutingQuery, payload: TuiPublishPayload, success: described(Schema.Boolean, "Event published successfully"), error: HttpApiError.BadRequest, @@ -151,6 +161,7 @@ export const TuiApi = HttpApi.make("tui") }), ), HttpApiEndpoint.post("selectSession", TuiPaths.selectSession, { + query: WorkspaceRoutingQuery, payload: TuiEvent.SessionSelect.properties, success: described(Schema.Boolean, "Session selected successfully"), error: [HttpApiError.BadRequest, ApiNotFoundError], @@ -162,6 +173,7 @@ export const TuiApi = HttpApi.make("tui") }), ), HttpApiEndpoint.get("controlNext", TuiPaths.controlNext, { + query: WorkspaceRoutingQuery, success: described(TuiRequestPayload, "Next TUI request"), }).annotateMerge( OpenApi.annotations({ @@ -171,6 +183,7 @@ export const TuiApi = HttpApi.make("tui") }), ), HttpApiEndpoint.post("controlResponse", TuiPaths.controlResponse, { + query: WorkspaceRoutingQuery, payload: Schema.Unknown, success: described(Schema.Boolean, "Response submitted successfully"), }).annotateMerge( diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/message.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/message.ts index 3b0b2fa5b1..060c6c8a83 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/message.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/message.ts @@ -3,46 +3,31 @@ import { SessionMessage } from "@/v2/session-message" import { Schema } from "effect" import { HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "../../middleware/authorization" +import { WorkspaceRoutingQueryFields } from "../../middleware/workspace-routing" + +export const MessagesQuery = Schema.Struct({ + ...WorkspaceRoutingQueryFields, + limit: Schema.optional( + Schema.NumberFromString.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(1), Schema.isLessThanOrEqualTo(200)), + ).annotate({ + description: "Maximum number of messages to return. When omitted, the endpoint returns its default page size.", + }), + order: Schema.optional(Schema.Union([Schema.Literal("asc"), Schema.Literal("desc")])).annotate({ + description: "Message order for the first page. Use desc for newest first or asc for oldest first.", + }), + cursor: Schema.optional( + Schema.String.annotate({ + description: + "Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response. Do not combine with order.", + }), + ), +}).annotate({ identifier: "V2SessionMessagesQuery" }) export const MessageGroup = HttpApiGroup.make("v2.message") .add( HttpApiEndpoint.get("messages", "/api/session/:sessionID/message", { params: { sessionID: SessionID }, - query: Schema.Union([ - Schema.Struct({ - limit: Schema.optional( - Schema.NumberFromString.check( - Schema.isInt(), - Schema.isGreaterThanOrEqualTo(1), - Schema.isLessThanOrEqualTo(200), - ), - ).annotate({ - description: - "Maximum number of messages to return. When omitted, the endpoint returns its default page size.", - }), - order: Schema.optional(Schema.Union([Schema.Literal("asc"), Schema.Literal("desc")])).annotate({ - description: "Message order for the first page. Use desc for newest first or asc for oldest first.", - }), - cursor: Schema.optional(Schema.Never), - }), - Schema.Struct({ - limit: Schema.optional( - Schema.NumberFromString.check( - Schema.isInt(), - Schema.isGreaterThanOrEqualTo(1), - Schema.isLessThanOrEqualTo(200), - ), - ).annotate({ - description: - "Maximum number of messages to return. When omitted, the endpoint returns its default page size.", - }), - cursor: Schema.String.annotate({ - description: - "Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response. Do not combine with order.", - }), - order: Schema.optional(Schema.Never), - }), - ]).annotate({ identifier: "V2SessionMessagesQuery" }), + query: MessagesQuery, success: Schema.Struct({ items: Schema.Array(SessionMessage.Message), cursor: Schema.Struct({ diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/session.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/session.ts index 17ddcaeda3..8b49382a77 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/session.ts @@ -1,4 +1,3 @@ -import { WorkspaceID } from "@/control-plane/schema" import { SessionID } from "@/session/schema" import { SessionMessage } from "@/v2/session-message" import { Prompt } from "@/v2/session-prompt" @@ -6,62 +5,41 @@ import { SessionV2 } from "@/v2/session" import { Schema, SchemaGetter } from "effect" import { HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "../../middleware/authorization" +import { WorkspaceRoutingQuery, WorkspaceRoutingQueryFields } from "../../middleware/workspace-routing" + +const QueryBoolean = Schema.Literals(["true", "false"]).pipe( + Schema.decodeTo(Schema.Boolean, { + decode: SchemaGetter.transform((value) => value === "true"), + encode: SchemaGetter.transform((value) => (value ? "true" : "false")), + }), +) + +export const SessionsQuery = Schema.Struct({ + ...WorkspaceRoutingQueryFields, + limit: Schema.optional( + Schema.NumberFromString.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(1), Schema.isLessThanOrEqualTo(200)), + ).annotate({ + description: "Maximum number of sessions to return. Defaults to the newest 50 sessions.", + }), + order: Schema.optional(Schema.Union([Schema.Literal("asc"), Schema.Literal("desc")])).annotate({ + description: "Session order for the first page. Use desc for newest first or asc for oldest first.", + }), + path: Schema.optional(Schema.String), + roots: Schema.optional(QueryBoolean), + start: Schema.optional(Schema.NumberFromString), + search: Schema.optional(Schema.String), + cursor: Schema.optional( + Schema.String.annotate({ + description: + "Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response. Do not combine with order or filters.", + }), + ), +}).annotate({ identifier: "V2SessionsQuery" }) export const SessionGroup = HttpApiGroup.make("v2.session") .add( HttpApiEndpoint.get("sessions", "/api/session", { - query: Schema.Union([ - Schema.Struct({ - limit: Schema.optional( - Schema.NumberFromString.check( - Schema.isInt(), - Schema.isGreaterThanOrEqualTo(1), - Schema.isLessThanOrEqualTo(200), - ), - ).annotate({ - description: "Maximum number of sessions to return. Defaults to the newest 50 sessions.", - }), - order: Schema.optional(Schema.Union([Schema.Literal("asc"), Schema.Literal("desc")])).annotate({ - description: "Session order for the first page. Use desc for newest first or asc for oldest first.", - }), - directory: Schema.String.pipe(Schema.optional), - path: Schema.String.pipe(Schema.optional), - workspace: WorkspaceID.pipe(Schema.optional), - roots: Schema.Literals(["true", "false"]) - .pipe( - Schema.decodeTo(Schema.Boolean, { - decode: SchemaGetter.transform((value) => value === "true"), - encode: SchemaGetter.transform((value) => (value ? "true" : "false")), - }), - ) - .pipe(Schema.optional), - start: Schema.NumberFromString.pipe(Schema.optional), - search: Schema.String.pipe(Schema.optional), - cursor: Schema.optional(Schema.Never), - }), - Schema.Struct({ - limit: Schema.optional( - Schema.NumberFromString.check( - Schema.isInt(), - Schema.isGreaterThanOrEqualTo(1), - Schema.isLessThanOrEqualTo(200), - ), - ).annotate({ - description: "Maximum number of sessions to return. Defaults to the newest 50 sessions.", - }), - cursor: Schema.String.annotate({ - description: - "Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response. Do not combine with order.", - }), - order: Schema.optional(Schema.Never), - directory: Schema.optional(Schema.Never), - path: Schema.optional(Schema.Never), - workspace: Schema.optional(Schema.Never), - roots: Schema.optional(Schema.Never), - start: Schema.optional(Schema.Never), - search: Schema.optional(Schema.Never), - }), - ]).annotate({ identifier: "V2SessionsQuery" }), + query: SessionsQuery, success: Schema.Struct({ items: Schema.Array(SessionV2.Info), cursor: Schema.Struct({ @@ -82,6 +60,7 @@ export const SessionGroup = HttpApiGroup.make("v2.session") .add( HttpApiEndpoint.post("prompt", "/api/session/:sessionID/prompt", { params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, payload: Schema.Struct({ prompt: Prompt, delivery: SessionV2.Delivery.pipe(Schema.optional), @@ -98,6 +77,7 @@ export const SessionGroup = HttpApiGroup.make("v2.session") .add( HttpApiEndpoint.post("compact", "/api/session/:sessionID/compact", { params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, success: HttpApiSchema.NoContent, }).annotateMerge( OpenApi.annotations({ @@ -110,6 +90,7 @@ export const SessionGroup = HttpApiGroup.make("v2.session") .add( HttpApiEndpoint.post("wait", "/api/session/:sessionID/wait", { params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, success: HttpApiSchema.NoContent, }).annotateMerge( OpenApi.annotations({ @@ -122,6 +103,7 @@ export const SessionGroup = HttpApiGroup.make("v2.session") .add( HttpApiEndpoint.get("context", "/api/session/:sessionID/context", { params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, success: Schema.Array(SessionMessage.Message), }).annotateMerge( OpenApi.annotations({ diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/workspace.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/workspace.ts index 1a9732800d..1c40ae3cb8 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/workspace.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/workspace.ts @@ -5,7 +5,7 @@ import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, Op import { ApiVcsApplyError } from "./instance" import { Authorization } from "../middleware/authorization" import { InstanceContextMiddleware } from "../middleware/instance-context" -import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" +import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing" import { described } from "./metadata" const root = "/experimental/workspace" @@ -40,6 +40,7 @@ export const WorkspaceApi = HttpApi.make("workspace") HttpApiGroup.make("workspace") .add( HttpApiEndpoint.get("adapters", WorkspacePaths.adapters, { + query: WorkspaceRoutingQuery, success: described(Schema.Array(WorkspaceAdapterEntry), "Workspace adapters"), }).annotateMerge( OpenApi.annotations({ @@ -49,6 +50,7 @@ export const WorkspaceApi = HttpApi.make("workspace") }), ), HttpApiEndpoint.get("list", WorkspacePaths.list, { + query: WorkspaceRoutingQuery, success: described(Schema.Array(Workspace.Info), "Workspaces"), }).annotateMerge( OpenApi.annotations({ @@ -58,6 +60,7 @@ export const WorkspaceApi = HttpApi.make("workspace") }), ), HttpApiEndpoint.post("create", WorkspacePaths.list, { + query: WorkspaceRoutingQuery, payload: CreatePayload, success: described(Workspace.Info, "Workspace created"), error: HttpApiError.BadRequest, @@ -69,6 +72,7 @@ export const WorkspaceApi = HttpApi.make("workspace") }), ), HttpApiEndpoint.post("syncList", WorkspacePaths.syncList, { + query: WorkspaceRoutingQuery, success: described(HttpApiSchema.NoContent, "Workspace list synced"), }).annotateMerge( OpenApi.annotations({ @@ -78,6 +82,7 @@ export const WorkspaceApi = HttpApi.make("workspace") }), ), HttpApiEndpoint.get("status", WorkspacePaths.status, { + query: WorkspaceRoutingQuery, success: described(Schema.Array(Workspace.ConnectionStatus), "Workspace status"), }).annotateMerge( OpenApi.annotations({ @@ -88,6 +93,7 @@ export const WorkspaceApi = HttpApi.make("workspace") ), HttpApiEndpoint.delete("remove", WorkspacePaths.remove, { params: { id: Workspace.Info.fields.id }, + query: WorkspaceRoutingQuery, success: described(Schema.UndefinedOr(Workspace.Info), "Workspace removed"), error: HttpApiError.BadRequest, }).annotateMerge( @@ -98,6 +104,7 @@ export const WorkspaceApi = HttpApi.make("workspace") }), ), HttpApiEndpoint.post("warp", WorkspacePaths.warp, { + query: WorkspaceRoutingQuery, payload: WarpPayload, success: described(HttpApiSchema.NoContent, "Session warped"), error: [ApiWorkspaceWarpError, ApiVcsApplyError], diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/message.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/message.ts index 3485d80fd6..92e37142b4 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/message.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/message.ts @@ -34,6 +34,7 @@ export const messageHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.message return handlers.handle( "messages", Effect.fn(function* (ctx) { + if (ctx.query.cursor && ctx.query.order !== undefined) return yield* new HttpApiError.BadRequest({}) const decoded = yield* Effect.try({ try: () => (ctx.query.cursor ? cursor.decode(ctx.query.cursor) : undefined), catch: () => new HttpApiError.BadRequest({}), diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/session.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/session.ts index 558e34dd18..275fa2956c 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/session.ts @@ -22,6 +22,31 @@ type SessionCursor = typeof SessionCursor.Type const decodeCursor = Schema.decodeUnknownSync(SessionCursor) +function hasCursorFilter(query: { + readonly order?: unknown + readonly path?: unknown + readonly roots?: unknown + readonly start?: unknown + readonly search?: unknown +}) { + return ( + query.order !== undefined || + query.path !== undefined || + query.roots !== undefined || + query.start !== undefined || + query.search !== undefined + ) +} + +function hasCursorRoutingMismatch( + query: { readonly directory?: string; readonly workspace?: string }, + decoded: SessionCursor | undefined, +) { + if (!decoded) return false + if (query.directory !== undefined && query.directory !== decoded.directory) return true + return query.workspace !== undefined && query.workspace !== decoded.workspaceID +} + const sessionCursor = { encode( session: SessionV2.Info, @@ -46,10 +71,12 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.session .handle( "sessions", Effect.fn(function* (ctx) { + if (ctx.query.cursor && hasCursorFilter(ctx.query)) return yield* new HttpApiError.BadRequest({}) const decoded = yield* Effect.try({ try: () => (ctx.query.cursor ? sessionCursor.decode(ctx.query.cursor) : undefined), catch: () => new HttpApiError.BadRequest({}), }) + if (hasCursorRoutingMismatch(ctx.query, decoded)) return yield* new HttpApiError.BadRequest({}) const order = decoded?.order ?? ctx.query.order ?? "desc" const filters = decoded ?? { directory: ctx.query.directory, diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts index fd5d534bbc..1d665fd5c9 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts @@ -24,6 +24,8 @@ export const WorkspaceRoutingQueryFields = { workspace: Schema.optional(Schema.String), } +export const WorkspaceRoutingQuery = Schema.Struct(WorkspaceRoutingQueryFields) + type RemoteTarget = Extract type RequestPlan = Data.TaggedEnum<{ diff --git a/packages/opencode/src/server/routes/instance/httpapi/public.ts b/packages/opencode/src/server/routes/instance/httpapi/public.ts index b2ac719a2a..876acf5be5 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/public.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/public.ts @@ -51,30 +51,14 @@ type OpenApiResponse = { content?: Record } -// Instance routes use middleware for directory/workspace resolution, but HttpApi -// doesn't surface middleware query params in the spec. Inject them explicitly. -const InstanceQueryParameters = [ - { - name: "directory", - in: "query", - required: false, - schema: { type: "string" }, - }, - { - name: "workspace", - in: "query", - required: false, - schema: { type: "string" }, - }, -] satisfies OpenApiParameter[] - // Query schemas describe decoded Effect values, but the generated SDK needs the // public call shape. These keep SDK callers passing numbers/booleans while the // server still decodes string query params at runtime. -const QueryNumberParameters = new Set(["start", "cursor", "limit", "method"]) +const QueryNumberParameters = new Set(["start", "limit", "method"]) const QueryBooleanParameters = new Set(["roots", "archived"]) const QueryParameterSchemas = { "GET /find/file limit": { type: "integer", minimum: 1, maximum: 200 }, + "GET /experimental/session cursor": { type: "number" }, "GET /session/{sessionID}/diff messageID": { type: "string", pattern: "^msg.*" }, "GET /session/{sessionID}/message limit": { type: "integer", minimum: 0, maximum: Number.MAX_SAFE_INTEGER }, } satisfies Record @@ -122,7 +106,6 @@ function matchLegacyOpenApi(input: Record) { delete spec.components?.securitySchemes for (const [path, item] of Object.entries(spec.paths ?? {})) { - const isInstanceRoute = !path.startsWith("/global/") && !path.startsWith("/auth/") for (const method of ["get", "post", "put", "delete", "patch"] as const) { const operation = item[method] if (!operation) continue @@ -183,14 +166,8 @@ function matchLegacyOpenApi(input: Record) { }, } } - if (!isInstanceRoute) continue - operation.parameters = [ - ...InstanceQueryParameters, - ...(operation.parameters ?? []).filter( - (param) => param.in !== "query" || (param.name !== "directory" && param.name !== "workspace"), - ), - ] - for (const param of operation.parameters) normalizeParameter(param, `${method.toUpperCase()} ${path}`) + const route = `${method.toUpperCase()} ${path}` + for (const param of operation.parameters ?? []) normalizeParameter(param, route) } } return input diff --git a/packages/opencode/test/server/httpapi-exercise/index.ts b/packages/opencode/test/server/httpapi-exercise/index.ts index 4560973abe..72bf866cb8 100644 --- a/packages/opencode/test/server/httpapi-exercise/index.ts +++ b/packages/opencode/test/server/httpapi-exercise/index.ts @@ -38,6 +38,10 @@ import { type Scenario } from "./types" void (await import("@opencode-ai/core/util/log")).init({ print: false }) +function cursor(input: Record) { + return Buffer.from(JSON.stringify(input)).toString("base64url") +} + const scenarios: Scenario[] = [ http.protected .get("/global/health", "global.health") @@ -598,6 +602,64 @@ const scenarios: Scenario[] = [ }, "none", ), + http.protected + .get("/api/session", "v2.session.list.filters") + .at((ctx) => ({ + path: `/api/session?${new URLSearchParams({ + limit: "2", + order: "asc", + path: ".", + roots: "false", + start: "0", + search: "missing", + directory: ctx.directory ?? "", + })}`, + headers: ctx.headers(), + })) + .json( + 200, + (body) => { + object(body) + array(body.items) + object(body.cursor) + }, + "none", + ), + http.protected + .get("/api/session", "v2.session.list.cursor") + .at((ctx) => ({ + path: `/api/session?${new URLSearchParams({ + limit: "2", + directory: ctx.directory ?? "", + cursor: cursor({ + id: "ses_httpapi_missing", + time: 0, + order: "desc", + direction: "next", + directory: ctx.directory, + }), + })}`, + headers: ctx.headers(), + })) + .json( + 200, + (body) => { + object(body) + array(body.items) + object(body.cursor) + }, + "none", + ), + http.protected + .get("/api/session", "v2.session.list.cursor.invalid") + .at((ctx) => ({ + path: `/api/session?${new URLSearchParams({ + cursor: cursor({ id: "ses_httpapi_missing", time: 0, order: "desc", direction: "next" }), + search: "not-allowed-with-cursor", + })}`, + headers: ctx.headers(), + })) + .status(400, undefined, "none"), http.protected .get("/api/session/{sessionID}/context", "v2.session.context") .at((ctx) => ({ @@ -620,6 +682,53 @@ const scenarios: Scenario[] = [ }, "none", ), + http.protected + .get("/api/session/{sessionID}/message", "v2.session.messages.params") + .at((ctx) => ({ + path: `${route("/api/session/{sessionID}/message", { sessionID: "ses_httpapi_missing" })}?${new URLSearchParams({ + limit: "2", + order: "asc", + })}`, + headers: ctx.headers(), + })) + .json( + 200, + (body) => { + object(body) + array(body.items) + object(body.cursor) + }, + "none", + ), + http.protected + .get("/api/session/{sessionID}/message", "v2.session.messages.cursor") + .at((ctx) => ({ + path: `${route("/api/session/{sessionID}/message", { sessionID: "ses_httpapi_missing" })}?${new URLSearchParams({ + limit: "2", + directory: ctx.directory ?? "", + cursor: cursor({ id: "msg_httpapi_missing", time: 0, order: "desc", direction: "next" }), + })}`, + headers: ctx.headers(), + })) + .json( + 200, + (body) => { + object(body) + array(body.items) + object(body.cursor) + }, + "none", + ), + http.protected + .get("/api/session/{sessionID}/message", "v2.session.messages.cursor.invalid") + .at((ctx) => ({ + path: `${route("/api/session/{sessionID}/message", { sessionID: "ses_httpapi_missing" })}?${new URLSearchParams({ + cursor: cursor({ id: "msg_httpapi_missing", time: 0, order: "desc", direction: "next" }), + order: "asc", + })}`, + headers: ctx.headers(), + })) + .status(400, undefined, "none"), http.protected .post("/api/session/{sessionID}/prompt", "v2.session.prompt.invalid") .at((ctx) => ({ diff --git a/packages/opencode/test/server/httpapi-query-schema-drift.test.ts b/packages/opencode/test/server/httpapi-query-schema-drift.test.ts index 68daeca1e9..ad07dbbb7b 100644 --- a/packages/opencode/test/server/httpapi-query-schema-drift.test.ts +++ b/packages/opencode/test/server/httpapi-query-schema-drift.test.ts @@ -1,14 +1,53 @@ import { afterEach, describe, expect } from "bun:test" import { Effect } from "effect" +import { OpenApi } from "effect/unstable/httpapi" import { Flag } from "@opencode-ai/core/flag/flag" import { Server } from "../../src/server/server" import { SessionID } from "../../src/session/schema" +import { PublicApi } from "../../src/server/routes/instance/httpapi/public" +import { + FilePaths, + FileQuery, + FindFileQuery, + FindTextQuery, +} from "../../src/server/routes/instance/httpapi/groups/file" +import { + ExperimentalPaths, + SessionListQuery as ExperimentalSessionListQuery, + ToolListQuery, +} from "../../src/server/routes/instance/httpapi/groups/experimental" +import { InstancePaths, VcsDiffQuery } from "../../src/server/routes/instance/httpapi/groups/instance" +import { + ListQuery as SessionListQuery, + MessagesQuery, + SessionPaths, +} from "../../src/server/routes/instance/httpapi/groups/session" +import { MessagesQuery as V2MessagesQuery } from "../../src/server/routes/instance/httpapi/groups/v2/message" +import { SessionsQuery as V2SessionsQuery } from "../../src/server/routes/instance/httpapi/groups/v2/session" import { resetDatabase } from "../fixture/db" import { disposeAllInstances, tmpdir } from "../fixture/fixture" import { it } from "../lib/effect" const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES +type Method = "get" | "post" | "put" | "delete" | "patch" +type QuerySchema = { readonly fields: Record } +type OpenApiParameter = { readonly name: string; readonly in: string } +type OpenApiOperation = { readonly parameters?: readonly OpenApiParameter[] } + +const openApiDriftRoutes = [ + { method: "get", path: SessionPaths.list, query: SessionListQuery }, + { method: "get", path: SessionPaths.messages, query: MessagesQuery }, + { method: "get", path: FilePaths.findFile, query: FindFileQuery }, + { method: "get", path: FilePaths.findText, query: FindTextQuery }, + { method: "get", path: FilePaths.list, query: FileQuery }, + { method: "get", path: ExperimentalPaths.session, query: ExperimentalSessionListQuery }, + { method: "get", path: ExperimentalPaths.tool, query: ToolListQuery }, + { method: "get", path: InstancePaths.vcsDiff, query: VcsDiffQuery }, + { method: "get", path: "/api/session", query: V2SessionsQuery }, + { method: "get", path: "/api/session/:sessionID/message", query: V2MessagesQuery }, +] satisfies Array<{ method: Method; path: string; query: QuerySchema }> + function app() { return Server.Default().app } @@ -27,6 +66,29 @@ function withTmp( ).pipe(Effect.flatMap(fn)) } +function openApiPath(path: string) { + return path.replace(/:([A-Za-z0-9_]+)/g, "{$1}") +} + +function queryParameters(operation: OpenApiOperation | undefined) { + return (operation?.parameters ?? []).filter((param) => param.in === "query").map((param) => param.name) +} + +function assertAdvertisedQueryParamsAreRuntimeFields(input: { + readonly method: Method + readonly operation: OpenApiOperation | undefined + readonly path: string + readonly query: QuerySchema +}) { + const runtimeFields = new Set(Object.keys(input.query.fields)) + const advertisedOnly = queryParameters(input.operation).filter((name) => !runtimeFields.has(name)) + + expect( + advertisedOnly, + `${input.method.toUpperCase()} ${input.path} advertises query params not accepted by runtime schema`, + ).toEqual([]) +} + afterEach(async () => { Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces await disposeAllInstances() @@ -44,6 +106,38 @@ describe("httpapi query schema drift", () => { expect(status, `route ${url} 400'd, query schema is missing routing fields`).not.toBe(400) } + it.effect( + "OpenAPI workspace query params are declared by runtime query schemas", + Effect.sync(() => { + const spec = OpenApi.fromApi(PublicApi) + for (const route of openApiDriftRoutes) { + assertAdvertisedQueryParamsAreRuntimeFields({ + ...route, + operation: spec.paths[openApiPath(route.path)]?.[route.method], + }) + } + }), + ) + + it.effect( + "drift assertion catches spec-only workspace query params", + Effect.sync(() => { + expect(() => + assertAdvertisedQueryParamsAreRuntimeFields({ + method: "get", + operation: { + parameters: [ + { name: "directory", in: "query" }, + { name: "workspace", in: "query" }, + ], + }, + path: "/fixture", + query: { fields: {} }, + }), + ).toThrow("advertises query params not accepted by runtime schema") + }), + ) + it.live( "session list accepts directory and workspace", withTmp({ config: { formatter: false, lsp: false } }, (tmp) => diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index f25596011e..bf3201a5c0 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -4163,6 +4163,13 @@ export class Session3 extends HeyApiClient { parameters?: { directory?: string workspace?: string + limit?: number + order?: "asc" | "desc" + path?: string + roots?: boolean | "true" | "false" + start?: number + search?: string + cursor?: string }, options?: Options, ) { @@ -4173,6 +4180,13 @@ export class Session3 extends HeyApiClient { args: [ { in: "query", key: "directory" }, { in: "query", key: "workspace" }, + { in: "query", key: "limit" }, + { in: "query", key: "order" }, + { in: "query", key: "path" }, + { in: "query", key: "roots" }, + { in: "query", key: "start" }, + { in: "query", key: "search" }, + { in: "query", key: "cursor" }, ], }, ], @@ -4331,6 +4345,9 @@ export class Session3 extends HeyApiClient { sessionID: string directory?: string workspace?: string + limit?: number + order?: "asc" | "desc" + cursor?: string }, options?: Options, ) { @@ -4342,6 +4359,9 @@ export class Session3 extends HeyApiClient { { in: "path", key: "sessionID" }, { in: "query", key: "directory" }, { in: "query", key: "workspace" }, + { in: "query", key: "limit" }, + { in: "query", key: "order" }, + { in: "query", key: "cursor" }, ], }, ], diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 186128242f..398a902841 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -5,6 +5,12 @@ export type ClientOptions = { } export type Event = + | EventTuiPromptAppend + | EventTuiCommandExecute + | EventTuiToastShow1 + | EventTuiSessionSelect + | EventServerConnected + | EventGlobalDisposed | EventServerInstanceDisposed | EventFileEdited | EventFileWatcherUpdated @@ -24,10 +30,6 @@ export type Event = | EventSessionStatus | EventSessionIdle | EventSessionCompacted - | EventTuiPromptAppend - | EventTuiCommandExecute - | EventTuiToastShow1 - | EventTuiSessionSelect | EventMcpToolsChanged | EventMcpBrowserOpenFailed | EventCommandExecuted @@ -75,8 +77,6 @@ export type Event = | EventSessionNextCompactionStarted | EventSessionNextCompactionDelta | EventSessionNextCompactionEnded - | EventServerConnected - | EventGlobalDisposed export type OAuth = { type: "oauth" @@ -103,6 +103,61 @@ export type WellKnownAuth = { export type Auth = OAuth | ApiAuth | WellKnownAuth +export type EventTuiPromptAppend = { + id: string + type: "tui.prompt.append" + properties: { + text: string + } +} + +export type EventTuiCommandExecute = { + id: string + type: "tui.command.execute" + properties: { + command: + | "session.list" + | "session.new" + | "session.share" + | "session.interrupt" + | "session.compact" + | "session.page.up" + | "session.page.down" + | "session.line.up" + | "session.line.down" + | "session.half.page.up" + | "session.half.page.down" + | "session.first" + | "session.last" + | "prompt.clear" + | "prompt.submit" + | "agent.cycle" + | string + } +} + +export type EventTuiToastShow = { + id: string + type: "tui.toast.show" + properties: { + title?: string + message: string + variant: "info" | "success" | "warning" | "error" + duration?: number + } +} + +export type EventTuiSessionSelect = { + id: string + type: "tui.session.select" + properties: { + /** + * Session ID to navigate to + */ + sessionID: string + } +} + export type PermissionRequest = { id: string sessionID: string @@ -280,61 +335,6 @@ export type SessionStatus = type: "busy" } -export type EventTuiPromptAppend = { - id: string - type: "tui.prompt.append" - properties: { - text: string - } -} - -export type EventTuiCommandExecute = { - id: string - type: "tui.command.execute" - properties: { - command: - | "session.list" - | "session.new" - | "session.share" - | "session.interrupt" - | "session.compact" - | "session.page.up" - | "session.page.down" - | "session.line.up" - | "session.line.down" - | "session.half.page.up" - | "session.half.page.down" - | "session.first" - | "session.last" - | "prompt.clear" - | "prompt.submit" - | "agent.cycle" - | string - } -} - -export type EventTuiToastShow = { - id: string - type: "tui.toast.show" - properties: { - title?: string - message: string - variant: "info" | "success" | "warning" | "error" - duration?: number - } -} - -export type EventTuiSessionSelect = { - id: string - type: "tui.session.select" - properties: { - /** - * Session ID to navigate to - */ - sessionID: string - } -} - export type Project = { id: string worktree: string @@ -778,6 +778,12 @@ export type GlobalEvent = { project?: string workspace?: string payload: + | EventTuiPromptAppend + | EventTuiCommandExecute + | EventTuiToastShow + | EventTuiSessionSelect + | EventServerConnected + | EventGlobalDisposed | EventServerInstanceDisposed | EventFileEdited | EventFileWatcherUpdated @@ -797,10 +803,6 @@ export type GlobalEvent = { | EventSessionStatus | EventSessionIdle | EventSessionCompacted - | EventTuiPromptAppend - | EventTuiCommandExecute - | EventTuiToastShow - | EventTuiSessionSelect | EventMcpToolsChanged | EventMcpBrowserOpenFailed | EventCommandExecuted @@ -848,8 +850,6 @@ export type GlobalEvent = { | EventSessionNextCompactionStarted | EventSessionNextCompactionDelta | EventSessionNextCompactionEnded - | EventServerConnected - | EventGlobalDisposed | SyncEventMessageUpdated | SyncEventMessageRemoved | SyncEventMessagePartUpdated @@ -2318,6 +2318,22 @@ export type SyncEventSessionNextCompactionEnded = { } } +export type EventServerConnected = { + id: string + type: "server.connected" + properties: { + [key: string]: unknown + } +} + +export type EventGlobalDisposed = { + id: string + type: "global.disposed" + properties: { + [key: string]: unknown + } +} + export type EventServerInstanceDisposed = { id: string type: "server.instance.disposed" @@ -3032,22 +3048,6 @@ export type EventSessionNextCompactionEnded = { } } -export type EventServerConnected = { - id: string - type: "server.connected" - properties: { - [key: string]: unknown - } -} - -export type EventGlobalDisposed = { - id: string - type: "global.disposed" - properties: { - [key: string]: unknown - } -} - export type SessionInfo = { id: string parentID?: string @@ -6235,6 +6235,16 @@ export type V2SessionListData = { query?: { directory?: string workspace?: string + limit?: number + order?: "asc" | "desc" + path?: string + roots?: boolean | "true" | "false" + start?: number + search?: string + /** + * Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response. Do not combine with order or filters. + */ + cursor?: string } url: "/api/session" } @@ -6352,6 +6362,12 @@ export type V2SessionMessagesData = { query?: { directory?: string workspace?: string + limit?: number + order?: "asc" | "desc" + /** + * Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response. Do not combine with order. + */ + cursor?: string } url: "/api/session/{sessionID}/message" } From 6d130e5deb5fbcb25c103e1557f4f2fe31b4774f Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sun, 10 May 2026 01:13:41 +0000 Subject: [PATCH 098/189] chore: generate --- packages/sdk/js/src/v2/gen/types.gen.ts | 166 +- packages/sdk/openapi.json | 2180 ++++++++++++----------- 2 files changed, 1219 insertions(+), 1127 deletions(-) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 398a902841..4779f7cebd 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -5,12 +5,6 @@ export type ClientOptions = { } export type Event = - | EventTuiPromptAppend - | EventTuiCommandExecute - | EventTuiToastShow1 - | EventTuiSessionSelect - | EventServerConnected - | EventGlobalDisposed | EventServerInstanceDisposed | EventFileEdited | EventFileWatcherUpdated @@ -30,6 +24,10 @@ export type Event = | EventSessionStatus | EventSessionIdle | EventSessionCompacted + | EventTuiPromptAppend + | EventTuiCommandExecute + | EventTuiToastShow1 + | EventTuiSessionSelect | EventMcpToolsChanged | EventMcpBrowserOpenFailed | EventCommandExecuted @@ -77,6 +75,8 @@ export type Event = | EventSessionNextCompactionStarted | EventSessionNextCompactionDelta | EventSessionNextCompactionEnded + | EventServerConnected + | EventGlobalDisposed export type OAuth = { type: "oauth" @@ -103,61 +103,6 @@ export type WellKnownAuth = { export type Auth = OAuth | ApiAuth | WellKnownAuth -export type EventTuiPromptAppend = { - id: string - type: "tui.prompt.append" - properties: { - text: string - } -} - -export type EventTuiCommandExecute = { - id: string - type: "tui.command.execute" - properties: { - command: - | "session.list" - | "session.new" - | "session.share" - | "session.interrupt" - | "session.compact" - | "session.page.up" - | "session.page.down" - | "session.line.up" - | "session.line.down" - | "session.half.page.up" - | "session.half.page.down" - | "session.first" - | "session.last" - | "prompt.clear" - | "prompt.submit" - | "agent.cycle" - | string - } -} - -export type EventTuiToastShow = { - id: string - type: "tui.toast.show" - properties: { - title?: string - message: string - variant: "info" | "success" | "warning" | "error" - duration?: number - } -} - -export type EventTuiSessionSelect = { - id: string - type: "tui.session.select" - properties: { - /** - * Session ID to navigate to - */ - sessionID: string - } -} - export type PermissionRequest = { id: string sessionID: string @@ -335,6 +280,61 @@ export type SessionStatus = type: "busy" } +export type EventTuiPromptAppend = { + id: string + type: "tui.prompt.append" + properties: { + text: string + } +} + +export type EventTuiCommandExecute = { + id: string + type: "tui.command.execute" + properties: { + command: + | "session.list" + | "session.new" + | "session.share" + | "session.interrupt" + | "session.compact" + | "session.page.up" + | "session.page.down" + | "session.line.up" + | "session.line.down" + | "session.half.page.up" + | "session.half.page.down" + | "session.first" + | "session.last" + | "prompt.clear" + | "prompt.submit" + | "agent.cycle" + | string + } +} + +export type EventTuiToastShow = { + id: string + type: "tui.toast.show" + properties: { + title?: string + message: string + variant: "info" | "success" | "warning" | "error" + duration?: number + } +} + +export type EventTuiSessionSelect = { + id: string + type: "tui.session.select" + properties: { + /** + * Session ID to navigate to + */ + sessionID: string + } +} + export type Project = { id: string worktree: string @@ -778,12 +778,6 @@ export type GlobalEvent = { project?: string workspace?: string payload: - | EventTuiPromptAppend - | EventTuiCommandExecute - | EventTuiToastShow - | EventTuiSessionSelect - | EventServerConnected - | EventGlobalDisposed | EventServerInstanceDisposed | EventFileEdited | EventFileWatcherUpdated @@ -803,6 +797,10 @@ export type GlobalEvent = { | EventSessionStatus | EventSessionIdle | EventSessionCompacted + | EventTuiPromptAppend + | EventTuiCommandExecute + | EventTuiToastShow + | EventTuiSessionSelect | EventMcpToolsChanged | EventMcpBrowserOpenFailed | EventCommandExecuted @@ -850,6 +848,8 @@ export type GlobalEvent = { | EventSessionNextCompactionStarted | EventSessionNextCompactionDelta | EventSessionNextCompactionEnded + | EventServerConnected + | EventGlobalDisposed | SyncEventMessageUpdated | SyncEventMessageRemoved | SyncEventMessagePartUpdated @@ -2318,22 +2318,6 @@ export type SyncEventSessionNextCompactionEnded = { } } -export type EventServerConnected = { - id: string - type: "server.connected" - properties: { - [key: string]: unknown - } -} - -export type EventGlobalDisposed = { - id: string - type: "global.disposed" - properties: { - [key: string]: unknown - } -} - export type EventServerInstanceDisposed = { id: string type: "server.instance.disposed" @@ -3048,6 +3032,22 @@ export type EventSessionNextCompactionEnded = { } } +export type EventServerConnected = { + id: string + type: "server.connected" + properties: { + [key: string]: unknown + } +} + +export type EventGlobalDisposed = { + id: string + type: "global.disposed" + properties: { + [key: string]: unknown + } +} + export type SessionInfo = { id: string parentID?: string diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 3717f57ec0..c683966881 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -115,18 +115,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -446,18 +446,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -490,18 +490,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -532,18 +532,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -595,18 +595,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -656,18 +656,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -710,18 +710,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -797,18 +797,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -861,18 +861,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "provider", @@ -931,18 +931,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -985,18 +985,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -1031,18 +1031,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -1092,18 +1092,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -1156,18 +1156,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -1220,18 +1220,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "roots", @@ -1332,18 +1332,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -1380,18 +1380,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "pattern", @@ -1496,18 +1496,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "query", @@ -1580,18 +1580,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "query", @@ -1636,18 +1636,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "path", @@ -1692,18 +1692,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "path", @@ -1744,18 +1744,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -1792,18 +1792,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -1837,18 +1837,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -1881,18 +1881,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -1925,18 +1925,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -1973,18 +1973,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "mode", @@ -2030,18 +2030,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -2074,18 +2074,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -2152,18 +2152,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -2200,18 +2200,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -2248,18 +2248,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -2312,18 +2312,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -2360,18 +2360,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -2408,18 +2408,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -2454,18 +2454,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -2535,22 +2535,6 @@ "tags": ["mcp"], "operationId": "mcp.auth.start", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "name", "in": "path", @@ -2558,6 +2542,22 @@ "type": "string" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -2616,22 +2616,6 @@ "tags": ["mcp"], "operationId": "mcp.auth.remove", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "name", "in": "path", @@ -2639,6 +2623,22 @@ "type": "string" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -2687,22 +2687,6 @@ "tags": ["mcp"], "operationId": "mcp.auth.callback", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "name", "in": "path", @@ -2710,6 +2694,22 @@ "type": "string" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -2775,22 +2775,6 @@ "tags": ["mcp"], "operationId": "mcp.auth.authenticate", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "name", "in": "path", @@ -2798,6 +2782,22 @@ "type": "string" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -2847,22 +2847,6 @@ "tags": ["mcp"], "operationId": "mcp.connect", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "name", "in": "path", @@ -2870,6 +2854,22 @@ "type": "string" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -2899,22 +2899,6 @@ "tags": ["mcp"], "operationId": "mcp.disconnect", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "name", "in": "path", @@ -2922,6 +2906,22 @@ "type": "string" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -2954,18 +2954,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -3002,18 +3002,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -3046,18 +3046,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -3087,22 +3087,6 @@ "tags": ["project"], "operationId": "project.update", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "projectID", "in": "path", @@ -3110,6 +3094,22 @@ "type": "string" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -3202,18 +3202,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -3263,18 +3263,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -3309,18 +3309,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -3393,22 +3393,6 @@ "tags": ["pty"], "operationId": "pty.get", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "ptyID", "in": "path", @@ -3417,6 +3401,22 @@ "pattern": "^pty.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -3454,22 +3454,6 @@ "tags": ["pty"], "operationId": "pty.update", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "ptyID", "in": "path", @@ -3478,6 +3462,22 @@ "pattern": "^pty.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -3545,22 +3545,6 @@ "tags": ["pty"], "operationId": "pty.remove", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "ptyID", "in": "path", @@ -3569,6 +3553,22 @@ "pattern": "^pty.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -3609,22 +3609,6 @@ "tags": ["pty"], "operationId": "pty.connectToken", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "ptyID", "in": "path", @@ -3633,6 +3617,22 @@ "pattern": "^pty.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -3697,18 +3697,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -3742,22 +3742,6 @@ "tags": ["question"], "operationId": "question.reply", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "requestID", "in": "path", @@ -3766,6 +3750,22 @@ "pattern": "^que.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -3836,22 +3836,6 @@ "tags": ["question"], "operationId": "question.reject", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "requestID", "in": "path", @@ -3860,6 +3844,22 @@ "pattern": "^que.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -3913,18 +3913,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -3958,22 +3958,6 @@ "tags": ["permission"], "operationId": "permission.reply", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "requestID", "in": "path", @@ -3982,6 +3966,22 @@ "pattern": "^per.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -4055,18 +4055,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -4122,18 +4122,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -4170,22 +4170,6 @@ "tags": ["provider"], "operationId": "provider.oauth.authorize", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "providerID", "in": "path", @@ -4193,6 +4177,22 @@ "type": "string" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -4255,22 +4255,6 @@ "tags": ["provider"], "operationId": "provider.oauth.callback", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "providerID", "in": "path", @@ -4278,6 +4262,22 @@ "type": "string" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -4341,18 +4341,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "scope", @@ -4444,18 +4444,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -4541,18 +4541,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -4596,22 +4596,6 @@ "tags": ["session"], "operationId": "session.get", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", @@ -4620,6 +4604,22 @@ "pattern": "^ses.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -4667,22 +4667,6 @@ "tags": ["session"], "operationId": "session.delete", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", @@ -4691,6 +4675,22 @@ "pattern": "^ses.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -4739,22 +4739,6 @@ "tags": ["session"], "operationId": "session.update", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", @@ -4763,6 +4747,22 @@ "pattern": "^ses.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -4839,22 +4839,6 @@ "tags": ["session"], "operationId": "session.children", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", @@ -4863,6 +4847,22 @@ "pattern": "^ses.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -4916,22 +4916,6 @@ "tags": ["session"], "operationId": "session.todo", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", @@ -4940,6 +4924,22 @@ "pattern": "^ses.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -4993,22 +4993,6 @@ "tags": ["session"], "operationId": "session.diff", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", @@ -5018,6 +5002,22 @@ }, "required": true }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, { "name": "messageID", "in": "query", @@ -5059,22 +5059,6 @@ "tags": ["session"], "operationId": "session.messages", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", @@ -5084,6 +5068,22 @@ }, "required": true }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, { "name": "limit", "in": "query", @@ -5165,22 +5165,6 @@ "tags": ["session"], "operationId": "session.prompt", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", @@ -5189,6 +5173,22 @@ "pattern": "^ses.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -5319,22 +5319,6 @@ "tags": ["session"], "operationId": "session.message", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", @@ -5352,6 +5336,22 @@ "pattern": "^msg.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -5413,22 +5413,6 @@ "tags": ["session"], "operationId": "session.deleteMessage", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", @@ -5446,6 +5430,22 @@ "pattern": "^msg.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -5496,22 +5496,6 @@ "tags": ["session"], "operationId": "session.fork", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", @@ -5520,6 +5504,22 @@ "pattern": "^ses.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -5574,22 +5574,6 @@ "tags": ["session"], "operationId": "session.abort", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", @@ -5598,6 +5582,22 @@ "pattern": "^ses.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -5648,22 +5648,6 @@ "tags": ["session"], "operationId": "session.init", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", @@ -5672,6 +5656,22 @@ "pattern": "^ses.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -5744,22 +5744,6 @@ "tags": ["session"], "operationId": "session.share", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", @@ -5768,6 +5752,22 @@ "pattern": "^ses.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -5815,22 +5815,6 @@ "tags": ["session"], "operationId": "session.unshare", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", @@ -5839,6 +5823,22 @@ "pattern": "^ses.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -5888,22 +5888,6 @@ "tags": ["session"], "operationId": "session.summarize", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", @@ -5912,6 +5896,22 @@ "pattern": "^ses.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -5984,22 +5984,6 @@ "tags": ["session"], "operationId": "session.prompt_async", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", @@ -6008,6 +5992,22 @@ "pattern": "^ses.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -6119,22 +6119,6 @@ "tags": ["session"], "operationId": "session.command", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", @@ -6143,6 +6127,22 @@ "pattern": "^ses.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -6264,22 +6264,6 @@ "tags": ["session"], "operationId": "session.shell", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", @@ -6288,6 +6272,22 @@ "pattern": "^ses.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -6386,22 +6386,6 @@ "tags": ["session"], "operationId": "session.revert", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", @@ -6410,6 +6394,22 @@ "pattern": "^ses.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -6478,22 +6478,6 @@ "tags": ["session"], "operationId": "session.unrevert", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", @@ -6502,6 +6486,22 @@ "pattern": "^ses.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -6551,22 +6551,6 @@ "tags": ["session"], "operationId": "permission.respond", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", @@ -6584,6 +6568,22 @@ "pattern": "^per.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -6652,22 +6652,6 @@ "tags": ["session"], "operationId": "part.delete", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", @@ -6694,6 +6678,22 @@ "pattern": "^prt.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -6741,22 +6741,6 @@ "tags": ["session"], "operationId": "part.update", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", @@ -6783,6 +6767,22 @@ "pattern": "^prt.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -6843,18 +6843,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -6888,18 +6888,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -6993,18 +6993,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -7071,18 +7071,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -7162,18 +7162,84 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false + }, + { + "name": "limit", + "in": "query", + "schema": { + "type": "number" + }, + "required": false + }, + { + "name": "order", + "in": "query", + "schema": { + "type": "string", + "enum": ["asc", "desc"] + }, + "required": false + }, + { + "name": "path", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "roots", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "enum": ["true", "false"] + } + ] + }, + "required": false + }, + { + "name": "start", + "in": "query", + "schema": { + "type": "number" + }, + "required": false + }, + { + "name": "search", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "cursor", + "in": "query", + "schema": { + "type": "string", + "description": "Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response. Do not combine with order or filters." + }, + "required": false } ], "responses": { @@ -7213,22 +7279,6 @@ "tags": ["v2"], "operationId": "v2.session.prompt", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", @@ -7237,6 +7287,22 @@ "pattern": "^ses.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -7285,22 +7351,6 @@ "tags": ["v2"], "operationId": "v2.session.compact", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", @@ -7309,6 +7359,22 @@ "pattern": "^ses.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -7331,22 +7397,6 @@ "tags": ["v2"], "operationId": "v2.session.wait", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", @@ -7355,6 +7405,22 @@ "pattern": "^ses.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -7377,22 +7443,6 @@ "tags": ["v2"], "operationId": "v2.session.context", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", @@ -7401,6 +7451,22 @@ "pattern": "^ses.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -7433,22 +7499,6 @@ "tags": ["v2 messages"], "operationId": "v2.session.messages", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "sessionID", "in": "path", @@ -7457,6 +7507,48 @@ "pattern": "^ses.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "limit", + "in": "query", + "schema": { + "type": "number" + }, + "required": false + }, + { + "name": "order", + "in": "query", + "schema": { + "type": "string", + "enum": ["asc", "desc"] + }, + "required": false + }, + { + "name": "cursor", + "in": "query", + "schema": { + "type": "string", + "description": "Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response. Do not combine with order." + }, + "required": false } ], "responses": { @@ -7499,18 +7591,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -7570,18 +7662,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -7615,18 +7707,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -7660,18 +7752,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -7705,18 +7797,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -7750,18 +7842,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -7795,18 +7887,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -7840,18 +7932,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -7911,18 +8003,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -7983,18 +8075,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -8060,18 +8152,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -8142,18 +8234,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -8195,18 +8287,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -8247,18 +8339,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -8308,18 +8400,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -8354,18 +8446,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -8445,18 +8537,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -8482,18 +8574,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -8538,22 +8630,6 @@ "tags": ["workspace"], "operationId": "experimental.workspace.remove", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "id", "in": "path", @@ -8562,6 +8638,22 @@ "pattern": "^wrk.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { @@ -8604,18 +8696,18 @@ { "name": "directory", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false }, { "name": "workspace", "in": "query", - "required": false, "schema": { "type": "string" - } + }, + "required": false } ], "responses": { @@ -8684,22 +8776,6 @@ "tags": ["pty"], "operationId": "pty.connect", "parameters": [ - { - "name": "directory", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "workspace", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, { "name": "ptyID", "in": "path", @@ -8708,6 +8784,22 @@ "pattern": "^pty.*" }, "required": true + }, + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { From 16866e1180dfeb39505456ec6d69c35e1ab16951 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 9 May 2026 21:41:15 -0400 Subject: [PATCH 099/189] Share HTTP API boolean query schema (#26615) --- .../specs/openapi-translation-cleanup.md | 2 +- .../instance/httpapi/groups/experimental.ts | 9 ++------- .../routes/instance/httpapi/groups/query.ts | 8 ++++++++ .../routes/instance/httpapi/groups/session.ts | 9 ++------- .../instance/httpapi/groups/v2/session.ts | 10 ++-------- .../test/server/httpapi-exercise/index.ts | 5 ++++- .../server/httpapi-query-schema-drift.test.ts | 20 ++++++++++++++++++- 7 files changed, 38 insertions(+), 25 deletions(-) create mode 100644 packages/opencode/src/server/routes/instance/httpapi/groups/query.ts diff --git a/packages/opencode/specs/openapi-translation-cleanup.md b/packages/opencode/specs/openapi-translation-cleanup.md index abf8fb2c4c..b61c07feb1 100644 --- a/packages/opencode/specs/openapi-translation-cleanup.md +++ b/packages/opencode/specs/openapi-translation-cleanup.md @@ -84,7 +84,7 @@ Verification: Concrete first targets: -- Replace `roots` / `archived` reliance on `QueryBooleanParameters` with explicit route schema helpers. +- `[x]` Consolidate `roots` / `archived` onto an explicit shared route schema helper. Keep `QueryBooleanParameters` until route-level schema metadata can preserve the SDK's `boolean | "true" | "false"` call shape without a global transform. - Replace `start` / `cursor` / `limit` reliance on `QueryNumberParameters` with explicit route schema constraints where missing. - Keep `GET /find/file limit`, `GET /session/{sessionID}/diff messageID`, and `GET /session/{sessionID}/message limit` overrides until their route schemas generate identical SDK types directly. diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts index 411e7398f8..99a8a21a9e 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts @@ -4,7 +4,7 @@ import { ProviderID, ModelID } from "@/provider/schema" import { Session } from "@/session/session" import { Worktree } from "@/worktree" import { NonNegativeInt } from "@opencode-ai/core/schema" -import { Schema, SchemaGetter } from "effect" +import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "../middleware/authorization" import { InstanceContextMiddleware } from "../middleware/instance-context" @@ -14,6 +14,7 @@ import { WorkspaceRoutingQueryFields, } from "../middleware/workspace-routing" import { described } from "./metadata" +import { QueryBoolean } from "./query" const ConsoleStateResponse = Schema.Struct({ consoleManagedProviders: Schema.mutable(Schema.Array(Schema.String)), @@ -52,12 +53,6 @@ export const ToolListQuery = Schema.Struct({ model: ModelID, }) -const QueryBoolean = Schema.Literals(["true", "false"]).pipe( - Schema.decodeTo(Schema.Boolean, { - decode: SchemaGetter.transform((value) => value === "true"), - encode: SchemaGetter.transform((value) => (value ? "true" : "false")), - }), -) const WorktreeList = Schema.Array(Schema.String) export const SessionListQuery = Schema.Struct({ ...WorkspaceRoutingQueryFields, diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/query.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/query.ts new file mode 100644 index 0000000000..d5b10d1800 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/query.ts @@ -0,0 +1,8 @@ +import { Schema, SchemaGetter } from "effect" + +export const QueryBoolean = Schema.Literals(["true", "false"]).pipe( + Schema.decodeTo(Schema.Boolean, { + decode: SchemaGetter.transform((value) => value === "true"), + encode: SchemaGetter.transform((value) => (value ? "true" : "false")), + }), +) diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts index 4a11db09a9..ea68e76caf 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts @@ -10,7 +10,7 @@ import { SessionSummary } from "@/session/summary" import { Todo } from "@/session/todo" import { MessageID, PartID, SessionID } from "@/session/schema" import { Snapshot } from "@/snapshot" -import { Schema, SchemaGetter, Struct } from "effect" +import { Schema, Struct } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "../middleware/authorization" import { InstanceContextMiddleware } from "../middleware/instance-context" @@ -21,14 +21,9 @@ import { } from "../middleware/workspace-routing" import { ApiNotFoundError } from "../errors" import { described } from "./metadata" +import { QueryBoolean } from "./query" const root = "/session" -const QueryBoolean = Schema.Literals(["true", "false"]).pipe( - Schema.decodeTo(Schema.Boolean, { - decode: SchemaGetter.transform((value) => value === "true"), - encode: SchemaGetter.transform((value) => (value ? "true" : "false")), - }), -) export const ListQuery = Schema.Struct({ ...WorkspaceRoutingQueryFields, scope: Schema.optional(Schema.Literals(["project"])), diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/session.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/session.ts index 8b49382a77..231f1915bb 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/session.ts @@ -2,17 +2,11 @@ import { SessionID } from "@/session/schema" import { SessionMessage } from "@/v2/session-message" import { Prompt } from "@/v2/session-prompt" import { SessionV2 } from "@/v2/session" -import { Schema, SchemaGetter } from "effect" +import { Schema } from "effect" import { HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "../../middleware/authorization" import { WorkspaceRoutingQuery, WorkspaceRoutingQueryFields } from "../../middleware/workspace-routing" - -const QueryBoolean = Schema.Literals(["true", "false"]).pipe( - Schema.decodeTo(Schema.Boolean, { - decode: SchemaGetter.transform((value) => value === "true"), - encode: SchemaGetter.transform((value) => (value ? "true" : "false")), - }), -) +import { QueryBoolean } from "../query" export const SessionsQuery = Schema.Struct({ ...WorkspaceRoutingQueryFields, diff --git a/packages/opencode/test/server/httpapi-exercise/index.ts b/packages/opencode/test/server/httpapi-exercise/index.ts index 72bf866cb8..0d6bec2dfe 100644 --- a/packages/opencode/test/server/httpapi-exercise/index.ts +++ b/packages/opencode/test/server/httpapi-exercise/index.ts @@ -524,7 +524,10 @@ const scenarios: Scenario[] = [ yield* ctx.worktreeRemove(ctx.state.directory) }), ), - http.protected.get("/experimental/session", "experimental.session.list").json(200, array), + http.protected + .get("/experimental/session", "experimental.session.list") + .at((ctx) => ({ path: "/experimental/session?roots=false&archived=false", headers: ctx.headers() })) + .json(200, array), http.protected.get("/experimental/resource", "experimental.resource.list").json(), http.protected .post("/sync/history", "sync.history.list") diff --git a/packages/opencode/test/server/httpapi-query-schema-drift.test.ts b/packages/opencode/test/server/httpapi-query-schema-drift.test.ts index ad07dbbb7b..1791a61f56 100644 --- a/packages/opencode/test/server/httpapi-query-schema-drift.test.ts +++ b/packages/opencode/test/server/httpapi-query-schema-drift.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect } from "bun:test" -import { Effect } from "effect" +import { Effect, Schema } from "effect" import { OpenApi } from "effect/unstable/httpapi" import { Flag } from "@opencode-ai/core/flag/flag" import { Server } from "../../src/server/server" @@ -24,6 +24,7 @@ import { } from "../../src/server/routes/instance/httpapi/groups/session" import { MessagesQuery as V2MessagesQuery } from "../../src/server/routes/instance/httpapi/groups/v2/message" import { SessionsQuery as V2SessionsQuery } from "../../src/server/routes/instance/httpapi/groups/v2/session" +import { QueryBoolean } from "../../src/server/routes/instance/httpapi/groups/query" import { resetDatabase } from "../fixture/db" import { disposeAllInstances, tmpdir } from "../fixture/fixture" import { it } from "../lib/effect" @@ -106,6 +107,23 @@ describe("httpapi query schema drift", () => { expect(status, `route ${url} 400'd, query schema is missing routing fields`).not.toBe(400) } + it.effect( + "boolean query schema accepts only true and false strings", + Effect.sync(() => { + const decode = Schema.decodeUnknownSync(QueryBoolean) + const encode = Schema.encodeUnknownSync(QueryBoolean) + + expect(decode("true")).toBe(true) + expect(decode("false")).toBe(false) + expect(encode(true)).toBe("true") + expect(encode(false)).toBe("false") + + for (const input of ["1", "yes", "True", "", true, false]) { + expect(() => decode(input)).toThrow() + } + }), + ) + it.effect( "OpenAPI workspace query params are declared by runtime query schemas", Effect.sync(() => { From 6e78f36a0f8e3fc80da216780fb74cae53a92820 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 9 May 2026 22:02:51 -0400 Subject: [PATCH 100/189] Narrow HTTP API numeric query overrides (#26618) --- .../specs/openapi-translation-cleanup.md | 2 +- .../server/routes/instance/httpapi/public.ts | 32 ++++++++-------- .../server/httpapi-query-schema-drift.test.ts | 38 ++++++++++++++++++- 3 files changed, 55 insertions(+), 17 deletions(-) diff --git a/packages/opencode/specs/openapi-translation-cleanup.md b/packages/opencode/specs/openapi-translation-cleanup.md index b61c07feb1..55e4c7268d 100644 --- a/packages/opencode/specs/openapi-translation-cleanup.md +++ b/packages/opencode/specs/openapi-translation-cleanup.md @@ -85,7 +85,7 @@ Verification: Concrete first targets: - `[x]` Consolidate `roots` / `archived` onto an explicit shared route schema helper. Keep `QueryBooleanParameters` until route-level schema metadata can preserve the SDK's `boolean | "true" | "false"` call shape without a global transform. -- Replace `start` / `cursor` / `limit` reliance on `QueryNumberParameters` with explicit route schema constraints where missing. +- `[x]` Replace broad `QueryNumberParameters` reliance for `start` / `cursor` / `limit` with route-specific SDK compatibility schemas. Keep improving route-level constraints where behavior is intentionally stricter. - Keep `GET /find/file limit`, `GET /session/{sessionID}/diff messageID`, and `GET /session/{sessionID}/message limit` overrides until their route schemas generate identical SDK types directly. Verification: diff --git a/packages/opencode/src/server/routes/instance/httpapi/public.ts b/packages/opencode/src/server/routes/instance/httpapi/public.ts index 876acf5be5..156ebf6834 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/public.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/public.ts @@ -54,28 +54,34 @@ type OpenApiResponse = { // Query schemas describe decoded Effect values, but the generated SDK needs the // public call shape. These keep SDK callers passing numbers/booleans while the // server still decodes string query params at runtime. -const QueryNumberParameters = new Set(["start", "limit", "method"]) const QueryBooleanParameters = new Set(["roots", "archived"]) -const QueryParameterSchemas = { +const QueryParameterSchemas: Record = { + "GET /experimental/session start": { type: "number" }, "GET /find/file limit": { type: "integer", minimum: 1, maximum: 200 }, "GET /experimental/session cursor": { type: "number" }, + "GET /experimental/session limit": { type: "number" }, + "GET /session start": { type: "number" }, + "GET /session limit": { type: "number" }, "GET /session/{sessionID}/diff messageID": { type: "string", pattern: "^msg.*" }, "GET /session/{sessionID}/message limit": { type: "integer", minimum: 0, maximum: Number.MAX_SAFE_INTEGER }, -} satisfies Record + "GET /api/session limit": { type: "number" }, + "GET /api/session start": { type: "number" }, + "GET /api/session/{sessionID}/message limit": { type: "number" }, +} -const PathParameterSchemas = { +const PathParameterSchemas: Record = { sessionID: { type: "string", pattern: "^ses.*" }, messageID: { type: "string", pattern: "^msg.*" }, partID: { type: "string", pattern: "^prt.*" }, permissionID: { type: "string", pattern: "^per.*" }, ptyID: { type: "string", pattern: "^pty.*" }, -} satisfies Record +} -const LegacyComponentDescriptions = { +const LegacyComponentDescriptions: Record = { LogLevel: "Log level", ServerConfig: "Server configuration for opencode serve and web commands", LayoutConfig: "@deprecated Always uses stretch layout.", -} satisfies Record +} function matchLegacyOpenApi(input: Record) { const spec = input as OpenApiSpec @@ -269,7 +275,7 @@ function applyLegacySchemaOverrides(spec: OpenApiSpec) { function normalizeComponentDescriptions(spec: OpenApiSpec) { for (const [name, schema] of Object.entries(spec.components?.schemas ?? {})) { - const description = LegacyComponentDescriptions[name as keyof typeof LegacyComponentDescriptions] + const description = LegacyComponentDescriptions[name] if (description) { schema.description = description continue @@ -415,7 +421,7 @@ function fixSelfReferencingComponents(spec: OpenApiSpec) { } } // Simplest fix: generate the raw spec (without transform) to get correct schemas - const raw = OpenApi.fromApi(OpenCodeHttpApi) as unknown as OpenApiSpec + const raw: OpenApiSpec = OpenApi.fromApi(OpenCodeHttpApi) const rawSchemas = raw.components?.schemas if (!rawSchemas) return for (const name of selfRefs) { @@ -484,15 +490,11 @@ function normalizeParameter(param: OpenApiParameter, route: string) { return } if (param.in === "query") { - const override = QueryParameterSchemas[`${route} ${param.name}` as keyof typeof QueryParameterSchemas] + const override = QueryParameterSchemas[`${route} ${param.name}`] if (override) { param.schema = override return } - if (QueryNumberParameters.has(param.name)) { - param.schema = { type: "number" } - return - } if (QueryBooleanParameters.has(param.name)) { param.schema = { anyOf: [{ type: "boolean" }, { type: "string", enum: ["true", "false"] }], @@ -504,7 +506,7 @@ function normalizeParameter(param: OpenApiParameter, route: string) { } function pathParameterSchema(route: string, name: string) { - if (name in PathParameterSchemas) return PathParameterSchemas[name as keyof typeof PathParameterSchemas] + if (name in PathParameterSchemas) return PathParameterSchemas[name] if (name === "id" && route.startsWith("DELETE /experimental/workspace/")) return { type: "string", pattern: "^wrk.*" } if (name === "id" && route.startsWith("POST /experimental/workspace/")) return { type: "string", pattern: "^wrk.*" } if (name === "requestID" && route.startsWith("POST /permission/")) return { type: "string", pattern: "^per.*" } diff --git a/packages/opencode/test/server/httpapi-query-schema-drift.test.ts b/packages/opencode/test/server/httpapi-query-schema-drift.test.ts index 1791a61f56..3aedd6b814 100644 --- a/packages/opencode/test/server/httpapi-query-schema-drift.test.ts +++ b/packages/opencode/test/server/httpapi-query-schema-drift.test.ts @@ -33,7 +33,8 @@ const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES type Method = "get" | "post" | "put" | "delete" | "patch" type QuerySchema = { readonly fields: Record } -type OpenApiParameter = { readonly name: string; readonly in: string } +type OpenApiSchema = { readonly maximum?: number; readonly minimum?: number; readonly type?: string } +type OpenApiParameter = { readonly name: string; readonly in: string; readonly schema?: OpenApiSchema } type OpenApiOperation = { readonly parameters?: readonly OpenApiParameter[] } const openApiDriftRoutes = [ @@ -49,6 +50,24 @@ const openApiDriftRoutes = [ { method: "get", path: "/api/session/:sessionID/message", query: V2MessagesQuery }, ] satisfies Array<{ method: Method; path: string; query: QuerySchema }> +const numericSdkQueryParams = [ + { method: "get", path: ExperimentalPaths.session, name: "start", schema: { type: "number" } }, + { method: "get", path: ExperimentalPaths.session, name: "cursor", schema: { type: "number" } }, + { method: "get", path: ExperimentalPaths.session, name: "limit", schema: { type: "number" } }, + { method: "get", path: FilePaths.findFile, name: "limit", schema: { type: "integer", minimum: 1, maximum: 200 } }, + { method: "get", path: SessionPaths.list, name: "start", schema: { type: "number" } }, + { method: "get", path: SessionPaths.list, name: "limit", schema: { type: "number" } }, + { + method: "get", + path: SessionPaths.messages, + name: "limit", + schema: { type: "integer", minimum: 0, maximum: Number.MAX_SAFE_INTEGER }, + }, + { method: "get", path: "/api/session", name: "limit", schema: { type: "number" } }, + { method: "get", path: "/api/session", name: "start", schema: { type: "number" } }, + { method: "get", path: "/api/session/:sessionID/message", name: "limit", schema: { type: "number" } }, +] satisfies Array<{ method: Method; path: string; name: string; schema: OpenApiSchema }> + function app() { return Server.Default().app } @@ -75,6 +94,10 @@ function queryParameters(operation: OpenApiOperation | undefined) { return (operation?.parameters ?? []).filter((param) => param.in === "query").map((param) => param.name) } +function queryParameter(operation: OpenApiOperation | undefined, name: string) { + return (operation?.parameters ?? []).find((param) => param.in === "query" && param.name === name) +} + function assertAdvertisedQueryParamsAreRuntimeFields(input: { readonly method: Method readonly operation: OpenApiOperation | undefined @@ -137,6 +160,19 @@ describe("httpapi query schema drift", () => { }), ) + it.effect( + "OpenAPI numeric query params preserve generated SDK call shapes", + Effect.sync(() => { + const spec = OpenApi.fromApi(PublicApi) + for (const expected of numericSdkQueryParams) { + expect( + queryParameter(spec.paths[openApiPath(expected.path)]?.[expected.method], expected.name)?.schema, + `${expected.method.toUpperCase()} ${expected.path} ${expected.name}`, + ).toEqual(expected.schema) + } + }), + ) + it.effect( "drift assertion catches spec-only workspace query params", Effect.sync(() => { From 79d6b10d7ceeaf89ef3019466f1ac72085aa19db Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 9 May 2026 22:03:59 -0400 Subject: [PATCH 101/189] fix(mcp): tolerate output schema ref failures (#26614) --- packages/opencode/src/mcp/index.ts | 48 ++++++++++++-- packages/opencode/test/mcp/lifecycle.test.ts | 68 +++++++++++++++++++- 2 files changed, 110 insertions(+), 6 deletions(-) diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index 20e8c912e1..ed74c648ad 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -6,6 +6,7 @@ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js" import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js" import { CallToolResultSchema, + ToolSchema, type Tool as MCPToolDef, ToolListChangedNotificationSchema, } from "@modelcontextprotocol/sdk/types.js" @@ -36,6 +37,15 @@ import { withStatics } from "@opencode-ai/core/schema" const log = Log.create({ service: "mcp" }) const DEFAULT_TIMEOUT = 30_000 +const TolerantToolSchema = ToolSchema.extend({ + outputSchema: z.unknown().optional(), +}) + +const TolerantListToolsResultSchema = z.looseObject({ + tools: z.array(TolerantToolSchema), + nextCursor: z.string().optional(), +}) + export const Resource = Schema.Struct({ name: Schema.String, uri: Schema.String, @@ -119,6 +129,38 @@ function remoteURL(key: string, value: string) { log.warn("invalid remote mcp url", { key }) } +function isOutputSchemaValidationError(error: Error) { + return /can't resolve reference|resolves to more than one schema|outputSchema|schema.*reference|reference.*schema/i.test( + error.message, + ) +} + +function listTools(key: string, client: MCPClient, timeout: number) { + return Effect.tryPromise({ + try: () => client.listTools(undefined, { timeout }), + catch: (err) => (err instanceof Error ? err : new Error(String(err))), + }).pipe( + Effect.map((result) => result.tools), + Effect.catch((error) => { + if (!isOutputSchemaValidationError(error)) return Effect.fail(error) + + log.warn("failed to validate MCP tool output schemas, retrying without output schema validation", { key, error }) + return Effect.tryPromise({ + try: () => client.request({ method: "tools/list" }, TolerantListToolsResultSchema, { timeout }), + catch: (err) => (err instanceof Error ? err : new Error(String(err))), + }).pipe( + Effect.map((result) => + result.tools.map((tool) => ({ + name: tool.name, + description: tool.description, + inputSchema: tool.inputSchema, + })), + ), + ) + }), + ) +} + // Convert MCP tool definition to AI SDK Tool type function convertMcpTool(mcpTool: MCPToolDef, client: MCPClient, timeout?: number): Tool { const inputSchema = mcpTool.inputSchema @@ -151,11 +193,7 @@ function convertMcpTool(mcpTool: MCPToolDef, client: MCPClient, timeout?: number } function defs(key: string, client: MCPClient, timeout?: number) { - return Effect.tryPromise({ - try: () => withTimeout(client.listTools(), timeout ?? DEFAULT_TIMEOUT), - catch: (err) => (err instanceof Error ? err : new Error(String(err))), - }).pipe( - Effect.map((result) => result.tools), + return listTools(key, client, timeout ?? DEFAULT_TIMEOUT).pipe( Effect.catch((err) => { log.error("failed to get tools from client", { key, error: err }) return Effect.succeed(undefined) diff --git a/packages/opencode/test/mcp/lifecycle.test.ts b/packages/opencode/test/mcp/lifecycle.test.ts index 10547c9f08..5afc85e3b5 100644 --- a/packages/opencode/test/mcp/lifecycle.test.ts +++ b/packages/opencode/test/mcp/lifecycle.test.ts @@ -7,8 +7,9 @@ import type { MCP as MCPNS } from "../../src/mcp/index" // Per-client state for controlling mock behavior interface MockClientState { - tools: Array<{ name: string; description?: string; inputSchema: object }> + tools: Array<{ name: string; description?: string; inputSchema: object; outputSchema?: object }> listToolsCalls: number + requestCalls: number listToolsShouldFail: boolean listToolsError: string listPromptsShouldFail: boolean @@ -36,6 +37,7 @@ function getOrCreateClientState(name?: string): MockClientState { state = { tools: [{ name: "test_tool", description: "A test tool", inputSchema: { type: "object", properties: {} } }], listToolsCalls: 0, + requestCalls: 0, listToolsShouldFail: false, listToolsError: "listTools failed", listPromptsShouldFail: false, @@ -139,6 +141,12 @@ void mock.module("@modelcontextprotocol/sdk/client/index.js", () => ({ return { tools: this._state?.tools ?? [] } } + async request(request: { method: string }, schema: { parse: (value: unknown) => unknown }) { + if (this._state) this._state.requestCalls++ + if (request.method === "tools/list") return schema.parse({ tools: this._state?.tools ?? [] }) + throw new Error(`unsupported request: ${request.method}`) + } + async listPrompts() { if (this._state?.listPromptsShouldFail) { throw new Error("listPrompts failed") @@ -205,6 +213,11 @@ function withInstance( } } +function statusName(status: Record | MCPNS.Status, server: string) { + if ("status" in status) return status.status + return status[server]?.status +} + // ======================================================================== // Test: tools() are cached after connect // ======================================================================== @@ -433,6 +446,59 @@ test( ), ) +test( + "falls back when MCP output schema refs fail SDK tool discovery", + withInstance({}, (mcp) => + Effect.gen(function* () { + lastCreatedClientName = "stitch-like-server" + const serverState = getOrCreateClientState("stitch-like-server") + serverState.listToolsShouldFail = true + serverState.listToolsError = "can't resolve reference #/$defs/ScreenInstance from id #" + serverState.tools = [ + { + name: "render_screen", + description: "renders a screen", + inputSchema: { type: "object", properties: { prompt: { type: "string" } }, required: ["prompt"] }, + outputSchema: { type: "object", properties: { screen: { $ref: "#/$defs/ScreenInstance" } } }, + }, + ] + + const addResult = yield* mcp.add("stitch-like-server", { + type: "local", + command: ["echo", "test"], + }) + + expect(statusName(addResult.status, "stitch-like-server")).toBe("connected") + + const tools = yield* mcp.tools() + expect(Object.keys(tools).some((key) => key.includes("render_screen"))).toBe(true) + expect(serverState.listToolsCalls).toBe(1) + expect(serverState.requestCalls).toBe(1) + }), + ), +) + +test( + "does not fall back for non-schema MCP tool discovery errors", + withInstance({}, (mcp) => + Effect.gen(function* () { + lastCreatedClientName = "broken-server" + const serverState = getOrCreateClientState("broken-server") + serverState.listToolsShouldFail = true + serverState.listToolsError = "transport closed" + + const addResult = yield* mcp.add("broken-server", { + type: "local", + command: ["echo", "test"], + }) + + expect(statusName(addResult.status, "broken-server")).toBe("failed") + expect(serverState.listToolsCalls).toBe(1) + expect(serverState.requestCalls).toBe(0) + }), + ), +) + // ======================================================================== // Test: disabled server via config // ======================================================================== From 10ea59066ff0cf4a416cff36d898d50c9d50642b Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 9 May 2026 22:05:37 -0400 Subject: [PATCH 102/189] feat(skill): built-in opencode-meta skill (#26617) --- packages/core/src/flag/flag.ts | 11 + packages/opencode/src/markdown.d.ts | 4 + packages/opencode/src/skill/index.ts | 20 + .../src/skill/prompt/customize-opencode.md | 353 ++++++++++++++++++ packages/opencode/test/preload.ts | 5 + 5 files changed, 393 insertions(+) create mode 100644 packages/opencode/src/markdown.d.ts create mode 100644 packages/opencode/src/skill/prompt/customize-opencode.md diff --git a/packages/core/src/flag/flag.ts b/packages/core/src/flag/flag.ts index 175c723c5f..3fe7655759 100644 --- a/packages/core/src/flag/flag.ts +++ b/packages/core/src/flag/flag.ts @@ -1,4 +1,5 @@ import { Config } from "effect" +import { InstallationChannel } from "../installation/version" function truthy(key: string) { const value = process.env[key]?.toLowerCase() @@ -10,6 +11,13 @@ function falsy(key: string) { return value === "false" || value === "0" } +// Channels where new experiments default to ON (unstable / internal users). +// Stable channels (`prod`, `latest`) stay opt-in. +const UNSTABLE_CHANNELS = new Set(["dev", "beta", "local"]) +function unstableDefault(key: string) { + return truthy(key) || (!falsy(key) && UNSTABLE_CHANNELS.has(InstallationChannel)) +} + function number(key: string) { const value = process.env[key] if (!value) return undefined @@ -48,6 +56,9 @@ export const Flag = { OPENCODE_DISABLE_CLAUDE_CODE_PROMPT: OPENCODE_DISABLE_CLAUDE_CODE || truthy("OPENCODE_DISABLE_CLAUDE_CODE_PROMPT"), OPENCODE_DISABLE_CLAUDE_CODE_SKILLS, OPENCODE_DISABLE_EXTERNAL_SKILLS: truthy("OPENCODE_DISABLE_EXTERNAL_SKILLS"), + // Default-on for dev/beta/local; opt-in for stable. Set + // OPENCODE_EXPERIMENTAL_CUSTOMIZE_SKILL=false to force off, =true to force on. + OPENCODE_EXPERIMENTAL_CUSTOMIZE_SKILL: unstableDefault("OPENCODE_EXPERIMENTAL_CUSTOMIZE_SKILL"), OPENCODE_FAKE_VCS: process.env["OPENCODE_FAKE_VCS"], OPENCODE_SERVER_PASSWORD: process.env["OPENCODE_SERVER_PASSWORD"], OPENCODE_SERVER_USERNAME: process.env["OPENCODE_SERVER_USERNAME"], diff --git a/packages/opencode/src/markdown.d.ts b/packages/opencode/src/markdown.d.ts new file mode 100644 index 0000000000..eb3e3b92d6 --- /dev/null +++ b/packages/opencode/src/markdown.d.ts @@ -0,0 +1,4 @@ +declare module "*.md" { + const content: string + export default content +} diff --git a/packages/opencode/src/skill/index.ts b/packages/opencode/src/skill/index.ts index 3000406ebc..01bffdb02a 100644 --- a/packages/opencode/src/skill/index.ts +++ b/packages/opencode/src/skill/index.ts @@ -17,6 +17,7 @@ import { ConfigMarkdown } from "@/config/markdown" import { Glob } from "@opencode-ai/core/util/glob" import * as Log from "@opencode-ai/core/util/log" import { Discovery } from "./discovery" +import CUSTOMIZE_OPENCODE_SKILL_BODY from "./prompt/customize-opencode.md" with { type: "text" } const log = Log.create({ service: "skill" }) const CLAUDE_EXTERNAL_DIR = ".claude" @@ -25,6 +26,15 @@ const EXTERNAL_SKILL_PATTERN = "skills/**/SKILL.md" const OPENCODE_SKILL_PATTERN = "{skill,skills}/**/SKILL.md" const SKILL_PATTERN = "**/SKILL.md" +// Built-in skill that ships with opencode. The model's intuition for what an +// opencode.json should look like is often wrong, and opencode hard-fails on +// invalid config, so users hit cryptic startup errors. Loading this skill +// when the model is asked to touch opencode's own config files gives it the +// actual schemas instead of guesses. +const CUSTOMIZE_OPENCODE_SKILL_NAME = "customize-opencode" +const CUSTOMIZE_OPENCODE_SKILL_DESCRIPTION = + "Use ONLY when the user is editing or creating opencode's own configuration: opencode.json, opencode.jsonc, files under .opencode/, or files under ~/.config/opencode/. Also use when creating or fixing opencode agents, subagents, skills, plugins, MCP servers, or permission rules. Do not use for the user's own application code, or for any project that is not configuring opencode itself." + export const Info = Schema.Struct({ name: Schema.String, description: Schema.optional(Schema.String), @@ -230,6 +240,16 @@ export const layer = Layer.effect( const state = yield* InstanceState.make( Effect.fn("Skill.state")(function* () { const s: State = { skills: {}, dirs: new Set() } + // Register the built-in skill BEFORE disk discovery so a user-disk + // skill with the same name can override it. + if (Flag.OPENCODE_EXPERIMENTAL_CUSTOMIZE_SKILL) { + s.skills[CUSTOMIZE_OPENCODE_SKILL_NAME] = { + name: CUSTOMIZE_OPENCODE_SKILL_NAME, + description: CUSTOMIZE_OPENCODE_SKILL_DESCRIPTION, + location: "", + content: CUSTOMIZE_OPENCODE_SKILL_BODY, + } + } yield* loadSkills(s, yield* InstanceState.get(discovered), bus) return s }), diff --git a/packages/opencode/src/skill/prompt/customize-opencode.md b/packages/opencode/src/skill/prompt/customize-opencode.md new file mode 100644 index 0000000000..b7b4be1b9f --- /dev/null +++ b/packages/opencode/src/skill/prompt/customize-opencode.md @@ -0,0 +1,353 @@ + + +# Customizing opencode + +opencode validates its own config strictly and refuses to start when a field +is wrong. The shapes below are the accepted shapes. When in doubt, fetch +`https://opencode.ai/config.json` (the JSON Schema) and validate against it. + +Every `opencode.json` should declare `"$schema": "https://opencode.ai/config.json"` +so the user's editor catches mistakes as they type. + +## Where files live + +| Scope | Path | +|---|---| +| Project config | `./opencode.json`, `./opencode.jsonc`, or `.opencode/opencode.json` (opencode walks up from the cwd to the worktree root) | +| Global config | `~/.config/opencode/opencode.json` (NOT `~/.opencode/`) | +| Project agents | `.opencode/agent/.md` or `.opencode/agents/.md` | +| Global agents | `~/.config/opencode/agent(s)/.md` | +| Project skills | `.opencode/skill(s)//SKILL.md` | +| Global skills | `~/.config/opencode/skill(s)//SKILL.md` | +| External skills (auto-loaded) | `~/.claude/skills//SKILL.md`, `~/.agents/skills//SKILL.md` | + +Configs from each scope are deep-merged. Project overrides global. Unknown +top-level keys in `opencode.json` are rejected with `ConfigInvalidError`. + +## opencode.json + +Every field is optional. + +```json +{ + "$schema": "https://opencode.ai/config.json", + "username": "string", + "model": "provider/model-id", + "small_model": "provider/model-id", + "default_agent": "agent-name", + "shell": "/bin/zsh", + "logLevel": "DEBUG" | "INFO" | "WARN" | "ERROR", + "share": "manual" | "auto" | "disabled", + "autoupdate": true | false | "notify", + "snapshot": true, + "instructions": ["AGENTS.md", "docs/style.md"], + + "skills": { + "paths": [".opencode/skills", "/abs/path/to/skills"], + "urls": ["https://example.com/.well-known/skills/"] + }, + + "agent": { + "my-agent": { + "model": "anthropic/claude-sonnet-4-6", + "mode": "subagent", + "description": "...", + "permission": { "edit": "deny" } + } + }, + + "command": { + "deploy": { "description": "...", "prompt": "..." } + }, + + "provider": { + "anthropic": { "options": { "apiKey": "..." } } + }, + "disabled_providers": ["openai"], + "enabled_providers": ["anthropic"], + + "mcp": { + "playwright": { + "type": "local", + "command": ["npx", "-y", "@playwright/mcp"], + "enabled": true, + "env": {} + }, + "remote-thing": { + "type": "remote", + "url": "https://...", + "headers": { "Authorization": "Bearer ..." } + } + }, + + "plugin": [ + "opencode-gemini-auth", + "opencode-foo@1.2.3", + "./local-plugin.ts", + ["opencode-bar", { "option": "value" }] + ], + + "permission": { + "edit": "deny", + "bash": { "git *": "allow", "*": "ask" } + }, + + "formatter": false, + "lsp": false, + + "experimental": { + "primary_tools": ["edit"], + "mcp_timeout": 30000 + }, + + "tool_output": { "max_lines": 200, "max_bytes": 8192 }, + + "compaction": { "auto": true, "tail_turns": 15 } +} +``` + +Shape notes worth being explicit about: + +- `model` always carries a provider prefix: `"anthropic/claude-sonnet-4-6"`. +- `skills` is an object with `paths` and/or `urls`, not an array. +- `agent` is an object keyed by agent name, not an array. +- `plugin` is an array of strings or `[name, options]` tuples, not an object. +- `mcp[name].command` is an array of strings, never a single string. `type` is required. +- `permission` is either a string action or an object keyed by tool name. + +## Skills + +opencode's skill loader scans for `**/SKILL.md` inside skill directories. The +file is named `SKILL.md` exactly, and lives in its own folder named after the +skill: + +``` +.opencode/skills/my-skill/SKILL.md +``` + +Frontmatter: + +```markdown +--- +name: my-skill +description: One sentence covering what this skill does AND when to trigger it. Front-load the literal keywords or filenames the user is likely to say. +--- + +# My Skill + +(skill body in markdown: instructions, examples, references) +``` + +- `name` is required, lowercase hyphen-separated, up to 64 chars, and matches the folder name. +- `description` is effectively required: skills without one are filtered out and never surfaced to the model. Cover both *what* the skill does and *when* to use it. Write in third person ("Use when...", not "I help with..."). Front-load concrete trigger keywords and filenames; gate with "Use ONLY when..." if the skill should stay quiet on adjacent topics. +- Optional: `license`, `compatibility`, `metadata` (string-string map). + +Register skills from non-default locations via `skills.paths` (scanned +recursively for `**/SKILL.md`) and `skills.urls` (each URL serves a list of +skills). + +## Agents + +Two ways to define an agent. Use the file form for anything non-trivial. + +### Inline (in `opencode.json`) + +```json +{ + "agent": { + "my-reviewer": { + "description": "Reviews PRs for style violations.", + "mode": "subagent", + "model": "anthropic/claude-sonnet-4-6", + "permission": { "edit": "deny", "bash": "ask" }, + "prompt": "You are a strict PR reviewer..." + } + } +} +``` + +### File + +``` +.opencode/agent/my-reviewer.md OR .opencode/agents/my-reviewer.md +``` + +```markdown +--- +description: Reviews PRs for style violations. +mode: subagent +model: anthropic/claude-sonnet-4-6 +permission: + edit: deny + bash: ask +--- + +You are a strict PR reviewer. Focus on... +``` + +The file body becomes the agent's `prompt`. Do not also put `prompt:` in the +frontmatter. + +`mode` is one of `"primary"`, `"subagent"`, `"all"`. + +Allowed top-level frontmatter fields: `name, model, variant, description, mode, +hidden, color, steps, options, permission, disable, temperature, top_p`. Any +unknown field is silently routed into `options`. + +To disable a built-in agent: `agent: { build: { disable: true } }`, or in a +file, `disable: true` in frontmatter. + +`default_agent` must point to a non-hidden, primary-mode agent. + +### Built-in agents + +opencode ships with `build`, `plan`, `general`, `explore`, plus optionally +`scout` (gated on `OPENCODE_EXPERIMENTAL_SCOUT`). Hidden internal agents: +`compaction`, `title`, `summary`. To override a built-in's fields, define the +same key in `agent: { : { ... } }`. + +## Plugins + +`plugin:` is an array. Each entry is one of: + +```json +"plugin": [ + "opencode-gemini-auth", // npm spec, latest + "opencode-foo@1.2.3", // npm spec, pinned + "./local-plugin.ts", // file path, relative to the declaring config + "file:///abs/path/plugin.js", // file URL + ["opencode-bar", { "key": "val" }] // tuple form with options +] +``` + +Auto-discovered plugins (no config entry needed): any `*.ts` or `*.js` file in +`.opencode/plugin/` or `.opencode/plugins/`. + +A plugin module exports `default` (or any named export) of type +`Plugin = (input: PluginInput, options?) => Promise`. The export is a +function, not a plain object literal, and the function returns an object +(return `{}` if there is nothing to register). + +```ts +import type { Plugin } from "@opencode-ai/plugin" + +export default (async ({ client, project, directory, $ }) => { + return { + config: (cfg) => { + // cfg is the live merged config; mutate fields here. + }, + "tool.execute.before": async (input, output) => { + // mutate output.args before the tool runs + }, + } +}) satisfies Plugin +``` + +Hook surface (mutate `output` in place; return `void`): +- `event(input)`: every bus event +- `config(cfg)`: once on init with the merged config +- `chat.message`, `chat.params`, `chat.headers` +- `tool.execute.before`, `tool.execute.after` +- `tool.definition` +- `command.execute.before` +- `shell.env` +- `permission.ask` +- `experimental.chat.messages.transform`, `experimental.chat.system.transform`, + `experimental.session.compacting`, `experimental.compaction.autocontinue`, + `experimental.text.complete` + +Special object-shaped (not callbacks): `tool: { my_tool: { ... } }`, +`auth: { ... }`, `provider: { ... }`. + +## MCP servers + +`mcp:` is an object keyed by server name. Each server is discriminated by +`type`: + +```json +{ + "mcp": { + "playwright": { + "type": "local", + "command": ["npx", "-y", "@playwright/mcp"], + "enabled": true, + "env": { "BROWSER": "chromium" } + }, + "github": { + "type": "remote", + "url": "https://...", + "enabled": true, + "headers": { "Authorization": "Bearer ${GITHUB_TOKEN}" } + }, + "old-server": { "enabled": false } + } +} +``` + +`command` is an array of strings. `type` is required. Use `enabled: false` to +disable a server inherited from a parent config. + +## Permissions + +```json +"permission": { + "edit": "deny", + "bash": { "git *": "allow", "rm *": "deny", "*": "ask" }, + "external_directory": { "~/secrets/**": "deny", "*": "allow" } +} +``` + +Actions: `"allow"`, `"ask"`, `"deny"`. + +Per-tool value forms: `"allow"` shorthand (treated as `{"*": "allow"}`), or an +object `{ pattern: action }`. Within an object, **insertion order matters**. +opencode evaluates the LAST matching rule, so put broad rules first and narrow +rules last. + +`permission: "allow"` (a string at the top level) is shorthand for "allow +everything" and is rarely what the user wants. + +Known permission keys: `read, edit, glob, grep, list, bash, task, +external_directory, todowrite, question, webfetch, websearch, codesearch, +repo_clone, repo_overview, lsp, doom_loop, skill`. Some of these (`todowrite, +question, webfetch, websearch, codesearch, doom_loop`) only accept a flat +action, not a per-pattern object. + +`external_directory` patterns are filesystem paths (use `~/`, absolute paths, +or globs like `~/projects/**`). + +Per-agent `permission:` overrides top-level `permission:`. Plan Mode lives on +the `plan` agent's permission ruleset (`edit: deny *`). + +## Escape hatches + +When a user's config is broken and opencode won't start, these env vars help: + +- `OPENCODE_DISABLE_PROJECT_CONFIG=1`: skip the project's local `opencode.json` + and start from globals only. Run from the project directory, opencode loads, + the user edits the broken file, then they restart without the flag. +- `OPENCODE_CONFIG=/path/to/file.json`: load an additional explicit config. +- `OPENCODE_CONFIG_CONTENT='{"$schema":"https://opencode.ai/config.json"}'`: + inject inline JSON as a final local-scope merge. +- `OPENCODE_DISABLE_DEFAULT_PLUGINS=1`: skip default plugins. +- `OPENCODE_PURE=1`: skip external plugins entirely. +- `OPENCODE_DISABLE_EXTERNAL_SKILLS=1`, + `OPENCODE_DISABLE_CLAUDE_CODE_SKILLS=1`: skip the external skill scans under + `~/.claude/` and `~/.agents/`. + +## When proposing edits + +- Validate against the schema before writing. If you are unsure of a field's + exact shape, fetch `https://opencode.ai/config.json` rather than guessing. +- Preserve `$schema` and any existing fields the user did not ask to change. +- For agent, skill, and plugin definitions, prefer creating new files in the + correct location over inlining everything in `opencode.json`. +- If the user's existing config is malformed, point them at the env-var escape + hatch above so they can edit from inside opencode without breaking their + session. +- opencode hard-fails on invalid config by design. There is no graceful + degradation, so get the shape right the first time. diff --git a/packages/opencode/test/preload.ts b/packages/opencode/test/preload.ts index b408f7ef11..1ba0554d3e 100644 --- a/packages/opencode/test/preload.ts +++ b/packages/opencode/test/preload.ts @@ -35,6 +35,11 @@ process.env["XDG_CONFIG_HOME"] = path.join(dir, "config") process.env["XDG_STATE_HOME"] = path.join(dir, "state") process.env["OPENCODE_MODELS_PATH"] = path.join(import.meta.dir, "tool", "fixtures", "models-api.json") process.env["OPENCODE_EXPERIMENTAL_EVENT_SYSTEM"] = "true" +// Tests assert exact skill counts from disk discovery; the built-in +// customize-opencode skill is opt-in for stable channels and on by default +// for unstable channels (including "local" where CI runs). Disable it here +// so disk-discovery tests aren't off-by-one. +process.env["OPENCODE_EXPERIMENTAL_CUSTOMIZE_SKILL"] = "false" // Set test home directory to isolate tests from user's actual home directory // This prevents tests from picking up real user configs/skills from ~/.claude/skills From d80e1199cafdd85f8154e96ec572e52a4891e5a9 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sun, 10 May 2026 02:06:39 +0000 Subject: [PATCH 103/189] chore: generate --- .../src/skill/prompt/customize-opencode.md | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/packages/opencode/src/skill/prompt/customize-opencode.md b/packages/opencode/src/skill/prompt/customize-opencode.md index b7b4be1b9f..6158aae085 100644 --- a/packages/opencode/src/skill/prompt/customize-opencode.md +++ b/packages/opencode/src/skill/prompt/customize-opencode.md @@ -15,15 +15,15 @@ so the user's editor catches mistakes as they type. ## Where files live -| Scope | Path | -|---|---| -| Project config | `./opencode.json`, `./opencode.jsonc`, or `.opencode/opencode.json` (opencode walks up from the cwd to the worktree root) | -| Global config | `~/.config/opencode/opencode.json` (NOT `~/.opencode/`) | -| Project agents | `.opencode/agent/.md` or `.opencode/agents/.md` | -| Global agents | `~/.config/opencode/agent(s)/.md` | -| Project skills | `.opencode/skill(s)//SKILL.md` | -| Global skills | `~/.config/opencode/skill(s)//SKILL.md` | -| External skills (auto-loaded) | `~/.claude/skills//SKILL.md`, `~/.agents/skills//SKILL.md` | +| Scope | Path | +| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------- | +| Project config | `./opencode.json`, `./opencode.jsonc`, or `.opencode/opencode.json` (opencode walks up from the cwd to the worktree root) | +| Global config | `~/.config/opencode/opencode.json` (NOT `~/.opencode/`) | +| Project agents | `.opencode/agent/.md` or `.opencode/agents/.md` | +| Global agents | `~/.config/opencode/agent(s)/.md` | +| Project skills | `.opencode/skill(s)//SKILL.md` | +| Global skills | `~/.config/opencode/skill(s)//SKILL.md` | +| External skills (auto-loaded) | `~/.claude/skills//SKILL.md`, `~/.agents/skills//SKILL.md` | Configs from each scope are deep-merged. Project overrides global. Unknown top-level keys in `opencode.json` are rejected with `ConfigInvalidError`. @@ -143,7 +143,7 @@ description: One sentence covering what this skill does AND when to trigger it. ``` - `name` is required, lowercase hyphen-separated, up to 64 chars, and matches the folder name. -- `description` is effectively required: skills without one are filtered out and never surfaced to the model. Cover both *what* the skill does and *when* to use it. Write in third person ("Use when...", not "I help with..."). Front-load concrete trigger keywords and filenames; gate with "Use ONLY when..." if the skill should stay quiet on adjacent topics. +- `description` is effectively required: skills without one are filtered out and never surfaced to the model. Cover both _what_ the skill does and _when_ to use it. Write in third person ("Use when...", not "I help with..."). Front-load concrete trigger keywords and filenames; gate with "Use ONLY when..." if the skill should stay quiet on adjacent topics. - Optional: `license`, `compatibility`, `metadata` (string-string map). Register skills from non-default locations via `skills.paths` (scanned @@ -248,6 +248,7 @@ export default (async ({ client, project, directory, $ }) => { ``` Hook surface (mutate `output` in place; return `void`): + - `event(input)`: every bus event - `config(cfg)`: once on init with the merged config - `chat.message`, `chat.params`, `chat.headers` From c6e6bdf59f8a5488f57ec9a521aa3a15e11e14ea Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 9 May 2026 22:10:44 -0400 Subject: [PATCH 104/189] fix(session): tolerate negative token counts in stored parts (#26620) --- packages/opencode/src/session/message-v2.ts | 24 ++--- packages/opencode/src/session/message.ts | 10 +- packages/opencode/src/session/session.ts | 2 +- packages/opencode/src/v2/session-event.ts | 10 +- .../server/negative-tokens-regression.test.ts | 97 +++++++++++++++++++ 5 files changed, 120 insertions(+), 23 deletions(-) create mode 100644 packages/opencode/test/server/negative-tokens-regression.test.ts diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 23a01a1704..3db0b30683 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -270,13 +270,13 @@ export const StepFinishPart = Schema.Struct({ snapshot: Schema.optional(Schema.String), cost: Schema.Finite, tokens: Schema.Struct({ - total: Schema.optional(NonNegativeInt), - input: NonNegativeInt, - output: NonNegativeInt, - reasoning: NonNegativeInt, + total: Schema.optional(Schema.Finite), + input: Schema.Finite, + output: Schema.Finite, + reasoning: Schema.Finite, cache: Schema.Struct({ - read: NonNegativeInt, - write: NonNegativeInt, + read: Schema.Finite, + write: Schema.Finite, }), }), }) @@ -554,13 +554,13 @@ export const Assistant = Schema.Struct({ summary: Schema.optional(Schema.Boolean), cost: Schema.Finite, tokens: Schema.Struct({ - total: Schema.optional(NonNegativeInt), - input: NonNegativeInt, - output: NonNegativeInt, - reasoning: NonNegativeInt, + total: Schema.optional(Schema.Finite), + input: Schema.Finite, + output: Schema.Finite, + reasoning: Schema.Finite, cache: Schema.Struct({ - read: NonNegativeInt, - write: NonNegativeInt, + read: Schema.Finite, + write: Schema.Finite, }), }), structured: Schema.optional(Schema.Any), diff --git a/packages/opencode/src/session/message.ts b/packages/opencode/src/session/message.ts index 12952a87b9..32a815db14 100644 --- a/packages/opencode/src/session/message.ts +++ b/packages/opencode/src/session/message.ts @@ -172,12 +172,12 @@ export const Info = Schema.Struct({ cost: Schema.Finite, summary: Schema.optional(Schema.Boolean), tokens: Schema.Struct({ - input: NonNegativeInt, - output: NonNegativeInt, - reasoning: NonNegativeInt, + input: Schema.Finite, + output: Schema.Finite, + reasoning: Schema.Finite, cache: Schema.Struct({ - read: NonNegativeInt, - write: NonNegativeInt, + read: Schema.Finite, + write: Schema.Finite, }), }), }), diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index 510d82a731..a8cfe27a47 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -353,7 +353,7 @@ export function plan(input: { slug: string; time: { created: number } }, instanc export const getUsage = (input: { model: Provider.Model; usage: LanguageModelUsage; metadata?: ProviderMetadata }) => { const safe = (value: number) => { if (!Number.isFinite(value)) return 0 - return value + return Math.max(0, value) } const inputTokens = safe(input.usage.inputTokens ?? 0) const outputTokens = safe(input.usage.outputTokens ?? 0) diff --git a/packages/opencode/src/v2/session-event.ts b/packages/opencode/src/v2/session-event.ts index f439b1f841..d9400c3534 100644 --- a/packages/opencode/src/v2/session-event.ts +++ b/packages/opencode/src/v2/session-event.ts @@ -118,12 +118,12 @@ export namespace Step { finish: Schema.String, cost: Schema.Finite, tokens: Schema.Struct({ - input: NonNegativeInt, - output: NonNegativeInt, - reasoning: NonNegativeInt, + input: Schema.Finite, + output: Schema.Finite, + reasoning: Schema.Finite, cache: Schema.Struct({ - read: NonNegativeInt, - write: NonNegativeInt, + read: Schema.Finite, + write: Schema.Finite, }), }), snapshot: Schema.String.pipe(Schema.optional), diff --git a/packages/opencode/test/server/negative-tokens-regression.test.ts b/packages/opencode/test/server/negative-tokens-regression.test.ts new file mode 100644 index 0000000000..77ad1bc279 --- /dev/null +++ b/packages/opencode/test/server/negative-tokens-regression.test.ts @@ -0,0 +1,97 @@ +// Regression: a stored step-finish part with a negative token count made the +// messages endpoint 400. Some providers reported `outputTokens` excluding +// reasoning while also reporting `reasoningTokens` separately, so the +// `outputTokens - reasoningTokens` math in Session.getUsage underflowed to +// negative. The pre-fix `safe()` clamp only guarded against non-finite. The +// strict `NonNegativeInt` schema then made every load of the message list +// fail to encode, killing Desktop boot for every user with such a row. +import { afterEach, describe, expect } from "bun:test" +import { Effect } from "effect" +import { eq } from "drizzle-orm" +import { ModelID, ProviderID } from "../../src/provider/schema" +import { WithInstance } from "../../src/project/with-instance" +import { Server } from "../../src/server/server" +import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session" +import { Session } from "@/session/session" +import { MessageID, PartID } from "../../src/session/schema" +import * as Database from "@/storage/db" +import { PartTable } from "@/session/session.sql" +import { resetDatabase } from "../fixture/db" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" +import { it } from "../lib/effect" + +afterEach(async () => { + await disposeAllInstances() + await resetDatabase() +}) + +function seedNegativeTokenSession(directory: string) { + return Effect.promise(async () => + WithInstance.provide({ + directory, + fn: () => + Effect.runPromise( + Effect.gen(function* () { + const session = yield* Session.Service + const info = yield* session.create({}) + const message = yield* session.updateMessage({ + id: MessageID.ascending(), + role: "user", + sessionID: info.id, + agent: "build", + model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") }, + time: { created: Date.now() }, + }) + const partID = PartID.ascending() + yield* session.updatePart({ + id: partID, + sessionID: info.id, + messageID: message.id, + type: "step-finish", + reason: "stop", + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + }) + + // Bypass the schema with a direct SQL update to install the + // negative `output` value we want to test loading. + Database.use((db) => + db + .update(PartTable) + .set({ + data: { + type: "step-finish", + reason: "stop", + cost: 0, + tokens: { input: 0, output: -42, reasoning: 0, cache: { read: 0, write: 0 } }, + } as never, + }) + .where(eq(PartTable.id, partID)) + .run(), + ) + + return info.id + }).pipe(Effect.provide(Session.defaultLayer)), + ), + }), + ) +} + +describe("messages endpoint tolerates legacy negative token counts", () => { + it.live( + "returns 200 even when a step-finish part has tokens.output < 0", + Effect.acquireRelease( + Effect.promise(() => tmpdir({ config: { formatter: false, lsp: false } })), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ).pipe( + Effect.flatMap((tmp) => + Effect.gen(function* () { + const sessionID = yield* seedNegativeTokenSession(tmp.path) + const url = `${SessionPaths.messages.replace(":sessionID", sessionID)}?limit=80&directory=${encodeURIComponent(tmp.path)}` + const res = yield* Effect.promise(async () => Server.Default().app.request(url)) + expect(res.status, "messages endpoint 400'd on legacy negative tokens").not.toBe(400) + }), + ), + ), + ) +}) From 29250a0efbb9bba43dcb4244716a41648ecd611f Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 9 May 2026 22:10:48 -0400 Subject: [PATCH 105/189] fix(session): loosen remaining stored numeric schemas to tolerate legacy data (#26622) --- packages/opencode/src/project/vcs.ts | 8 ++++---- packages/opencode/src/session/message-v2.ts | 4 ++-- packages/opencode/src/session/session.ts | 6 +++--- packages/opencode/src/snapshot/index.ts | 4 ++-- packages/opencode/src/v2/session-event.ts | 4 ++-- 5 files changed, 13 insertions(+), 13 deletions(-) diff --git a/packages/opencode/src/project/vcs.ts b/packages/opencode/src/project/vcs.ts index 21ee882c41..092444c444 100644 --- a/packages/opencode/src/project/vcs.ts +++ b/packages/opencode/src/project/vcs.ts @@ -234,8 +234,8 @@ export const FileDiff = Schema.Struct({ // populates patch, but loosening matches the sibling schema so a // future code path that omits it can't crash /instance/vcs/diff. patch: Schema.optional(Schema.String), - additions: NonNegativeInt, - deletions: NonNegativeInt, + additions: Schema.Finite, + deletions: Schema.Finite, status: Schema.optional(Schema.Literals(["added", "deleted", "modified"])), }) .annotate({ identifier: "VcsFileDiff" }) @@ -244,8 +244,8 @@ export type FileDiff = Schema.Schema.Type export const FileStatus = Schema.Struct({ file: Schema.String, - additions: NonNegativeInt, - deletions: NonNegativeInt, + additions: Schema.Finite, + deletions: Schema.Finite, status: Schema.Literals(["added", "deleted", "modified"]), }) .annotate({ identifier: "VcsFileStatus" }) diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 3db0b30683..3eb6f07b82 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -143,8 +143,8 @@ export type ReasoningPart = Types.DeepMutable Date: Sun, 10 May 2026 02:11:45 +0000 Subject: [PATCH 106/189] chore: generate --- packages/sdk/openapi.json | 126 +++++++++++++------------------------- 1 file changed, 42 insertions(+), 84 deletions(-) diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index c683966881..8e7d56a987 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -9200,12 +9200,10 @@ "type": "string" }, "additions": { - "type": "integer", - "minimum": 0 + "type": "number" }, "deletions": { - "type": "integer", - "minimum": 0 + "type": "number" }, "status": { "type": "string", @@ -10047,31 +10045,25 @@ "type": "object", "properties": { "total": { - "type": "integer", - "minimum": 0 + "type": "number" }, "input": { - "type": "integer", - "minimum": 0 + "type": "number" }, "output": { - "type": "integer", - "minimum": 0 + "type": "number" }, "reasoning": { - "type": "integer", - "minimum": 0 + "type": "number" }, "cache": { "type": "object", "properties": { "read": { - "type": "integer", - "minimum": 0 + "type": "number" }, "write": { - "type": "integer", - "minimum": 0 + "type": "number" } }, "required": ["read", "write"], @@ -10255,12 +10247,10 @@ "type": "string" }, "start": { - "type": "integer", - "minimum": 0 + "type": "number" }, "end": { - "type": "integer", - "minimum": 0 + "type": "number" } }, "required": ["value", "start", "end"], @@ -10643,31 +10633,25 @@ "type": "object", "properties": { "total": { - "type": "integer", - "minimum": 0 + "type": "number" }, "input": { - "type": "integer", - "minimum": 0 + "type": "number" }, "output": { - "type": "integer", - "minimum": 0 + "type": "number" }, "reasoning": { - "type": "integer", - "minimum": 0 + "type": "number" }, "cache": { "type": "object", "properties": { "read": { - "type": "integer", - "minimum": 0 + "type": "number" }, "write": { - "type": "integer", - "minimum": 0 + "type": "number" } }, "required": ["read", "write"], @@ -10935,16 +10919,13 @@ "type": "object", "properties": { "additions": { - "type": "integer", - "minimum": 0 + "type": "number" }, "deletions": { - "type": "integer", - "minimum": 0 + "type": "number" }, "files": { - "type": "integer", - "minimum": 0 + "type": "number" }, "diffs": { "type": "array", @@ -12767,16 +12748,13 @@ "type": "object", "properties": { "additions": { - "type": "integer", - "minimum": 0 + "type": "number" }, "deletions": { - "type": "integer", - "minimum": 0 + "type": "number" }, "files": { - "type": "integer", - "minimum": 0 + "type": "number" }, "diffs": { "type": "array", @@ -13094,12 +13072,10 @@ "type": "string" }, "additions": { - "type": "integer", - "minimum": 0 + "type": "number" }, "deletions": { - "type": "integer", - "minimum": 0 + "type": "number" }, "status": { "type": "string", @@ -13119,12 +13095,10 @@ "type": "string" }, "additions": { - "type": "integer", - "minimum": 0 + "type": "number" }, "deletions": { - "type": "integer", - "minimum": 0 + "type": "number" }, "status": { "type": "string", @@ -14247,16 +14221,13 @@ "type": "object", "properties": { "additions": { - "type": "integer", - "minimum": 0 + "type": "number" }, "deletions": { - "type": "integer", - "minimum": 0 + "type": "number" }, "files": { - "type": "integer", - "minimum": 0 + "type": "number" }, "diffs": { "type": "array", @@ -14841,27 +14812,22 @@ "type": "object", "properties": { "input": { - "type": "integer", - "minimum": 0 + "type": "number" }, "output": { - "type": "integer", - "minimum": 0 + "type": "number" }, "reasoning": { - "type": "integer", - "minimum": 0 + "type": "number" }, "cache": { "type": "object", "properties": { "read": { - "type": "integer", - "minimum": 0 + "type": "number" }, "write": { - "type": "integer", - "minimum": 0 + "type": "number" } }, "required": ["read", "write"], @@ -15579,8 +15545,7 @@ "type": "string" }, "attempt": { - "type": "integer", - "minimum": 0 + "type": "number" }, "error": { "$ref": "#/components/schemas/SessionNextRetry_error" @@ -17083,27 +17048,22 @@ "type": "object", "properties": { "input": { - "type": "integer", - "minimum": 0 + "type": "number" }, "output": { - "type": "integer", - "minimum": 0 + "type": "number" }, "reasoning": { - "type": "integer", - "minimum": 0 + "type": "number" }, "cache": { "type": "object", "properties": { "read": { - "type": "integer", - "minimum": 0 + "type": "number" }, "write": { - "type": "integer", - "minimum": 0 + "type": "number" } }, "required": ["read", "write"], @@ -17691,8 +17651,7 @@ "type": "string" }, "statusCode": { - "type": "integer", - "minimum": 0 + "type": "number" }, "isRetryable": { "type": "boolean" @@ -17736,8 +17695,7 @@ "type": "string" }, "attempt": { - "type": "integer", - "minimum": 0 + "type": "number" }, "error": { "$ref": "#/components/schemas/SessionNextRetry_error" From 2f11c9f7ed00980101655c14b82dc5dc7524a4cf Mon Sep 17 00:00:00 2001 From: opencode Date: Sun, 10 May 2026 02:34:36 +0000 Subject: [PATCH 107/189] sync release versions for v1.14.46 --- bun.lock | 34 +++++++++++++------------- packages/app/package.json | 2 +- packages/console/app/package.json | 2 +- packages/console/core/package.json | 2 +- packages/console/function/package.json | 2 +- packages/console/mail/package.json | 2 +- packages/core/package.json | 2 +- packages/desktop/package.json | 2 +- packages/enterprise/package.json | 2 +- packages/extensions/zed/extension.toml | 12 ++++----- packages/function/package.json | 2 +- packages/http-recorder/package.json | 2 +- packages/llm/package.json | 2 +- packages/opencode/package.json | 2 +- packages/plugin/package.json | 2 +- packages/sdk/js/package.json | 2 +- packages/slack/package.json | 2 +- packages/ui/package.json | 2 +- packages/web/package.json | 2 +- sdks/vscode/package.json | 2 +- 20 files changed, 41 insertions(+), 41 deletions(-) diff --git a/bun.lock b/bun.lock index 22ad0279a7..9dcad8f126 100644 --- a/bun.lock +++ b/bun.lock @@ -29,7 +29,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "1.14.45", + "version": "1.14.46", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/core": "workspace:*", @@ -85,7 +85,7 @@ }, "packages/console/app": { "name": "@opencode-ai/console-app", - "version": "1.14.45", + "version": "1.14.46", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@ibm/plex": "6.4.1", @@ -120,7 +120,7 @@ }, "packages/console/core": { "name": "@opencode-ai/console-core", - "version": "1.14.45", + "version": "1.14.46", "dependencies": { "@aws-sdk/client-sts": "3.782.0", "@jsx-email/render": "1.1.1", @@ -147,7 +147,7 @@ }, "packages/console/function": { "name": "@opencode-ai/console-function", - "version": "1.14.45", + "version": "1.14.46", "dependencies": { "@ai-sdk/anthropic": "3.0.64", "@ai-sdk/openai": "3.0.48", @@ -171,7 +171,7 @@ }, "packages/console/mail": { "name": "@opencode-ai/console-mail", - "version": "1.14.45", + "version": "1.14.46", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", @@ -195,7 +195,7 @@ }, "packages/core": { "name": "@opencode-ai/core", - "version": "1.14.45", + "version": "1.14.46", "bin": { "opencode": "./bin/opencode", }, @@ -229,7 +229,7 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "1.14.45", + "version": "1.14.46", "dependencies": { "drizzle-orm": "catalog:", "effect": "catalog:", @@ -283,7 +283,7 @@ }, "packages/enterprise": { "name": "@opencode-ai/enterprise", - "version": "1.14.45", + "version": "1.14.46", "dependencies": { "@opencode-ai/core": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -313,7 +313,7 @@ }, "packages/function": { "name": "@opencode-ai/function", - "version": "1.14.45", + "version": "1.14.46", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "catalog:", @@ -329,7 +329,7 @@ }, "packages/http-recorder": { "name": "@opencode-ai/http-recorder", - "version": "1.14.45", + "version": "1.14.46", "dependencies": { "@effect/platform-node": "catalog:", "effect": "catalog:", @@ -342,7 +342,7 @@ }, "packages/llm": { "name": "@opencode-ai/llm", - "version": "1.14.45", + "version": "1.14.46", "dependencies": { "@smithy/eventstream-codec": "4.2.14", "@smithy/util-utf8": "4.2.2", @@ -360,7 +360,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.14.45", + "version": "1.14.46", "bin": { "opencode": "./bin/opencode", }, @@ -495,7 +495,7 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.14.45", + "version": "1.14.46", "dependencies": { "@opencode-ai/sdk": "workspace:*", "effect": "catalog:", @@ -533,7 +533,7 @@ }, "packages/sdk/js": { "name": "@opencode-ai/sdk", - "version": "1.14.45", + "version": "1.14.46", "dependencies": { "cross-spawn": "catalog:", }, @@ -548,7 +548,7 @@ }, "packages/slack": { "name": "@opencode-ai/slack", - "version": "1.14.45", + "version": "1.14.46", "dependencies": { "@opencode-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -583,7 +583,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.14.45", + "version": "1.14.46", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/core": "workspace:*", @@ -632,7 +632,7 @@ }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.14.45", + "version": "1.14.46", "dependencies": { "@astrojs/cloudflare": "12.6.3", "@astrojs/markdown-remark": "6.3.1", diff --git a/packages/app/package.json b/packages/app/package.json index 16d1f6b10d..635614bf13 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "1.14.45", + "version": "1.14.46", "description": "", "type": "module", "exports": { diff --git a/packages/console/app/package.json b/packages/console/app/package.json index 1ec8d79a66..9c7cbbc3bf 100644 --- a/packages/console/app/package.json +++ b/packages/console/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-app", - "version": "1.14.45", + "version": "1.14.46", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/console/core/package.json b/packages/console/core/package.json index 2c7f23d6df..fa9d8fe15c 100644 --- a/packages/console/core/package.json +++ b/packages/console/core/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/console-core", - "version": "1.14.45", + "version": "1.14.46", "private": true, "type": "module", "license": "MIT", diff --git a/packages/console/function/package.json b/packages/console/function/package.json index a5f01a32ab..a24da8fa1d 100644 --- a/packages/console/function/package.json +++ b/packages/console/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-function", - "version": "1.14.45", + "version": "1.14.46", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/console/mail/package.json b/packages/console/mail/package.json index c522a5f283..b5015ec22a 100644 --- a/packages/console/mail/package.json +++ b/packages/console/mail/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-mail", - "version": "1.14.45", + "version": "1.14.46", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", diff --git a/packages/core/package.json b/packages/core/package.json index a68a1d3f7a..1567df4b85 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.14.45", + "version": "1.14.46", "name": "@opencode-ai/core", "type": "module", "license": "MIT", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 1d3f9b961b..9a1d15ea12 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop", "private": true, - "version": "1.14.45", + "version": "1.14.46", "type": "module", "license": "MIT", "homepage": "https://opencode.ai", diff --git a/packages/enterprise/package.json b/packages/enterprise/package.json index 7c7cc1ed24..b02d14c3b1 100644 --- a/packages/enterprise/package.json +++ b/packages/enterprise/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/enterprise", - "version": "1.14.45", + "version": "1.14.46", "private": true, "type": "module", "license": "MIT", diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml index 7d84cd3730..061cd6ab38 100644 --- a/packages/extensions/zed/extension.toml +++ b/packages/extensions/zed/extension.toml @@ -1,7 +1,7 @@ id = "opencode" name = "OpenCode" description = "The open source coding agent." -version = "1.14.45" +version = "1.14.46" schema_version = 1 authors = ["Anomaly"] repository = "https://github.com/anomalyco/opencode" @@ -11,26 +11,26 @@ name = "OpenCode" icon = "./icons/opencode.svg" [agent_servers.opencode.targets.darwin-aarch64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.45/opencode-darwin-arm64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.46/opencode-darwin-arm64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.darwin-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.45/opencode-darwin-x64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.46/opencode-darwin-x64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-aarch64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.45/opencode-linux-arm64.tar.gz" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.46/opencode-linux-arm64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.45/opencode-linux-x64.tar.gz" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.46/opencode-linux-x64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.windows-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.45/opencode-windows-x64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.46/opencode-windows-x64.zip" cmd = "./opencode.exe" args = ["acp"] diff --git a/packages/function/package.json b/packages/function/package.json index 731e814501..90ad91f89d 100644 --- a/packages/function/package.json +++ b/packages/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/function", - "version": "1.14.45", + "version": "1.14.46", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/http-recorder/package.json b/packages/http-recorder/package.json index ff23648540..9cf71a7436 100644 --- a/packages/http-recorder/package.json +++ b/packages/http-recorder/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.14.45", + "version": "1.14.46", "name": "@opencode-ai/http-recorder", "type": "module", "license": "MIT", diff --git a/packages/llm/package.json b/packages/llm/package.json index 4d2c2fdfbf..8fa5e833e6 100644 --- a/packages/llm/package.json +++ b/packages/llm/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.14.45", + "version": "1.14.46", "name": "@opencode-ai/llm", "type": "module", "license": "MIT", diff --git a/packages/opencode/package.json b/packages/opencode/package.json index f55b75c0d2..dbd766f934 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.14.45", + "version": "1.14.46", "name": "opencode", "type": "module", "license": "MIT", diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 5e7deabcdf..e25f89338a 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/plugin", - "version": "1.14.45", + "version": "1.14.46", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index bd881db451..3faace81cd 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/sdk", - "version": "1.14.45", + "version": "1.14.46", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/slack/package.json b/packages/slack/package.json index c30e9c3b54..5f4aa7eeba 100644 --- a/packages/slack/package.json +++ b/packages/slack/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/slack", - "version": "1.14.45", + "version": "1.14.46", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/ui/package.json b/packages/ui/package.json index 75a8509400..5301e38032 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/ui", - "version": "1.14.45", + "version": "1.14.46", "type": "module", "license": "MIT", "exports": { diff --git a/packages/web/package.json b/packages/web/package.json index 47f1d91e85..490fea2aff 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -2,7 +2,7 @@ "name": "@opencode-ai/web", "type": "module", "license": "MIT", - "version": "1.14.45", + "version": "1.14.46", "scripts": { "dev": "astro dev", "dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev", diff --git a/sdks/vscode/package.json b/sdks/vscode/package.json index f04148b881..1430192895 100644 --- a/sdks/vscode/package.json +++ b/sdks/vscode/package.json @@ -2,7 +2,7 @@ "name": "opencode", "displayName": "opencode", "description": "opencode for VS Code", - "version": "1.14.45", + "version": "1.14.46", "publisher": "sst-dev", "repository": { "type": "git", From 67b9c9c02790eda3144722bbcf1d517b11d7cac4 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 9 May 2026 22:58:47 -0400 Subject: [PATCH 108/189] Source HTTP API ID path patterns (#26623) --- .../specs/openapi-translation-cleanup.md | 10 ++-- packages/opencode/src/pty/schema.ts | 4 +- .../server/routes/instance/httpapi/public.ts | 11 ---- packages/opencode/src/session/schema.ts | 54 +++++++++++-------- .../opencode/test/cli/github-action.test.ts | 24 ++++----- .../test/project/migrate-global.test.ts | 13 ++--- .../server/httpapi-query-schema-drift.test.ts | 35 +++++++++++- .../opencode/test/server/httpapi-ui.test.ts | 2 +- .../opencode/test/session/instruction.test.ts | 16 +++--- packages/opencode/test/session/llm.test.ts | 16 +++--- .../opencode/test/session/message-v2.test.ts | 4 +- .../opencode/test/tool/apply_patch.test.ts | 2 +- packages/opencode/test/tool/edit.test.ts | 2 +- .../test/tool/external-directory.test.ts | 2 +- packages/opencode/test/tool/glob.test.ts | 2 +- packages/opencode/test/tool/grep.test.ts | 2 +- packages/opencode/test/tool/lsp.test.ts | 2 +- packages/opencode/test/tool/question.test.ts | 2 +- packages/opencode/test/tool/read.test.ts | 2 +- .../opencode/test/tool/repo_clone.test.ts | 2 +- .../opencode/test/tool/repo_overview.test.ts | 2 +- packages/opencode/test/tool/shell.test.ts | 2 +- packages/opencode/test/tool/skill.test.ts | 2 +- packages/opencode/test/tool/webfetch.test.ts | 2 +- packages/opencode/test/tool/write.test.ts | 2 +- 25 files changed, 127 insertions(+), 90 deletions(-) diff --git a/packages/opencode/specs/openapi-translation-cleanup.md b/packages/opencode/specs/openapi-translation-cleanup.md index 55e4c7268d..472e686ab0 100644 --- a/packages/opencode/specs/openapi-translation-cleanup.md +++ b/packages/opencode/specs/openapi-translation-cleanup.md @@ -105,11 +105,11 @@ Verification: Concrete first targets: -- `sessionID` -- `messageID` -- `partID` -- `permissionID` -- `ptyID` +- `[x]` `sessionID` +- `[x]` `messageID` +- `[x]` `partID` +- `[x]` `permissionID` +- `[x]` `ptyID` Leave ambiguous route-local `id` overrides for workspace routes until they are renamed or explicitly typed in endpoint params. diff --git a/packages/opencode/src/pty/schema.ts b/packages/opencode/src/pty/schema.ts index 0f1d6996df..f6508bace0 100644 --- a/packages/opencode/src/pty/schema.ts +++ b/packages/opencode/src/pty/schema.ts @@ -4,7 +4,9 @@ import { Identifier } from "@/id/id" import { zod, ZodOverride } from "@opencode-ai/core/effect-zod" import { withStatics } from "@opencode-ai/core/schema" -const ptyIdSchema = Schema.String.annotate({ [ZodOverride]: Identifier.schema("pty") }).pipe(Schema.brand("PtyID")) +const ptyIdSchema = Schema.String.check(Schema.isStartsWith("pty")) + .annotate({ [ZodOverride]: Identifier.schema("pty") }) + .pipe(Schema.brand("PtyID")) export type PtyID = typeof ptyIdSchema.Type diff --git a/packages/opencode/src/server/routes/instance/httpapi/public.ts b/packages/opencode/src/server/routes/instance/httpapi/public.ts index 156ebf6834..c67a6071e6 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/public.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/public.ts @@ -69,14 +69,6 @@ const QueryParameterSchemas: Record = { "GET /api/session/{sessionID}/message limit": { type: "number" }, } -const PathParameterSchemas: Record = { - sessionID: { type: "string", pattern: "^ses.*" }, - messageID: { type: "string", pattern: "^msg.*" }, - partID: { type: "string", pattern: "^prt.*" }, - permissionID: { type: "string", pattern: "^per.*" }, - ptyID: { type: "string", pattern: "^pty.*" }, -} - const LegacyComponentDescriptions: Record = { LogLevel: "Log level", ServerConfig: "Server configuration for opencode serve and web commands", @@ -506,11 +498,8 @@ function normalizeParameter(param: OpenApiParameter, route: string) { } function pathParameterSchema(route: string, name: string) { - if (name in PathParameterSchemas) return PathParameterSchemas[name] if (name === "id" && route.startsWith("DELETE /experimental/workspace/")) return { type: "string", pattern: "^wrk.*" } if (name === "id" && route.startsWith("POST /experimental/workspace/")) return { type: "string", pattern: "^wrk.*" } - if (name === "requestID" && route.startsWith("POST /permission/")) return { type: "string", pattern: "^per.*" } - if (name === "requestID" && route.startsWith("POST /question/")) return { type: "string", pattern: "^que.*" } return undefined } diff --git a/packages/opencode/src/session/schema.ts b/packages/opencode/src/session/schema.ts index d0e6cd4cb7..11085eeb56 100644 --- a/packages/opencode/src/session/schema.ts +++ b/packages/opencode/src/session/schema.ts @@ -4,32 +4,44 @@ import { Identifier } from "@/id/id" import { zod, ZodOverride } from "@opencode-ai/core/effect-zod" import { withStatics } from "@opencode-ai/core/schema" -export const SessionID = Schema.String.annotate({ [ZodOverride]: Identifier.schema("session") }).pipe( - Schema.brand("SessionID"), - withStatics((s) => ({ - descending: (id?: string) => s.make(Identifier.descending("session", id)), - zod: zod(s), - })), -) +export const SessionID = Schema.String.check(Schema.isStartsWith("ses")) + .annotate({ + [ZodOverride]: Identifier.schema("session"), + }) + .pipe( + Schema.brand("SessionID"), + withStatics((s) => ({ + descending: (id?: string) => s.make(Identifier.descending("session", id)), + zod: zod(s), + })), + ) export type SessionID = Schema.Schema.Type -export const MessageID = Schema.String.annotate({ [ZodOverride]: Identifier.schema("message") }).pipe( - Schema.brand("MessageID"), - withStatics((s) => ({ - ascending: (id?: string) => s.make(Identifier.ascending("message", id)), - zod: zod(s), - })), -) +export const MessageID = Schema.String.check(Schema.isStartsWith("msg")) + .annotate({ + [ZodOverride]: Identifier.schema("message"), + }) + .pipe( + Schema.brand("MessageID"), + withStatics((s) => ({ + ascending: (id?: string) => s.make(Identifier.ascending("message", id)), + zod: zod(s), + })), + ) export type MessageID = Schema.Schema.Type -export const PartID = Schema.String.annotate({ [ZodOverride]: Identifier.schema("part") }).pipe( - Schema.brand("PartID"), - withStatics((s) => ({ - ascending: (id?: string) => s.make(Identifier.ascending("part", id)), - zod: zod(s), - })), -) +export const PartID = Schema.String.check(Schema.isStartsWith("prt")) + .annotate({ + [ZodOverride]: Identifier.schema("part"), + }) + .pipe( + Schema.brand("PartID"), + withStatics((s) => ({ + ascending: (id?: string) => s.make(Identifier.ascending("part", id)), + zod: zod(s), + })), + ) export type PartID = Schema.Schema.Type diff --git a/packages/opencode/test/cli/github-action.test.ts b/packages/opencode/test/cli/github-action.test.ts index 279ed27d08..263f3a45f3 100644 --- a/packages/opencode/test/cli/github-action.test.ts +++ b/packages/opencode/test/cli/github-action.test.ts @@ -7,8 +7,8 @@ import { SessionID, MessageID, PartID } from "../../src/session/schema" function createTextPart(text: string): MessageV2.Part { return { id: PartID.ascending(), - sessionID: SessionID.make("s"), - messageID: MessageID.make("m"), + sessionID: SessionID.make("ses_test"), + messageID: MessageID.make("msg_test"), type: "text" as const, text, } @@ -17,8 +17,8 @@ function createTextPart(text: string): MessageV2.Part { function createReasoningPart(text: string): MessageV2.Part { return { id: PartID.ascending(), - sessionID: SessionID.make("s"), - messageID: MessageID.make("m"), + sessionID: SessionID.make("ses_test"), + messageID: MessageID.make("msg_test"), type: "reasoning" as const, text, time: { start: 0 }, @@ -29,8 +29,8 @@ function createToolPart(tool: string, title: string, status: "completed" | "runn if (status === "completed") { return { id: PartID.ascending(), - sessionID: SessionID.make("s"), - messageID: MessageID.make("m"), + sessionID: SessionID.make("ses_test"), + messageID: MessageID.make("msg_test"), type: "tool" as const, callID: "c1", tool, @@ -46,8 +46,8 @@ function createToolPart(tool: string, title: string, status: "completed" | "runn } return { id: PartID.ascending(), - sessionID: SessionID.make("s"), - messageID: MessageID.make("m"), + sessionID: SessionID.make("ses_test"), + messageID: MessageID.make("msg_test"), type: "tool" as const, callID: "c1", tool, @@ -62,8 +62,8 @@ function createToolPart(tool: string, title: string, status: "completed" | "runn function createStepStartPart(): MessageV2.Part { return { id: PartID.ascending(), - sessionID: SessionID.make("s"), - messageID: MessageID.make("m"), + sessionID: SessionID.make("ses_test"), + messageID: MessageID.make("msg_test"), type: "step-start" as const, } } @@ -71,8 +71,8 @@ function createStepStartPart(): MessageV2.Part { function createStepFinishPart(): MessageV2.Part { return { id: PartID.ascending(), - sessionID: SessionID.make("s"), - messageID: MessageID.make("m"), + sessionID: SessionID.make("ses_test"), + messageID: MessageID.make("msg_test"), type: "step-finish" as const, reason: "done", cost: 0, diff --git a/packages/opencode/test/project/migrate-global.test.ts b/packages/opencode/test/project/migrate-global.test.ts index 2a1580579d..c476c108b4 100644 --- a/packages/opencode/test/project/migrate-global.test.ts +++ b/packages/opencode/test/project/migrate-global.test.ts @@ -22,8 +22,9 @@ function run(fn: (svc: Project.Interface) => Effect.Effect) { ) } -function uid() { - return SessionID.make(crypto.randomUUID()) +function legacySessionID() { + // Global-session migration covers persisted IDs from before prefixed session IDs. + return crypto.randomUUID() as SessionID } function seed(opts: { id: SessionID; dir: string; project: ProjectID }) { @@ -73,7 +74,7 @@ describe("migrateFromGlobal", () => { expect(pre.id).toBe(ProjectID.global) // 2. Seed a session under "global" with matching directory - const id = uid() + const id = legacySessionID() seed({ id, dir: tmp.path, project: ProjectID.global }) // 3. Make a commit so the project gets a real ID @@ -100,7 +101,7 @@ describe("migrateFromGlobal", () => { // 3. Seed a session under "global" with matching directory. // This simulates a session created before git init that wasn't // present when the real project row was first created. - const id = uid() + const id = legacySessionID() seed({ id, dir: tmp.path, project: ProjectID.global }) // 4. Call fromDirectory again — project row already exists, @@ -121,7 +122,7 @@ describe("migrateFromGlobal", () => { // Legacy sessions may lack a directory value. // Without a matching origin directory, they should remain global. - const id = uid() + const id = legacySessionID() seed({ id, dir: "", project: ProjectID.global }) await run((svc) => svc.fromDirectory(tmp.path)) @@ -139,7 +140,7 @@ describe("migrateFromGlobal", () => { ensureGlobal() // Seed a session under "global" but for a DIFFERENT directory - const id = uid() + const id = legacySessionID() seed({ id, dir: "/some/other/dir", project: ProjectID.global }) await run((svc) => svc.fromDirectory(tmp.path)) diff --git a/packages/opencode/test/server/httpapi-query-schema-drift.test.ts b/packages/opencode/test/server/httpapi-query-schema-drift.test.ts index 3aedd6b814..cd3f8a5d51 100644 --- a/packages/opencode/test/server/httpapi-query-schema-drift.test.ts +++ b/packages/opencode/test/server/httpapi-query-schema-drift.test.ts @@ -22,6 +22,7 @@ import { MessagesQuery, SessionPaths, } from "../../src/server/routes/instance/httpapi/groups/session" +import { PtyPaths } from "../../src/server/routes/instance/httpapi/groups/pty" import { MessagesQuery as V2MessagesQuery } from "../../src/server/routes/instance/httpapi/groups/v2/message" import { SessionsQuery as V2SessionsQuery } from "../../src/server/routes/instance/httpapi/groups/v2/session" import { QueryBoolean } from "../../src/server/routes/instance/httpapi/groups/query" @@ -33,7 +34,12 @@ const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES type Method = "get" | "post" | "put" | "delete" | "patch" type QuerySchema = { readonly fields: Record } -type OpenApiSchema = { readonly maximum?: number; readonly minimum?: number; readonly type?: string } +type OpenApiSchema = { + readonly maximum?: number + readonly minimum?: number + readonly pattern?: string + readonly type?: string +} type OpenApiParameter = { readonly name: string; readonly in: string; readonly schema?: OpenApiSchema } type OpenApiOperation = { readonly parameters?: readonly OpenApiParameter[] } @@ -68,6 +74,16 @@ const numericSdkQueryParams = [ { method: "get", path: "/api/session/:sessionID/message", name: "limit", schema: { type: "number" } }, ] satisfies Array<{ method: Method; path: string; name: string; schema: OpenApiSchema }> +const pathParamPatterns = [ + { method: "get", path: SessionPaths.get, name: "sessionID", pattern: "^ses" }, + { method: "get", path: SessionPaths.message, name: "messageID", pattern: "^msg" }, + { method: "patch", path: SessionPaths.updatePart, name: "partID", pattern: "^prt" }, + { method: "post", path: SessionPaths.permissions, name: "permissionID", pattern: "^per" }, + { method: "post", path: "/permission/:requestID/reply", name: "requestID", pattern: "^per" }, + { method: "post", path: "/question/:requestID/reply", name: "requestID", pattern: "^que" }, + { method: "put", path: PtyPaths.update, name: "ptyID", pattern: "^pty" }, +] satisfies Array<{ method: Method; path: string; name: string; pattern: string }> + function app() { return Server.Default().app } @@ -98,6 +114,10 @@ function queryParameter(operation: OpenApiOperation | undefined, name: string) { return (operation?.parameters ?? []).find((param) => param.in === "query" && param.name === name) } +function pathParameter(operation: OpenApiOperation | undefined, name: string) { + return (operation?.parameters ?? []).find((param) => param.in === "path" && param.name === name) +} + function assertAdvertisedQueryParamsAreRuntimeFields(input: { readonly method: Method readonly operation: OpenApiOperation | undefined @@ -173,6 +193,19 @@ describe("httpapi query schema drift", () => { }), ) + it.effect( + "OpenAPI path parameter patterns come from runtime schemas", + Effect.sync(() => { + const spec = OpenApi.fromApi(PublicApi) + for (const expected of pathParamPatterns) { + expect( + pathParameter(spec.paths[openApiPath(expected.path)]?.[expected.method], expected.name)?.schema, + `${expected.method.toUpperCase()} ${expected.path} ${expected.name}`, + ).toEqual({ type: "string", pattern: expected.pattern }) + } + }), + ) + it.effect( "drift assertion catches spec-only workspace query params", Effect.sync(() => { diff --git a/packages/opencode/test/server/httpapi-ui.test.ts b/packages/opencode/test/server/httpapi-ui.test.ts index 68ed2a3e25..256c450193 100644 --- a/packages/opencode/test/server/httpapi-ui.test.ts +++ b/packages/opencode/test/server/httpapi-ui.test.ts @@ -287,7 +287,7 @@ describe("HttpApi UI fallback", () => { }) test("keeps matched API routes ahead of the UI fallback", async () => { - const response = await Server.Default().app.request("/session/nope") + const response = await Server.Default().app.request("/session/ses_nope") expect(response.status).toBe(404) }) diff --git a/packages/opencode/test/session/instruction.test.ts b/packages/opencode/test/session/instruction.test.ts index 3bb38c8786..5d40933954 100644 --- a/packages/opencode/test/session/instruction.test.ts +++ b/packages/opencode/test/session/instruction.test.ts @@ -61,7 +61,7 @@ const tmpWithFiles = (files: Record) => function loaded(filepath: string): MessageV2.WithParts[] { const sessionID = SessionID.make("session-loaded-1") - const messageID = MessageID.make("message-loaded-1") + const messageID = MessageID.make("msg_message-loaded-1") return [ { @@ -78,7 +78,7 @@ function loaded(filepath: string): MessageV2.WithParts[] { }, parts: [ { - id: PartID.make("part-loaded-1"), + id: PartID.make("prt_part-loaded-1"), messageID, sessionID, type: "tool", @@ -106,7 +106,7 @@ describe("Instruction.resolve", () => { const system = yield* svc.systemPaths() expect(system.has(path.join(dir, "AGENTS.md"))).toBe(true) - const results = yield* svc.resolve([], path.join(dir, "src", "file.ts"), MessageID.make("message-test-1")) + const results = yield* svc.resolve([], path.join(dir, "src", "file.ts"), MessageID.make("msg_message-test-1")) expect(results).toEqual([]) }), ), @@ -122,7 +122,7 @@ describe("Instruction.resolve", () => { const results = yield* svc.resolve( [], path.join(dir, "subdir", "nested", "file.ts"), - MessageID.make("message-test-2"), + MessageID.make("msg_message-test-2"), ) expect(results.length).toBe(1) expect(results[0].filepath).toBe(path.join(dir, "subdir", "AGENTS.md")) @@ -138,7 +138,7 @@ describe("Instruction.resolve", () => { const system = yield* svc.systemPaths() expect(system.has(filepath)).toBe(false) - const results = yield* svc.resolve([], filepath, MessageID.make("message-test-3")) + const results = yield* svc.resolve([], filepath, MessageID.make("msg_message-test-3")) expect(results).toEqual([]) }), ), @@ -149,7 +149,7 @@ describe("Instruction.resolve", () => { Effect.gen(function* () { const svc = yield* Instruction.Service const filepath = path.join(dir, "subdir", "nested", "file.ts") - const id = MessageID.make("message-claim-1") + const id = MessageID.make("msg_message-claim-1") const first = yield* svc.resolve([], filepath, id) const second = yield* svc.resolve([], filepath, id) @@ -166,7 +166,7 @@ describe("Instruction.resolve", () => { Effect.gen(function* () { const svc = yield* Instruction.Service const filepath = path.join(dir, "subdir", "nested", "file.ts") - const id = MessageID.make("message-claim-2") + const id = MessageID.make("msg_message-claim-2") const first = yield* svc.resolve([], filepath, id) yield* svc.clear(id) @@ -185,7 +185,7 @@ describe("Instruction.resolve", () => { const svc = yield* Instruction.Service const agents = path.join(dir, "subdir", "AGENTS.md") const filepath = path.join(dir, "subdir", "nested", "file.ts") - const id = MessageID.make("message-claim-3") + const id = MessageID.make("msg_message-claim-3") const results = yield* svc.resolve(loaded(agents), filepath, id) expect(results).toEqual([]) diff --git a/packages/opencode/test/session/llm.test.ts b/packages/opencode/test/session/llm.test.ts index 7b96084832..2879d04812 100644 --- a/packages/opencode/test/session/llm.test.ts +++ b/packages/opencode/test/session/llm.test.ts @@ -354,7 +354,7 @@ describe("session.llm.stream", () => { } satisfies Agent.Info const user = { - id: MessageID.make("user-1"), + id: MessageID.make("msg_user-1"), sessionID, role: "user", time: { created: Date.now() }, @@ -438,7 +438,7 @@ describe("session.llm.stream", () => { permission: [{ permission: "*", pattern: "*", action: "allow" }], } satisfies Agent.Info const user = { - id: MessageID.make("user-service-abort"), + id: MessageID.make("msg_user-service-abort"), sessionID, role: "user", time: { created: Date.now() }, @@ -529,7 +529,7 @@ describe("session.llm.stream", () => { } satisfies Agent.Info const user = { - id: MessageID.make("user-tools"), + id: MessageID.make("msg_user-tools"), sessionID, role: "user", time: { created: Date.now() }, @@ -644,7 +644,7 @@ describe("session.llm.stream", () => { } satisfies Agent.Info const user = { - id: MessageID.make("user-2"), + id: MessageID.make("msg_user-2"), sessionID, role: "user", time: { created: Date.now() }, @@ -759,7 +759,7 @@ describe("session.llm.stream", () => { } satisfies Agent.Info const user = { - id: MessageID.make("user-data-url"), + id: MessageID.make("msg_user-data-url"), sessionID, role: "user", time: { created: Date.now() }, @@ -880,7 +880,7 @@ describe("session.llm.stream", () => { } satisfies Agent.Info const user = { - id: MessageID.make("user-3"), + id: MessageID.make("msg_user-3"), sessionID, role: "user", time: { created: Date.now() }, @@ -995,7 +995,7 @@ describe("session.llm.stream", () => { permission: [{ permission: "*", pattern: "*", action: "allow" }], } satisfies Agent.Info const user = { - id: MessageID.make("user-anthropic-tools"), + id: MessageID.make("msg_user-anthropic-tools"), sessionID, role: "user", time: { created: Date.now() }, @@ -1239,7 +1239,7 @@ describe("session.llm.stream", () => { } satisfies Agent.Info const user = { - id: MessageID.make("user-4"), + id: MessageID.make("msg_user-4"), sessionID, role: "user", time: { created: Date.now() }, diff --git a/packages/opencode/test/session/message-v2.test.ts b/packages/opencode/test/session/message-v2.test.ts index 08629f5b1b..f742b7afc8 100644 --- a/packages/opencode/test/session/message-v2.test.ts +++ b/packages/opencode/test/session/message-v2.test.ts @@ -102,9 +102,9 @@ function assistantInfo( function basePart(messageID: string, id: string) { return { - id: PartID.make(id), + id: PartID.make(id.startsWith("prt") ? id : `prt_${id}`), sessionID, - messageID: MessageID.make(messageID), + messageID: MessageID.make(messageID.startsWith("msg") ? messageID : `msg_${messageID}`), } } diff --git a/packages/opencode/test/tool/apply_patch.test.ts b/packages/opencode/test/tool/apply_patch.test.ts index fd24b557b3..3fc034e4e5 100644 --- a/packages/opencode/test/tool/apply_patch.test.ts +++ b/packages/opencode/test/tool/apply_patch.test.ts @@ -27,7 +27,7 @@ const runtime = ManagedRuntime.make( const baseCtx = { sessionID: SessionID.make("ses_test"), - messageID: MessageID.make(""), + messageID: MessageID.make("msg_test"), callID: "", agent: "build", abort: AbortSignal.any([]), diff --git a/packages/opencode/test/tool/edit.test.ts b/packages/opencode/test/tool/edit.test.ts index 23ae0e9090..a629ff07d1 100644 --- a/packages/opencode/test/tool/edit.test.ts +++ b/packages/opencode/test/tool/edit.test.ts @@ -17,7 +17,7 @@ import { SessionID, MessageID } from "../../src/session/schema" const ctx = { sessionID: SessionID.make("ses_test-edit-session"), - messageID: MessageID.make(""), + messageID: MessageID.make("msg_test"), callID: "", agent: "build", abort: AbortSignal.any([]), diff --git a/packages/opencode/test/tool/external-directory.test.ts b/packages/opencode/test/tool/external-directory.test.ts index 5914918178..0560ea0300 100644 --- a/packages/opencode/test/tool/external-directory.test.ts +++ b/packages/opencode/test/tool/external-directory.test.ts @@ -12,7 +12,7 @@ import { SessionID, MessageID } from "../../src/session/schema" const baseCtx: Omit = { sessionID: SessionID.make("ses_test"), - messageID: MessageID.make(""), + messageID: MessageID.make("msg_test"), callID: "", agent: "build", abort: AbortSignal.any([]), diff --git a/packages/opencode/test/tool/glob.test.ts b/packages/opencode/test/tool/glob.test.ts index 94f401afd8..9ff1f8a99a 100644 --- a/packages/opencode/test/tool/glob.test.ts +++ b/packages/opencode/test/tool/glob.test.ts @@ -23,7 +23,7 @@ const it = testEffect( const ctx = { sessionID: SessionID.make("ses_test"), - messageID: MessageID.make(""), + messageID: MessageID.make("msg_test"), callID: "", agent: "build", abort: AbortSignal.any([]), diff --git a/packages/opencode/test/tool/grep.test.ts b/packages/opencode/test/tool/grep.test.ts index 4b0da7c698..dfb74b8bed 100644 --- a/packages/opencode/test/tool/grep.test.ts +++ b/packages/opencode/test/tool/grep.test.ts @@ -23,7 +23,7 @@ const it = testEffect( const ctx = { sessionID: SessionID.make("ses_test"), - messageID: MessageID.make(""), + messageID: MessageID.make("msg_test"), callID: "", agent: "build", abort: AbortSignal.any([]), diff --git a/packages/opencode/test/tool/lsp.test.ts b/packages/opencode/test/tool/lsp.test.ts index 27623375c2..875af8e010 100644 --- a/packages/opencode/test/tool/lsp.test.ts +++ b/packages/opencode/test/tool/lsp.test.ts @@ -20,7 +20,7 @@ afterEach(async () => { const ctx = { sessionID: SessionID.make("ses_test"), - messageID: MessageID.make(""), + messageID: MessageID.make("msg_test"), callID: "", agent: "build", abort: AbortSignal.any([]), diff --git a/packages/opencode/test/tool/question.test.ts b/packages/opencode/test/tool/question.test.ts index 3f2cba8941..da215db770 100644 --- a/packages/opencode/test/tool/question.test.ts +++ b/packages/opencode/test/tool/question.test.ts @@ -10,7 +10,7 @@ import { testEffect } from "../lib/effect" const ctx = { sessionID: SessionID.make("ses_test-session"), - messageID: MessageID.make("test-message"), + messageID: MessageID.make("msg_test-message"), callID: "test-call", agent: "test-agent", abort: AbortSignal.any([]), diff --git a/packages/opencode/test/tool/read.test.ts b/packages/opencode/test/tool/read.test.ts index 969364bad9..9e2cc04895 100644 --- a/packages/opencode/test/tool/read.test.ts +++ b/packages/opencode/test/tool/read.test.ts @@ -24,7 +24,7 @@ afterEach(async () => { const ctx = { sessionID: SessionID.make("ses_test"), - messageID: MessageID.make(""), + messageID: MessageID.make("msg_test"), callID: "", agent: "build", abort: AbortSignal.any([]), diff --git a/packages/opencode/test/tool/repo_clone.test.ts b/packages/opencode/test/tool/repo_clone.test.ts index e32c6ee439..1ac913328d 100644 --- a/packages/opencode/test/tool/repo_clone.test.ts +++ b/packages/opencode/test/tool/repo_clone.test.ts @@ -19,7 +19,7 @@ afterEach(async () => { const ctx = { sessionID: SessionID.make("ses_test"), - messageID: MessageID.make(""), + messageID: MessageID.make("msg_test"), callID: "", agent: "scout", abort: AbortSignal.any([]), diff --git a/packages/opencode/test/tool/repo_overview.test.ts b/packages/opencode/test/tool/repo_overview.test.ts index b4214b7af4..556fa05d1f 100644 --- a/packages/opencode/test/tool/repo_overview.test.ts +++ b/packages/opencode/test/tool/repo_overview.test.ts @@ -18,7 +18,7 @@ afterEach(async () => { const ctx = { sessionID: SessionID.make("ses_test"), - messageID: MessageID.make(""), + messageID: MessageID.make("msg_test"), callID: "", agent: "scout", abort: AbortSignal.any([]), diff --git a/packages/opencode/test/tool/shell.test.ts b/packages/opencode/test/tool/shell.test.ts index 9b5c17c222..287844141f 100644 --- a/packages/opencode/test/tool/shell.test.ts +++ b/packages/opencode/test/tool/shell.test.ts @@ -36,7 +36,7 @@ const initShell = initBash const ctx = { sessionID: SessionID.make("ses_test"), - messageID: MessageID.make(""), + messageID: MessageID.make("msg_test"), callID: "", agent: "build", abort: AbortSignal.any([]), diff --git a/packages/opencode/test/tool/skill.test.ts b/packages/opencode/test/tool/skill.test.ts index 7473d2d56a..c58d1a190d 100644 --- a/packages/opencode/test/tool/skill.test.ts +++ b/packages/opencode/test/tool/skill.test.ts @@ -14,7 +14,7 @@ import { testEffect } from "../lib/effect" const baseCtx: Omit = { sessionID: SessionID.make("ses_test"), - messageID: MessageID.make(""), + messageID: MessageID.make("msg_test"), callID: "", agent: "build", abort: AbortSignal.any([]), diff --git a/packages/opencode/test/tool/webfetch.test.ts b/packages/opencode/test/tool/webfetch.test.ts index 6c7f6aba77..f3890c0161 100644 --- a/packages/opencode/test/tool/webfetch.test.ts +++ b/packages/opencode/test/tool/webfetch.test.ts @@ -13,7 +13,7 @@ const projectRoot = path.join(import.meta.dir, "../..") const ctx = { sessionID: SessionID.make("ses_test"), - messageID: MessageID.make("message"), + messageID: MessageID.make("msg_message"), callID: "", agent: "build", abort: AbortSignal.any([]), diff --git a/packages/opencode/test/tool/write.test.ts b/packages/opencode/test/tool/write.test.ts index 8bba52a4b2..f6ac57a8ce 100644 --- a/packages/opencode/test/tool/write.test.ts +++ b/packages/opencode/test/tool/write.test.ts @@ -18,7 +18,7 @@ import { testEffect } from "../lib/effect" const ctx = { sessionID: SessionID.make("ses_test-write-session"), - messageID: MessageID.make(""), + messageID: MessageID.make("msg_test"), callID: "", agent: "build", abort: AbortSignal.any([]), From 235a86fb60d845ab6f892d061ae4aaf7bd6e016e Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sun, 10 May 2026 02:59:46 +0000 Subject: [PATCH 109/189] chore: generate --- packages/sdk/openapi.json | 592 ++++++++++++++++++++++++-------------- 1 file changed, 381 insertions(+), 211 deletions(-) diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 8e7d56a987..68ca2e2698 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -3398,7 +3398,7 @@ "in": "path", "schema": { "type": "string", - "pattern": "^pty.*" + "pattern": "^pty" }, "required": true }, @@ -3459,7 +3459,7 @@ "in": "path", "schema": { "type": "string", - "pattern": "^pty.*" + "pattern": "^pty" }, "required": true }, @@ -3550,7 +3550,7 @@ "in": "path", "schema": { "type": "string", - "pattern": "^pty.*" + "pattern": "^pty" }, "required": true }, @@ -3614,7 +3614,7 @@ "in": "path", "schema": { "type": "string", - "pattern": "^pty.*" + "pattern": "^pty" }, "required": true }, @@ -3747,7 +3747,7 @@ "in": "path", "schema": { "type": "string", - "pattern": "^que.*" + "pattern": "^que" }, "required": true }, @@ -3841,7 +3841,7 @@ "in": "path", "schema": { "type": "string", - "pattern": "^que.*" + "pattern": "^que" }, "required": true }, @@ -3963,7 +3963,7 @@ "in": "path", "schema": { "type": "string", - "pattern": "^per.*" + "pattern": "^per" }, "required": true }, @@ -4489,7 +4489,8 @@ "type": "object", "properties": { "parentID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "title": { "type": "string" @@ -4601,7 +4602,7 @@ "in": "path", "schema": { "type": "string", - "pattern": "^ses.*" + "pattern": "^ses" }, "required": true }, @@ -4672,7 +4673,7 @@ "in": "path", "schema": { "type": "string", - "pattern": "^ses.*" + "pattern": "^ses" }, "required": true }, @@ -4744,7 +4745,7 @@ "in": "path", "schema": { "type": "string", - "pattern": "^ses.*" + "pattern": "^ses" }, "required": true }, @@ -4844,7 +4845,7 @@ "in": "path", "schema": { "type": "string", - "pattern": "^ses.*" + "pattern": "^ses" }, "required": true }, @@ -4921,7 +4922,7 @@ "in": "path", "schema": { "type": "string", - "pattern": "^ses.*" + "pattern": "^ses" }, "required": true }, @@ -4998,7 +4999,7 @@ "in": "path", "schema": { "type": "string", - "pattern": "^ses.*" + "pattern": "^ses" }, "required": true }, @@ -5064,7 +5065,7 @@ "in": "path", "schema": { "type": "string", - "pattern": "^ses.*" + "pattern": "^ses" }, "required": true }, @@ -5170,7 +5171,7 @@ "in": "path", "schema": { "type": "string", - "pattern": "^ses.*" + "pattern": "^ses" }, "required": true }, @@ -5244,7 +5245,8 @@ "type": "object", "properties": { "messageID": { - "type": "string" + "type": "string", + "pattern": "^msg" }, "model": { "type": "object", @@ -5324,7 +5326,7 @@ "in": "path", "schema": { "type": "string", - "pattern": "^ses.*" + "pattern": "^ses" }, "required": true }, @@ -5333,7 +5335,7 @@ "in": "path", "schema": { "type": "string", - "pattern": "^msg.*" + "pattern": "^msg" }, "required": true }, @@ -5418,7 +5420,7 @@ "in": "path", "schema": { "type": "string", - "pattern": "^ses.*" + "pattern": "^ses" }, "required": true }, @@ -5427,7 +5429,7 @@ "in": "path", "schema": { "type": "string", - "pattern": "^msg.*" + "pattern": "^msg" }, "required": true }, @@ -5501,7 +5503,7 @@ "in": "path", "schema": { "type": "string", - "pattern": "^ses.*" + "pattern": "^ses" }, "required": true }, @@ -5553,7 +5555,8 @@ "type": "object", "properties": { "messageID": { - "type": "string" + "type": "string", + "pattern": "^msg" } }, "additionalProperties": false @@ -5579,7 +5582,7 @@ "in": "path", "schema": { "type": "string", - "pattern": "^ses.*" + "pattern": "^ses" }, "required": true }, @@ -5653,7 +5656,7 @@ "in": "path", "schema": { "type": "string", - "pattern": "^ses.*" + "pattern": "^ses" }, "required": true }, @@ -5722,7 +5725,8 @@ "type": "string" }, "messageID": { - "type": "string" + "type": "string", + "pattern": "^msg" } }, "required": ["modelID", "providerID", "messageID"], @@ -5749,7 +5753,7 @@ "in": "path", "schema": { "type": "string", - "pattern": "^ses.*" + "pattern": "^ses" }, "required": true }, @@ -5820,7 +5824,7 @@ "in": "path", "schema": { "type": "string", - "pattern": "^ses.*" + "pattern": "^ses" }, "required": true }, @@ -5893,7 +5897,7 @@ "in": "path", "schema": { "type": "string", - "pattern": "^ses.*" + "pattern": "^ses" }, "required": true }, @@ -5989,7 +5993,7 @@ "in": "path", "schema": { "type": "string", - "pattern": "^ses.*" + "pattern": "^ses" }, "required": true }, @@ -6044,7 +6048,8 @@ "type": "object", "properties": { "messageID": { - "type": "string" + "type": "string", + "pattern": "^msg" }, "model": { "type": "object", @@ -6124,7 +6129,7 @@ "in": "path", "schema": { "type": "string", - "pattern": "^ses.*" + "pattern": "^ses" }, "required": true }, @@ -6198,7 +6203,8 @@ "type": "object", "properties": { "messageID": { - "type": "string" + "type": "string", + "pattern": "^msg" }, "agent": { "type": "string" @@ -6221,7 +6227,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^prt" }, "type": { "type": "string", @@ -6269,7 +6276,7 @@ "in": "path", "schema": { "type": "string", - "pattern": "^ses.*" + "pattern": "^ses" }, "required": true }, @@ -6345,7 +6352,8 @@ "type": "object", "properties": { "messageID": { - "type": "string" + "type": "string", + "pattern": "^msg" }, "agent": { "type": "string" @@ -6391,7 +6399,7 @@ "in": "path", "schema": { "type": "string", - "pattern": "^ses.*" + "pattern": "^ses" }, "required": true }, @@ -6453,10 +6461,12 @@ "type": "object", "properties": { "messageID": { - "type": "string" + "type": "string", + "pattern": "^msg" }, "partID": { - "type": "string" + "type": "string", + "pattern": "^prt" } }, "required": ["messageID"], @@ -6483,7 +6493,7 @@ "in": "path", "schema": { "type": "string", - "pattern": "^ses.*" + "pattern": "^ses" }, "required": true }, @@ -6556,7 +6566,7 @@ "in": "path", "schema": { "type": "string", - "pattern": "^ses.*" + "pattern": "^ses" }, "required": true }, @@ -6565,7 +6575,7 @@ "in": "path", "schema": { "type": "string", - "pattern": "^per.*" + "pattern": "^per" }, "required": true }, @@ -6657,7 +6667,7 @@ "in": "path", "schema": { "type": "string", - "pattern": "^ses.*" + "pattern": "^ses" }, "required": true }, @@ -6666,7 +6676,7 @@ "in": "path", "schema": { "type": "string", - "pattern": "^msg.*" + "pattern": "^msg" }, "required": true }, @@ -6675,7 +6685,7 @@ "in": "path", "schema": { "type": "string", - "pattern": "^prt.*" + "pattern": "^prt" }, "required": true }, @@ -6746,7 +6756,7 @@ "in": "path", "schema": { "type": "string", - "pattern": "^ses.*" + "pattern": "^ses" }, "required": true }, @@ -6755,7 +6765,7 @@ "in": "path", "schema": { "type": "string", - "pattern": "^msg.*" + "pattern": "^msg" }, "required": true }, @@ -6764,7 +6774,7 @@ "in": "path", "schema": { "type": "string", - "pattern": "^prt.*" + "pattern": "^prt" }, "required": true }, @@ -7016,7 +7026,8 @@ "type": "object", "properties": { "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" } }, "required": ["sessionID"], @@ -7046,7 +7057,8 @@ "type": "object", "properties": { "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" } }, "required": ["sessionID"], @@ -7284,7 +7296,7 @@ "in": "path", "schema": { "type": "string", - "pattern": "^ses.*" + "pattern": "^ses" }, "required": true }, @@ -7356,7 +7368,7 @@ "in": "path", "schema": { "type": "string", - "pattern": "^ses.*" + "pattern": "^ses" }, "required": true }, @@ -7402,7 +7414,7 @@ "in": "path", "schema": { "type": "string", - "pattern": "^ses.*" + "pattern": "^ses" }, "required": true }, @@ -7448,7 +7460,7 @@ "in": "path", "schema": { "type": "string", - "pattern": "^ses.*" + "pattern": "^ses" }, "required": true }, @@ -7504,7 +7516,7 @@ "in": "path", "schema": { "type": "string", - "pattern": "^ses.*" + "pattern": "^ses" }, "required": true }, @@ -8209,6 +8221,7 @@ "properties": { "sessionID": { "type": "string", + "pattern": "^ses", "description": "Session ID to navigate to" } }, @@ -8751,7 +8764,8 @@ ] }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "copyChanges": { "type": "boolean" @@ -8781,7 +8795,7 @@ "in": "path", "schema": { "type": "string", - "pattern": "^pty.*" + "pattern": "^pty" }, "required": true }, @@ -9153,7 +9167,8 @@ "pattern": "^per" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "permission": { "type": "string" @@ -9177,7 +9192,8 @@ "type": "object", "properties": { "messageID": { - "type": "string" + "type": "string", + "pattern": "^msg" }, "callID": { "type": "string" @@ -9433,7 +9449,8 @@ "type": "object", "properties": { "messageID": { - "type": "string" + "type": "string", + "pattern": "^msg" }, "callID": { "type": "string" @@ -9450,7 +9467,8 @@ "pattern": "^que" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "questions": { "type": "array", @@ -9476,7 +9494,8 @@ "type": "object", "properties": { "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "requestID": { "type": "string", @@ -9496,7 +9515,8 @@ "type": "object", "properties": { "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "requestID": { "type": "string", @@ -9721,6 +9741,7 @@ "properties": { "sessionID": { "type": "string", + "pattern": "^ses", "description": "Session ID to navigate to" } }, @@ -9805,7 +9826,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^pty" }, "title": { "type": "string" @@ -9880,10 +9902,12 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^msg" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "role": { "type": "string", @@ -9958,10 +9982,12 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^msg" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "role": { "type": "string", @@ -10008,7 +10034,8 @@ ] }, "parentID": { - "type": "string" + "type": "string", + "pattern": "^msg" }, "modelID": { "type": "string" @@ -10111,13 +10138,16 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^prt" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "messageID": { - "type": "string" + "type": "string", + "pattern": "^msg" }, "type": { "type": "string", @@ -10158,13 +10188,16 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^prt" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "messageID": { - "type": "string" + "type": "string", + "pattern": "^msg" }, "type": { "type": "string", @@ -10203,13 +10236,16 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^prt" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "messageID": { - "type": "string" + "type": "string", + "pattern": "^msg" }, "type": { "type": "string", @@ -10374,13 +10410,16 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^prt" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "messageID": { - "type": "string" + "type": "string", + "pattern": "^msg" }, "type": { "type": "string", @@ -10553,13 +10592,16 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^prt" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "messageID": { - "type": "string" + "type": "string", + "pattern": "^msg" }, "type": { "type": "string", @@ -10585,13 +10627,16 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^prt" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "messageID": { - "type": "string" + "type": "string", + "pattern": "^msg" }, "type": { "type": "string", @@ -10608,13 +10653,16 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^prt" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "messageID": { - "type": "string" + "type": "string", + "pattern": "^msg" }, "type": { "type": "string", @@ -10669,13 +10717,16 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^prt" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "messageID": { - "type": "string" + "type": "string", + "pattern": "^msg" }, "type": { "type": "string", @@ -10692,13 +10743,16 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^prt" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "messageID": { - "type": "string" + "type": "string", + "pattern": "^msg" }, "type": { "type": "string", @@ -10721,13 +10775,16 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^prt" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "messageID": { - "type": "string" + "type": "string", + "pattern": "^msg" }, "type": { "type": "string", @@ -10762,13 +10819,16 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^prt" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "messageID": { - "type": "string" + "type": "string", + "pattern": "^msg" }, "type": { "type": "string", @@ -10800,13 +10860,16 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^prt" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "messageID": { - "type": "string" + "type": "string", + "pattern": "^msg" }, "type": { "type": "string", @@ -10819,7 +10882,8 @@ "type": "boolean" }, "tail_start_id": { - "type": "string" + "type": "string", + "pattern": "^msg" } }, "required": ["id", "sessionID", "messageID", "type", "auto"], @@ -10895,7 +10959,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "slug": { "type": "string" @@ -10913,7 +10978,8 @@ "type": "string" }, "parentID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "summary": { "type": "object", @@ -11001,10 +11067,12 @@ "type": "object", "properties": { "messageID": { - "type": "string" + "type": "string", + "pattern": "^msg" }, "partID": { - "type": "string" + "type": "string", + "pattern": "^prt" }, "snapshot": { "type": "string" @@ -12724,7 +12792,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "slug": { "type": "string" @@ -12742,7 +12811,8 @@ "type": "string" }, "parentID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "summary": { "type": "object", @@ -12830,10 +12900,12 @@ "type": "object", "properties": { "messageID": { - "type": "string" + "type": "string", + "pattern": "^msg" }, "partID": { - "type": "string" + "type": "string", + "pattern": "^prt" }, "snapshot": { "type": "string" @@ -13519,7 +13591,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^prt" }, "type": { "type": "string", @@ -13560,7 +13633,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^prt" }, "type": { "type": "string", @@ -13586,7 +13660,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^prt" }, "type": { "type": "string", @@ -13621,7 +13696,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^prt" }, "type": { "type": "string", @@ -13817,6 +13893,7 @@ "properties": { "sessionID": { "type": "string", + "pattern": "^ses", "description": "Session ID to navigate to" } }, @@ -13943,7 +14020,8 @@ "type": "object", "properties": { "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "info": { "$ref": "#/components/schemas/Message" @@ -13981,10 +14059,12 @@ "type": "object", "properties": { "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "messageID": { - "type": "string" + "type": "string", + "pattern": "^msg" } }, "required": ["sessionID", "messageID"], @@ -14019,7 +14099,8 @@ "type": "object", "properties": { "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "part": { "$ref": "#/components/schemas/Part" @@ -14061,13 +14142,16 @@ "type": "object", "properties": { "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "messageID": { - "type": "string" + "type": "string", + "pattern": "^msg" }, "partID": { - "type": "string" + "type": "string", + "pattern": "^prt" } }, "required": ["sessionID", "messageID", "partID"], @@ -14102,7 +14186,8 @@ "type": "object", "properties": { "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "info": { "$ref": "#/components/schemas/Session" @@ -14140,7 +14225,8 @@ "type": "object", "properties": { "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "info": { "type": "object", @@ -14148,7 +14234,8 @@ "id": { "anyOf": [ { - "type": "string" + "type": "string", + "pattern": "^ses" }, { "type": "null" @@ -14208,7 +14295,8 @@ "parentID": { "anyOf": [ { - "type": "string" + "type": "string", + "pattern": "^ses" }, { "type": "null" @@ -14378,10 +14466,12 @@ "type": "object", "properties": { "messageID": { - "type": "string" + "type": "string", + "pattern": "^msg" }, "partID": { - "type": "string" + "type": "string", + "pattern": "^prt" }, "snapshot": { "type": "string" @@ -14434,7 +14524,8 @@ "type": "object", "properties": { "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "info": { "$ref": "#/components/schemas/Session" @@ -14475,7 +14566,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "agent": { "type": "string" @@ -14516,7 +14608,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "model": { "type": "object", @@ -14570,7 +14663,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "prompt": { "$ref": "#/components/schemas/Prompt" @@ -14611,7 +14705,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "text": { "type": "string" @@ -14652,7 +14747,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "callID": { "type": "string" @@ -14696,7 +14792,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "callID": { "type": "string" @@ -14740,7 +14837,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "agent": { "type": "string" @@ -14800,7 +14898,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "finish": { "type": "string" @@ -14876,7 +14975,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "error": { "$ref": "#/components/schemas/SessionErrorUnknown" @@ -14917,7 +15017,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" } }, "required": ["timestamp", "sessionID"], @@ -14955,7 +15056,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "delta": { "type": "string" @@ -14996,7 +15098,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "text": { "type": "string" @@ -15037,7 +15140,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "reasoningID": { "type": "string" @@ -15078,7 +15182,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "reasoningID": { "type": "string" @@ -15122,7 +15227,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "reasoningID": { "type": "string" @@ -15166,7 +15272,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "callID": { "type": "string" @@ -15210,7 +15317,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "callID": { "type": "string" @@ -15254,7 +15362,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "callID": { "type": "string" @@ -15298,7 +15407,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "callID": { "type": "string" @@ -15358,7 +15468,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "callID": { "type": "string" @@ -15415,7 +15526,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "callID": { "type": "string" @@ -15485,7 +15597,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "callID": { "type": "string" @@ -15542,7 +15655,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "attempt": { "type": "number" @@ -15586,7 +15700,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "reason": { "type": "string", @@ -15628,7 +15743,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "text": { "type": "string" @@ -15669,7 +15785,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "text": { "type": "string" @@ -15820,13 +15937,16 @@ "type": "object", "properties": { "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "messageID": { - "type": "string" + "type": "string", + "pattern": "^msg" }, "partID": { - "type": "string" + "type": "string", + "pattern": "^prt" }, "field": { "type": "string" @@ -15873,7 +15993,8 @@ "type": "object", "properties": { "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "requestID": { "type": "string", @@ -15905,7 +16026,8 @@ "type": "object", "properties": { "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "diff": { "type": "array", @@ -15935,7 +16057,8 @@ "type": "object", "properties": { "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "error": { "anyOf": [ @@ -16082,7 +16205,8 @@ "type": "object", "properties": { "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "todos": { "type": "array", @@ -16112,7 +16236,8 @@ "type": "object", "properties": { "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "status": { "$ref": "#/components/schemas/SessionStatus" @@ -16139,7 +16264,8 @@ "type": "object", "properties": { "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" } }, "required": ["sessionID"], @@ -16163,7 +16289,8 @@ "type": "object", "properties": { "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" } }, "required": ["sessionID"], @@ -16241,13 +16368,15 @@ "type": "string" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "arguments": { "type": "string" }, "messageID": { - "type": "string" + "type": "string", + "pattern": "^msg" } }, "required": ["name", "sessionID", "arguments", "messageID"], @@ -16486,7 +16615,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^pty" }, "exitCode": { "type": "integer", @@ -16514,7 +16644,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^pty" } }, "required": ["id"], @@ -16538,7 +16669,8 @@ "type": "object", "properties": { "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "info": { "$ref": "#/components/schemas/Message" @@ -16565,10 +16697,12 @@ "type": "object", "properties": { "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "messageID": { - "type": "string" + "type": "string", + "pattern": "^msg" } }, "required": ["sessionID", "messageID"], @@ -16592,7 +16726,8 @@ "type": "object", "properties": { "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "part": { "$ref": "#/components/schemas/Part" @@ -16623,13 +16758,16 @@ "type": "object", "properties": { "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "messageID": { - "type": "string" + "type": "string", + "pattern": "^msg" }, "partID": { - "type": "string" + "type": "string", + "pattern": "^prt" } }, "required": ["sessionID", "messageID", "partID"], @@ -16653,7 +16791,8 @@ "type": "object", "properties": { "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "info": { "$ref": "#/components/schemas/Session" @@ -16680,7 +16819,8 @@ "type": "object", "properties": { "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "info": { "$ref": "#/components/schemas/Session" @@ -16707,7 +16847,8 @@ "type": "object", "properties": { "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "info": { "$ref": "#/components/schemas/Session" @@ -16737,7 +16878,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "agent": { "type": "string" @@ -16767,7 +16909,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "model": { "type": "object", @@ -16861,7 +17004,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "prompt": { "$ref": "#/components/schemas/Prompt" @@ -16891,7 +17035,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "text": { "type": "string" @@ -16921,7 +17066,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "callID": { "type": "string" @@ -16954,7 +17100,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "callID": { "type": "string" @@ -16987,7 +17134,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "agent": { "type": "string" @@ -17036,7 +17184,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "finish": { "type": "string" @@ -17115,7 +17264,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "error": { "$ref": "#/components/schemas/SessionErrorUnknown" @@ -17145,7 +17295,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" } }, "required": ["timestamp", "sessionID"], @@ -17172,7 +17323,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "delta": { "type": "string" @@ -17202,7 +17354,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "text": { "type": "string" @@ -17232,7 +17385,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "reasoningID": { "type": "string" @@ -17262,7 +17416,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "reasoningID": { "type": "string" @@ -17295,7 +17450,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "reasoningID": { "type": "string" @@ -17328,7 +17484,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "callID": { "type": "string" @@ -17361,7 +17518,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "callID": { "type": "string" @@ -17394,7 +17552,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "callID": { "type": "string" @@ -17427,7 +17586,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "callID": { "type": "string" @@ -17510,7 +17670,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "callID": { "type": "string" @@ -17556,7 +17717,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "callID": { "type": "string" @@ -17615,7 +17777,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "callID": { "type": "string" @@ -17692,7 +17855,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "attempt": { "type": "number" @@ -17725,7 +17889,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "reason": { "type": "string", @@ -17756,7 +17921,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "text": { "type": "string" @@ -17786,7 +17952,8 @@ "type": "number" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "text": { "type": "string" @@ -17842,10 +18009,12 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "parentID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "projectID": { "type": "string" @@ -18037,7 +18206,8 @@ "additionalProperties": false }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses" }, "text": { "type": "string" From f220f02a2f12ad0f30584d9a822fea202a20c0d6 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 9 May 2026 23:02:31 -0400 Subject: [PATCH 110/189] Source workspace path pattern (#26632) --- packages/opencode/specs/openapi-translation-cleanup.md | 2 +- packages/opencode/src/control-plane/schema.ts | 6 +++--- .../opencode/src/server/routes/instance/httpapi/public.ts | 8 +------- .../test/server/httpapi-query-schema-drift.test.ts | 2 ++ 4 files changed, 7 insertions(+), 11 deletions(-) diff --git a/packages/opencode/specs/openapi-translation-cleanup.md b/packages/opencode/specs/openapi-translation-cleanup.md index 472e686ab0..255c09644f 100644 --- a/packages/opencode/specs/openapi-translation-cleanup.md +++ b/packages/opencode/specs/openapi-translation-cleanup.md @@ -111,7 +111,7 @@ Concrete first targets: - `[x]` `permissionID` - `[x]` `ptyID` -Leave ambiguous route-local `id` overrides for workspace routes until they are renamed or explicitly typed in endpoint params. +- `[x]` Remove ambiguous workspace `id` path overrides once the endpoint source schema emits the `wrk` pattern. Verification: diff --git a/packages/opencode/src/control-plane/schema.ts b/packages/opencode/src/control-plane/schema.ts index dd4c325490..a106ae8990 100644 --- a/packages/opencode/src/control-plane/schema.ts +++ b/packages/opencode/src/control-plane/schema.ts @@ -4,9 +4,9 @@ import { Identifier } from "@/id/id" import { zod, ZodOverride } from "@opencode-ai/core/effect-zod" import { withStatics } from "@opencode-ai/core/schema" -const workspaceIdSchema = Schema.String.annotate({ [ZodOverride]: Identifier.schema("workspace") }).pipe( - Schema.brand("WorkspaceID"), -) +const workspaceIdSchema = Schema.String.check(Schema.isStartsWith("wrk")) + .annotate({ [ZodOverride]: Identifier.schema("workspace") }) + .pipe(Schema.brand("WorkspaceID")) export type WorkspaceID = typeof workspaceIdSchema.Type diff --git a/packages/opencode/src/server/routes/instance/httpapi/public.ts b/packages/opencode/src/server/routes/instance/httpapi/public.ts index c67a6071e6..1a294f84e7 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/public.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/public.ts @@ -478,7 +478,7 @@ function flattenOptions(options: OpenApiSchema[] | undefined): OpenApiSchema[] | function normalizeParameter(param: OpenApiParameter, route: string) { if (!param.schema || typeof param.schema !== "object") return if (param.in === "path") { - param.schema = pathParameterSchema(route, param.name) ?? stripOptionalNull(param.schema) + param.schema = stripOptionalNull(param.schema) return } if (param.in === "query") { @@ -497,12 +497,6 @@ function normalizeParameter(param: OpenApiParameter, route: string) { param.schema = stripOptionalNull(param.schema) } -function pathParameterSchema(route: string, name: string) { - if (name === "id" && route.startsWith("DELETE /experimental/workspace/")) return { type: "string", pattern: "^wrk.*" } - if (name === "id" && route.startsWith("POST /experimental/workspace/")) return { type: "string", pattern: "^wrk.*" } - return undefined -} - export const PublicApi = OpenCodeHttpApi.annotateMerge( OpenApi.annotations({ title: "opencode", diff --git a/packages/opencode/test/server/httpapi-query-schema-drift.test.ts b/packages/opencode/test/server/httpapi-query-schema-drift.test.ts index cd3f8a5d51..bf24f90353 100644 --- a/packages/opencode/test/server/httpapi-query-schema-drift.test.ts +++ b/packages/opencode/test/server/httpapi-query-schema-drift.test.ts @@ -17,6 +17,7 @@ import { ToolListQuery, } from "../../src/server/routes/instance/httpapi/groups/experimental" import { InstancePaths, VcsDiffQuery } from "../../src/server/routes/instance/httpapi/groups/instance" +import { WorkspacePaths } from "../../src/server/routes/instance/httpapi/groups/workspace" import { ListQuery as SessionListQuery, MessagesQuery, @@ -82,6 +83,7 @@ const pathParamPatterns = [ { method: "post", path: "/permission/:requestID/reply", name: "requestID", pattern: "^per" }, { method: "post", path: "/question/:requestID/reply", name: "requestID", pattern: "^que" }, { method: "put", path: PtyPaths.update, name: "ptyID", pattern: "^pty" }, + { method: "delete", path: WorkspacePaths.remove, name: "id", pattern: "^wrk" }, ] satisfies Array<{ method: Method; path: string; name: string; pattern: string }> function app() { From b3526f6ce9b7cacaeb4d969784c8c4f52dabbadb Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sun, 10 May 2026 03:03:37 +0000 Subject: [PATCH 111/189] chore: generate --- packages/sdk/openapi.json | 32 +++++++++++++++++++++----------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 68ca2e2698..a428a3179c 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -4518,7 +4518,8 @@ "$ref": "#/components/schemas/PermissionRuleset" }, "workspaceID": { - "type": "string" + "type": "string", + "pattern": "^wrk" } }, "additionalProperties": false @@ -8504,7 +8505,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^wrk" }, "type": { "type": "string" @@ -8612,7 +8614,8 @@ "type": "object", "properties": { "workspaceID": { - "type": "string" + "type": "string", + "pattern": "^wrk" }, "status": { "type": "string", @@ -8648,7 +8651,7 @@ "in": "path", "schema": { "type": "string", - "pattern": "^wrk.*" + "pattern": "^wrk" }, "required": true }, @@ -8756,7 +8759,8 @@ "id": { "anyOf": [ { - "type": "string" + "type": "string", + "pattern": "^wrk" }, { "type": "null" @@ -10969,7 +10973,8 @@ "type": "string" }, "workspaceID": { - "type": "string" + "type": "string", + "pattern": "^wrk" }, "directory": { "type": "string" @@ -12802,7 +12807,8 @@ "type": "string" }, "workspaceID": { - "type": "string" + "type": "string", + "pattern": "^wrk" }, "directory": { "type": "string" @@ -13908,7 +13914,8 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^wrk" }, "type": { "type": "string" @@ -14265,7 +14272,8 @@ "workspaceID": { "anyOf": [ { - "type": "string" + "type": "string", + "pattern": "^wrk" }, { "type": "null" @@ -16488,7 +16496,8 @@ "type": "object", "properties": { "workspaceID": { - "type": "string" + "type": "string", + "pattern": "^wrk" }, "status": { "type": "string", @@ -18020,7 +18029,8 @@ "type": "string" }, "workspaceID": { - "type": "string" + "type": "string", + "pattern": "^wrk" }, "path": { "type": "string" From fb4bab8a662a80f517aa02021fc4ef6e111296ec Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 9 May 2026 23:12:21 -0400 Subject: [PATCH 112/189] Remove redundant ID Zod overrides (#26633) --- packages/opencode/src/control-plane/schema.ts | 6 +- packages/opencode/src/id/id.ts | 8 --- packages/opencode/src/permission/schema.ts | 4 +- packages/opencode/src/pty/schema.ts | 6 +- packages/opencode/src/question/schema.ts | 7 +-- packages/opencode/src/session/schema.ts | 56 ++++++++----------- packages/opencode/src/sync/schema.ts | 4 +- packages/opencode/src/tool/schema.ts | 4 +- 8 files changed, 34 insertions(+), 61 deletions(-) diff --git a/packages/opencode/src/control-plane/schema.ts b/packages/opencode/src/control-plane/schema.ts index a106ae8990..53ce0cff97 100644 --- a/packages/opencode/src/control-plane/schema.ts +++ b/packages/opencode/src/control-plane/schema.ts @@ -1,12 +1,10 @@ import { Schema } from "effect" import { Identifier } from "@/id/id" -import { zod, ZodOverride } from "@opencode-ai/core/effect-zod" +import { zod } from "@opencode-ai/core/effect-zod" import { withStatics } from "@opencode-ai/core/schema" -const workspaceIdSchema = Schema.String.check(Schema.isStartsWith("wrk")) - .annotate({ [ZodOverride]: Identifier.schema("workspace") }) - .pipe(Schema.brand("WorkspaceID")) +const workspaceIdSchema = Schema.String.check(Schema.isStartsWith("wrk")).pipe(Schema.brand("WorkspaceID")) export type WorkspaceID = typeof workspaceIdSchema.Type diff --git a/packages/opencode/src/id/id.ts b/packages/opencode/src/id/id.ts index 6d9a6447a0..9e163cd6b8 100644 --- a/packages/opencode/src/id/id.ts +++ b/packages/opencode/src/id/id.ts @@ -1,4 +1,3 @@ -import z from "zod" import { randomBytes } from "crypto" const prefixes = { @@ -7,19 +6,12 @@ const prefixes = { message: "msg", permission: "per", question: "que", - user: "usr", part: "prt", pty: "pty", tool: "tool", workspace: "wrk", - entry: "ent", - account: "act", } as const -export function schema(prefix: keyof typeof prefixes) { - return z.string().startsWith(prefixes[prefix]) -} - const LENGTH = 26 // State for monotonic ID generation diff --git a/packages/opencode/src/permission/schema.ts b/packages/opencode/src/permission/schema.ts index 725030935d..f7c6e2c5b7 100644 --- a/packages/opencode/src/permission/schema.ts +++ b/packages/opencode/src/permission/schema.ts @@ -1,12 +1,12 @@ import { Schema } from "effect" import { Identifier } from "@/id/id" -import { zod, ZodOverride } from "@opencode-ai/core/effect-zod" +import { zod } from "@opencode-ai/core/effect-zod" import { Newtype } from "@opencode-ai/core/schema" export class PermissionID extends Newtype()( "PermissionID", - Schema.String.check(Schema.isStartsWith("per")).annotate({ [ZodOverride]: Identifier.schema("permission") }), + Schema.String.check(Schema.isStartsWith("per")), ) { static ascending(id?: string): PermissionID { return this.make(Identifier.ascending("permission", id)) diff --git a/packages/opencode/src/pty/schema.ts b/packages/opencode/src/pty/schema.ts index f6508bace0..fadb0457e7 100644 --- a/packages/opencode/src/pty/schema.ts +++ b/packages/opencode/src/pty/schema.ts @@ -1,12 +1,10 @@ import { Schema } from "effect" import { Identifier } from "@/id/id" -import { zod, ZodOverride } from "@opencode-ai/core/effect-zod" +import { zod } from "@opencode-ai/core/effect-zod" import { withStatics } from "@opencode-ai/core/schema" -const ptyIdSchema = Schema.String.check(Schema.isStartsWith("pty")) - .annotate({ [ZodOverride]: Identifier.schema("pty") }) - .pipe(Schema.brand("PtyID")) +const ptyIdSchema = Schema.String.check(Schema.isStartsWith("pty")).pipe(Schema.brand("PtyID")) export type PtyID = typeof ptyIdSchema.Type diff --git a/packages/opencode/src/question/schema.ts b/packages/opencode/src/question/schema.ts index c18eca3e23..1856c94bc7 100644 --- a/packages/opencode/src/question/schema.ts +++ b/packages/opencode/src/question/schema.ts @@ -1,13 +1,10 @@ import { Schema } from "effect" import { Identifier } from "@/id/id" -import { zod, ZodOverride } from "@opencode-ai/core/effect-zod" +import { zod } from "@opencode-ai/core/effect-zod" import { Newtype } from "@opencode-ai/core/schema" -export class QuestionID extends Newtype()( - "QuestionID", - Schema.String.check(Schema.isStartsWith("que")).annotate({ [ZodOverride]: Identifier.schema("question") }), -) { +export class QuestionID extends Newtype()("QuestionID", Schema.String.check(Schema.isStartsWith("que"))) { static ascending(id?: string): QuestionID { return this.make(Identifier.ascending("question", id)) } diff --git a/packages/opencode/src/session/schema.ts b/packages/opencode/src/session/schema.ts index 11085eeb56..991c9ccc6b 100644 --- a/packages/opencode/src/session/schema.ts +++ b/packages/opencode/src/session/schema.ts @@ -1,47 +1,35 @@ import { Schema } from "effect" import { Identifier } from "@/id/id" -import { zod, ZodOverride } from "@opencode-ai/core/effect-zod" +import { zod } from "@opencode-ai/core/effect-zod" import { withStatics } from "@opencode-ai/core/schema" -export const SessionID = Schema.String.check(Schema.isStartsWith("ses")) - .annotate({ - [ZodOverride]: Identifier.schema("session"), - }) - .pipe( - Schema.brand("SessionID"), - withStatics((s) => ({ - descending: (id?: string) => s.make(Identifier.descending("session", id)), - zod: zod(s), - })), - ) +export const SessionID = Schema.String.check(Schema.isStartsWith("ses")).pipe( + Schema.brand("SessionID"), + withStatics((s) => ({ + descending: (id?: string) => s.make(Identifier.descending("session", id)), + zod: zod(s), + })), +) export type SessionID = Schema.Schema.Type -export const MessageID = Schema.String.check(Schema.isStartsWith("msg")) - .annotate({ - [ZodOverride]: Identifier.schema("message"), - }) - .pipe( - Schema.brand("MessageID"), - withStatics((s) => ({ - ascending: (id?: string) => s.make(Identifier.ascending("message", id)), - zod: zod(s), - })), - ) +export const MessageID = Schema.String.check(Schema.isStartsWith("msg")).pipe( + Schema.brand("MessageID"), + withStatics((s) => ({ + ascending: (id?: string) => s.make(Identifier.ascending("message", id)), + zod: zod(s), + })), +) export type MessageID = Schema.Schema.Type -export const PartID = Schema.String.check(Schema.isStartsWith("prt")) - .annotate({ - [ZodOverride]: Identifier.schema("part"), - }) - .pipe( - Schema.brand("PartID"), - withStatics((s) => ({ - ascending: (id?: string) => s.make(Identifier.ascending("part", id)), - zod: zod(s), - })), - ) +export const PartID = Schema.String.check(Schema.isStartsWith("prt")).pipe( + Schema.brand("PartID"), + withStatics((s) => ({ + ascending: (id?: string) => s.make(Identifier.ascending("part", id)), + zod: zod(s), + })), +) export type PartID = Schema.Schema.Type diff --git a/packages/opencode/src/sync/schema.ts b/packages/opencode/src/sync/schema.ts index e4e2e75b73..dde2e53d17 100644 --- a/packages/opencode/src/sync/schema.ts +++ b/packages/opencode/src/sync/schema.ts @@ -1,10 +1,10 @@ import { Schema } from "effect" import { Identifier } from "@/id/id" -import { zod, ZodOverride } from "@opencode-ai/core/effect-zod" +import { zod } from "@opencode-ai/core/effect-zod" import { withStatics } from "@opencode-ai/core/schema" -export const EventID = Schema.String.annotate({ [ZodOverride]: Identifier.schema("event") }).pipe( +export const EventID = Schema.String.check(Schema.isStartsWith("evt")).pipe( Schema.brand("EventID"), withStatics((s) => ({ ascending: (id?: string) => s.make(Identifier.ascending("event", id)), diff --git a/packages/opencode/src/tool/schema.ts b/packages/opencode/src/tool/schema.ts index b6c263a4ce..a80d915153 100644 --- a/packages/opencode/src/tool/schema.ts +++ b/packages/opencode/src/tool/schema.ts @@ -1,10 +1,10 @@ import { Schema } from "effect" import { Identifier } from "@/id/id" -import { zod, ZodOverride } from "@opencode-ai/core/effect-zod" +import { zod } from "@opencode-ai/core/effect-zod" import { withStatics } from "@opencode-ai/core/schema" -const toolIdSchema = Schema.String.annotate({ [ZodOverride]: Identifier.schema("tool") }).pipe(Schema.brand("ToolID")) +const toolIdSchema = Schema.String.check(Schema.isStartsWith("tool")).pipe(Schema.brand("ToolID")) export type ToolID = typeof toolIdSchema.Type From 3753601f87559ab0d6e6ad75e63f8f765cffd5eb Mon Sep 17 00:00:00 2001 From: Dax Date: Sun, 10 May 2026 00:29:02 -0400 Subject: [PATCH 113/189] Format TUI paths relative to session directory (#26648) --- .../src/cli/cmd/tui/context/path-format.tsx | 37 ++++++++ .../src/cli/cmd/tui/routes/session/index.tsx | 93 ++++++++----------- .../cli/cmd/tui/routes/session/permission.tsx | 33 ++----- 3 files changed, 83 insertions(+), 80 deletions(-) create mode 100644 packages/opencode/src/cli/cmd/tui/context/path-format.tsx diff --git a/packages/opencode/src/cli/cmd/tui/context/path-format.tsx b/packages/opencode/src/cli/cmd/tui/context/path-format.tsx new file mode 100644 index 0000000000..eb9293cbc9 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/context/path-format.tsx @@ -0,0 +1,37 @@ +import path from "path" +import { createContext, useContext, type ParentProps } from "solid-js" +import { Global } from "@opencode-ai/core/global" + +const context = createContext<{ + path: () => string + format: (input?: string) => string +}>() + +export function PathFormatterProvider(props: ParentProps<{ path: string | undefined }>) { + return ( + props.path || process.cwd(), format: (input) => formatPath(input, props.path) }}> + {props.children} + + ) +} + +export function usePathFormatter() { + const value = useContext(context) + if (!value) throw new Error("PathFormatter context must be used within a PathFormatterProvider") + return value +} + +function formatPath(input: string | undefined, base: string | undefined) { + if (!input) return "" + + const root = base || process.cwd() + const absolute = path.isAbsolute(input) ? input : path.resolve(root, input) + const relative = path.relative(root, absolute) + + if (!relative) return "." + if (relative !== ".." && !relative.startsWith(".." + path.sep)) return relative + if (Global.Path.home && (absolute === Global.Path.home || absolute.startsWith(Global.Path.home + path.sep))) { + return absolute.replace(Global.Path.home, "~") + } + return absolute +} diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 508ba49416..b0fd9ed219 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -75,7 +75,6 @@ import stripAnsi from "strip-ansi" import { usePromptRef } from "../../context/prompt" import { useExit } from "../../context/exit" import { Filesystem } from "@/util/filesystem" -import { Global } from "@opencode-ai/core/global" import { PermissionPrompt } from "./permission" import { QuestionPrompt } from "./question" import { DialogExportOptions } from "../../ui/dialog-export-options" @@ -90,6 +89,7 @@ import { SessionRetry } from "@/session/retry" import { getRevertDiffFiles } from "../../util/revert-diff" import { useCommandPalette } from "../../context/command-palette" import { useBindings, useCommandShortcut } from "../../keymap" +import { PathFormatterProvider, usePathFormatter } from "../../context/path-format" addDefaultParsers(parsers.parsers) @@ -1078,23 +1078,24 @@ export function Session() { createEffect(on(() => route.sessionID, toBottom)) return ( - + + @@ -1270,7 +1271,8 @@ export function Session() { - + + ) } @@ -1827,7 +1829,7 @@ function BlockTool(props: { function Shell(props: ToolProps) { const { theme } = useTheme() - const sync = useSync() + const pathFormatter = usePathFormatter() const isRunning = createMemo(() => props.part.state.status === "running") const output = createMemo(() => stripAnsi(props.metadata.output?.trim() ?? "")) const [expanded, setExpanded] = createSignal(false) @@ -1841,18 +1843,7 @@ function Shell(props: ToolProps) { const workdirDisplay = createMemo(() => { const workdir = props.input.workdir if (!workdir || workdir === ".") return undefined - - const base = sync.path.directory - if (!base) return undefined - - const absolute = path.resolve(base, workdir) - if (absolute === base) return undefined - - const home = Global.Path.home - if (!home) return absolute - - const match = absolute === home || absolute.startsWith(home + path.sep) - return match ? absolute.replace(home, "~") : absolute + return pathFormatter.format(workdir) }) const title = createMemo(() => { @@ -1894,6 +1885,7 @@ function Shell(props: ToolProps) { function Write(props: ToolProps) { const { theme, syntax } = useTheme() + const pathFormatter = usePathFormatter() const code = createMemo(() => { if (!props.input.content) return "" return props.input.content @@ -1902,7 +1894,7 @@ function Write(props: ToolProps) { return ( - + ) { - Write {normalizePath(props.input.filePath!)} + Write {pathFormatter.format(props.input.filePath)} @@ -1925,9 +1917,10 @@ function Write(props: ToolProps) { } function Glob(props: ToolProps) { + const pathFormatter = usePathFormatter() return ( - Glob "{props.input.pattern}" in {normalizePath(props.input.path)} + Glob "{props.input.pattern}" in {pathFormatter.format(props.input.path)} ({props.metadata.count} {props.metadata.count === 1 ? "match" : "matches"}) @@ -1937,6 +1930,7 @@ function Glob(props: ToolProps) { function Read(props: ToolProps) { const { theme } = useTheme() + const pathFormatter = usePathFormatter() const isRunning = createMemo(() => props.part.state.status === "running") const loaded = createMemo(() => { if (props.part.state.status !== "completed") return [] @@ -1954,13 +1948,13 @@ function Read(props: ToolProps) { spinner={isRunning()} part={props.part} > - Read {normalizePath(props.input.filePath!)} {input(props.input, ["filePath"])} + Read {pathFormatter.format(props.input.filePath)} {input(props.input, ["filePath"])} {(filepath) => ( - ↳ Loaded {normalizePath(filepath)} + ↳ Loaded {pathFormatter.format(filepath)} )} @@ -1970,9 +1964,10 @@ function Read(props: ToolProps) { } function Grep(props: ToolProps) { + const pathFormatter = usePathFormatter() return ( - Grep "{props.input.pattern}" in {normalizePath(props.input.path)} + Grep "{props.input.pattern}" in {pathFormatter.format(props.input.path)} ({props.metadata.matches} {props.metadata.matches === 1 ? "match" : "matches"}) @@ -2071,6 +2066,7 @@ function Task(props: ToolProps) { function Edit(props: ToolProps) { const ctx = use() const { theme, syntax } = useTheme() + const pathFormatter = usePathFormatter() const view = createMemo(() => { const diffStyle = ctx.tui.diff_style @@ -2086,7 +2082,7 @@ function Edit(props: ToolProps) { return ( - + ) { - Edit {normalizePath(props.input.filePath!)} {input({ replaceAll: props.input.replaceAll })} + Edit {pathFormatter.format(props.input.filePath)} {input({ replaceAll: props.input.replaceAll })} @@ -2123,6 +2119,7 @@ function Edit(props: ToolProps) { function ApplyPatch(props: ToolProps) { const ctx = use() const { theme, syntax } = useTheme() + const pathFormatter = usePathFormatter() const files = createMemo(() => props.metadata.files ?? []) @@ -2161,7 +2158,7 @@ function ApplyPatch(props: ToolProps) { function title(file: { type: string; relativePath: string; filePath: string; deletions: number }) { if (file.type === "delete") return "# Deleted " + file.relativePath if (file.type === "add") return "# Created " + file.relativePath - if (file.type === "move") return "# Moved " + normalizePath(file.filePath) + " → " + file.relativePath + if (file.type === "move") return "# Moved " + pathFormatter.format(file.filePath) + " → " + file.relativePath return "← Patched " + file.relativePath } @@ -2281,20 +2278,6 @@ function Diagnostics(props: { diagnostics?: Record[] ) } -function normalizePath(input?: string) { - if (!input) return "" - - const cwd = process.cwd() - const absolute = path.isAbsolute(input) ? input : path.resolve(cwd, input) - const relative = path.relative(cwd, absolute) - - if (!relative) return "." - if (!relative.startsWith("..")) return relative - - // outside cwd - use absolute - return absolute -} - function input(input: Record, omit?: string[]): string { const primitives = Object.entries(input).filter(([key, value]) => { if (omit?.includes(key)) return false diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx index 0ccc3d7262..5b40c3c318 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx @@ -11,34 +11,16 @@ import { useProject } from "../../context/project" import path from "path" import { LANGUAGE_EXTENSIONS } from "@/lsp/language" import { Locale } from "@/util/locale" -import { Global } from "@opencode-ai/core/global" import { ShellID } from "@/tool/shell/id" import { webSearchProviderLabel } from "@/tool/websearch" import { useDialog } from "../../ui/dialog" import { getScrollAcceleration } from "../../util/scroll" import { useTuiConfig } from "../../context/tui-config" import { useBindings, useCommandShortcut } from "../../keymap" +import { usePathFormatter } from "../../context/path-format" type PermissionStage = "permission" | "always" | "reject" -function normalizePath(input?: string) { - if (!input) return "" - - const cwd = process.cwd() - const home = Global.Path.home - const absolute = path.isAbsolute(input) ? input : path.resolve(cwd, input) - const relative = path.relative(cwd, absolute) - - if (!relative) return "." - if (!relative.startsWith("..")) return relative - - // outside cwd - use ~ or absolute - if (home && (absolute === home || absolute.startsWith(home + path.sep))) { - return absolute.replace(home, "~") - } - return absolute -} - function filetype(input?: string) { if (!input) return "none" const ext = path.extname(input) @@ -137,6 +119,7 @@ export function PermissionPrompt(props: { request: PermissionRequest }) { const [store, setStore] = createStore({ stage: "permission" as PermissionStage, }) + const pathFormatter = usePathFormatter() const session = createMemo(() => sync.data.session.find((s) => s.id === props.request.sessionID)) @@ -220,7 +203,7 @@ export function PermissionPrompt(props: { request: PermissionRequest }) { const filepath = typeof raw === "string" ? raw : "" return { icon: "→", - title: `Edit ${normalizePath(filepath)}`, + title: `Edit ${pathFormatter.format(filepath)}`, body: , } } @@ -230,11 +213,11 @@ export function PermissionPrompt(props: { request: PermissionRequest }) { const filePath = typeof raw === "string" ? raw : "" return { icon: "→", - title: `Read ${normalizePath(filePath)}`, + title: `Read ${pathFormatter.format(filePath)}`, body: ( - {"Path: " + normalizePath(filePath)} + {"Path: " + pathFormatter.format(filePath)} ), @@ -276,11 +259,11 @@ export function PermissionPrompt(props: { request: PermissionRequest }) { const dir = typeof raw === "string" ? raw : "" return { icon: "→", - title: `List ${normalizePath(dir)}`, + title: `List ${pathFormatter.format(dir)}`, body: ( - {"Path: " + normalizePath(dir)} + {"Path: " + pathFormatter.format(dir)} ), @@ -359,7 +342,7 @@ export function PermissionPrompt(props: { request: PermissionRequest }) { typeof pattern === "string" ? (pattern.includes("*") ? path.dirname(pattern) : pattern) : undefined const raw = parent ?? filepath ?? derived - const dir = normalizePath(raw) + const dir = pathFormatter.format(raw) const patterns = (props.request.patterns ?? []).filter((p): p is string => typeof p === "string") return { From a9a2a597d59611aacf7366eda491476a83088fa5 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sun, 10 May 2026 04:30:04 +0000 Subject: [PATCH 114/189] chore: generate --- .../src/cli/cmd/tui/context/path-format.tsx | 4 +- .../src/cli/cmd/tui/routes/session/index.tsx | 346 +++++++++--------- 2 files changed, 176 insertions(+), 174 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/context/path-format.tsx b/packages/opencode/src/cli/cmd/tui/context/path-format.tsx index eb9293cbc9..1c9f19c6c6 100644 --- a/packages/opencode/src/cli/cmd/tui/context/path-format.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/path-format.tsx @@ -9,7 +9,9 @@ const context = createContext<{ export function PathFormatterProvider(props: ParentProps<{ path: string | undefined }>) { return ( - props.path || process.cwd(), format: (input) => formatPath(input, props.path) }}> + props.path || process.cwd(), format: (input) => formatPath(input, props.path) }} + > {props.children} ) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index b0fd9ed219..b0b48ec42c 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -1096,181 +1096,181 @@ export function Session() { tui: tuiConfig, }} > - - - - (scroll = r)} - viewportOptions={{ - paddingRight: showScrollbar() ? 1 : 0, - }} - verticalScrollbarOptions={{ - paddingLeft: 1, - visible: showScrollbar(), - trackOptions: { - backgroundColor: theme.backgroundElement, - foregroundColor: theme.border, - }, - }} - stickyScroll={true} - stickyStart="bottom" - flexGrow={1} - scrollAcceleration={scrollAcceleration()} - > - - - {(message, index) => ( - - - {(function () { - const command = useCommandPalette() - const redoShortcut = useCommandShortcut("session.redo") - const [hover, setHover] = createSignal(false) - const dialog = useDialog() - - const handleUnrevert = async () => { - const confirmed = await DialogConfirm.show( - dialog, - "Confirm Redo", - "Are you sure you want to restore the reverted messages?", - ) - if (confirmed) { - command.run("session.redo") - } - } - - return ( - setHover(true)} - onMouseOut={() => setHover(false)} - onMouseUp={handleUnrevert} - marginTop={1} - flexShrink={0} - border={["left"]} - customBorderChars={SplitBorder.customBorderChars} - borderColor={theme.backgroundPanel} - > - - {revert()!.reverted.length} message reverted - - {redoShortcut()} or /redo to restore - - - - - {(file) => ( - - {file.filename} - 0}> - +{file.additions} - - 0}> - -{file.deletions} - - - )} - - - - - - ) - })()} - - = revert()!.messageID}> - <> - - - { - if (renderer.getSelection()?.getSelectedText()) return - dialog.replace(() => ( - prompt?.set(promptInfo)} - /> - )) - }} - message={message as UserMessage} - parts={sync.data.part[message.id] ?? []} - pending={pending()} - /> - - - - - - )} - - - - 0}> - - - 0}> - - - - - - - - { - toBottom() - }} - sessionID={route.sessionID} - right={} - /> - - - - - - - - - - - - - + + + (scroll = r)} + viewportOptions={{ + paddingRight: showScrollbar() ? 1 : 0, + }} + verticalScrollbarOptions={{ + paddingLeft: 1, + visible: showScrollbar(), + trackOptions: { + backgroundColor: theme.backgroundElement, + foregroundColor: theme.border, + }, + }} + stickyScroll={true} + stickyStart="bottom" + flexGrow={1} + scrollAcceleration={scrollAcceleration()} > - + + + {(message, index) => ( + + + {(function () { + const command = useCommandPalette() + const redoShortcut = useCommandShortcut("session.redo") + const [hover, setHover] = createSignal(false) + const dialog = useDialog() + + const handleUnrevert = async () => { + const confirmed = await DialogConfirm.show( + dialog, + "Confirm Redo", + "Are you sure you want to restore the reverted messages?", + ) + if (confirmed) { + command.run("session.redo") + } + } + + return ( + setHover(true)} + onMouseOut={() => setHover(false)} + onMouseUp={handleUnrevert} + marginTop={1} + flexShrink={0} + border={["left"]} + customBorderChars={SplitBorder.customBorderChars} + borderColor={theme.backgroundPanel} + > + + {revert()!.reverted.length} message reverted + + {redoShortcut()} or /redo to restore + + + + + {(file) => ( + + {file.filename} + 0}> + +{file.additions} + + 0}> + -{file.deletions} + + + )} + + + + + + ) + })()} + + = revert()!.messageID}> + <> + + + { + if (renderer.getSelection()?.getSelectedText()) return + dialog.replace(() => ( + prompt?.set(promptInfo)} + /> + )) + }} + message={message as UserMessage} + parts={sync.data.part[message.id] ?? []} + pending={pending()} + /> + + + + + + )} + + + + 0}> + + + 0}> + + + + + + + + { + toBottom() + }} + sessionID={route.sessionID} + right={} + /> + + - - - - + + + + + + + + + + + + + + + + ) From ce3235e115766b7e3b38a7686d0958eee71881c1 Mon Sep 17 00:00:00 2001 From: Frank Date: Sun, 10 May 2026 02:17:32 -0400 Subject: [PATCH 115/189] sync --- packages/http-recorder/sst-env.d.ts | 10 ++++++++++ packages/llm/sst-env.d.ts | 10 ++++++++++ 2 files changed, 20 insertions(+) create mode 100644 packages/http-recorder/sst-env.d.ts create mode 100644 packages/llm/sst-env.d.ts diff --git a/packages/http-recorder/sst-env.d.ts b/packages/http-recorder/sst-env.d.ts new file mode 100644 index 0000000000..64441936d7 --- /dev/null +++ b/packages/http-recorder/sst-env.d.ts @@ -0,0 +1,10 @@ +/* This file is auto-generated by SST. Do not edit. */ +/* tslint:disable */ +/* eslint-disable */ +/* deno-fmt-ignore-file */ +/* biome-ignore-all lint: auto-generated */ + +/// + +import "sst" +export {} \ No newline at end of file diff --git a/packages/llm/sst-env.d.ts b/packages/llm/sst-env.d.ts new file mode 100644 index 0000000000..64441936d7 --- /dev/null +++ b/packages/llm/sst-env.d.ts @@ -0,0 +1,10 @@ +/* This file is auto-generated by SST. Do not edit. */ +/* tslint:disable */ +/* eslint-disable */ +/* deno-fmt-ignore-file */ +/* biome-ignore-all lint: auto-generated */ + +/// + +import "sst" +export {} \ No newline at end of file From bcbc1dba22f1524dbc2c8ade6b3f87d27a30da57 Mon Sep 17 00:00:00 2001 From: Jack Date: Sun, 10 May 2026 14:23:34 +0800 Subject: [PATCH 116/189] Go add hy3 preview (#26533) --- packages/console/app/src/i18n/ar.ts | 8 ++++---- packages/console/app/src/i18n/br.ts | 8 ++++---- packages/console/app/src/i18n/da.ts | 8 ++++---- packages/console/app/src/i18n/de.ts | 8 ++++---- packages/console/app/src/i18n/en.ts | 8 ++++---- packages/console/app/src/i18n/es.ts | 8 ++++---- packages/console/app/src/i18n/fr.ts | 8 ++++---- packages/console/app/src/i18n/it.ts | 8 ++++---- packages/console/app/src/i18n/ja.ts | 8 ++++---- packages/console/app/src/i18n/ko.ts | 8 ++++---- packages/console/app/src/i18n/no.ts | 8 ++++---- packages/console/app/src/i18n/pl.ts | 8 ++++---- packages/console/app/src/i18n/ru.ts | 8 ++++---- packages/console/app/src/i18n/th.ts | 8 ++++---- packages/console/app/src/i18n/tr.ts | 8 ++++---- packages/console/app/src/i18n/zh.ts | 8 ++++---- packages/console/app/src/i18n/zht.ts | 8 ++++---- packages/console/app/src/routes/go/index.tsx | 2 ++ .../app/src/routes/workspace/[id]/go/lite-section.tsx | 1 + packages/web/src/content/docs/ar/go.mdx | 4 ++++ packages/web/src/content/docs/ar/zen.mdx | 4 ---- packages/web/src/content/docs/bs/go.mdx | 4 ++++ packages/web/src/content/docs/bs/zen.mdx | 4 ---- packages/web/src/content/docs/da/go.mdx | 4 ++++ packages/web/src/content/docs/da/zen.mdx | 4 ---- packages/web/src/content/docs/de/go.mdx | 4 ++++ packages/web/src/content/docs/de/zen.mdx | 4 ---- packages/web/src/content/docs/es/go.mdx | 4 ++++ packages/web/src/content/docs/es/zen.mdx | 4 ---- packages/web/src/content/docs/fr/go.mdx | 4 ++++ packages/web/src/content/docs/fr/zen.mdx | 4 ---- packages/web/src/content/docs/go.mdx | 4 ++++ packages/web/src/content/docs/it/go.mdx | 4 ++++ packages/web/src/content/docs/it/zen.mdx | 4 ---- packages/web/src/content/docs/ja/go.mdx | 4 ++++ packages/web/src/content/docs/ja/zen.mdx | 4 ---- packages/web/src/content/docs/ko/go.mdx | 4 ++++ packages/web/src/content/docs/ko/zen.mdx | 4 ---- packages/web/src/content/docs/nb/go.mdx | 4 ++++ packages/web/src/content/docs/nb/zen.mdx | 4 ---- packages/web/src/content/docs/pl/go.mdx | 4 ++++ packages/web/src/content/docs/pl/zen.mdx | 4 ---- packages/web/src/content/docs/pt-br/go.mdx | 4 ++++ packages/web/src/content/docs/pt-br/zen.mdx | 4 ---- packages/web/src/content/docs/ru/go.mdx | 4 ++++ packages/web/src/content/docs/ru/zen.mdx | 4 ---- packages/web/src/content/docs/th/go.mdx | 4 ++++ packages/web/src/content/docs/th/zen.mdx | 4 ---- packages/web/src/content/docs/tr/go.mdx | 4 ++++ packages/web/src/content/docs/tr/zen.mdx | 4 ---- packages/web/src/content/docs/zen.mdx | 4 ---- packages/web/src/content/docs/zh-cn/go.mdx | 4 ++++ packages/web/src/content/docs/zh-cn/zen.mdx | 4 ---- packages/web/src/content/docs/zh-tw/go.mdx | 4 ++++ packages/web/src/content/docs/zh-tw/zen.mdx | 4 ---- 55 files changed, 143 insertions(+), 140 deletions(-) diff --git a/packages/console/app/src/i18n/ar.ts b/packages/console/app/src/i18n/ar.ts index f413b5572f..f2c40b0bbd 100644 --- a/packages/console/app/src/i18n/ar.ts +++ b/packages/console/app/src/i18n/ar.ts @@ -249,7 +249,7 @@ export const dict = { "go.title": "OpenCode Go | نماذج برمجة منخفضة التكلفة للجميع", "go.meta.description": - "يبدأ Go من $5 للشهر الأول، ثم $10/شهر، مع حدود طلب سخية لمدة 5 ساعات لـ GLM-5.1 وGLM-5 وKimi K2.5 وKimi K2.6 وMiMo-V2.5-Pro وMiMo-V2.5 وQwen3.5 Plus وQwen3.6 Plus وMiniMax M2.5 وMiniMax M2.7 وDeepSeek V4 Pro وDeepSeek V4 Flash.", + "يبدأ Go من $5 للشهر الأول، ثم $10/شهر، مع حدود طلب سخية لمدة 5 ساعات لـ GLM-5.1 وGLM-5 وKimi K2.5 وKimi K2.6 وMiMo-V2.5-Pro وMiMo-V2.5 وQwen3.5 Plus وQwen3.6 Plus وMiniMax M2.5 وMiniMax M2.7 وDeepSeek V4 Pro وDeepSeek V4 Flash وHy3 preview.", "go.hero.title": "نماذج برمجة منخفضة التكلفة للجميع", "go.hero.body": "يجلب Go البرمجة الوكيلة للمبرمجين حول العالم. يوفر حدودًا سخية ووصولًا موثوقًا إلى أقوى النماذج مفتوحة المصدر، حتى تتمكن من البناء باستخدام وكلاء أقوياء دون القلق بشأن التكلفة أو التوفر.", @@ -298,7 +298,7 @@ export const dict = { "go.problem.item2": "حدود سخية ووصول موثوق", "go.problem.item3": "مصمم لأكبر عدد ممكن من المبرمجين", "go.problem.item4": - "يتضمن GLM-5.1 وGLM-5 وKimi K2.5 وKimi K2.6 وMiMo-V2.5-Pro وMiMo-V2.5 وQwen3.5 Plus وQwen3.6 Plus وMiniMax M2.5 وMiniMax M2.7 وDeepSeek V4 Pro وDeepSeek V4 Flash", + "يتضمن GLM-5.1 وGLM-5 وKimi K2.5 وKimi K2.6 وMiMo-V2.5-Pro وMiMo-V2.5 وQwen3.5 Plus وQwen3.6 Plus وMiniMax M2.5 وMiniMax M2.7 وDeepSeek V4 Pro وDeepSeek V4 Flash وHy3 preview", "go.how.title": "كيف يعمل Go", "go.how.body": "يبدأ Go من $5 للشهر الأول، ثم $10/شهر. يمكنك استخدامه مع OpenCode أو أي وكيل.", "go.how.step1.title": "أنشئ حسابًا", @@ -322,7 +322,7 @@ export const dict = { "go.faq.a2": "يتضمن Go النماذج المدرجة أدناه، مع حدود سخية وإتاحة موثوقة.", "go.faq.q3": "هل Go هو نفسه Zen؟", "go.faq.a3": - "لا. Zen هو الدفع حسب الاستخدام، بينما يبدأ Go من $5 للشهر الأول، ثم $10/شهر، مع حدود سخية ووصول موثوق إلى نماذج المصدر المفتوح GLM-5.1 وGLM-5 وKimi K2.5 وKimi K2.6 وMiMo-V2.5-Pro وMiMo-V2.5 وQwen3.5 Plus وQwen3.6 Plus وMiniMax M2.5 وMiniMax M2.7 وDeepSeek V4 Pro وDeepSeek V4 Flash.", + "لا. Zen هو الدفع حسب الاستخدام، بينما يبدأ Go من $5 للشهر الأول، ثم $10/شهر، مع حدود سخية ووصول موثوق إلى نماذج المصدر المفتوح GLM-5.1 وGLM-5 وKimi K2.5 وKimi K2.6 وMiMo-V2.5-Pro وMiMo-V2.5 وQwen3.5 Plus وQwen3.6 Plus وMiniMax M2.5 وMiniMax M2.7 وDeepSeek V4 Pro وDeepSeek V4 Flash وHy3 preview.", "go.faq.q4": "كم تكلفة Go؟", "go.faq.a4.p1.beforePricing": "تكلفة Go", "go.faq.a4.p1.pricingLink": "$5 للشهر الأول", @@ -345,7 +345,7 @@ export const dict = { "go.faq.q9": "ما الفرق بين النماذج المجانية وGo؟", "go.faq.a9": - "تشمل النماذج المجانية Big Pickle بالإضافة إلى النماذج الترويجية المتاحة في ذلك الوقت، مع حصة 200 طلب/يوم. يتضمن Go نماذج GLM-5.1 وGLM-5 وKimi K2.5 وKimi K2.6 وMiMo-V2.5-Pro وMiMo-V2.5 وQwen3.5 Plus وQwen3.6 Plus وMiniMax M2.5 وMiniMax M2.7 وDeepSeek V4 Pro وDeepSeek V4 Flash مع حصص طلبات أعلى مطبقة عبر نوافذ متجددة (5 ساعات، أسبوعيًا، وشهريًا)، تعادل تقريبًا 12 دولارًا كل 5 ساعات، و30 دولارًا في الأسبوع، و60 دولارًا في الشهر (تختلف أعداد الطلبات الفعلية حسب النموذج والاستخدام).", + "تشمل النماذج المجانية Big Pickle بالإضافة إلى النماذج الترويجية المتاحة في ذلك الوقت، مع حصة 200 طلب/يوم. يتضمن Go نماذج GLM-5.1 وGLM-5 وKimi K2.5 وKimi K2.6 وMiMo-V2.5-Pro وMiMo-V2.5 وQwen3.5 Plus وQwen3.6 Plus وMiniMax M2.5 وMiniMax M2.7 وDeepSeek V4 Pro وDeepSeek V4 Flash وHy3 preview مع حصص طلبات أعلى مطبقة عبر نوافذ متجددة (5 ساعات، أسبوعيًا، وشهريًا)، تعادل تقريبًا 12 دولارًا كل 5 ساعات، و30 دولارًا في الأسبوع، و60 دولارًا في الشهر (تختلف أعداد الطلبات الفعلية حسب النموذج والاستخدام).", "zen.api.error.rateLimitExceeded": "تم تجاوز حد الطلبات. يرجى المحاولة مرة أخرى لاحقًا.", "zen.api.error.modelNotSupported": "النموذج {{model}} غير مدعوم", diff --git a/packages/console/app/src/i18n/br.ts b/packages/console/app/src/i18n/br.ts index 8466acc5fd..120210acf4 100644 --- a/packages/console/app/src/i18n/br.ts +++ b/packages/console/app/src/i18n/br.ts @@ -253,7 +253,7 @@ export const dict = { "go.title": "OpenCode Go | Modelos de codificação de baixo custo para todos", "go.meta.description": - "O Go começa em $5 no primeiro mês, depois $10/mês, com limites generosos de solicitação de 5 horas para GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro e DeepSeek V4 Flash.", + "O Go começa em $5 no primeiro mês, depois $10/mês, com limites generosos de solicitação de 5 horas para GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, DeepSeek V4 Flash e Hy3 preview.", "go.hero.title": "Modelos de codificação de baixo custo para todos", "go.hero.body": "O Go traz a codificação com agentes para programadores em todo o mundo. Oferecendo limites generosos e acesso confiável aos modelos de código aberto mais capazes, para que você possa construir com agentes poderosos sem se preocupar com custos ou disponibilidade.", @@ -303,7 +303,7 @@ export const dict = { "go.problem.item2": "Limites generosos e acesso confiável", "go.problem.item3": "Feito para o maior número possível de programadores", "go.problem.item4": - "Inclui GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro e DeepSeek V4 Flash", + "Inclui GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, DeepSeek V4 Flash e Hy3 preview", "go.how.title": "Como o Go funciona", "go.how.body": "O Go começa em $5 no primeiro mês, depois $10/mês. Você pode usá-lo com o OpenCode ou qualquer agente.", @@ -329,7 +329,7 @@ export const dict = { "go.faq.a2": "O Go inclui os modelos listados abaixo, com limites generosos e acesso confiável.", "go.faq.q3": "O Go é o mesmo que o Zen?", "go.faq.a3": - "Não. Zen é pay-as-you-go, enquanto o Go começa em $5 no primeiro mês, depois $10/mês, com limites generosos e acesso confiável aos modelos open source GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro e DeepSeek V4 Flash.", + "Não. Zen é pay-as-you-go, enquanto o Go começa em $5 no primeiro mês, depois $10/mês, com limites generosos e acesso confiável aos modelos open source GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, DeepSeek V4 Flash e Hy3 preview.", "go.faq.q4": "Quanto custa o Go?", "go.faq.a4.p1.beforePricing": "O Go custa", "go.faq.a4.p1.pricingLink": "$5 no primeiro mês", @@ -353,7 +353,7 @@ export const dict = { "go.faq.q9": "Qual a diferença entre os modelos gratuitos e o Go?", "go.faq.a9": - "Os modelos gratuitos incluem Big Pickle e modelos promocionais disponíveis no momento, com uma cota de 200 requisições/dia. O Go inclui GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro e DeepSeek V4 Flash com cotas de requisição mais altas aplicadas em janelas móveis (5 horas, semanal e mensal), aproximadamente equivalentes a $12 por 5 horas, $30 por semana e $60 por mês (as contagens reais de requisições variam de acordo com o modelo e o uso).", + "Os modelos gratuitos incluem Big Pickle e modelos promocionais disponíveis no momento, com uma cota de 200 requisições/dia. O Go inclui GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, DeepSeek V4 Flash e Hy3 preview com cotas de requisição mais altas aplicadas em janelas móveis (5 horas, semanal e mensal), aproximadamente equivalentes a $12 por 5 horas, $30 por semana e $60 por mês (as contagens reais de requisições variam de acordo com o modelo e o uso).", "zen.api.error.rateLimitExceeded": "Limite de taxa excedido. Por favor, tente novamente mais tarde.", "zen.api.error.modelNotSupported": "Modelo {{model}} não suportado", diff --git a/packages/console/app/src/i18n/da.ts b/packages/console/app/src/i18n/da.ts index 9338e3add5..a6e55ea343 100644 --- a/packages/console/app/src/i18n/da.ts +++ b/packages/console/app/src/i18n/da.ts @@ -251,7 +251,7 @@ export const dict = { "go.title": "OpenCode Go | Kodningsmodeller til lav pris for alle", "go.meta.description": - "Go starter ved $5 for den første måned, derefter $10/måned, med generøse 5-timers anmodningsgrænser for GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro og DeepSeek V4 Flash.", + "Go starter ved $5 for den første måned, derefter $10/måned, med generøse 5-timers anmodningsgrænser for GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, DeepSeek V4 Flash og Hy3 preview.", "go.hero.title": "Kodningsmodeller til lav pris for alle", "go.hero.body": "Go bringer agentisk kodning til programmører over hele verden. Med generøse grænser og pålidelig adgang til de mest kapable open source-modeller, så du kan bygge med kraftfulde agenter uden at bekymre dig om omkostninger eller tilgængelighed.", @@ -300,7 +300,7 @@ export const dict = { "go.problem.item2": "Generøse grænser og pålidelig adgang", "go.problem.item3": "Bygget til så mange programmører som muligt", "go.problem.item4": - "Inkluderer GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro og DeepSeek V4 Flash", + "Inkluderer GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, DeepSeek V4 Flash og Hy3 preview", "go.how.title": "Hvordan Go virker", "go.how.body": "Go starter ved $5 for den første måned, derefter $10/måned. Du kan bruge det med OpenCode eller enhver agent.", @@ -326,7 +326,7 @@ export const dict = { "go.faq.a2": "Go inkluderer modellerne nedenfor med generøse grænser og pålidelig adgang.", "go.faq.q3": "Er Go det samme som Zen?", "go.faq.a3": - "Nej. Zen er pay-as-you-go, mens Go starter ved $5 for den første måned, derefter $10/måned, med generøse grænser og pålidelig adgang til open source-modellerne GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro og DeepSeek V4 Flash.", + "Nej. Zen er pay-as-you-go, mens Go starter ved $5 for den første måned, derefter $10/måned, med generøse grænser og pålidelig adgang til open source-modellerne GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, DeepSeek V4 Flash og Hy3 preview.", "go.faq.q4": "Hvad koster Go?", "go.faq.a4.p1.beforePricing": "Go koster", "go.faq.a4.p1.pricingLink": "$5 første måned", @@ -349,7 +349,7 @@ export const dict = { "go.faq.q9": "Hvad er forskellen på gratis modeller og Go?", "go.faq.a9": - "Gratis modeller inkluderer Big Pickle plus salgsfremmende modeller tilgængelige på det tidspunkt, med en kvote på 200 forespørgsler/dag. Go inkluderer GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro og DeepSeek V4 Flash med højere anmodningskvoter håndhævet over rullende vinduer (5-timers, ugentlig og månedlig), nogenlunde svarende til $12 pr. 5 timer, $30 pr. uge og $60 pr. måned (faktiske anmodningstal varierer efter model og brug).", + "Gratis modeller inkluderer Big Pickle plus salgsfremmende modeller tilgængelige på det tidspunkt, med en kvote på 200 forespørgsler/dag. Go inkluderer GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, DeepSeek V4 Flash og Hy3 preview med højere anmodningskvoter håndhævet over rullende vinduer (5-timers, ugentlig og månedlig), nogenlunde svarende til $12 pr. 5 timer, $30 pr. uge og $60 pr. måned (faktiske anmodningstal varierer efter model og brug).", "zen.api.error.rateLimitExceeded": "Hastighedsgrænse overskredet. Prøv venligst igen senere.", "zen.api.error.modelNotSupported": "Model {{model}} understøttes ikke", diff --git a/packages/console/app/src/i18n/de.ts b/packages/console/app/src/i18n/de.ts index 7a2d3e91b4..185c4fa202 100644 --- a/packages/console/app/src/i18n/de.ts +++ b/packages/console/app/src/i18n/de.ts @@ -253,7 +253,7 @@ export const dict = { "go.title": "OpenCode Go | Kostengünstige Coding-Modelle für alle", "go.meta.description": - "Go beginnt bei $5 für den ersten Monat, danach $10/Monat, mit großzügigen 5-Stunden-Anfragelimits für GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro und DeepSeek V4 Flash.", + "Go beginnt bei $5 für den ersten Monat, danach $10/Monat, mit großzügigen 5-Stunden-Anfragelimits für GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, DeepSeek V4 Flash und Hy3 preview.", "go.hero.title": "Kostengünstige Coding-Modelle für alle", "go.hero.body": "Go bringt Agentic Coding zu Programmierern auf der ganzen Welt. Mit großzügigen Limits und zuverlässigem Zugang zu den leistungsfähigsten Open-Source-Modellen, damit du mit leistungsstarken Agenten entwickeln kannst, ohne dir Gedanken über Kosten oder Verfügbarkeit zu machen.", @@ -302,7 +302,7 @@ export const dict = { "go.problem.item2": "Großzügige Limits und zuverlässiger Zugang", "go.problem.item3": "Für so viele Programmierer wie möglich gebaut", "go.problem.item4": - "Beinhaltet GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro und DeepSeek V4 Flash", + "Beinhaltet GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, DeepSeek V4 Flash und Hy3 preview", "go.how.title": "Wie Go funktioniert", "go.how.body": "Go beginnt bei $5 für den ersten Monat, danach $10/Monat. Du kannst es mit OpenCode oder jedem Agenten nutzen.", @@ -328,7 +328,7 @@ export const dict = { "go.faq.a2": "Go umfasst die unten aufgeführten Modelle mit großzügigen Limits und zuverlässigem Zugriff.", "go.faq.q3": "Ist Go dasselbe wie Zen?", "go.faq.a3": - "Nein. Zen ist Pay-as-you-go, während Go bei $5 für den ersten Monat beginnt, danach $10/Monat, mit großzügigen Limits und zuverlässigem Zugang zu den Open-Source-Modellen GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro und DeepSeek V4 Flash.", + "Nein. Zen ist Pay-as-you-go, während Go bei $5 für den ersten Monat beginnt, danach $10/Monat, mit großzügigen Limits und zuverlässigem Zugang zu den Open-Source-Modellen GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, DeepSeek V4 Flash und Hy3 preview.", "go.faq.q4": "Wie viel kostet Go?", "go.faq.a4.p1.beforePricing": "Go kostet", "go.faq.a4.p1.pricingLink": "$5 im ersten Monat", @@ -352,7 +352,7 @@ export const dict = { "go.faq.q9": "Was ist der Unterschied zwischen kostenlosen Modellen und Go?", "go.faq.a9": - "Kostenlose Modelle beinhalten Big Pickle sowie Werbemodelle, die zum jeweiligen Zeitpunkt verfügbar sind, mit einem Kontingent von 200 Anfragen/Tag. Go beinhaltet GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro und DeepSeek V4 Flash mit höheren Anfragekontingenten, die über rollierende Zeitfenster (5 Stunden, wöchentlich und monatlich) durchgesetzt werden, grob äquivalent zu $12 pro 5 Stunden, $30 pro Woche und $60 pro Monat (tatsächliche Anfragezahlen variieren je nach Modell und Nutzung).", + "Kostenlose Modelle beinhalten Big Pickle sowie Werbemodelle, die zum jeweiligen Zeitpunkt verfügbar sind, mit einem Kontingent von 200 Anfragen/Tag. Go beinhaltet GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, DeepSeek V4 Flash und Hy3 preview mit höheren Anfragekontingenten, die über rollierende Zeitfenster (5 Stunden, wöchentlich und monatlich) durchgesetzt werden, grob äquivalent zu $12 pro 5 Stunden, $30 pro Woche und $60 pro Monat (tatsächliche Anfragezahlen variieren je nach Modell und Nutzung).", "zen.api.error.rateLimitExceeded": "Ratenlimit überschritten. Bitte versuche es später erneut.", "zen.api.error.modelNotSupported": "Modell {{model}} wird nicht unterstützt", diff --git a/packages/console/app/src/i18n/en.ts b/packages/console/app/src/i18n/en.ts index b7ef397be6..0aa4b2ee4c 100644 --- a/packages/console/app/src/i18n/en.ts +++ b/packages/console/app/src/i18n/en.ts @@ -248,7 +248,7 @@ export const dict = { "go.title": "OpenCode Go | Low cost coding models for everyone", "go.meta.description": - "Go starts at $5 for your first month, then $10/month, with generous 5-hour request limits for GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, and DeepSeek V4 Flash.", + "Go starts at $5 for your first month, then $10/month, with generous 5-hour request limits for GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, DeepSeek V4 Flash, and Hy3 preview.", "go.hero.title": "Low cost coding models for everyone", "go.hero.body": "Go brings agentic coding to programmers around the world. Offering generous limits and reliable access to the most capable open-source models, so you can build with powerful agents without worrying about cost or availability.", @@ -296,7 +296,7 @@ export const dict = { "go.problem.item2": "Generous limits and reliable access", "go.problem.item3": "Built for as many programmers as possible", "go.problem.item4": - "Includes GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, and DeepSeek V4 Flash", + "Includes GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, DeepSeek V4 Flash, and Hy3 preview", "go.how.title": "How Go works", "go.how.body": "Go starts at $5 for your first month, then $10/month. You can use it with OpenCode or any agent.", "go.how.step1.title": "Create an account", @@ -321,7 +321,7 @@ export const dict = { "go.faq.a2": "Go includes the models listed below, with generous limits and reliable access.", "go.faq.q3": "Is Go the same as Zen?", "go.faq.a3": - "No. Zen is pay-as-you-go, while Go starts at $5 for your first month, then $10/month, with generous limits and reliable access to open-source models GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, and DeepSeek V4 Flash.", + "No. Zen is pay-as-you-go, while Go starts at $5 for your first month, then $10/month, with generous limits and reliable access to open-source models GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, DeepSeek V4 Flash, and Hy3 preview.", "go.faq.q4": "How much does Go cost?", "go.faq.a4.p1.beforePricing": "Go costs", "go.faq.a4.p1.pricingLink": "$5 first month", @@ -345,7 +345,7 @@ export const dict = { "go.faq.q9": "What is the difference between free models and Go?", "go.faq.a9": - "Free models include Big Pickle plus promotional models available at the time, with a quota of 200 requests/day. Go includes GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, and DeepSeek V4 Flash with higher request quotas enforced across rolling windows (5-hour, weekly, and monthly), roughly equivalent to $12 per 5 hours, $30 per week, and $60 per month (actual request counts vary by model and usage).", + "Free models include Big Pickle plus promotional models available at the time, with a quota of 200 requests/day. Go includes GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, DeepSeek V4 Flash, and Hy3 preview with higher request quotas enforced across rolling windows (5-hour, weekly, and monthly), roughly equivalent to $12 per 5 hours, $30 per week, and $60 per month (actual request counts vary by model and usage).", "zen.api.error.rateLimitExceeded": "Rate limit exceeded. Please try again later.", "zen.api.error.modelNotSupported": "Model {{model}} not supported", diff --git a/packages/console/app/src/i18n/es.ts b/packages/console/app/src/i18n/es.ts index f6347d3b52..e90d3b9f46 100644 --- a/packages/console/app/src/i18n/es.ts +++ b/packages/console/app/src/i18n/es.ts @@ -254,7 +254,7 @@ export const dict = { "go.title": "OpenCode Go | Modelos de programación de bajo coste para todos", "go.meta.description": - "Go comienza en $5 el primer mes, luego 10 $/mes, con generosos límites de solicitudes de 5 horas para GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro y DeepSeek V4 Flash.", + "Go comienza en $5 el primer mes, luego 10 $/mes, con generosos límites de solicitudes de 5 horas para GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, DeepSeek V4 Flash y Hy3 preview.", "go.hero.title": "Modelos de programación de bajo coste para todos", "go.hero.body": "Go lleva la programación agéntica a programadores de todo el mundo. Ofrece límites generosos y acceso fiable a los modelos de código abierto más capaces, para que puedas crear con agentes potentes sin preocuparte por el coste o la disponibilidad.", @@ -304,7 +304,7 @@ export const dict = { "go.problem.item2": "Límites generosos y acceso fiable", "go.problem.item3": "Creado para tantos programadores como sea posible", "go.problem.item4": - "Incluye GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro y DeepSeek V4 Flash", + "Incluye GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, DeepSeek V4 Flash y Hy3 preview", "go.how.title": "Cómo funciona Go", "go.how.body": "Go comienza en $5 el primer mes, luego 10 $/mes. Puedes usarlo con OpenCode o cualquier agente.", "go.how.step1.title": "Crear una cuenta", @@ -329,7 +329,7 @@ export const dict = { "go.faq.a2": "Go incluye los modelos que se indican abajo, con límites generosos y acceso confiable.", "go.faq.q3": "¿Es Go lo mismo que Zen?", "go.faq.a3": - "No. Zen es pago por uso, mientras que Go comienza en $5 el primer mes, luego 10 $/mes, con límites generosos y acceso fiable a los modelos de código abierto GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro y DeepSeek V4 Flash.", + "No. Zen es pago por uso, mientras que Go comienza en $5 el primer mes, luego 10 $/mes, con límites generosos y acceso fiable a los modelos de código abierto GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, DeepSeek V4 Flash y Hy3 preview.", "go.faq.q4": "¿Cuánto cuesta Go?", "go.faq.a4.p1.beforePricing": "Go cuesta", "go.faq.a4.p1.pricingLink": "$5 el primer mes", @@ -353,7 +353,7 @@ export const dict = { "go.faq.q9": "¿Cuál es la diferencia entre los modelos gratuitos y Go?", "go.faq.a9": - "Los modelos gratuitos incluyen Big Pickle más modelos promocionales disponibles en el momento, con una cuota de 200 solicitudes/día. Go incluye GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro y DeepSeek V4 Flash con cuotas de solicitud más altas aplicadas a través de ventanas móviles (5 horas, semanal y mensual), aproximadamente equivalente a 12 $ por 5 horas, 30 $ por semana y 60 $ por mes (los recuentos reales de solicitudes varían según el modelo y el uso).", + "Los modelos gratuitos incluyen Big Pickle más modelos promocionales disponibles en el momento, con una cuota de 200 solicitudes/día. Go incluye GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, DeepSeek V4 Flash y Hy3 preview con cuotas de solicitud más altas aplicadas a través de ventanas móviles (5 horas, semanal y mensual), aproximadamente equivalente a 12 $ por 5 horas, 30 $ por semana y 60 $ por mes (los recuentos reales de solicitudes varían según el modelo y el uso).", "zen.api.error.rateLimitExceeded": "Límite de tasa excedido. Por favor, inténtalo de nuevo más tarde.", "zen.api.error.modelNotSupported": "Modelo {{model}} no soportado", diff --git a/packages/console/app/src/i18n/fr.ts b/packages/console/app/src/i18n/fr.ts index 5d1cd0fab7..c9bb02d4ee 100644 --- a/packages/console/app/src/i18n/fr.ts +++ b/packages/console/app/src/i18n/fr.ts @@ -255,7 +255,7 @@ export const dict = { "go.title": "OpenCode Go | Modèles de code à faible coût pour tous", "go.meta.description": - "Go commence à $5 pour le premier mois, puis 10 $/mois, avec des limites de requêtes généreuses sur 5 heures pour GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro et DeepSeek V4 Flash.", + "Go commence à $5 pour le premier mois, puis 10 $/mois, avec des limites de requêtes généreuses sur 5 heures pour GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, DeepSeek V4 Flash et Hy3 preview.", "go.hero.title": "Modèles de code à faible coût pour tous", "go.hero.body": "Go apporte le codage agentique aux programmeurs du monde entier. Offrant des limites généreuses et un accès fiable aux modèles open source les plus capables, pour que vous puissiez construire avec des agents puissants sans vous soucier du coût ou de la disponibilité.", @@ -304,7 +304,7 @@ export const dict = { "go.problem.item2": "Limites généreuses et accès fiable", "go.problem.item3": "Conçu pour autant de programmeurs que possible", "go.problem.item4": - "Inclut GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro et DeepSeek V4 Flash", + "Inclut GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, DeepSeek V4 Flash et Hy3 preview", "go.how.title": "Comment fonctionne Go", "go.how.body": "Go commence à $5 pour le premier mois, puis 10 $/mois. Vous pouvez l'utiliser avec OpenCode ou n'importe quel agent.", @@ -330,7 +330,7 @@ export const dict = { "go.faq.a2": "Go inclut les modèles ci-dessous, avec des limites généreuses et un accès fiable.", "go.faq.q3": "Est-ce que Go est la même chose que Zen ?", "go.faq.a3": - "Non. Zen est un paiement à l'utilisation, tandis que Go commence à $5 pour le premier mois, puis 10 $/mois, avec des limites généreuses et un accès fiable aux modèles open source GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro et DeepSeek V4 Flash.", + "Non. Zen est un paiement à l'utilisation, tandis que Go commence à $5 pour le premier mois, puis 10 $/mois, avec des limites généreuses et un accès fiable aux modèles open source GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, DeepSeek V4 Flash et Hy3 preview.", "go.faq.q4": "Combien coûte Go ?", "go.faq.a4.p1.beforePricing": "Go coûte", "go.faq.a4.p1.pricingLink": "$5 le premier mois", @@ -353,7 +353,7 @@ export const dict = { "Oui, vous pouvez utiliser Go avec n'importe quel agent. Suivez les instructions de configuration dans votre agent de code préféré.", "go.faq.q9": "Quelle est la différence entre les modèles gratuits et Go ?", "go.faq.a9": - "Les modèles gratuits incluent Big Pickle ainsi que des modèles promotionnels disponibles à ce moment-là, avec un quota de 200 requêtes/jour. Go inclut GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro et DeepSeek V4 Flash avec des quotas de requêtes plus élevés appliqués sur des fenêtres glissantes (5 heures, hebdomadaire et mensuelle), à peu près équivalent à 12 $ par 5 heures, 30 $ par semaine et 60 $ par mois (le nombre réel de requêtes varie selon le modèle et l'utilisation).", + "Les modèles gratuits incluent Big Pickle ainsi que des modèles promotionnels disponibles à ce moment-là, avec un quota de 200 requêtes/jour. Go inclut GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, DeepSeek V4 Flash et Hy3 preview avec des quotas de requêtes plus élevés appliqués sur des fenêtres glissantes (5 heures, hebdomadaire et mensuelle), à peu près équivalent à 12 $ par 5 heures, 30 $ par semaine et 60 $ par mois (le nombre réel de requêtes varie selon le modèle et l'utilisation).", "zen.api.error.rateLimitExceeded": "Limite de débit dépassée. Veuillez réessayer plus tard.", "zen.api.error.modelNotSupported": "Modèle {{model}} non pris en charge", diff --git a/packages/console/app/src/i18n/it.ts b/packages/console/app/src/i18n/it.ts index 07da9434eb..68a6756f99 100644 --- a/packages/console/app/src/i18n/it.ts +++ b/packages/console/app/src/i18n/it.ts @@ -251,7 +251,7 @@ export const dict = { "go.title": "OpenCode Go | Modelli di coding a basso costo per tutti", "go.meta.description": - "Go inizia a $5 per il primo mese, poi $10/mese, con generosi limiti di richiesta di 5 ore per GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro e DeepSeek V4 Flash.", + "Go inizia a $5 per il primo mese, poi $10/mese, con generosi limiti di richiesta di 5 ore per GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, DeepSeek V4 Flash e Hy3 preview.", "go.hero.title": "Modelli di coding a basso costo per tutti", "go.hero.body": "Go porta il coding agentico ai programmatori di tutto il mondo. Offrendo limiti generosi e un accesso affidabile ai modelli open source più capaci, in modo da poter costruire con agenti potenti senza preoccuparsi dei costi o della disponibilità.", @@ -300,7 +300,7 @@ export const dict = { "go.problem.item2": "Limiti generosi e accesso affidabile", "go.problem.item3": "Costruito per il maggior numero possibile di programmatori", "go.problem.item4": - "Include GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro e DeepSeek V4 Flash", + "Include GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, DeepSeek V4 Flash e Hy3 preview", "go.how.title": "Come funziona Go", "go.how.body": "Go inizia a $5 per il primo mese, poi $10/mese. Puoi usarlo con OpenCode o qualsiasi agente.", "go.how.step1.title": "Crea un account", @@ -325,7 +325,7 @@ export const dict = { "go.faq.a2": "Go include i modelli elencati di seguito, con limiti generosi e accesso affidabile.", "go.faq.q3": "Go è lo stesso di Zen?", "go.faq.a3": - "No. Zen è a consumo, mentre Go inizia a $5 per il primo mese, poi $10/mese, con limiti generosi e accesso affidabile ai modelli open source GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro e DeepSeek V4 Flash.", + "No. Zen è a consumo, mentre Go inizia a $5 per il primo mese, poi $10/mese, con limiti generosi e accesso affidabile ai modelli open source GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, DeepSeek V4 Flash e Hy3 preview.", "go.faq.q4": "Quanto costa Go?", "go.faq.a4.p1.beforePricing": "Go costa", "go.faq.a4.p1.pricingLink": "$5 il primo mese", @@ -349,7 +349,7 @@ export const dict = { "go.faq.q9": "Qual è la differenza tra i modelli gratuiti e Go?", "go.faq.a9": - "I modelli gratuiti includono Big Pickle più modelli promozionali disponibili al momento, con una quota di 200 richieste/giorno. Go include GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro e DeepSeek V4 Flash con quote di richiesta più elevate applicate su finestre mobili (5 ore, settimanale e mensile), approssimativamente equivalenti a $12 ogni 5 ore, $30 a settimana e $60 al mese (il conteggio effettivo delle richieste varia in base al modello e all'utilizzo).", + "I modelli gratuiti includono Big Pickle più modelli promozionali disponibili al momento, con una quota di 200 richieste/giorno. Go include GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, DeepSeek V4 Flash e Hy3 preview con quote di richiesta più elevate applicate su finestre mobili (5 ore, settimanale e mensile), approssimativamente equivalenti a $12 ogni 5 ore, $30 a settimana e $60 al mese (il conteggio effettivo delle richieste varia in base al modello e all'utilizzo).", "zen.api.error.rateLimitExceeded": "Limite di richieste superato. Riprova più tardi.", "zen.api.error.modelNotSupported": "Modello {{model}} non supportato", diff --git a/packages/console/app/src/i18n/ja.ts b/packages/console/app/src/i18n/ja.ts index 975728fe7e..1918045e14 100644 --- a/packages/console/app/src/i18n/ja.ts +++ b/packages/console/app/src/i18n/ja.ts @@ -250,7 +250,7 @@ export const dict = { "go.title": "OpenCode Go | すべての人のための低価格なコーディングモデル", "go.meta.description": - "Goは最初の月$5、その後$10/月で、GLM-5.1、GLM-5、Kimi K2.5、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.5 Plus、Qwen3.6 Plus、MiniMax M2.5、MiniMax M2.7、DeepSeek V4 Pro、DeepSeek V4 Flashに対して5時間のゆとりあるリクエスト上限があります。", + "Goは最初の月$5、その後$10/月で、GLM-5.1、GLM-5、Kimi K2.5、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.5 Plus、Qwen3.6 Plus、MiniMax M2.5、MiniMax M2.7、DeepSeek V4 Pro、DeepSeek V4 Flash、Hy3 previewに対して5時間のゆとりあるリクエスト上限があります。", "go.hero.title": "すべての人のための低価格なコーディングモデル", "go.hero.body": "Goは、世界中のプログラマーにエージェント型コーディングをもたらします。最も高性能なオープンソースモデルへの十分な制限と安定したアクセスを提供し、コストや可用性を気にすることなく強力なエージェントで構築できます。", @@ -300,7 +300,7 @@ export const dict = { "go.problem.item2": "十分な制限と安定したアクセス", "go.problem.item3": "できるだけ多くのプログラマーのために構築", "go.problem.item4": - "GLM-5.1、GLM-5、Kimi K2.5、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.5 Plus、Qwen3.6 Plus、MiniMax M2.5、MiniMax M2.7、DeepSeek V4 Pro、DeepSeek V4 Flashを含む", + "GLM-5.1、GLM-5、Kimi K2.5、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.5 Plus、Qwen3.6 Plus、MiniMax M2.5、MiniMax M2.7、DeepSeek V4 Pro、DeepSeek V4 Flash、Hy3 previewを含む", "go.how.title": "Goの仕組み", "go.how.body": "Goは最初の月$5、その後$10/月で始まります。OpenCodeまたは任意のエージェントで使えます。", "go.how.step1.title": "アカウントを作成", @@ -325,7 +325,7 @@ export const dict = { "go.faq.a2": "Go には、十分な利用上限と安定したアクセスを備えた、以下のモデルが含まれます。", "go.faq.q3": "GoはZenと同じですか?", "go.faq.a3": - "いいえ。Zenは従量課金制ですが、Goは最初の月$5、その後$10/月で始まり、GLM-5.1、GLM-5、Kimi K2.5、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.5 Plus、Qwen3.6 Plus、MiniMax M2.5、MiniMax M2.7、DeepSeek V4 Pro、DeepSeek V4 Flashのオープンソースモデルに対して、ゆとりある上限と信頼できるアクセスを提供します。", + "いいえ。Zenは従量課金制ですが、Goは最初の月$5、その後$10/月で始まり、GLM-5.1、GLM-5、Kimi K2.5、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.5 Plus、Qwen3.6 Plus、MiniMax M2.5、MiniMax M2.7、DeepSeek V4 Pro、DeepSeek V4 Flash、Hy3 previewのオープンソースモデルに対して、ゆとりある上限と信頼できるアクセスを提供します。", "go.faq.q4": "Goの料金は?", "go.faq.a4.p1.beforePricing": "Goは", "go.faq.a4.p1.pricingLink": "最初の月$5", @@ -349,7 +349,7 @@ export const dict = { "go.faq.q9": "無料モデルとGoの違いは何ですか?", "go.faq.a9": - "無料モデルにはBig Pickleと、その時点で利用可能なプロモーションモデルが含まれ、1日200リクエストの制限があります。GoにはGLM-5.1、GLM-5、Kimi K2.5、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.5 Plus、Qwen3.6 Plus、MiniMax M2.5、MiniMax M2.7、DeepSeek V4 Pro、DeepSeek V4 Flashが含まれ、ローリングウィンドウ(5時間、週間、月間)全体でより高いリクエスト制限が適用されます。これは概算で5時間あたり$12、週間$30、月間$60相当です(実際のリクエスト数はモデルと使用状況により異なります)。", + "無料モデルにはBig Pickleと、その時点で利用可能なプロモーションモデルが含まれ、1日200リクエストの制限があります。GoにはGLM-5.1、GLM-5、Kimi K2.5、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.5 Plus、Qwen3.6 Plus、MiniMax M2.5、MiniMax M2.7、DeepSeek V4 Pro、DeepSeek V4 Flash、Hy3 previewが含まれ、ローリングウィンドウ(5時間、週間、月間)全体でより高いリクエスト制限が適用されます。これは概算で5時間あたり$12、週間$30、月間$60相当です(実際のリクエスト数はモデルと使用状況により異なります)。", "zen.api.error.rateLimitExceeded": "レート制限を超えました。後でもう一度お試しください。", "zen.api.error.modelNotSupported": "モデル {{model}} はサポートされていません", diff --git a/packages/console/app/src/i18n/ko.ts b/packages/console/app/src/i18n/ko.ts index 293c3eb7d9..66428a1e77 100644 --- a/packages/console/app/src/i18n/ko.ts +++ b/packages/console/app/src/i18n/ko.ts @@ -247,7 +247,7 @@ export const dict = { "go.title": "OpenCode Go | 모두를 위한 저비용 코딩 모델", "go.meta.description": - "Go는 첫 달 $5, 이후 $10/월로 시작하며, GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, DeepSeek V4 Flash에 대해 넉넉한 5시간 요청 한도를 제공합니다.", + "Go는 첫 달 $5, 이후 $10/월로 시작하며, GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, DeepSeek V4 Flash, Hy3 preview에 대해 넉넉한 5시간 요청 한도를 제공합니다.", "go.hero.title": "모두를 위한 저비용 코딩 모델", "go.hero.body": "Go는 전 세계 프로그래머들에게 에이전트 코딩을 제공합니다. 가장 유능한 오픈 소스 모델에 대한 넉넉한 한도와 안정적인 액세스를 제공하므로, 비용이나 가용성 걱정 없이 강력한 에이전트로 빌드할 수 있습니다.", @@ -297,7 +297,7 @@ export const dict = { "go.problem.item2": "넉넉한 한도와 안정적인 액세스", "go.problem.item3": "가능한 한 많은 프로그래머를 위해 제작됨", "go.problem.item4": - "GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, DeepSeek V4 Flash 포함", + "GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, DeepSeek V4 Flash, Hy3 preview 포함", "go.how.title": "Go 작동 방식", "go.how.body": "Go는 첫 달 $5, 이후 $10/월로 시작합니다. OpenCode 또는 어떤 에이전트와도 함께 사용할 수 있습니다.", "go.how.step1.title": "계정 생성", @@ -321,7 +321,7 @@ export const dict = { "go.faq.a2": "Go에는 넉넉한 한도와 안정적인 액세스를 제공하는 아래 모델이 포함됩니다.", "go.faq.q3": "Go는 Zen과 같은가요?", "go.faq.a3": - "아니요. Zen은 종량제인 반면, Go는 첫 달 $5, 이후 $10/월로 시작하며, GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, DeepSeek V4 Flash 오픈 소스 모델에 대한 넉넉한 한도와 안정적인 액세스를 제공합니다.", + "아니요. Zen은 종량제인 반면, Go는 첫 달 $5, 이후 $10/월로 시작하며, GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, DeepSeek V4 Flash, Hy3 preview 오픈 소스 모델에 대한 넉넉한 한도와 안정적인 액세스를 제공합니다.", "go.faq.q4": "Go 비용은 얼마인가요?", "go.faq.a4.p1.beforePricing": "Go 비용은", "go.faq.a4.p1.pricingLink": "첫 달 $5", @@ -344,7 +344,7 @@ export const dict = { "go.faq.q9": "무료 모델과 Go의 차이점은 무엇인가요?", "go.faq.a9": - "무료 모델에는 Big Pickle과 당시 사용 가능한 프로모션 모델이 포함되며, 하루 200회 요청 할당량이 적용됩니다. Go는 GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, DeepSeek V4 Flash를 포함하며, 롤링 윈도우(5시간, 주간, 월간)에 걸쳐 더 높은 요청 할당량을 적용합니다. 이는 대략 5시간당 $12, 주당 $30, 월 $60에 해당합니다(실제 요청 수는 모델 및 사용량에 따라 다름).", + "무료 모델에는 Big Pickle과 당시 사용 가능한 프로모션 모델이 포함되며, 하루 200회 요청 할당량이 적용됩니다. Go는 GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, DeepSeek V4 Flash, Hy3 preview를 포함하며, 롤링 윈도우(5시간, 주간, 월간)에 걸쳐 더 높은 요청 할당량을 적용합니다. 이는 대략 5시간당 $12, 주당 $30, 월 $60에 해당합니다(실제 요청 수는 모델 및 사용량에 따라 다름).", "zen.api.error.rateLimitExceeded": "속도 제한을 초과했습니다. 나중에 다시 시도해 주세요.", "zen.api.error.modelNotSupported": "{{model}} 모델은 지원되지 않습니다", diff --git a/packages/console/app/src/i18n/no.ts b/packages/console/app/src/i18n/no.ts index 27b5522e32..2a9e78aaa2 100644 --- a/packages/console/app/src/i18n/no.ts +++ b/packages/console/app/src/i18n/no.ts @@ -251,7 +251,7 @@ export const dict = { "go.title": "OpenCode Go | Rimelige kodemodeller for alle", "go.meta.description": - "Go starter på $5 for den første måneden, deretter $10/måned, med sjenerøse 5-timers forespørselsgrenser for GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro og DeepSeek V4 Flash.", + "Go starter på $5 for den første måneden, deretter $10/måned, med sjenerøse 5-timers forespørselsgrenser for GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, DeepSeek V4 Flash og Hy3 preview.", "go.hero.title": "Rimelige kodemodeller for alle", "go.hero.body": "Go bringer agent-koding til programmerere over hele verden. Med rause grenser og pålitelig tilgang til de mest kapable åpen kildekode-modellene, kan du bygge med kraftige agenter uten å bekymre deg for kostnader eller tilgjengelighet.", @@ -300,7 +300,7 @@ export const dict = { "go.problem.item2": "Rause grenser og pålitelig tilgang", "go.problem.item3": "Bygget for så mange programmerere som mulig", "go.problem.item4": - "Inkluderer GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro og DeepSeek V4 Flash", + "Inkluderer GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, DeepSeek V4 Flash og Hy3 preview", "go.how.title": "Hvordan Go fungerer", "go.how.body": "Go starter på $5 for den første måneden, deretter $10/måned. Du kan bruke det med OpenCode eller hvilken som helst agent.", @@ -326,7 +326,7 @@ export const dict = { "go.faq.a2": "Go inkluderer modellene nedenfor, med høye grenser og pålitelig tilgang.", "go.faq.q3": "Er Go det samme som Zen?", "go.faq.a3": - "Nei. Zen er betaling etter bruk, mens Go starter på $5 for den første måneden, deretter $10/måned, med sjenerøse grenser og pålitelig tilgang til åpen kildekode-modellene GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro og DeepSeek V4 Flash.", + "Nei. Zen er betaling etter bruk, mens Go starter på $5 for den første måneden, deretter $10/måned, med sjenerøse grenser og pålitelig tilgang til åpen kildekode-modellene GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, DeepSeek V4 Flash og Hy3 preview.", "go.faq.q4": "Hva koster Go?", "go.faq.a4.p1.beforePricing": "Go koster", "go.faq.a4.p1.pricingLink": "$5 første måned", @@ -350,7 +350,7 @@ export const dict = { "go.faq.q9": "Hva er forskjellen mellom gratis modeller og Go?", "go.faq.a9": - "Gratis modeller inkluderer Big Pickle pluss kampanjemodeller tilgjengelig på det tidspunktet, med en kvote på 200 forespørsler/dag. Go inkluderer GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro og DeepSeek V4 Flash med høyere kvoter håndhevet over rullerende vinduer (5 timer, ukentlig og månedlig), omtrent tilsvarende $12 per 5 timer, $30 per uke og $60 per måned (faktiske forespørselsantall varierer etter modell og bruk).", + "Gratis modeller inkluderer Big Pickle pluss kampanjemodeller tilgjengelig på det tidspunktet, med en kvote på 200 forespørsler/dag. Go inkluderer GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, DeepSeek V4 Flash og Hy3 preview med høyere kvoter håndhevet over rullerende vinduer (5 timer, ukentlig og månedlig), omtrent tilsvarende $12 per 5 timer, $30 per uke og $60 per måned (faktiske forespørselsantall varierer etter modell og bruk).", "zen.api.error.rateLimitExceeded": "Rate limit overskredet. Vennligst prøv igjen senere.", "zen.api.error.modelNotSupported": "Modell {{model}} støttes ikke", diff --git a/packages/console/app/src/i18n/pl.ts b/packages/console/app/src/i18n/pl.ts index 7f8c849156..c21fc87630 100644 --- a/packages/console/app/src/i18n/pl.ts +++ b/packages/console/app/src/i18n/pl.ts @@ -252,7 +252,7 @@ export const dict = { "go.title": "OpenCode Go | Niskokosztowe modele do kodowania dla każdego", "go.meta.description": - "Go zaczyna się od $5 za pierwszy miesiąc, potem $10/miesiąc, z hojnymi 5-godzinnymi limitami zapytań dla GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro i DeepSeek V4 Flash.", + "Go zaczyna się od $5 za pierwszy miesiąc, potem $10/miesiąc, z hojnymi 5-godzinnymi limitami zapytań dla GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, DeepSeek V4 Flash i Hy3 preview.", "go.hero.title": "Niskokosztowe modele do kodowania dla każdego", "go.hero.body": "Go udostępnia programowanie z agentami programistom na całym świecie. Oferuje hojne limity i niezawodny dostęp do najzdolniejszych modeli open source, dzięki czemu możesz budować za pomocą potężnych agentów, nie martwiąc się o koszty czy dostępność.", @@ -301,7 +301,7 @@ export const dict = { "go.problem.item2": "Hojne limity i niezawodny dostęp", "go.problem.item3": "Stworzony dla jak największej liczby programistów", "go.problem.item4": - "Zawiera GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro i DeepSeek V4 Flash", + "Zawiera GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, DeepSeek V4 Flash i Hy3 preview", "go.how.title": "Jak działa Go", "go.how.body": "Go zaczyna się od $5 za pierwszy miesiąc, potem $10/miesiąc. Możesz go używać z OpenCode lub dowolnym agentem.", @@ -327,7 +327,7 @@ export const dict = { "go.faq.a2": "Go obejmuje poniższe modele z wysokimi limitami i niezawodnym dostępem.", "go.faq.q3": "Czy Go to to samo co Zen?", "go.faq.a3": - "Nie. Zen to model płatności za użycie, podczas gdy Go zaczyna się od $5 za pierwszy miesiąc, potem $10/miesiąc, z hojnymi limitami i niezawodnym dostępem do modeli open source GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro i DeepSeek V4 Flash.", + "Nie. Zen to model płatności za użycie, podczas gdy Go zaczyna się od $5 za pierwszy miesiąc, potem $10/miesiąc, z hojnymi limitami i niezawodnym dostępem do modeli open source GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, DeepSeek V4 Flash i Hy3 preview.", "go.faq.q4": "Ile kosztuje Go?", "go.faq.a4.p1.beforePricing": "Go kosztuje", "go.faq.a4.p1.pricingLink": "$5 za pierwszy miesiąc", @@ -351,7 +351,7 @@ export const dict = { "go.faq.q9": "Jaka jest różnica między darmowymi modelami a Go?", "go.faq.a9": - "Darmowe modele obejmują Big Pickle oraz modele promocyjne dostępne w danym momencie, z limitem 200 zapytań/dzień. Go zawiera GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro i DeepSeek V4 Flash z wyższymi limitami zapytań egzekwowanymi w oknach kroczących (5-godzinnych, tygodniowych i miesięcznych), w przybliżeniu równoważnymi $12 na 5 godzin, $30 tygodniowo i $60 miesięcznie (rzeczywista liczba zapytań zależy od modelu i użycia).", + "Darmowe modele obejmują Big Pickle oraz modele promocyjne dostępne w danym momencie, z limitem 200 zapytań/dzień. Go zawiera GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, DeepSeek V4 Flash i Hy3 preview z wyższymi limitami zapytań egzekwowanymi w oknach kroczących (5-godzinnych, tygodniowych i miesięcznych), w przybliżeniu równoważnymi $12 na 5 godzin, $30 tygodniowo i $60 miesięcznie (rzeczywista liczba zapytań zależy od modelu i użycia).", "zen.api.error.rateLimitExceeded": "Przekroczono limit zapytań. Spróbuj ponownie później.", "zen.api.error.modelNotSupported": "Model {{model}} nie jest obsługiwany", diff --git a/packages/console/app/src/i18n/ru.ts b/packages/console/app/src/i18n/ru.ts index 4ac54c2ac0..df096db032 100644 --- a/packages/console/app/src/i18n/ru.ts +++ b/packages/console/app/src/i18n/ru.ts @@ -255,7 +255,7 @@ export const dict = { "go.title": "OpenCode Go | Недорогие модели для кодинга для всех", "go.meta.description": - "Go начинается с $5 за первый месяц, затем $10/месяц, с щедрыми лимитами запросов за 5 часов для GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro и DeepSeek V4 Flash.", + "Go начинается с $5 за первый месяц, затем $10/месяц, с щедрыми лимитами запросов за 5 часов для GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, DeepSeek V4 Flash и Hy3 preview.", "go.hero.title": "Недорогие модели для кодинга для всех", "go.hero.body": "Go открывает доступ к агентам-программистам разработчикам по всему миру. Предлагая щедрые лимиты и надежный доступ к наиболее способным моделям с открытым исходным кодом, вы можете создавать проекты с мощными агентами, не беспокоясь о затратах или доступности.", @@ -305,7 +305,7 @@ export const dict = { "go.problem.item2": "Щедрые лимиты и надежный доступ", "go.problem.item3": "Создан для максимального числа программистов", "go.problem.item4": - "Включает GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro и DeepSeek V4 Flash", + "Включает GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, DeepSeek V4 Flash и Hy3 preview", "go.how.title": "Как работает Go", "go.how.body": "Go начинается с $5 за первый месяц, затем $10/месяц. Вы можете использовать его с OpenCode или любым агентом.", @@ -331,7 +331,7 @@ export const dict = { "go.faq.a2": "Go включает перечисленные ниже модели с щедрыми лимитами и надежным доступом.", "go.faq.q3": "Go — это то же самое, что и Zen?", "go.faq.a3": - "Нет. Zen - это оплата по мере использования, в то время как Go начинается с $5 за первый месяц, затем $10/месяц, с щедрыми лимитами и надежным доступом к моделям с открытым исходным кодом GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro и DeepSeek V4 Flash.", + "Нет. Zen - это оплата по мере использования, в то время как Go начинается с $5 за первый месяц, затем $10/месяц, с щедрыми лимитами и надежным доступом к моделям с открытым исходным кодом GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, DeepSeek V4 Flash и Hy3 preview.", "go.faq.q4": "Сколько стоит Go?", "go.faq.a4.p1.beforePricing": "Go стоит", "go.faq.a4.p1.pricingLink": "$5 за первый месяц", @@ -355,7 +355,7 @@ export const dict = { "go.faq.q9": "В чем разница между бесплатными моделями и Go?", "go.faq.a9": - "Бесплатные модели включают Big Pickle плюс промо-модели, доступные на данный момент, с квотой 200 запросов/день. Go включает GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro и DeepSeek V4 Flash с более высокими квотами запросов, применяемыми в скользящих окнах (5 часов, неделя и месяц), что примерно эквивалентно $12 за 5 часов, $30 в неделю и $60 в месяц (фактическое количество запросов зависит от модели и использования).", + "Бесплатные модели включают Big Pickle плюс промо-модели, доступные на данный момент, с квотой 200 запросов/день. Go включает GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, DeepSeek V4 Flash и Hy3 preview с более высокими квотами запросов, применяемыми в скользящих окнах (5 часов, неделя и месяц), что примерно эквивалентно $12 за 5 часов, $30 в неделю и $60 в месяц (фактическое количество запросов зависит от модели и использования).", "zen.api.error.rateLimitExceeded": "Превышен лимит запросов. Пожалуйста, попробуйте позже.", "zen.api.error.modelNotSupported": "Модель {{model}} не поддерживается", diff --git a/packages/console/app/src/i18n/th.ts b/packages/console/app/src/i18n/th.ts index 280b9d9fa8..2f71846978 100644 --- a/packages/console/app/src/i18n/th.ts +++ b/packages/console/app/src/i18n/th.ts @@ -250,7 +250,7 @@ export const dict = { "go.title": "OpenCode Go | โมเดลเขียนโค้ดราคาประหยัดสำหรับทุกคน", "go.meta.description": - "Go เริ่มต้นที่ $5 สำหรับเดือนแรก จากนั้น $10/เดือน พร้อมขีดจำกัดคำขอ 5 ชั่วโมงที่เอื้อเฟื้อสำหรับ GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro และ DeepSeek V4 Flash", + "Go เริ่มต้นที่ $5 สำหรับเดือนแรก จากนั้น $10/เดือน พร้อมขีดจำกัดคำขอ 5 ชั่วโมงที่เอื้อเฟื้อสำหรับ GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, DeepSeek V4 Flash และ Hy3 preview", "go.hero.title": "โมเดลเขียนโค้ดราคาประหยัดสำหรับทุกคน", "go.hero.body": "Go นำการเขียนโค้ดแบบเอเจนต์มาสู่นักเขียนโปรแกรมทั่วโลก เสนอขีดจำกัดที่กว้างขวางและการเข้าถึงโมเดลโอเพนซอร์สที่มีความสามารถสูงสุดได้อย่างน่าเชื่อถือ เพื่อให้คุณสามารถสร้างสรรค์ด้วยเอเจนต์ที่ทรงพลังโดยไม่ต้องกังวลเรื่องค่าใช้จ่ายหรือความพร้อมใช้งาน", @@ -298,7 +298,7 @@ export const dict = { "go.problem.item2": "ขีดจำกัดที่กว้างขวางและการเข้าถึงที่เชื่อถือได้", "go.problem.item3": "สร้างขึ้นเพื่อโปรแกรมเมอร์จำนวนมากที่สุดเท่าที่จะเป็นไปได้", "go.problem.item4": - "รวมถึง GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro และ DeepSeek V4 Flash", + "รวมถึง GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, DeepSeek V4 Flash และ Hy3 preview", "go.how.title": "Go ทำงานอย่างไร", "go.how.body": "Go เริ่มต้นที่ $5 สำหรับเดือนแรก จากนั้น $10/เดือน คุณสามารถใช้กับ OpenCode หรือเอเจนต์ใดก็ได้", "go.how.step1.title": "สร้างบัญชี", @@ -323,7 +323,7 @@ export const dict = { "go.faq.a2": "Go รวมโมเดลด้านล่างนี้ พร้อมขีดจำกัดที่มากและการเข้าถึงที่เชื่อถือได้", "go.faq.q3": "Go เหมือนกับ Zen หรือไม่?", "go.faq.a3": - "ไม่ Zen เป็นแบบจ่ายตามการใช้งาน ในขณะที่ Go เริ่มต้นที่ $5 สำหรับเดือนแรก จากนั้น $10/เดือน พร้อมขีดจำกัดที่เอื้อเฟื้อและการเข้าถึงโมเดลโอเพนซอร์ส GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro และ DeepSeek V4 Flash อย่างเชื่อถือได้", + "ไม่ Zen เป็นแบบจ่ายตามการใช้งาน ในขณะที่ Go เริ่มต้นที่ $5 สำหรับเดือนแรก จากนั้น $10/เดือน พร้อมขีดจำกัดที่เอื้อเฟื้อและการเข้าถึงโมเดลโอเพนซอร์ส GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, DeepSeek V4 Flash และ Hy3 preview อย่างเชื่อถือได้", "go.faq.q4": "Go ราคาเท่าไหร่?", "go.faq.a4.p1.beforePricing": "Go ราคา", "go.faq.a4.p1.pricingLink": "$5 เดือนแรก", @@ -346,7 +346,7 @@ export const dict = { "go.faq.q9": "ความแตกต่างระหว่างโมเดลฟรีและ Go คืออะไร?", "go.faq.a9": - "โมเดลฟรีรวมถึง Big Pickle บวกกับโมเดลโปรโมชั่นที่มีให้ในขณะนั้น ด้วยโควต้า 200 คำขอ/วัน Go รวมถึง GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro และ DeepSeek V4 Flash ที่มีโควต้าคำขอสูงกว่า ซึ่งบังคับใช้ผ่านช่วงเวลาหมุนเวียน (5 ชั่วโมง, รายสัปดาห์ และรายเดือน) เทียบเท่าประมาณ $12 ต่อ 5 ชั่วโมง, $30 ต่อสัปดาห์ และ $60 ต่อเดือน (จำนวนคำขอจริงจะแตกต่างกันไปตามโมเดลและการใช้งาน)", + "โมเดลฟรีรวมถึง Big Pickle บวกกับโมเดลโปรโมชั่นที่มีให้ในขณะนั้น ด้วยโควต้า 200 คำขอ/วัน Go รวมถึง GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, DeepSeek V4 Flash และ Hy3 preview ที่มีโควต้าคำขอสูงกว่า ซึ่งบังคับใช้ผ่านช่วงเวลาหมุนเวียน (5 ชั่วโมง, รายสัปดาห์ และรายเดือน) เทียบเท่าประมาณ $12 ต่อ 5 ชั่วโมง, $30 ต่อสัปดาห์ และ $60 ต่อเดือน (จำนวนคำขอจริงจะแตกต่างกันไปตามโมเดลและการใช้งาน)", "zen.api.error.rateLimitExceeded": "เกินขีดจำกัดอัตราการใช้งาน กรุณาลองใหม่ในภายหลัง", "zen.api.error.modelNotSupported": "ไม่รองรับโมเดล {{model}}", diff --git a/packages/console/app/src/i18n/tr.ts b/packages/console/app/src/i18n/tr.ts index a8f449dc47..732a304e85 100644 --- a/packages/console/app/src/i18n/tr.ts +++ b/packages/console/app/src/i18n/tr.ts @@ -253,7 +253,7 @@ export const dict = { "go.title": "OpenCode Go | Herkes için düşük maliyetli kodlama modelleri", "go.meta.description": - "Go ilk ay $5, sonrasında ayda 10$ fiyatıyla başlar; GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro ve DeepSeek V4 Flash için cömert 5 saatlik istek limitleri sunar.", + "Go ilk ay $5, sonrasında ayda 10$ fiyatıyla başlar; GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, DeepSeek V4 Flash ve Hy3 preview için cömert 5 saatlik istek limitleri sunar.", "go.hero.title": "Herkes için düşük maliyetli kodlama modelleri", "go.hero.body": "Go, dünya çapındaki programcılara ajan tabanlı kodlama getiriyor. En yetenekli açık kaynaklı modellere cömert limitler ve güvenilir erişim sunarak, maliyet veya erişilebilirlik konusunda endişelenmeden güçlü ajanlarla geliştirme yapmanızı sağlar.", @@ -303,7 +303,7 @@ export const dict = { "go.problem.item2": "Cömert limitler ve güvenilir erişim", "go.problem.item3": "Mümkün olduğunca çok programcı için geliştirildi", "go.problem.item4": - "GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro ve DeepSeek V4 Flash içerir", + "GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, DeepSeek V4 Flash ve Hy3 preview içerir", "go.how.title": "Go nasıl çalışır?", "go.how.body": "Go ilk ay $5, sonrasında ayda 10$ fiyatıyla başlar. OpenCode veya herhangi bir ajanla kullanabilirsiniz.", @@ -329,7 +329,7 @@ export const dict = { "go.faq.a2": "Go, aşağıda listelenen modelleri cömert limitler ve güvenilir erişimle sunar.", "go.faq.q3": "Go, Zen ile aynı mı?", "go.faq.a3": - "Hayır. Zen kullandıkça öde modelidir, Go ise ilk ay $5, sonrasında ayda 10$ fiyatıyla başlar; GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro ve DeepSeek V4 Flash açık kaynak modellerine cömert limitler ve güvenilir erişim sunar.", + "Hayır. Zen kullandıkça öde modelidir, Go ise ilk ay $5, sonrasında ayda 10$ fiyatıyla başlar; GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, DeepSeek V4 Flash ve Hy3 preview açık kaynak modellerine cömert limitler ve güvenilir erişim sunar.", "go.faq.q4": "Go ne kadar?", "go.faq.a4.p1.beforePricing": "Go'nun maliyeti", "go.faq.a4.p1.pricingLink": "İlk ay $5", @@ -353,7 +353,7 @@ export const dict = { "go.faq.q9": "Ücretsiz modeller ve Go arasındaki fark nedir?", "go.faq.a9": - "Ücretsiz modeller, günlük 200 istek kotası ile Big Pickle ve o sırada mevcut olan promosyonel modelleri içerir. Go ise GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro ve DeepSeek V4 Flash modellerini; yuvarlanan pencereler (5 saatlik, haftalık ve aylık) üzerinden uygulanan daha yüksek istek kotalarıyla içerir. Bu kotalar kabaca her 5 saatte 12$, haftada 30$ ve ayda 60$ değerine eşdeğerdir (gerçek istek sayıları modele ve kullanıma göre değişir).", + "Ücretsiz modeller, günlük 200 istek kotası ile Big Pickle ve o sırada mevcut olan promosyonel modelleri içerir. Go ise GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, DeepSeek V4 Flash ve Hy3 preview modellerini; yuvarlanan pencereler (5 saatlik, haftalık ve aylık) üzerinden uygulanan daha yüksek istek kotalarıyla içerir. Bu kotalar kabaca her 5 saatte 12$, haftada 30$ ve ayda 60$ değerine eşdeğerdir (gerçek istek sayıları modele ve kullanıma göre değişir).", "zen.api.error.rateLimitExceeded": "İstek limiti aşıldı. Lütfen daha sonra tekrar deneyin.", "zen.api.error.modelNotSupported": "{{model}} modeli desteklenmiyor", diff --git a/packages/console/app/src/i18n/zh.ts b/packages/console/app/src/i18n/zh.ts index ced0060ca0..70623a9264 100644 --- a/packages/console/app/src/i18n/zh.ts +++ b/packages/console/app/src/i18n/zh.ts @@ -241,7 +241,7 @@ export const dict = { "go.title": "OpenCode Go | 人人可用的低成本编程模型", "go.meta.description": - "Go 首月 $5,之后 $10/月,提供对 GLM-5.1、GLM-5、Kimi K2.5、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.5 Plus、Qwen3.6 Plus、MiniMax M2.5、MiniMax M2.7、DeepSeek V4 Pro 和 DeepSeek V4 Flash 的 5 小时充裕请求额度。", + "Go 首月 $5,之后 $10/月,提供对 GLM-5.1、GLM-5、Kimi K2.5、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.5 Plus、Qwen3.6 Plus、MiniMax M2.5、MiniMax M2.7、DeepSeek V4 Pro, DeepSeek V4 Flash 和 Hy3 preview 的 5 小时充裕请求额度。", "go.hero.title": "人人可用的低成本编程模型", "go.hero.body": "Go 将代理编程带给全世界的程序员。提供充裕的限额和对最强大的开源模型的可靠访问,让您可以利用强大的代理进行构建,而无需担心成本或可用性。", @@ -289,7 +289,7 @@ export const dict = { "go.problem.item2": "充裕的限额和可靠的访问", "go.problem.item3": "为尽可能多的程序员打造", "go.problem.item4": - "包含 GLM-5.1, GLM-5, Kimi K2.5、Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5、MiniMax M2.7、DeepSeek V4 Pro 和 DeepSeek V4 Flash", + "包含 GLM-5.1, GLM-5, Kimi K2.5、Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5、MiniMax M2.7、DeepSeek V4 Pro, DeepSeek V4 Flash 和 Hy3 preview", "go.how.title": "Go 如何工作", "go.how.body": "Go 起价为首月 $5,之后 $10/月。您可以将其与 OpenCode 或任何代理搭配使用。", "go.how.step1.title": "创建账户", @@ -311,7 +311,7 @@ export const dict = { "go.faq.a2": "Go 包含下方列出的模型,提供充足的限额和可靠的访问。", "go.faq.q3": "Go 和 Zen 一样吗?", "go.faq.a3": - "不。Zen 是按量付费,而 Go 首月 $5,之后 $10/月,提供充裕的额度,并可可靠地访问 GLM-5.1、GLM-5、Kimi K2.5、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.5 Plus、Qwen3.6 Plus、MiniMax M2.5、MiniMax M2.7、DeepSeek V4 Pro 和 DeepSeek V4 Flash 等开源模型。", + "不。Zen 是按量付费,而 Go 首月 $5,之后 $10/月,提供充裕的额度,并可可靠地访问 GLM-5.1、GLM-5、Kimi K2.5、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.5 Plus、Qwen3.6 Plus、MiniMax M2.5、MiniMax M2.7、DeepSeek V4 Pro, DeepSeek V4 Flash 和 Hy3 preview 等开源模型。", "go.faq.q4": "Go 多少钱?", "go.faq.a4.p1.beforePricing": "Go 费用为", "go.faq.a4.p1.pricingLink": "首月 $5", @@ -333,7 +333,7 @@ export const dict = { "go.faq.q9": "免费模型和 Go 之间的区别是什么?", "go.faq.a9": - "免费模型包含 Big Pickle 加上当时可用的促销模型,每天有 200 次请求的配额。Go 包含 GLM-5.1, GLM-5, Kimi K2.5、Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5、MiniMax M2.7、DeepSeek V4 Pro 和 DeepSeek V4 Flash,并在滚动窗口(5 小时、每周和每月)内执行更高的请求配额,大致相当于每 5 小时 $12、每周 $30 和每月 $60(实际请求计数因模型和使用情况而异)。", + "免费模型包含 Big Pickle 加上当时可用的促销模型,每天有 200 次请求的配额。Go 包含 GLM-5.1, GLM-5, Kimi K2.5、Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5、MiniMax M2.7、DeepSeek V4 Pro, DeepSeek V4 Flash 和 Hy3 preview,并在滚动窗口(5 小时、每周和每月)内执行更高的请求配额,大致相当于每 5 小时 $12、每周 $30 和每月 $60(实际请求计数因模型和使用情况而异)。", "zen.api.error.rateLimitExceeded": "超出速率限制。请稍后重试。", "zen.api.error.modelNotSupported": "不支持模型 {{model}}", diff --git a/packages/console/app/src/i18n/zht.ts b/packages/console/app/src/i18n/zht.ts index e3e374a329..aeb45c601c 100644 --- a/packages/console/app/src/i18n/zht.ts +++ b/packages/console/app/src/i18n/zht.ts @@ -241,7 +241,7 @@ export const dict = { "go.title": "OpenCode Go | 低成本全民編碼模型", "go.meta.description": - "Go 首月 $5,之後 $10/月,提供對 GLM-5.1、GLM-5、Kimi K2.5、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.5 Plus、Qwen3.6 Plus、MiniMax M2.5、MiniMax M2.7、DeepSeek V4 Pro 和 DeepSeek V4 Flash 的 5 小時充裕請求額度。", + "Go 首月 $5,之後 $10/月,提供對 GLM-5.1、GLM-5、Kimi K2.5、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.5 Plus、Qwen3.6 Plus、MiniMax M2.5、MiniMax M2.7、DeepSeek V4 Pro, DeepSeek V4 Flash 和 Hy3 preview 的 5 小時充裕請求額度。", "go.hero.title": "低成本全民編碼模型", "go.hero.body": "Go 將代理編碼帶給全世界的程式設計師。提供寬裕的限額以及對最強大開源模型的穩定存取,讓你可以使用強大的代理進行構建,而無需擔心成本或可用性。", @@ -289,7 +289,7 @@ export const dict = { "go.problem.item2": "寬裕的限額與穩定存取", "go.problem.item3": "專為盡可能多的程式設計師打造", "go.problem.item4": - "包含 GLM-5.1、GLM-5、Kimi K2.5、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.5 Plus、Qwen3.6 Plus、MiniMax M2.5、MiniMax M2.7、DeepSeek V4 Pro 與 DeepSeek V4 Flash", + "包含 GLM-5.1、GLM-5、Kimi K2.5、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.5 Plus、Qwen3.6 Plus、MiniMax M2.5、MiniMax M2.7、DeepSeek V4 Pro, DeepSeek V4 Flash 與 Hy3 preview", "go.how.title": "Go 如何運作", "go.how.body": "Go 起價為首月 $5,之後 $10/月。您可以將其與 OpenCode 或任何代理搭配使用。", "go.how.step1.title": "建立帳號", @@ -311,7 +311,7 @@ export const dict = { "go.faq.a2": "Go 包含下方列出的模型,提供充足的額度與穩定的存取。", "go.faq.q3": "Go 與 Zen 一樣嗎?", "go.faq.a3": - "不。Zen 是按量付費,而 Go 首月 $5,之後 $10/月,提供充裕的額度,並可可靠地存取 GLM-5.1、GLM-5、Kimi K2.5、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.5 Plus、Qwen3.6 Plus、MiniMax M2.5、MiniMax M2.7、DeepSeek V4 Pro 和 DeepSeek V4 Flash 等開源模型。", + "不。Zen 是按量付費,而 Go 首月 $5,之後 $10/月,提供充裕的額度,並可可靠地存取 GLM-5.1、GLM-5、Kimi K2.5、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.5 Plus、Qwen3.6 Plus、MiniMax M2.5、MiniMax M2.7、DeepSeek V4 Pro, DeepSeek V4 Flash 和 Hy3 preview 等開源模型。", "go.faq.q4": "Go 費用是多少?", "go.faq.a4.p1.beforePricing": "Go 費用為", "go.faq.a4.p1.pricingLink": "首月 $5", @@ -333,7 +333,7 @@ export const dict = { "go.faq.q9": "免費模型與 Go 有什麼區別?", "go.faq.a9": - "免費模型包括 Big Pickle 以及當時可用的促銷模型,配額為 200 次請求/天。Go 包括 GLM-5.1、GLM-5、Kimi K2.5、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.5 Plus、Qwen3.6 Plus、MiniMax M2.5、MiniMax M2.7、DeepSeek V4 Pro 與 DeepSeek V4 Flash,並在滾動視窗(5 小時、每週和每月)內執行更高的請求配額,大約相當於每 5 小時 $12、每週 $30 和每月 $60(實際請求數因模型和使用情況而異)。", + "免費模型包括 Big Pickle 以及當時可用的促銷模型,配額為 200 次請求/天。Go 包括 GLM-5.1、GLM-5、Kimi K2.5、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.5 Plus、Qwen3.6 Plus、MiniMax M2.5、MiniMax M2.7、DeepSeek V4 Pro, DeepSeek V4 Flash 與 Hy3 preview,並在滾動視窗(5 小時、每週和每月)內執行更高的請求配額,大約相當於每 5 小時 $12、每週 $30 和每月 $60(實際請求數因模型和使用情況而異)。", "zen.api.error.rateLimitExceeded": "超出頻率限制。請稍後再試。", "zen.api.error.modelNotSupported": "不支援模型 {{model}}", diff --git a/packages/console/app/src/routes/go/index.tsx b/packages/console/app/src/routes/go/index.tsx index 71102c7227..06529907dc 100644 --- a/packages/console/app/src/routes/go/index.tsx +++ b/packages/console/app/src/routes/go/index.tsx @@ -35,6 +35,7 @@ const models = [ { name: "MiniMax M2.5", provider: "MiniMax" }, { name: "DeepSeek V4 Pro", provider: "DeepSeek" }, { name: "DeepSeek V4 Flash", provider: "DeepSeek" }, + { name: "Hy3 preview", provider: "Tencent" }, ] function LimitsGraph(props: { href: string }) { @@ -63,6 +64,7 @@ function LimitsGraph(props: { href: string }) { { id: "glm-5.1", name: "GLM-5.1", req: 880, d: "100ms" }, { id: "kimi-k2.6", name: "Kimi K2.6", req: 1150, d: "150ms" }, { id: "mimo-v2.5-pro", name: "MiMo-V2.5-Pro", req: 1290, d: "150ms" }, + { id: "hy3-preview", name: "Hy3 preview", req: 1875, d: "180ms" }, { id: "qwen3.6-plus", name: "Qwen3.6 Plus", req: 3300, d: "280ms" }, { id: "minimax-m2.7", name: "MiniMax M2.7", req: 3400, d: "300ms" }, { id: "deepseek-v4-pro", name: "DeepSeek V4 Pro", req: 3450, d: "200ms" }, diff --git a/packages/console/app/src/routes/workspace/[id]/go/lite-section.tsx b/packages/console/app/src/routes/workspace/[id]/go/lite-section.tsx index eba52b0e17..d12bc6ce0b 100644 --- a/packages/console/app/src/routes/workspace/[id]/go/lite-section.tsx +++ b/packages/console/app/src/routes/workspace/[id]/go/lite-section.tsx @@ -297,6 +297,7 @@ export function LiteSection() {
  • Qwen3.6 Plus
  • DeepSeek V4 Pro
  • DeepSeek V4 Flash
  • +
  • Hy3 preview
  • {i18n.t("workspace.lite.promo.footer")}

    diff --git a/packages/web/src/content/docs/ar/go.mdx b/packages/web/src/content/docs/ar/go.mdx index 81f885335c..d65e63f9b6 100644 --- a/packages/web/src/content/docs/ar/go.mdx +++ b/packages/web/src/content/docs/ar/go.mdx @@ -65,6 +65,7 @@ OpenCode Go حاليًا في المرحلة التجريبية. - **MiniMax M2.7** - **DeepSeek V4 Pro** - **DeepSeek V4 Flash** +- **Hy3 preview** قد تتغير قائمة النماذج مع استمرارنا في اختبار نماذج جديدة وإضافتها. @@ -96,6 +97,7 @@ OpenCode Go حاليًا في المرحلة التجريبية. | Qwen3.5 Plus | 10,200 | 25,200 | 50,500 | | DeepSeek V4 Pro | 3,450 | 8,550 | 17,150 | | DeepSeek V4 Flash | 31,650 | 79,050 | 158,150 | +| Hy3 preview | 1,875 | 4,690 | 9,375 | تستند التقديرات إلى متوسطات أنماط الطلبات المرصودة: @@ -108,6 +110,7 @@ OpenCode Go حاليًا في المرحلة التجريبية. - Qwen3.6 Plus — ‏500 input، و57,000 cached، و190 output tokens لكل طلب - MiMo-V2.5 — ‏1000 input، و60,000 cached، و140 output tokens لكل طلب - MiMo-V2.5-Pro — ‏350 input، و41,000 cached، و250 output tokens لكل طلب +- Hy3 preview — 800 input, 50,000 cached, 400 output tokens per request يمكنك تتبّع استخدامك الحالي في **console**. @@ -143,6 +146,7 @@ OpenCode Go حاليًا في المرحلة التجريبية. | MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` | | Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` | +| Hy3 preview | hy3-preview | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | يستخدم [model id](/docs/config/#models) في إعدادات OpenCode لديك التنسيق `opencode-go/`. على سبيل المثال، بالنسبة إلى Kimi K2.6، ستستخدم `opencode-go/kimi-k2.6` في إعداداتك. diff --git a/packages/web/src/content/docs/ar/zen.mdx b/packages/web/src/content/docs/ar/zen.mdx index 33fd9493ba..e73c56b0aa 100644 --- a/packages/web/src/content/docs/ar/zen.mdx +++ b/packages/web/src/content/docs/ar/zen.mdx @@ -98,7 +98,6 @@ OpenCode Zen هي بوابة AI تتيح لك الوصول إلى هذه الن | Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Ling 2.6 Flash | ling-2.6-flash | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Hy3 Preview Free | hy3-preview-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Nemotron 3 Super Free | nemotron-3-super-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | يستخدم [معرّف النموذج](/docs/config/#models) في إعدادات OpenCode الصيغة `opencode/`. على سبيل المثال، بالنسبة إلى GPT 5.5، ستستخدم `opencode/gpt-5.5` في إعداداتك. @@ -124,7 +123,6 @@ https://opencode.ai/zen/v1/models | Big Pickle | Free | Free | Free | - | | MiniMax M2.5 Free | Free | Free | Free | - | | Ling 2.6 Flash Free | Free | Free | Free | - | -| Hy3 Preview Free | Free | Free | Free | - | | Nemotron 3 Super Free | Free | Free | Free | - | | MiniMax M2.7 | $0.30 | $1.20 | $0.06 | $0.375 | | MiniMax M2.5 | $0.30 | $1.20 | $0.06 | $0.375 | @@ -177,7 +175,6 @@ https://opencode.ai/zen/v1/models - MiniMax M2.5 Free متاح على OpenCode لفترة محدودة. يستخدم الفريق هذه الفترة لجمع الملاحظات وتحسين النموذج. - Ling 2.6 Flash Free متاح على OpenCode لفترة محدودة. يستخدم الفريق هذه الفترة لجمع الملاحظات وتحسين النموذج. -- Hy3 Preview Flash Free متاح على OpenCode لفترة محدودة. يستخدم الفريق هذه الفترة لجمع الملاحظات وتحسين النموذج. - Nemotron 3 Super Free متاح على OpenCode لفترة محدودة. يستخدم الفريق هذه الفترة لجمع الملاحظات وتحسين النموذج. - Big Pickle نموذج خفي ومتاح مجانا على OpenCode لفترة محدودة. يستخدم الفريق هذه الفترة لجمع الملاحظات وتحسين النموذج. @@ -230,7 +227,6 @@ https://opencode.ai/zen/v1/models - Big Pickle: خلال فترته المجانية، قد تُستخدم البيانات المجمعة لتحسين النموذج. - MiniMax M2.5 Free: خلال فترته المجانية، قد تُستخدم البيانات المجمعة لتحسين النموذج. - Ling 2.6 Flash Free: خلال فترته المجانية، قد تُستخدم البيانات المجمعة لتحسين النموذج. -- Hy3 Preview Free: خلال فترته المجانية، قد تُستخدم البيانات المجمعة لتحسين النموذج. - Nemotron 3 Super Free (نقاط نهاية NVIDIA المجانية): يُقدَّم بموجب [شروط خدمة النسخة التجريبية من واجهة NVIDIA API](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). للاستخدام التجريبي فقط، وليس للإنتاج أو البيانات الحساسة. تقوم NVIDIA بتسجيل المطالبات والمخرجات لتحسين نماذجها وخدماتها. لا ترسل بيانات شخصية أو سرية. - OpenAI APIs: يتم الاحتفاظ بالطلبات لمدة 30 يوما وفقا لـ [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data). - Anthropic APIs: يتم الاحتفاظ بالطلبات لمدة 30 يوما وفقا لـ [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage). diff --git a/packages/web/src/content/docs/bs/go.mdx b/packages/web/src/content/docs/bs/go.mdx index d2df6aaad8..9886a10840 100644 --- a/packages/web/src/content/docs/bs/go.mdx +++ b/packages/web/src/content/docs/bs/go.mdx @@ -75,6 +75,7 @@ Trenutna lista modela uključuje: - **MiniMax M2.7** - **DeepSeek V4 Pro** - **DeepSeek V4 Flash** +- **Hy3 preview** Lista modela se može mijenjati dok testiramo i dodajemo nove. @@ -106,6 +107,7 @@ Tabela ispod pruža procijenjeni broj zahtjeva na osnovu tipičnih obrazaca kori | Qwen3.5 Plus | 10,200 | 25,200 | 50,500 | | DeepSeek V4 Pro | 3,450 | 8,550 | 17,150 | | DeepSeek V4 Flash | 31,650 | 79,050 | 158,150 | +| Hy3 preview | 1,875 | 4,690 | 9,375 | Procjene se zasnivaju na zapaženim prosječnim obrascima zahtjeva: @@ -118,6 +120,7 @@ Procjene se zasnivaju na zapaženim prosječnim obrascima zahtjeva: - Qwen3.6 Plus — 500 ulaznih, 57,000 keširanih, 190 izlaznih tokena po zahtjevu - MiMo-V2.5 — 1000 ulaznih, 60,000 keširanih, 140 izlaznih tokena po zahtjevu - MiMo-V2.5-Pro — 350 ulaznih, 41,000 keširanih, 250 izlaznih tokena po zahtjevu +- Hy3 preview — 800 input, 50,000 cached, 400 output tokens per request Svoju trenutnu potrošnju možete pratiti u **konzoli**. @@ -155,6 +158,7 @@ Također možete pristupiti Go modelima putem sljedećih API endpointa. | MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` | | Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` | +| Hy3 preview | hy3-preview | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | [Model id](/docs/config/#models) u vašoj OpenCode konfiguraciji koristi format `opencode-go/`. Na primjer, za Kimi K2.6, koristili biste diff --git a/packages/web/src/content/docs/bs/zen.mdx b/packages/web/src/content/docs/bs/zen.mdx index 3723cbaa3c..a5487ade7f 100644 --- a/packages/web/src/content/docs/bs/zen.mdx +++ b/packages/web/src/content/docs/bs/zen.mdx @@ -103,7 +103,6 @@ Našim modelima možete pristupiti i preko sljedećih API endpointa. | Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Ling 2.6 Flash | ling-2.6-flash | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Hy3 Preview Free | hy3-preview-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Nemotron 3 Super Free | nemotron-3-super-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | [model id](/docs/config/#models) u vašoj OpenCode konfiguraciji koristi format @@ -131,7 +130,6 @@ Podržavamo pay-as-you-go model. Ispod su cijene **po 1M tokena**. | Big Pickle | Free | Free | Free | - | | MiniMax M2.5 Free | Free | Free | Free | - | | Ling 2.6 Flash Free | Free | Free | Free | - | -| Hy3 Preview Free | Free | Free | Free | - | | Nemotron 3 Super Free | Free | Free | Free | - | | MiniMax M2.7 | $0.30 | $1.20 | $0.06 | $0.375 | | MiniMax M2.5 | $0.30 | $1.20 | $0.06 | $0.375 | @@ -184,7 +182,6 @@ Besplatni modeli: - MiniMax M2.5 Free je dostupan na OpenCode ograničeno vrijeme. Tim koristi ovo vrijeme da prikupi povratne informacije i poboljša model. - Ling 2.6 Flash Free je dostupan na OpenCode ograničeno vrijeme. Tim koristi ovo vrijeme da prikupi povratne informacije i poboljša model. -- Hy3 Preview Free je dostupan na OpenCode ograničeno vrijeme. Tim koristi ovo vrijeme da prikupi povratne informacije i poboljša model. - Nemotron 3 Super Free je dostupan na OpenCode ograničeno vrijeme. Tim koristi ovo vrijeme da prikupi povratne informacije i poboljša model. - Big Pickle je stealth model koji je besplatan na OpenCode ograničeno vrijeme. Tim koristi ovo vrijeme da prikupi povratne informacije i poboljša model. @@ -242,7 +239,6 @@ i ne koriste vaše podatke za treniranje modela, uz sljedeće izuzetke: - Big Pickle: Tokom besplatnog perioda, prikupljeni podaci mogu se koristiti za poboljšanje modela. - MiniMax M2.5 Free: Tokom besplatnog perioda, prikupljeni podaci mogu se koristiti za poboljšanje modela. - Ling 2.6 Flash Free: Tokom besplatnog perioda, prikupljeni podaci mogu se koristiti za poboljšanje modela. -- Hy3 Preview Free: Tokom besplatnog perioda, prikupljeni podaci mogu se koristiti za poboljšanje modela. - Nemotron 3 Super Free (besplatni NVIDIA endpointi): Dostupan je prema [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). Samo za probnu upotrebu, nije za produkciju niti osjetljive podatke. NVIDIA bilježi promptove i izlaze radi poboljšanja svojih modela i usluga. Nemojte slati lične ili povjerljive podatke. - OpenAI APIs: Requests are retained for 30 days in accordance with [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data). - Anthropic APIs: Requests are retained for 30 days in accordance with [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage). diff --git a/packages/web/src/content/docs/da/go.mdx b/packages/web/src/content/docs/da/go.mdx index 6891e6d579..d57c44b71b 100644 --- a/packages/web/src/content/docs/da/go.mdx +++ b/packages/web/src/content/docs/da/go.mdx @@ -75,6 +75,7 @@ Den nuværende liste over modeller inkluderer: - **MiniMax M2.7** - **DeepSeek V4 Pro** - **DeepSeek V4 Flash** +- **Hy3 preview** Listen over modeller kan ændre sig, efterhånden som vi tester og tilføjer nye. @@ -106,6 +107,7 @@ Tabellen nedenfor giver et estimeret antal anmodninger baseret på typiske Go-fo | Qwen3.5 Plus | 10,200 | 25,200 | 50,500 | | DeepSeek V4 Pro | 3,450 | 8,550 | 17,150 | | DeepSeek V4 Flash | 31,650 | 79,050 | 158,150 | +| Hy3 preview | 1,875 | 4,690 | 9,375 | Estimaterne er baseret på observerede gennemsnitlige anmodningsmønstre: @@ -118,6 +120,7 @@ Estimaterne er baseret på observerede gennemsnitlige anmodningsmønstre: - Qwen3.6 Plus — 500 input, 57.000 cachelagrede, 190 output-tokens pr. anmodning - MiMo-V2.5 — 1000 input, 60.000 cachelagrede, 140 output-tokens pr. anmodning - MiMo-V2.5-Pro — 350 input, 41.000 cachelagrede, 250 output-tokens pr. anmodning +- Hy3 preview — 800 input, 50,000 cached, 400 output tokens per request Du kan spore dit nuværende forbrug i **konsollen**. @@ -155,6 +158,7 @@ Du kan også få adgang til Go-modeller gennem følgende API-endpoints. | MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` | | Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` | +| Hy3 preview | hy3-preview | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | Dit [model id](/docs/config/#models) i din OpenCode config bruger formatet `opencode-go/`. For eksempel for Kimi K2.6, vil du diff --git a/packages/web/src/content/docs/da/zen.mdx b/packages/web/src/content/docs/da/zen.mdx index d45f785a59..b0004cb2e0 100644 --- a/packages/web/src/content/docs/da/zen.mdx +++ b/packages/web/src/content/docs/da/zen.mdx @@ -103,7 +103,6 @@ Du kan også få adgang til vores modeller gennem følgende API-endpoints. | Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Ling 2.6 Flash | ling-2.6-flash | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Hy3 Preview Free | hy3-preview-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Nemotron 3 Super Free | nemotron-3-super-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | [model id](/docs/config/#models) i din OpenCode-konfiguration @@ -131,7 +130,6 @@ Vi understøtter en pay-as-you-go-model. Nedenfor er priserne **pr. 1M tokens**. | Big Pickle | Free | Free | Free | - | | MiniMax M2.5 Free | Free | Free | Free | - | | Ling 2.6 Flash Free | Free | Free | Free | - | -| Hy3 Preview Free | Free | Free | Free | - | | Nemotron 3 Super Free | Free | Free | Free | - | | MiniMax M2.7 | $0.30 | $1.20 | $0.06 | $0.375 | | MiniMax M2.5 | $0.30 | $1.20 | $0.06 | $0.375 | @@ -184,7 +182,6 @@ De gratis modeller: - MiniMax M2.5 Free er tilgængelig på OpenCode i en begrænset periode. Teamet bruger denne tid til at indsamle feedback og forbedre modellen. - Ling 2.6 Flash Free er tilgængelig på OpenCode i en begrænset periode. Teamet bruger denne tid til at indsamle feedback og forbedre modellen. -- Hy3 Preview Free er tilgængelig på OpenCode i en begrænset periode. Teamet bruger denne tid til at indsamle feedback og forbedre modellen. - Nemotron 3 Super Free er tilgængelig på OpenCode i en begrænset periode. Teamet bruger denne tid til at indsamle feedback og forbedre modellen. - Big Pickle er en stealth-model, som er gratis på OpenCode i en begrænset periode. Teamet bruger denne tid til at indsamle feedback og forbedre modellen. @@ -240,7 +237,6 @@ Alle vores modeller hostes i US. Vores udbydere følger en nul-opbevaringspoliti - Big Pickle: I den gratis periode kan indsamlede data blive brugt til at forbedre modellen. - MiniMax M2.5 Free: I den gratis periode kan indsamlede data blive brugt til at forbedre modellen. - Ling 2.6 Flash Free: I den gratis periode kan indsamlede data blive brugt til at forbedre modellen. -- Hy3 Preview Free: I den gratis periode kan indsamlede data blive brugt til at forbedre modellen. - Nemotron 3 Super Free (gratis NVIDIA-endpoints): Leveres under [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). Kun til prøvebrug, ikke til produktion eller følsomme data. Prompts og outputs logges af NVIDIA for at forbedre deres modeller og tjenester. Indsend ikke personlige eller fortrolige data. - OpenAI APIs: Anmodninger opbevares i 30 dage i overensstemmelse med [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data). - Anthropic APIs: Anmodninger opbevares i 30 dage i overensstemmelse med [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage). diff --git a/packages/web/src/content/docs/de/go.mdx b/packages/web/src/content/docs/de/go.mdx index 917ea340ef..087f2bbaa0 100644 --- a/packages/web/src/content/docs/de/go.mdx +++ b/packages/web/src/content/docs/de/go.mdx @@ -67,6 +67,7 @@ Die aktuelle Liste der Modelle umfasst: - **MiniMax M2.7** - **DeepSeek V4 Pro** - **DeepSeek V4 Flash** +- **Hy3 preview** Die Liste der Modelle kann sich ändern, während wir neue testen und hinzufügen. @@ -98,6 +99,7 @@ Die folgende Tabelle zeigt eine geschätzte Anzahl von Anfragen basierend auf ty | Qwen3.5 Plus | 10,200 | 25,200 | 50,500 | | DeepSeek V4 Pro | 3,450 | 8,550 | 17,150 | | DeepSeek V4 Flash | 31,650 | 79,050 | 158,150 | +| Hy3 preview | 1,875 | 4,690 | 9,375 | Die Schätzungen basieren auf beobachteten durchschnittlichen Anfragemustern: @@ -110,6 +112,7 @@ Die Schätzungen basieren auf beobachteten durchschnittlichen Anfragemustern: - Qwen3.6 Plus — 500 Input-, 57.000 Cached-, 190 Output-Tokens pro Anfrage - MiMo-V2.5 — 1.000 Input-, 60.000 Cached-, 140 Output-Tokens pro Anfrage - MiMo-V2.5-Pro — 350 Input-, 41.000 Cached-, 250 Output-Tokens pro Anfrage +- Hy3 preview — 800 input, 50,000 cached, 400 output tokens per request Du kannst deine aktuelle Nutzung in der **Console** verfolgen. @@ -145,6 +148,7 @@ Du kannst auf die Go-Modelle auch über die folgenden API-Endpunkte zugreifen. | MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` | | Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` | +| Hy3 preview | hy3-preview | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | Die [Modell-ID](/docs/config/#models) in deiner OpenCode Config verwendet das Format `opencode-go/`. Für Kimi K2.6 würdest du beispielsweise `opencode-go/kimi-k2.6` in deiner Config verwenden. diff --git a/packages/web/src/content/docs/de/zen.mdx b/packages/web/src/content/docs/de/zen.mdx index 5e6c8eee80..0439159e77 100644 --- a/packages/web/src/content/docs/de/zen.mdx +++ b/packages/web/src/content/docs/de/zen.mdx @@ -94,7 +94,6 @@ Du kannst auch über die folgenden API-Endpunkte auf unsere Modelle zugreifen. | Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Ling 2.6 Flash | ling-2.6-flash | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Hy3 Preview Free | hy3-preview-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Nemotron 3 Super Free | nemotron-3-super-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | Die [Model-ID](/docs/config/#models) in deiner OpenCode-Konfiguration verwendet das Format `opencode/`. Für GPT 5.5 würdest du zum Beispiel `opencode/gpt-5.5` in deiner Konfiguration verwenden. @@ -120,7 +119,6 @@ Wir unterstützen ein Pay-as-you-go-Modell. Unten findest du die Preise **pro 1M | Big Pickle | Free | Free | Free | - | | MiniMax M2.5 Free | Free | Free | Free | - | | Ling 2.6 Flash Free | Free | Free | Free | - | -| Hy3 Preview Free | Free | Free | Free | - | | Nemotron 3 Super Free | Free | Free | Free | - | | MiniMax M2.7 | $0.30 | $1.20 | $0.06 | $0.375 | | MiniMax M2.5 | $0.30 | $1.20 | $0.06 | $0.375 | @@ -173,7 +171,6 @@ Die kostenlosen Modelle: - MiniMax M2.5 Free ist für begrenzte Zeit auf OpenCode verfügbar. Das Team nutzt diese Zeit, um Feedback zu sammeln und das Modell zu verbessern. - Ling 2.6 Flash Free ist für begrenzte Zeit auf OpenCode verfügbar. Das Team nutzt diese Zeit, um Feedback zu sammeln und das Modell zu verbessern. -- Hy3 Preview Flash Free ist für begrenzte Zeit auf OpenCode verfügbar. Das Team nutzt diese Zeit, um Feedback zu sammeln und das Modell zu verbessern. - Nemotron 3 Super Free ist für begrenzte Zeit auf OpenCode verfügbar. Das Team nutzt diese Zeit, um Feedback zu sammeln und das Modell zu verbessern. - Big Pickle ist ein Stealth-Modell, das für begrenzte Zeit kostenlos auf OpenCode verfügbar ist. Das Team nutzt diese Zeit, um Feedback zu sammeln und das Modell zu verbessern. @@ -226,7 +223,6 @@ Alle unsere Modelle werden in den USA gehostet. Unsere Provider folgen einer Zer - Big Pickle: Während des kostenlosen Zeitraums können gesammelte Daten zur Verbesserung des Modells verwendet werden. - MiniMax M2.5 Free: Während des kostenlosen Zeitraums können gesammelte Daten zur Verbesserung des Modells verwendet werden. - Ling 2.6 Flash Free: Während des kostenlosen Zeitraums können gesammelte Daten zur Verbesserung des Modells verwendet werden. -- Hy3 Preview Free: Während des kostenlosen Zeitraums können gesammelte Daten zur Verbesserung des Modells verwendet werden. - Nemotron 3 Super Free (kostenlose NVIDIA-Endpunkte): Bereitgestellt gemäß den [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). Nur für Testzwecke, nicht für Produktion oder sensible Daten. Eingaben und Ausgaben werden von NVIDIA protokolliert, um seine Modelle und Dienste zu verbessern. Übermitteln Sie keine personenbezogenen oder vertraulichen Daten. - OpenAI APIs: Anfragen werden in Übereinstimmung mit [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data) 30 Tage lang gespeichert. - Anthropic APIs: Anfragen werden in Übereinstimmung mit [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage) 30 Tage lang gespeichert. diff --git a/packages/web/src/content/docs/es/go.mdx b/packages/web/src/content/docs/es/go.mdx index 0be23b3fa4..5eff59afc2 100644 --- a/packages/web/src/content/docs/es/go.mdx +++ b/packages/web/src/content/docs/es/go.mdx @@ -75,6 +75,7 @@ La lista actual de modelos incluye: - **MiniMax M2.7** - **DeepSeek V4 Pro** - **DeepSeek V4 Flash** +- **Hy3 preview** La lista de modelos puede cambiar a medida que probamos y agregamos otros nuevos. @@ -106,6 +107,7 @@ La siguiente tabla proporciona una cantidad estimada de peticiones basada en los | Qwen3.5 Plus | 10,200 | 25,200 | 50,500 | | DeepSeek V4 Pro | 3,450 | 8,550 | 17,150 | | DeepSeek V4 Flash | 31,650 | 79,050 | 158,150 | +| Hy3 preview | 1,875 | 4,690 | 9,375 | Las estimaciones se basan en los patrones de peticiones promedio observados: @@ -118,6 +120,7 @@ Las estimaciones se basan en los patrones de peticiones promedio observados: - Qwen3.6 Plus — 500 tokens de entrada, 57,000 en caché, 190 tokens de salida por petición - MiMo-V2.5 — 1000 tokens de entrada, 60,000 en caché, 140 tokens de salida por petición - MiMo-V2.5-Pro — 350 tokens de entrada, 41,000 en caché, 250 tokens de salida por petición +- Hy3 preview — 800 input, 50,000 cached, 400 output tokens per request Puedes realizar un seguimiento de tu uso actual en la **consola**. @@ -155,6 +158,7 @@ También puedes acceder a los modelos de Go a través de los siguientes endpoint | MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` | | Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` | +| Hy3 preview | hy3-preview | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | El [ID del modelo](/docs/config/#models) en tu configuración de OpenCode usa el formato `opencode-go/`. Por ejemplo, para Kimi K2.6, usarías diff --git a/packages/web/src/content/docs/es/zen.mdx b/packages/web/src/content/docs/es/zen.mdx index 15436226a5..377ad2df05 100644 --- a/packages/web/src/content/docs/es/zen.mdx +++ b/packages/web/src/content/docs/es/zen.mdx @@ -103,7 +103,6 @@ También puedes acceder a nuestros modelos a través de los siguientes endpoints | Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Ling 2.6 Flash | ling-2.6-flash | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Hy3 Preview Free | hy3-preview-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Nemotron 3 Super Free | nemotron-3-super-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | El [identificador del modelo](/docs/config/#models) en tu configuración de OpenCode @@ -131,7 +130,6 @@ Admitimos un modelo de pago por uso. A continuación se muestran los precios **p | Big Pickle | Free | Free | Free | - | | MiniMax M2.5 Free | Free | Free | Free | - | | Ling 2.6 Flash Free | Free | Free | Free | - | -| Hy3 Preview Free | Free | Free | Free | - | | Nemotron 3 Super Free | Free | Free | Free | - | | MiniMax M2.7 | $0.30 | $1.20 | $0.06 | $0.375 | | MiniMax M2.5 | $0.30 | $1.20 | $0.06 | $0.375 | @@ -184,7 +182,6 @@ Los modelos gratuitos: - MiniMax M2.5 Free está disponible en OpenCode por tiempo limitado. El equipo está usando este tiempo para recopilar comentarios y mejorar el modelo. - Ling 2.6 Flash Free está disponible en OpenCode por tiempo limitado. El equipo está usando este tiempo para recopilar comentarios y mejorar el modelo. -- Hy3 Preview Flash Free está disponible en OpenCode por tiempo limitado. El equipo está usando este tiempo para recopilar comentarios y mejorar el modelo. - Nemotron 3 Super Free está disponible en OpenCode por tiempo limitado. El equipo está usando este tiempo para recopilar comentarios y mejorar el modelo. - Big Pickle es un modelo stealth que es gratuito en OpenCode por tiempo limitado. El equipo está usando este tiempo para recopilar comentarios y mejorar el modelo. @@ -240,7 +237,6 @@ Todos nuestros modelos están alojados en US. Nuestros proveedores siguen una po - Big Pickle: Durante su período gratuito, los datos recopilados pueden usarse para mejorar el modelo. - MiniMax M2.5 Free: Durante su período gratuito, los datos recopilados pueden usarse para mejorar el modelo. - Ling 2.6 Flash Free: Durante su período gratuito, los datos recopilados pueden usarse para mejorar el modelo. -- Hy3 Preview Free: Durante su período gratuito, los datos recopilados pueden usarse para mejorar el modelo. - Nemotron 3 Super Free (endpoints gratuitos de NVIDIA): Se ofrece bajo los [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). Solo para uso de prueba, no para producción ni datos sensibles. NVIDIA registra los prompts y las salidas para mejorar sus modelos y servicios. No envíes datos personales ni confidenciales. - OpenAI APIs: Las solicitudes se conservan durante 30 días de acuerdo con [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data). - Anthropic APIs: Las solicitudes se conservan durante 30 días de acuerdo con [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage). diff --git a/packages/web/src/content/docs/fr/go.mdx b/packages/web/src/content/docs/fr/go.mdx index 3dd9c25f32..97caa705db 100644 --- a/packages/web/src/content/docs/fr/go.mdx +++ b/packages/web/src/content/docs/fr/go.mdx @@ -65,6 +65,7 @@ La liste actuelle des modèles comprend : - **MiniMax M2.7** - **DeepSeek V4 Pro** - **DeepSeek V4 Flash** +- **Hy3 preview** La liste des modèles peut changer au fur et à mesure que nous en testons et en ajoutons de nouveaux. @@ -96,6 +97,7 @@ Le tableau ci-dessous fournit une estimation du nombre de requêtes basée sur d | Qwen3.5 Plus | 10,200 | 25,200 | 50,500 | | DeepSeek V4 Pro | 3,450 | 8,550 | 17,150 | | DeepSeek V4 Flash | 31,650 | 79,050 | 158,150 | +| Hy3 preview | 1,875 | 4,690 | 9,375 | Les estimations sont basées sur les modèles de requêtes moyens observés : @@ -108,6 +110,7 @@ Les estimations sont basées sur les modèles de requêtes moyens observés : - Qwen3.6 Plus — 500 tokens en entrée, 57,000 en cache, 190 tokens en sortie par requête - MiMo-V2.5 — 1000 tokens en entrée, 60,000 en cache, 140 tokens en sortie par requête - MiMo-V2.5-Pro — 350 tokens en entrée, 41,000 en cache, 250 tokens en sortie par requête +- Hy3 preview — 800 input, 50,000 cached, 400 output tokens per request Vous pouvez suivre votre utilisation actuelle dans la **console**. @@ -143,6 +146,7 @@ Vous pouvez également accéder aux modèles Go via les points de terminaison d' | MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` | | Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` | +| Hy3 preview | hy3-preview | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | L'[ID de modèle](/docs/config/#models) dans votre configuration OpenCode utilise le format `opencode-go/`. Par exemple, pour Kimi K2.6, vous utiliseriez `opencode-go/kimi-k2.6` dans votre configuration. diff --git a/packages/web/src/content/docs/fr/zen.mdx b/packages/web/src/content/docs/fr/zen.mdx index fdf14e8fb0..95157792c4 100644 --- a/packages/web/src/content/docs/fr/zen.mdx +++ b/packages/web/src/content/docs/fr/zen.mdx @@ -94,7 +94,6 @@ Vous pouvez également accéder à nos modèles via les points de terminaison AP | Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Ling 2.6 Flash | ling-2.6-flash | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Hy3 Preview Free | hy3-preview-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Nemotron 3 Super Free | nemotron-3-super-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | Le [model id](/docs/config/#models) dans votre configuration OpenCode utilise le format `opencode/`. Par exemple, pour GPT 5.5, vous utiliseriez `opencode/gpt-5.5` dans votre configuration. @@ -120,7 +119,6 @@ Nous prenons en charge un modèle de paiement à l'utilisation. Vous trouverez c | Big Pickle | Free | Free | Free | - | | MiniMax M2.5 Free | Free | Free | Free | - | | Ling 2.6 Flash Free | Free | Free | Free | - | -| Hy3 Preview Free | Free | Free | Free | - | | Nemotron 3 Super Free | Free | Free | Free | - | | MiniMax M2.7 | $0.30 | $1.20 | $0.06 | $0.375 | | MiniMax M2.5 | $0.30 | $1.20 | $0.06 | $0.375 | @@ -173,7 +171,6 @@ Les modèles gratuits : - MiniMax M2.5 Free est disponible sur OpenCode pour une durée limitée. L'équipe utilise cette période pour recueillir des retours et améliorer le modèle. - Ling 2.6 Flash Free est disponible sur OpenCode pour une durée limitée. L'équipe utilise cette période pour recueillir des retours et améliorer le modèle. -- Hy3 Preview Free est disponible sur OpenCode pour une durée limitée. L'équipe utilise cette période pour recueillir des retours et améliorer le modèle. - Nemotron 3 Super Free est disponible sur OpenCode pour une durée limitée. L'équipe utilise cette période pour recueillir des retours et améliorer le modèle. - Big Pickle est un modèle stealth gratuit sur OpenCode pour une durée limitée. L'équipe utilise cette période pour recueillir des retours et améliorer le modèle. @@ -226,7 +223,6 @@ Tous nos modèles sont hébergés aux US. Nos fournisseurs suivent une politique - Big Pickle : Pendant sa période gratuite, les données collectées peuvent être utilisées pour améliorer le modèle. - MiniMax M2.5 Free : Pendant sa période gratuite, les données collectées peuvent être utilisées pour améliorer le modèle. - Ling 2.6 Flash Free : Pendant sa période gratuite, les données collectées peuvent être utilisées pour améliorer le modèle. -- Hy3 Preview Free : Pendant sa période gratuite, les données collectées peuvent être utilisées pour améliorer le modèle. - Nemotron 3 Super Free (endpoints NVIDIA gratuits) : Fourni dans le cadre des [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). Réservé à un usage d'essai, pas à la production ni aux données sensibles. Les prompts et les sorties sont journalisés par NVIDIA pour améliorer ses modèles et services. N'envoyez pas de données personnelles ou confidentielles. - OpenAI APIs : Les requêtes sont conservées pendant 30 jours conformément à [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data). - Anthropic APIs : Les requêtes sont conservées pendant 30 jours conformément à [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage). diff --git a/packages/web/src/content/docs/go.mdx b/packages/web/src/content/docs/go.mdx index 237d1c4b84..a99c67cf30 100644 --- a/packages/web/src/content/docs/go.mdx +++ b/packages/web/src/content/docs/go.mdx @@ -75,6 +75,7 @@ The current list of models includes: - **Qwen3.6 Plus** - **DeepSeek V4 Pro** - **DeepSeek V4 Flash** +- **Hy3 preview** The list of models may change as we test and add new ones. @@ -106,6 +107,7 @@ The table below provides an estimated request count based on typical Go usage pa | Qwen3.5 Plus | 10,200 | 25,200 | 50,500 | | DeepSeek V4 Pro | 3,450 | 8,550 | 17,150 | | DeepSeek V4 Flash | 31,650 | 79,050 | 158,150 | +| Hy3 preview | 1,875 | 4,690 | 9,375 | Estimates are based on observed average request patterns: @@ -118,6 +120,7 @@ Estimates are based on observed average request patterns: - MiMo-V2.5-Pro — 350 input, 41,000 cached, 250 output tokens per request - Qwen3.5 Plus — 410 input, 47,000 cached, 140 output tokens per request - Qwen3.6 Plus — 500 input, 57,000 cached, 190 output tokens per request +- Hy3 preview — 800 input, 50,000 cached, 400 output tokens per request You can track your current usage in the **console**. @@ -155,6 +158,7 @@ You can also access Go models through the following API endpoints. | MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` | | Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` | +| Hy3 preview | hy3-preview | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | The [model id](/docs/config/#models) in your OpenCode config uses the format `opencode-go/`. For example, for Kimi K2.6, you would diff --git a/packages/web/src/content/docs/it/go.mdx b/packages/web/src/content/docs/it/go.mdx index df4f6dd1ca..9561874dc2 100644 --- a/packages/web/src/content/docs/it/go.mdx +++ b/packages/web/src/content/docs/it/go.mdx @@ -73,6 +73,7 @@ L'elenco attuale dei modelli include: - **MiniMax M2.7** - **DeepSeek V4 Pro** - **DeepSeek V4 Flash** +- **Hy3 preview** L'elenco dei modelli potrebbe cambiare man mano che ne testiamo e aggiungiamo di nuovi. @@ -104,6 +105,7 @@ La tabella seguente fornisce una stima del conteggio delle richieste in base a p | Qwen3.5 Plus | 10,200 | 25,200 | 50,500 | | DeepSeek V4 Pro | 3,450 | 8,550 | 17,150 | | DeepSeek V4 Flash | 31,650 | 79,050 | 158,150 | +| Hy3 preview | 1,875 | 4,690 | 9,375 | Le stime si basano sui pattern medi di richieste osservati: @@ -116,6 +118,7 @@ Le stime si basano sui pattern medi di richieste osservati: - Qwen3.6 Plus — 500 di input, 57.000 in cache, 190 token di output per richiesta - MiMo-V2.5 — 1000 di input, 60.000 in cache, 140 token di output per richiesta - MiMo-V2.5-Pro — 350 di input, 41.000 in cache, 250 token di output per richiesta +- Hy3 preview — 800 input, 50,000 cached, 400 output tokens per request Puoi monitorare il tuo utilizzo attuale nella **console**. @@ -153,6 +156,7 @@ Puoi anche accedere ai modelli Go tramite i seguenti endpoint API. | MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` | | Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` | +| Hy3 preview | hy3-preview | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | Il [model id](/docs/config/#models) nella tua OpenCode config utilizza il formato `opencode-go/`. Ad esempio, per Kimi K2.6, useresti diff --git a/packages/web/src/content/docs/it/zen.mdx b/packages/web/src/content/docs/it/zen.mdx index a53d6a2ba1..43fb7756da 100644 --- a/packages/web/src/content/docs/it/zen.mdx +++ b/packages/web/src/content/docs/it/zen.mdx @@ -103,7 +103,6 @@ Puoi anche accedere ai nostri modelli tramite i seguenti endpoint API. | Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Ling 2.6 Flash | ling-2.6-flash | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Hy3 Preview Free | hy3-preview-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Nemotron 3 Super Free | nemotron-3-super-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | Il [model id](/docs/config/#models) nella config di OpenCode @@ -131,7 +130,6 @@ Supportiamo un modello pay-as-you-go. Qui sotto trovi i prezzi **per 1M token**. | Big Pickle | Free | Free | Free | - | | MiniMax M2.5 Free | Free | Free | Free | - | | Ling 2.6 Flash Free | Free | Free | Free | - | -| Hy3 Preview Free | Free | Free | Free | - | | Nemotron 3 Super Free | Free | Free | Free | - | | MiniMax M2.7 | $0.30 | $1.20 | $0.06 | $0.375 | | MiniMax M2.5 | $0.30 | $1.20 | $0.06 | $0.375 | @@ -184,7 +182,6 @@ I modelli gratuiti: - MiniMax M2.5 Free è disponibile su OpenCode per un periodo limitato. Il team usa questo periodo per raccogliere feedback e migliorare il modello. - Ling 2.6 Flash Free è disponibile su OpenCode per un periodo limitato. Il team usa questo periodo per raccogliere feedback e migliorare il modello. -- Hy3 Preview Flash Free è disponibile su OpenCode per un periodo limitato. Il team usa questo periodo per raccogliere feedback e migliorare il modello. - Nemotron 3 Super Free è disponibile su OpenCode per un periodo limitato. Il team usa questo periodo per raccogliere feedback e migliorare il modello. - Big Pickle è un modello stealth che è gratuito su OpenCode per un periodo limitato. Il team usa questo periodo per raccogliere feedback e migliorare il modello. @@ -240,7 +237,6 @@ Tutti i nostri modelli sono ospitati negli US. I nostri provider seguono una pol - Big Pickle: durante il periodo gratuito, i dati raccolti possono essere usati per migliorare il modello. - MiniMax M2.5 Free: durante il periodo gratuito, i dati raccolti possono essere usati per migliorare il modello. - Ling 2.6 Flash Free: durante il periodo gratuito, i dati raccolti possono essere usati per migliorare il modello. -- Hy3 Preview Free: durante il periodo gratuito, i dati raccolti possono essere usati per migliorare il modello. - Nemotron 3 Super Free (endpoint NVIDIA gratuiti): fornito secondo i [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). Solo per uso di prova, non per produzione o dati sensibili. NVIDIA registra prompt e output per migliorare i propri modelli e servizi. Non inviare dati personali o riservati. - OpenAI APIs: le richieste vengono conservate per 30 giorni in conformità con [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data). - Anthropic APIs: le richieste vengono conservate per 30 giorni in conformità con [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage). diff --git a/packages/web/src/content/docs/ja/go.mdx b/packages/web/src/content/docs/ja/go.mdx index 0cb294754f..795905b337 100644 --- a/packages/web/src/content/docs/ja/go.mdx +++ b/packages/web/src/content/docs/ja/go.mdx @@ -65,6 +65,7 @@ OpenCode Goをサブスクライブできるのは、1つのワークスペー - **MiniMax M2.7** - **DeepSeek V4 Pro** - **DeepSeek V4 Flash** +- **Hy3 preview** 新しいモデルをテストして追加するにつれて、モデルのリストは変更される場合があります。 @@ -96,6 +97,7 @@ OpenCode Goには以下の制限が含まれています: | Qwen3.5 Plus | 10,200 | 25,200 | 50,500 | | DeepSeek V4 Pro | 3,450 | 8,550 | 17,150 | | DeepSeek V4 Flash | 31,650 | 79,050 | 158,150 | +| Hy3 preview | 1,875 | 4,690 | 9,375 | 推定値は、観測された平均的なリクエストパターンに基づいています: @@ -108,6 +110,7 @@ OpenCode Goには以下の制限が含まれています: - Qwen3.6 Plus — リクエストあたり 入力 500トークン、キャッシュ 57,000トークン、出力 190トークン - MiMo-V2.5 — リクエストあたり 入力 1000トークン、キャッシュ 60,000トークン、出力 140トークン - MiMo-V2.5-Pro — リクエストあたり 入力 350トークン、キャッシュ 41,000トークン、出力 250トークン +- Hy3 preview — 800 input, 50,000 cached, 400 output tokens per request 現在の利用状況は**コンソール**で追跡できます。 @@ -143,6 +146,7 @@ Zen残高にクレジットがある場合は、コンソールで**Use balance* | MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` | | Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` | +| Hy3 preview | hy3-preview | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | OpenCode設定の[model id](/docs/config/#models)は、`opencode-go/`という形式を使用します。たとえば、Kimi K2.6の場合は、設定で`opencode-go/kimi-k2.6`を使用します。 diff --git a/packages/web/src/content/docs/ja/zen.mdx b/packages/web/src/content/docs/ja/zen.mdx index 64427a72ec..15c4cd3688 100644 --- a/packages/web/src/content/docs/ja/zen.mdx +++ b/packages/web/src/content/docs/ja/zen.mdx @@ -94,7 +94,6 @@ OpenCode Zen は、OpenCode のほかのプロバイダーと同じように動 | Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Ling 2.6 Flash | ling-2.6-flash | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Hy3 Preview Free | hy3-preview-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Nemotron 3 Super Free | nemotron-3-super-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | OpenCode 設定で使う [model id](/docs/config/#models) は `opencode/` 形式です。たとえば、GPT 5.5 では設定に `opencode/gpt-5.5` を使用します。 @@ -120,7 +119,6 @@ https://opencode.ai/zen/v1/models | Big Pickle | Free | Free | Free | - | | MiniMax M2.5 Free | Free | Free | Free | - | | Ling 2.6 Flash Free | Free | Free | Free | - | -| Hy3 Preview Free | Free | Free | Free | - | | Nemotron 3 Super Free | Free | Free | Free | - | | MiniMax M2.7 | $0.30 | $1.20 | $0.06 | $0.375 | | MiniMax M2.5 | $0.30 | $1.20 | $0.06 | $0.375 | @@ -173,7 +171,6 @@ https://opencode.ai/zen/v1/models - MiniMax M2.5 Free は期間限定で OpenCode で利用できます。チームはこの期間中にフィードバックを集め、モデルを改善しています。 - Ling 2.6 Flash Free は期間限定で OpenCode で利用できます。チームはこの期間中にフィードバックを集め、モデルを改善しています。 -- Hy3 Preview Flash Free は期間限定で OpenCode で利用できます。チームはこの期間中にフィードバックを集め、モデルを改善しています。 - Nemotron 3 Super Free は期間限定で OpenCode で利用できます。チームはこの期間中にフィードバックを集め、モデルを改善しています。 - Big Pickle はステルスモデルで、期間限定で OpenCode で無料提供されています。チームはこの期間中にフィードバックを集め、モデルを改善しています。 @@ -226,7 +223,6 @@ https://opencode.ai/zen/v1/models - Big Pickle: 無料提供期間中、収集されたデータがモデル改善に使われる場合があります。 - MiniMax M2.5 Free: 無料提供期間中、収集されたデータがモデル改善に使われる場合があります。 - Ling 2.6 Flash Free: 無料提供期間中、収集されたデータがモデル改善に使われる場合があります。 -- Hy3 Preview Free: 無料提供期間中、収集されたデータがモデル改善に使われる場合があります。 - Nemotron 3 Super Free(NVIDIA の無料エンドポイント): [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf) に基づいて提供されます。試用専用であり、本番環境や機密性の高いデータには使用しないでください。プロンプトと出力は、NVIDIA が自社のモデルとサービスを改善するために記録します。個人情報や機密データは送信しないでください。 - OpenAI APIs: リクエストは [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data) に従って 30 日間保持されます。 - Anthropic APIs: リクエストは [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage) に従って 30 日間保持されます。 diff --git a/packages/web/src/content/docs/ko/go.mdx b/packages/web/src/content/docs/ko/go.mdx index d0a3b9d0d1..3d63387173 100644 --- a/packages/web/src/content/docs/ko/go.mdx +++ b/packages/web/src/content/docs/ko/go.mdx @@ -65,6 +65,7 @@ workspace당 한 명의 멤버만 OpenCode Go를 구독할 수 있습니다. - **MiniMax M2.7** - **DeepSeek V4 Pro** - **DeepSeek V4 Flash** +- **Hy3 preview** 새로운 모델을 테스트하고 추가함에 따라 이 목록은 변경될 수 있습니다. @@ -96,6 +97,7 @@ OpenCode Go에는 다음과 같은 한도가 포함됩니다. | Qwen3.5 Plus | 10,200 | 25,200 | 50,500 | | DeepSeek V4 Pro | 3,450 | 8,550 | 17,150 | | DeepSeek V4 Flash | 31,650 | 79,050 | 158,150 | +| Hy3 preview | 1,875 | 4,690 | 9,375 | 예상치는 관찰된 평균 요청 패턴을 기준으로 합니다. @@ -108,6 +110,7 @@ OpenCode Go에는 다음과 같은 한도가 포함됩니다. - Qwen3.6 Plus — 요청당 입력 500, 캐시 57,000, 출력 토큰 190 - MiMo-V2.5 — 요청당 입력 1000, 캐시 60,000, 출력 토큰 140 - MiMo-V2.5-Pro — 요청당 입력 350, 캐시 41,000, 출력 토큰 250 +- Hy3 preview — 800 input, 50,000 cached, 400 output tokens per request 현재 사용량은 **console**에서 확인할 수 있습니다. @@ -143,6 +146,7 @@ Zen 잔액에 크레딧도 있다면, console에서 **Use balance** 옵션을 | MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` | | Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` | +| Hy3 preview | hy3-preview | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | OpenCode config의 [model id](/docs/config/#models)는 `opencode-go/` 형식을 사용합니다. 예를 들어 Kimi K2.6의 경우 config에서 `opencode-go/kimi-k2.6`를 사용하면 됩니다. diff --git a/packages/web/src/content/docs/ko/zen.mdx b/packages/web/src/content/docs/ko/zen.mdx index e80a5e8710..b0e7d49136 100644 --- a/packages/web/src/content/docs/ko/zen.mdx +++ b/packages/web/src/content/docs/ko/zen.mdx @@ -94,7 +94,6 @@ OpenCode Zen은 OpenCode의 다른 provider와 똑같이 작동합니다. | Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Ling 2.6 Flash | ling-2.6-flash | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Hy3 Preview Free | hy3-preview-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Nemotron 3 Super Free | nemotron-3-super-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | OpenCode config에서 사용하는 [모델 ID](/docs/config/#models)는 `opencode/` 형식입니다. 예를 들어 GPT 5.5를 사용하려면 config에서 `opencode/gpt-5.5`를 사용하면 됩니다. @@ -120,7 +119,6 @@ https://opencode.ai/zen/v1/models | Big Pickle | Free | Free | Free | - | | MiniMax M2.5 Free | Free | Free | Free | - | | Ling 2.6 Flash Free | Free | Free | Free | - | -| Hy3 Preview Free | Free | Free | Free | - | | Nemotron 3 Super Free | Free | Free | Free | - | | MiniMax M2.7 | $0.30 | $1.20 | $0.06 | $0.375 | | MiniMax M2.5 | $0.30 | $1.20 | $0.06 | $0.375 | @@ -173,7 +171,6 @@ https://opencode.ai/zen/v1/models - MiniMax M2.5 Free는 한정된 기간 동안 OpenCode에서 제공됩니다. 팀은 이 기간에 피드백을 수집하고 모델을 개선합니다. - Ling 2.6 Flash Free는 한정된 기간 동안 OpenCode에서 제공됩니다. 팀은 이 기간에 피드백을 수집하고 모델을 개선합니다. -- Hy3 Preview Flash Free는 한정된 기간 동안 OpenCode에서 제공됩니다. 팀은 이 기간에 피드백을 수집하고 모델을 개선합니다. - Nemotron 3 Super Free는 한정된 기간 동안 OpenCode에서 제공됩니다. 팀은 이 기간에 피드백을 수집하고 모델을 개선합니다. - Big Pickle은 한정된 기간 동안 OpenCode에서 무료로 제공되는 stealth model입니다. 팀은 이 기간에 피드백을 수집하고 모델을 개선합니다. @@ -226,7 +223,6 @@ https://opencode.ai/zen/v1/models - Big Pickle: 무료 제공 기간에는 수집된 데이터가 모델 개선에 사용될 수 있습니다. - MiniMax M2.5 Free: 무료 제공 기간에는 수집된 데이터가 모델 개선에 사용될 수 있습니다. - Ling 2.6 Flash Free: 무료 제공 기간에는 수집된 데이터가 모델 개선에 사용될 수 있습니다. -- Hy3 Preview Free: 무료 제공 기간에는 수집된 데이터가 모델 개선에 사용될 수 있습니다. - Nemotron 3 Super Free(NVIDIA 무료 엔드포인트): [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf)에 따라 제공됩니다. 평가판 전용이며 프로덕션 환경이나 민감한 데이터에는 사용할 수 없습니다. NVIDIA는 자사 모델과 서비스를 개선하기 위해 프롬프트와 출력을 기록합니다. 개인 정보나 기밀 데이터는 제출하지 마세요. - OpenAI APIs: 요청은 [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data)에 따라 30일 동안 보관됩니다. - Anthropic APIs: 요청은 [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage)에 따라 30일 동안 보관됩니다. diff --git a/packages/web/src/content/docs/nb/go.mdx b/packages/web/src/content/docs/nb/go.mdx index e19b6ccce1..eefc224d92 100644 --- a/packages/web/src/content/docs/nb/go.mdx +++ b/packages/web/src/content/docs/nb/go.mdx @@ -75,6 +75,7 @@ Den nåværende listen over modeller inkluderer: - **MiniMax M2.7** - **DeepSeek V4 Pro** - **DeepSeek V4 Flash** +- **Hy3 preview** Listen over modeller kan endres etter hvert som vi tester og legger til nye. @@ -106,6 +107,7 @@ Tabellen nedenfor gir et estimert antall forespørsler basert på typiske bruksm | Qwen3.5 Plus | 10,200 | 25,200 | 50,500 | | DeepSeek V4 Pro | 3,450 | 8,550 | 17,150 | | DeepSeek V4 Flash | 31,650 | 79,050 | 158,150 | +| Hy3 preview | 1,875 | 4,690 | 9,375 | Estimatene er basert på observerte gjennomsnittlige forespørselsmønstre: @@ -118,6 +120,7 @@ Estimatene er basert på observerte gjennomsnittlige forespørselsmønstre: - Qwen3.6 Plus — 500 input, 57 000 bufret, 190 output-tokens per forespørsel - MiMo-V2.5 — 1000 input, 60 000 bufret, 140 output-tokens per forespørsel - MiMo-V2.5-Pro — 350 input, 41 000 bufret, 250 output-tokens per forespørsel +- Hy3 preview — 800 input, 50,000 cached, 400 output tokens per request Du kan spore din nåværende bruk i **konsollen**. @@ -155,6 +158,7 @@ Du kan også få tilgang til Go-modeller gjennom følgende API-endepunkter. | MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` | | Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` | +| Hy3 preview | hy3-preview | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | [Modell-ID-en](/docs/config/#models) i din OpenCode-konfigurasjon bruker formatet `opencode-go/`. For eksempel, for Kimi K2.6, vil du diff --git a/packages/web/src/content/docs/nb/zen.mdx b/packages/web/src/content/docs/nb/zen.mdx index 4bd1e6115e..6b5847df8e 100644 --- a/packages/web/src/content/docs/nb/zen.mdx +++ b/packages/web/src/content/docs/nb/zen.mdx @@ -103,7 +103,6 @@ Du kan også få tilgang til modellene våre gjennom følgende API-endepunkter. | Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Ling 2.6 Flash | ling-2.6-flash | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Hy3 Preview Free | hy3-preview-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Nemotron 3 Super Free | nemotron-3-super-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | [modell-id](/docs/config/#models) i OpenCode-konfigurasjonen din @@ -131,7 +130,6 @@ Vi støtter en pay-as-you-go-modell. Nedenfor er prisene **per 1M tokens**. | Big Pickle | Free | Free | Free | - | | MiniMax M2.5 Free | Free | Free | Free | - | | Ling 2.6 Flash Free | Free | Free | Free | - | -| Hy3 Preview Free | Free | Free | Free | - | | Nemotron 3 Super Free | Free | Free | Free | - | | MiniMax M2.7 | $0.30 | $1.20 | $0.06 | $0.375 | | MiniMax M2.5 | $0.30 | $1.20 | $0.06 | $0.375 | @@ -184,7 +182,6 @@ Gratis-modellene: - MiniMax M2.5 Free er tilgjengelig på OpenCode i en begrenset periode. Teamet bruker denne tiden til å samle inn tilbakemeldinger og forbedre modellen. - Ling 2.6 Flash Free er tilgjengelig på OpenCode i en begrenset periode. Teamet bruker denne tiden til å samle inn tilbakemeldinger og forbedre modellen. -- Hy3 Preview Free er tilgjengelig på OpenCode i en begrenset periode. Teamet bruker denne tiden til å samle inn tilbakemeldinger og forbedre modellen. - Nemotron 3 Super Free er tilgjengelig på OpenCode i en begrenset periode. Teamet bruker denne tiden til å samle inn tilbakemeldinger og forbedre modellen. - Big Pickle er en stealth-modell som er gratis på OpenCode i en begrenset periode. Teamet bruker denne tiden til å samle inn tilbakemeldinger og forbedre modellen. @@ -240,7 +237,6 @@ Alle modellene våre hostes i US. Leverandørene våre følger en policy for zer - Big Pickle: I gratisperioden kan innsamlede data brukes til å forbedre modellen. - MiniMax M2.5 Free: I gratisperioden kan innsamlede data brukes til å forbedre modellen. - Ling 2.6 Flash Free: I gratisperioden kan innsamlede data brukes til å forbedre modellen. -- Hy3 Preview Free: I gratisperioden kan innsamlede data brukes til å forbedre modellen. - Nemotron 3 Super Free (gratis NVIDIA-endepunkter): Leveres under [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). Kun for prøvebruk, ikke for produksjon eller sensitive data. Prompter og svar logges av NVIDIA for å forbedre modellene og tjenestene deres. Ikke send inn personopplysninger eller konfidensielle data. - OpenAI APIs: Forespørsler lagres i 30 dager i samsvar med [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data). - Anthropic APIs: Forespørsler lagres i 30 dager i samsvar med [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage). diff --git a/packages/web/src/content/docs/pl/go.mdx b/packages/web/src/content/docs/pl/go.mdx index 00f76a103f..0d9d7884d0 100644 --- a/packages/web/src/content/docs/pl/go.mdx +++ b/packages/web/src/content/docs/pl/go.mdx @@ -69,6 +69,7 @@ Obecna lista modeli obejmuje: - **MiniMax M2.7** - **DeepSeek V4 Pro** - **DeepSeek V4 Flash** +- **Hy3 preview** Lista modeli może ulec zmianie w miarę testowania i dodawania nowych. @@ -100,6 +101,7 @@ Poniższa tabela przedstawia szacunkową liczbę żądań na podstawie typowych | Qwen3.5 Plus | 10,200 | 25,200 | 50,500 | | DeepSeek V4 Pro | 3,450 | 8,550 | 17,150 | | DeepSeek V4 Flash | 31,650 | 79,050 | 158,150 | +| Hy3 preview | 1,875 | 4,690 | 9,375 | Szacunki opierają się na zaobserwowanych średnich wzorcach żądań: @@ -112,6 +114,7 @@ Szacunki opierają się na zaobserwowanych średnich wzorcach żądań: - Qwen3.6 Plus — 500 tokenów wejściowych, 57 000 w pamięci podręcznej, 190 tokenów wyjściowych na żądanie - MiMo-V2.5 — 1000 tokenów wejściowych, 60 000 w pamięci podręcznej, 140 tokenów wyjściowych na żądanie - MiMo-V2.5-Pro — 350 tokenów wejściowych, 41 000 w pamięci podręcznej, 250 tokenów wyjściowych na żądanie +- Hy3 preview — 800 input, 50,000 cached, 400 output tokens per request Możesz śledzić swoje bieżące zużycie w **konsoli**. @@ -147,6 +150,7 @@ Możesz również uzyskać dostęp do modeli Go za pośrednictwem następującyc | MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` | | Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` | +| Hy3 preview | hy3-preview | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | [ID modelu](/docs/config/#models) w Twojej konfiguracji OpenCode używa formatu `opencode-go/`. Na przykład dla Kimi K2.6 należy użyć diff --git a/packages/web/src/content/docs/pl/zen.mdx b/packages/web/src/content/docs/pl/zen.mdx index ebd16d7856..7b9fb25ce4 100644 --- a/packages/web/src/content/docs/pl/zen.mdx +++ b/packages/web/src/content/docs/pl/zen.mdx @@ -103,7 +103,6 @@ Możesz też uzyskać dostęp do naszych modeli przez poniższe endpointy API. | Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Ling 2.6 Flash | ling-2.6-flash | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Hy3 Preview Free | hy3-preview-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Nemotron 3 Super Free | nemotron-3-super-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | [ID modelu](/docs/config/#models) w Twojej konfiguracji OpenCode używa formatu @@ -131,7 +130,6 @@ Obsługujemy model pay-as-you-go. Poniżej znajdują się ceny **za 1M tokenów* | Big Pickle | Free | Free | Free | - | | MiniMax M2.5 Free | Free | Free | Free | - | | Ling 2.6 Flash Free | Free | Free | Free | - | -| Hy3 Preview Free | Free | Free | Free | - | | Nemotron 3 Super Free | Free | Free | Free | - | | MiniMax M2.7 | $0.30 | $1.20 | $0.06 | $0.375 | | MiniMax M2.5 | $0.30 | $1.20 | $0.06 | $0.375 | @@ -185,7 +183,6 @@ Darmowe modele: - MiniMax M2.5 Free jest dostępny w OpenCode przez ograniczony czas. Zespół wykorzystuje ten czas do zbierania opinii i ulepszania modelu. - Ling 2.6 Flash Free jest dostępny w OpenCode przez ograniczony czas. Zespół wykorzystuje ten czas do zbierania opinii i ulepszania modelu. -- Hy3 Preview Flash Free jest dostępny w OpenCode przez ograniczony czas. Zespół wykorzystuje ten czas do zbierania opinii i ulepszania modelu. - Nemotron 3 Super Free jest dostępny w OpenCode przez ograniczony czas. Zespół wykorzystuje ten czas do zbierania opinii i ulepszania modelu. - Big Pickle to stealth model, który jest darmowy w OpenCode przez ograniczony czas. Zespół wykorzystuje ten czas do zbierania opinii i ulepszania modelu. @@ -241,7 +238,6 @@ Wszystkie nasze modele są hostowane w US. Nasi dostawcy stosują politykę zero - Big Pickle: W czasie darmowego okresu zebrane dane mogą być wykorzystywane do ulepszania modelu. - MiniMax M2.5 Free: W czasie darmowego okresu zebrane dane mogą być wykorzystywane do ulepszania modelu. - Ling 2.6 Flash Free: W czasie darmowego okresu zebrane dane mogą być wykorzystywane do ulepszania modelu. -- Hy3 Preview Free: W czasie darmowego okresu zebrane dane mogą być wykorzystywane do ulepszania modelu. - Nemotron 3 Super Free (darmowe endpointy NVIDIA): Udostępniany zgodnie z [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). Tylko do użytku próbnego, nie do produkcji ani danych wrażliwych. NVIDIA rejestruje prompty i odpowiedzi, aby ulepszać swoje modele i usługi. Nie przesyłaj danych osobowych ani poufnych. - OpenAI APIs: Żądania są przechowywane przez 30 dni zgodnie z [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data). - Anthropic APIs: Żądania są przechowywane przez 30 dni zgodnie z [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage). diff --git a/packages/web/src/content/docs/pt-br/go.mdx b/packages/web/src/content/docs/pt-br/go.mdx index 44c5092a00..247f5b54ff 100644 --- a/packages/web/src/content/docs/pt-br/go.mdx +++ b/packages/web/src/content/docs/pt-br/go.mdx @@ -75,6 +75,7 @@ A lista atual de modelos inclui: - **MiniMax M2.7** - **DeepSeek V4 Pro** - **DeepSeek V4 Flash** +- **Hy3 preview** A lista de modelos pode mudar conforme testamos e adicionamos novos. @@ -106,6 +107,7 @@ A tabela abaixo fornece uma contagem estimada de requisições com base nos padr | Qwen3.5 Plus | 10,200 | 25,200 | 50,500 | | DeepSeek V4 Pro | 3,450 | 8,550 | 17,150 | | DeepSeek V4 Flash | 31,650 | 79,050 | 158,150 | +| Hy3 preview | 1,875 | 4,690 | 9,375 | As estimativas baseiam-se nos padrões médios de requisições observados: @@ -118,6 +120,7 @@ As estimativas baseiam-se nos padrões médios de requisições observados: - Qwen3.6 Plus — 500 tokens de entrada, 57.000 em cache, 190 tokens de saída por requisição - MiMo-V2.5 — 1000 tokens de entrada, 60.000 em cache, 140 tokens de saída por requisição - MiMo-V2.5-Pro — 350 tokens de entrada, 41.000 em cache, 250 tokens de saída por requisição +- Hy3 preview — 800 input, 50,000 cached, 400 output tokens per request Você pode acompanhar o seu uso atual no **console**. @@ -155,6 +158,7 @@ Você também pode acessar os modelos do Go através dos seguintes endpoints de | MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` | | Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` | +| Hy3 preview | hy3-preview | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | O [ID do modelo](/docs/config/#models) na sua configuração do OpenCode usa o formato `opencode-go/`. Por exemplo, para o Kimi K2.6, você usaria diff --git a/packages/web/src/content/docs/pt-br/zen.mdx b/packages/web/src/content/docs/pt-br/zen.mdx index 1dcc98c5d5..af813cb659 100644 --- a/packages/web/src/content/docs/pt-br/zen.mdx +++ b/packages/web/src/content/docs/pt-br/zen.mdx @@ -94,7 +94,6 @@ Você também pode acessar nossos modelos pelos seguintes endpoints de API. | Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Ling 2.6 Flash | ling-2.6-flash | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Hy3 Preview Free | hy3-preview-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Nemotron 3 Super Free | nemotron-3-super-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | O [model id](/docs/config/#models) na sua configuração do OpenCode usa o formato `opencode/`. Por exemplo, para GPT 5.5, você usaria `opencode/gpt-5.5` na sua configuração. @@ -120,7 +119,6 @@ Oferecemos um modelo pay-as-you-go. Abaixo estão os preços **por 1M tokens**. | Big Pickle | Free | Free | Free | - | | MiniMax M2.5 Free | Free | Free | Free | - | | Ling 2.6 Flash Free | Free | Free | Free | - | -| Hy3 Preview Free | Free | Free | Free | - | | Nemotron 3 Super Free | Free | Free | Free | - | | MiniMax M2.7 | $0.30 | $1.20 | $0.06 | $0.375 | | MiniMax M2.5 | $0.30 | $1.20 | $0.06 | $0.375 | @@ -173,7 +171,6 @@ Os modelos gratuitos: - MiniMax M2.5 Free está disponível no OpenCode por tempo limitado. A equipe está usando esse período para coletar feedback e melhorar o modelo. - Ling 2.6 Flash Free está disponível no OpenCode por tempo limitado. A equipe está usando esse período para coletar feedback e melhorar o modelo. -- Hy3 Preview Free está disponível no OpenCode por tempo limitado. A equipe está usando esse período para coletar feedback e melhorar o modelo. - Nemotron 3 Super Free está disponível no OpenCode por tempo limitado. A equipe está usando esse período para coletar feedback e melhorar o modelo. - Big Pickle é um modelo stealth que está gratuito no OpenCode por tempo limitado. A equipe está usando esse período para coletar feedback e melhorar o modelo. @@ -226,7 +223,6 @@ Todos os nossos modelos são hospedados nos US. Nossos provedores seguem uma pol - Big Pickle: Durante seu período gratuito, os dados coletados podem ser usados para melhorar o modelo. - MiniMax M2.5 Free: Durante seu período gratuito, os dados coletados podem ser usados para melhorar o modelo. - Ling 2.6 Flash Free: Durante seu período gratuito, os dados coletados podem ser usados para melhorar o modelo. -- Hy3 Preview Free: Durante seu período gratuito, os dados coletados podem ser usados para melhorar o modelo. - Nemotron 3 Super Free (endpoints gratuitos da NVIDIA): Fornecido sob os [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). Apenas para uso de avaliação, não para produção nem dados sensíveis. A NVIDIA registra prompts e saídas para melhorar seus modelos e serviços. Não envie dados pessoais ou confidenciais. - OpenAI APIs: As solicitações são retidas por 30 dias de acordo com [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data). - Anthropic APIs: As solicitações são retidas por 30 dias de acordo com [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage). diff --git a/packages/web/src/content/docs/ru/go.mdx b/packages/web/src/content/docs/ru/go.mdx index 66e929c5f4..258deecc7e 100644 --- a/packages/web/src/content/docs/ru/go.mdx +++ b/packages/web/src/content/docs/ru/go.mdx @@ -75,6 +75,7 @@ OpenCode Go работает так же, как и любой другой пр - **MiniMax M2.7** - **DeepSeek V4 Pro** - **DeepSeek V4 Flash** +- **Hy3 preview** Список моделей может меняться по мере того, как мы тестируем и добавляем новые. @@ -106,6 +107,7 @@ OpenCode Go включает следующие лимиты: | Qwen3.5 Plus | 10,200 | 25,200 | 50,500 | | DeepSeek V4 Pro | 3,450 | 8,550 | 17,150 | | DeepSeek V4 Flash | 31,650 | 79,050 | 158,150 | +| Hy3 preview | 1,875 | 4,690 | 9,375 | Оценки основаны на наблюдаемых средних показателях запросов: @@ -118,6 +120,7 @@ OpenCode Go включает следующие лимиты: - Qwen3.6 Plus — 500 входных, 57,000 кешированных, 190 выходных токенов на запрос - MiMo-V2.5 — 1000 входных, 60,000 кешированных, 140 выходных токенов на запрос - MiMo-V2.5-Pro — 350 входных, 41,000 кешированных, 250 выходных токенов на запрос +- Hy3 preview — 800 input, 50,000 cached, 400 output tokens per request Вы можете отслеживать текущее использование в **консоли**. @@ -155,6 +158,7 @@ OpenCode Go включает следующие лимиты: | MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` | | Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` | +| Hy3 preview | hy3-preview | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | [ID модели](/docs/config/#models) в вашем конфиге OpenCode использует формат `opencode-go/`. Например, для Kimi K2.6 вам нужно diff --git a/packages/web/src/content/docs/ru/zen.mdx b/packages/web/src/content/docs/ru/zen.mdx index 10c55fc4dd..b7ba2ee256 100644 --- a/packages/web/src/content/docs/ru/zen.mdx +++ b/packages/web/src/content/docs/ru/zen.mdx @@ -103,7 +103,6 @@ OpenCode Zen работает как любой другой провайдер | Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Ling 2.6 Flash | ling-2.6-flash | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Hy3 Preview Free | hy3-preview-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Nemotron 3 Super Free | nemotron-3-super-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | [идентификатор модели](/docs/config/#models) в вашей конфигурации OpenCode @@ -131,7 +130,6 @@ https://opencode.ai/zen/v1/models | Big Pickle | Free | Free | Free | - | | MiniMax M2.5 Free | Free | Free | Free | - | | Ling 2.6 Flash Free | Free | Free | Free | - | -| Hy3 Preview Free | Free | Free | Free | - | | Nemotron 3 Super Free | Free | Free | Free | - | | MiniMax M2.7 | $0.30 | $1.20 | $0.06 | $0.375 | | MiniMax M2.5 | $0.30 | $1.20 | $0.06 | $0.375 | @@ -184,7 +182,6 @@ https://opencode.ai/zen/v1/models - MiniMax M2.5 Free доступна в OpenCode ограниченное время. Команда использует это время, чтобы собирать отзывы и улучшать модель. - Ling 2.6 Flash Free доступна в OpenCode ограниченное время. Команда использует это время, чтобы собирать отзывы и улучшать модель. -- Hy3 Preview Flash Free доступна в OpenCode ограниченное время. Команда использует это время, чтобы собирать отзывы и улучшать модель. - Nemotron 3 Super Free доступна в OpenCode ограниченное время. Команда использует это время, чтобы собирать отзывы и улучшать модель. - Big Pickle — это скрытая модель, которая доступна бесплатно в OpenCode ограниченное время. Команда использует это время, чтобы собирать отзывы и улучшать модель. @@ -240,7 +237,6 @@ https://opencode.ai/zen/v1/models - Big Pickle: во время бесплатного периода собранные данные могут использоваться для улучшения модели. - MiniMax M2.5 Free: во время бесплатного периода собранные данные могут использоваться для улучшения модели. - Ling 2.6 Flash Free: во время бесплатного периода собранные данные могут использоваться для улучшения модели. -- Hy3 Preview Free: во время бесплатного периода собранные данные могут использоваться для улучшения модели. - Nemotron 3 Super Free (бесплатные эндпоинты NVIDIA): предоставляется в соответствии с [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). Только для пробного использования, не для продакшена и не для чувствительных данных. NVIDIA логирует запросы и ответы, чтобы улучшать свои модели и сервисы. Не отправляйте персональные или конфиденциальные данные. - OpenAI APIs: запросы хранятся 30 дней в соответствии с [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data). - Anthropic APIs: запросы хранятся 30 дней в соответствии с [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage). diff --git a/packages/web/src/content/docs/th/go.mdx b/packages/web/src/content/docs/th/go.mdx index 1fa0f8cc2a..ed46f70526 100644 --- a/packages/web/src/content/docs/th/go.mdx +++ b/packages/web/src/content/docs/th/go.mdx @@ -65,6 +65,7 @@ OpenCode Go ทำงานเหมือนกับผู้ให้บร - **MiniMax M2.7** - **DeepSeek V4 Pro** - **DeepSeek V4 Flash** +- **Hy3 preview** รายชื่อโมเดลอาจมีการเปลี่ยนแปลงเมื่อเราทำการทดสอบและเพิ่มโมเดลใหม่ๆ @@ -96,6 +97,7 @@ OpenCode Go มีขีดจำกัดดังต่อไปนี้: | Qwen3.5 Plus | 10,200 | 25,200 | 50,500 | | DeepSeek V4 Pro | 3,450 | 8,550 | 17,150 | | DeepSeek V4 Flash | 31,650 | 79,050 | 158,150 | +| Hy3 preview | 1,875 | 4,690 | 9,375 | การประมาณการอ้างอิงจากรูปแบบการใช้งาน request โดยเฉลี่ยที่สังเกตพบ: @@ -108,6 +110,7 @@ OpenCode Go มีขีดจำกัดดังต่อไปนี้: - Qwen3.6 Plus — 500 input, 57,000 cached, 190 output tokens ต่อ request - MiMo-V2.5 — 1000 input, 60,000 cached, 140 output tokens ต่อ request - MiMo-V2.5-Pro — 350 input, 41,000 cached, 250 output tokens ต่อ request +- Hy3 preview — 800 input, 50,000 cached, 400 output tokens per request คุณสามารถติดตามการใช้งานปัจจุบันของคุณได้ใน **console** @@ -143,6 +146,7 @@ OpenCode Go มีขีดจำกัดดังต่อไปนี้: | MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` | | Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` | +| Hy3 preview | hy3-preview | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | [model id](/docs/config/#models) ใน OpenCode config ของคุณจะใช้รูปแบบ `opencode-go/` ตัวอย่างเช่น สำหรับ Kimi K2.6 คุณจะใช้ `opencode-go/kimi-k2.6` ใน config ของคุณ diff --git a/packages/web/src/content/docs/th/zen.mdx b/packages/web/src/content/docs/th/zen.mdx index cb2556ef63..3808ed2ab6 100644 --- a/packages/web/src/content/docs/th/zen.mdx +++ b/packages/web/src/content/docs/th/zen.mdx @@ -96,7 +96,6 @@ OpenCode Zen ทำงานเหมือน provider อื่น ๆ ใน | Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Ling 2.6 Flash | ling-2.6-flash | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Hy3 Preview Free | hy3-preview-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Nemotron 3 Super Free | nemotron-3-super-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | [model id](/docs/config/#models) ใน OpenCode config ของคุณใช้รูปแบบ `opencode/` ตัวอย่างเช่น สำหรับ GPT 5.5 คุณจะใช้ `opencode/gpt-5.5` ใน config ของคุณ @@ -122,7 +121,6 @@ https://opencode.ai/zen/v1/models | Big Pickle | Free | Free | Free | - | | MiniMax M2.5 Free | Free | Free | Free | - | | Ling 2.6 Flash Free | Free | Free | Free | - | -| Hy3 Preview Free | Free | Free | Free | - | | Nemotron 3 Super Free | Free | Free | Free | - | | MiniMax M2.7 | $0.30 | $1.20 | $0.06 | $0.375 | | MiniMax M2.5 | $0.30 | $1.20 | $0.06 | $0.375 | @@ -175,7 +173,6 @@ https://opencode.ai/zen/v1/models - MiniMax M2.5 Free เปิดให้ใช้บน OpenCode ในช่วงเวลาจำกัด ทีมกำลังใช้ช่วงเวลานี้เพื่อเก็บ feedback และปรับปรุงโมเดล - Ling 2.6 Flash Free เปิดให้ใช้บน OpenCode ในช่วงเวลาจำกัด ทีมกำลังใช้ช่วงเวลานี้เพื่อเก็บ feedback และปรับปรุงโมเดล -- Hy3 Preview Free เปิดให้ใช้บน OpenCode ในช่วงเวลาจำกัด ทีมกำลังใช้ช่วงเวลานี้เพื่อเก็บ feedback และปรับปรุงโมเดล - Nemotron 3 Super Free เปิดให้ใช้บน OpenCode ในช่วงเวลาจำกัด ทีมกำลังใช้ช่วงเวลานี้เพื่อเก็บ feedback และปรับปรุงโมเดล - Big Pickle เป็น stealth model ที่ใช้งานฟรีบน OpenCode ในช่วงเวลาจำกัด ทีมกำลังใช้ช่วงเวลานี้เพื่อเก็บ feedback และปรับปรุงโมเดล @@ -228,7 +225,6 @@ https://opencode.ai/zen/v1/models - Big Pickle: ระหว่างช่วงที่เปิดให้ใช้ฟรี ข้อมูลที่เก็บรวบรวมอาจถูกนำไปใช้เพื่อปรับปรุงโมเดล - MiniMax M2.5 Free: ระหว่างช่วงที่เปิดให้ใช้ฟรี ข้อมูลที่เก็บรวบรวมอาจถูกนำไปใช้เพื่อปรับปรุงโมเดล - Ling 2.6 Flash Free: ระหว่างช่วงที่เปิดให้ใช้ฟรี ข้อมูลที่เก็บรวบรวมอาจถูกนำไปใช้เพื่อปรับปรุงโมเดล -- Hy3 Preview Free: ระหว่างช่วงที่เปิดให้ใช้ฟรี ข้อมูลที่เก็บรวบรวมอาจถูกนำไปใช้เพื่อปรับปรุงโมเดล - Nemotron 3 Super Free (endpoint ฟรีของ NVIDIA): ให้บริการภายใต้ [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf) ใช้สำหรับการทดลองเท่านั้น ไม่เหมาะสำหรับ production หรือข้อมูลที่อ่อนไหว NVIDIA จะบันทึก prompt และ output เพื่อนำไปปรับปรุงโมเดลและบริการของตน โปรดอย่าส่งข้อมูลส่วนบุคคลหรือข้อมูลลับ. - OpenAI APIs: คำขอจะถูกเก็บไว้เป็นเวลา 30 วันตาม [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data). - Anthropic APIs: คำขอจะถูกเก็บไว้เป็นเวลา 30 วันตาม [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage). diff --git a/packages/web/src/content/docs/tr/go.mdx b/packages/web/src/content/docs/tr/go.mdx index 367be5a750..57829d50c4 100644 --- a/packages/web/src/content/docs/tr/go.mdx +++ b/packages/web/src/content/docs/tr/go.mdx @@ -65,6 +65,7 @@ Mevcut model listesi şunları içerir: - **MiniMax M2.7** - **DeepSeek V4 Pro** - **DeepSeek V4 Flash** +- **Hy3 preview** Test edip yenilerini ekledikçe model listesi değişebilir. @@ -96,6 +97,7 @@ Aşağıdaki tablo, tipik Go kullanım modellerine dayalı tahmini bir istek say | Qwen3.5 Plus | 10,200 | 25,200 | 50,500 | | DeepSeek V4 Pro | 3,450 | 8,550 | 17,150 | | DeepSeek V4 Flash | 31,650 | 79,050 | 158,150 | +| Hy3 preview | 1,875 | 4,690 | 9,375 | Tahminler, gözlemlenen ortalama istek modellerine dayanmaktadır: @@ -108,6 +110,7 @@ Tahminler, gözlemlenen ortalama istek modellerine dayanmaktadır: - Qwen3.6 Plus — İstek başına 500 girdi, 57.000 önbelleğe alınmış, 190 çıktı token'ı - MiMo-V2.5 — İstek başına 1000 girdi, 60.000 önbelleğe alınmış, 140 çıktı token'ı - MiMo-V2.5-Pro — İstek başına 350 girdi, 41.000 önbelleğe alınmış, 250 çıktı token'ı +- Hy3 preview — 800 input, 50,000 cached, 400 output tokens per request Mevcut kullanımınızı **konsoldan** takip edebilirsiniz. @@ -143,6 +146,7 @@ Go modellerine aşağıdaki API uç noktaları aracılığıyla da erişebilirsi | MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` | | Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` | +| Hy3 preview | hy3-preview | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | OpenCode yapılandırmanızdaki [model id](/docs/config/#models) formatı `opencode-go/` şeklindedir. Örneğin, Kimi K2.6 için yapılandırmanızda `opencode-go/kimi-k2.6` kullanmalısınız. diff --git a/packages/web/src/content/docs/tr/zen.mdx b/packages/web/src/content/docs/tr/zen.mdx index 36c1bfc66e..37885db43b 100644 --- a/packages/web/src/content/docs/tr/zen.mdx +++ b/packages/web/src/content/docs/tr/zen.mdx @@ -94,7 +94,6 @@ Modellerimize aşağıdaki API uç noktaları aracılığıyla da erişebilirsin | Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Ling 2.6 Flash | ling-2.6-flash | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Hy3 Preview Free | hy3-preview-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Nemotron 3 Super Free | nemotron-3-super-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | OpenCode yapılandırmanızdaki [model id](/docs/config/#models) `opencode/` biçimini kullanır. Örneğin, GPT 5.5 için yapılandırmanızda `opencode/gpt-5.5` kullanırsınız. @@ -120,7 +119,6 @@ Kullandıkça öde modelini destekliyoruz. Aşağıda **1M token başına** fiya | Big Pickle | Free | Free | Free | - | | MiniMax M2.5 Free | Free | Free | Free | - | | Ling 2.6 Flash Free | Free | Free | Free | - | -| Hy3 Preview Free | Free | Free | Free | - | | Nemotron 3 Super Free | Free | Free | Free | - | | MiniMax M2.7 | $0.30 | $1.20 | $0.06 | $0.375 | | MiniMax M2.5 | $0.30 | $1.20 | $0.06 | $0.375 | @@ -173,7 +171,6 @@ Kredi kartı ücretleri maliyet üzerinden yansıtılır (%4.4 + işlem başına - MiniMax M2.5 Free, sınırlı bir süre için OpenCode'da ücretsizdir. Ekip bu süreyi geri bildirim toplamak ve modeli iyileştirmek için kullanıyor. - Ling 2.6 Flash Free, sınırlı bir süre için OpenCode'da ücretsizdir. Ekip bu süreyi geri bildirim toplamak ve modeli iyileştirmek için kullanıyor. -- Hy3 Preview Free, sınırlı bir süre için OpenCode'da ücretsizdir. Ekip bu süreyi geri bildirim toplamak ve modeli iyileştirmek için kullanıyor. - Nemotron 3 Super Free, sınırlı bir süre için OpenCode'da ücretsizdir. Ekip bu süreyi geri bildirim toplamak ve modeli iyileştirmek için kullanıyor. - Big Pickle, sınırlı bir süre için OpenCode'da ücretsiz olan gizli bir modeldir. Ekip bu süreyi geri bildirim toplamak ve modeli iyileştirmek için kullanıyor. @@ -226,7 +223,6 @@ Tüm modellerimiz US'de barındırılıyor. Sağlayıcılarımız zero-retention - Big Pickle: Ücretsiz döneminde toplanan veriler modeli iyileştirmek için kullanılabilir. - MiniMax M2.5 Free: Ücretsiz döneminde toplanan veriler modeli iyileştirmek için kullanılabilir. - Ling 2.6 Flash Free: Ücretsiz döneminde toplanan veriler modeli iyileştirmek için kullanılabilir. -- Hy3 Preview Free: Ücretsiz döneminde toplanan veriler modeli iyileştirmek için kullanılabilir. - Nemotron 3 Super Free (ücretsiz NVIDIA uç noktaları): [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf) kapsamında sunulur. Yalnızca deneme amaçlıdır; üretim veya hassas veriler için uygun değildir. NVIDIA, modellerini ve hizmetlerini geliştirmek için promptları ve çıktıları kaydeder. Kişisel veya gizli veri göndermeyin. - OpenAI APIs: İstekler [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data) uyarınca 30 gün boyunca saklanır. - Anthropic APIs: İstekler [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage) uyarınca 30 gün boyunca saklanır. diff --git a/packages/web/src/content/docs/zen.mdx b/packages/web/src/content/docs/zen.mdx index 333e74434b..fcec86cfd6 100644 --- a/packages/web/src/content/docs/zen.mdx +++ b/packages/web/src/content/docs/zen.mdx @@ -103,7 +103,6 @@ You can also access our models through the following API endpoints. | Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Ling 2.6 Flash | ling-2.6-flash | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Hy3 Preview Free | hy3-preview-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Nemotron 3 Super Free | nemotron-3-super-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | The [model id](/docs/config/#models) in your OpenCode config @@ -131,7 +130,6 @@ We support a pay-as-you-go model. Below are the prices **per 1M tokens**. | Big Pickle | Free | Free | Free | - | | MiniMax M2.5 Free | Free | Free | Free | - | | Ling 2.6 Flash Free | Free | Free | Free | - | -| Hy3 Preview Free | Free | Free | Free | - | | Nemotron 3 Super Free | Free | Free | Free | - | | MiniMax M2.7 | $0.30 | $1.20 | $0.06 | $0.375 | | MiniMax M2.5 | $0.30 | $1.20 | $0.06 | $0.375 | @@ -184,7 +182,6 @@ The free models: - MiniMax M2.5 Free is available on OpenCode for a limited time. The team is using this time to collect feedback and improve the model. - Ling 2.6 Flash Free is available on OpenCode for a limited time. The team is using this time to collect feedback and improve the model. -- Hy3 Preview Flash Free is available on OpenCode for a limited time. The team is using this time to collect feedback and improve the model. - Nemotron 3 Super Free is available on OpenCode for a limited time. The team is using this time to collect feedback and improve the model. - Big Pickle is a stealth model that's free on OpenCode for a limited time. The team is using this time to collect feedback and improve the model. @@ -240,7 +237,6 @@ All our models are hosted in the US. Our providers follow a zero-retention polic - Big Pickle: During its free period, collected data may be used to improve the model. - MiniMax M2.5 Free: During its free period, collected data may be used to improve the model. - Ling 2.6 Flash Free: During its free period, collected data may be used to improve the model. -- Hy3 Preview Free: During its free period, collected data may be used to improve the model. - Nemotron 3 Super Free (NVIDIA free endpoints): Provided under the [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). Trial use only — not for production or sensitive data. Prompts and outputs are logged by NVIDIA to improve its models and services. Do not submit personal or confidential data. - OpenAI APIs: Requests are retained for 30 days in accordance with [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data). - Anthropic APIs: Requests are retained for 30 days in accordance with [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage). diff --git a/packages/web/src/content/docs/zh-cn/go.mdx b/packages/web/src/content/docs/zh-cn/go.mdx index 17934ee2a0..91287d3747 100644 --- a/packages/web/src/content/docs/zh-cn/go.mdx +++ b/packages/web/src/content/docs/zh-cn/go.mdx @@ -65,6 +65,7 @@ OpenCode Go 的工作方式与 OpenCode 中的其他提供商一样。 - **MiniMax M2.7** - **DeepSeek V4 Pro** - **DeepSeek V4 Flash** +- **Hy3 preview** 随着我们进行测试和添加新模型,该列表可能会发生变化。 @@ -96,6 +97,7 @@ OpenCode Go 包含以下限制: | Qwen3.5 Plus | 10,200 | 25,200 | 50,500 | | DeepSeek V4 Pro | 3,450 | 8,550 | 17,150 | | DeepSeek V4 Flash | 31,650 | 79,050 | 158,150 | +| Hy3 preview | 1,875 | 4,690 | 9,375 | 预估值基于观察到的平均请求模式: @@ -108,6 +110,7 @@ OpenCode Go 包含以下限制: - MiniMax M2.7/M2.5 — 每次请求 300 个输入 token,55,000 个缓存 token,125 个输出 token - Qwen3.5 Plus — 每次请求 410 个输入 token,47,000 个缓存 token,140 个输出 token - Qwen3.6 Plus — 每次请求 500 个输入 token,57,000 个缓存 token,190 个输出 token +- Hy3 preview — 800 input, 50,000 cached, 400 output tokens per request 你可以在 **控制台** 中跟踪你当前的使用情况。 @@ -143,6 +146,7 @@ OpenCode Go 包含以下限制: | MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` | | Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` | +| Hy3 preview | hy3-preview | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | 你 OpenCode 配置中的 [模型 ID](/docs/config/#models) 使用 `opencode-go/` 格式。例如,对于 Kimi K2.6,你将在配置中使用 `opencode-go/kimi-k2.6`。 diff --git a/packages/web/src/content/docs/zh-cn/zen.mdx b/packages/web/src/content/docs/zh-cn/zen.mdx index 9ad7e6b53d..1ba577ab2a 100644 --- a/packages/web/src/content/docs/zh-cn/zen.mdx +++ b/packages/web/src/content/docs/zh-cn/zen.mdx @@ -94,7 +94,6 @@ OpenCode Zen 的工作方式与 OpenCode 中的任何其他提供商相同。 | Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Ling 2.6 Flash | ling-2.6-flash | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Hy3 Preview Free | hy3-preview-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Nemotron 3 Super Free | nemotron-3-super-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | 在你的 OpenCode 配置中,[模型 ID](/docs/config/#models) 使用 `opencode/` 格式。例如,对于 GPT 5.5,你需要在配置中使用 `opencode/gpt-5.5`。 @@ -120,7 +119,6 @@ https://opencode.ai/zen/v1/models | Big Pickle | Free | Free | Free | - | | MiniMax M2.5 Free | Free | Free | Free | - | | Ling 2.6 Flash Free | Free | Free | Free | - | -| Hy3 Preview Free | Free | Free | Free | - | | Nemotron 3 Super Free | Free | Free | Free | - | | MiniMax M2.7 | $0.30 | $1.20 | $0.06 | $0.375 | | MiniMax M2.5 | $0.30 | $1.20 | $0.06 | $0.375 | @@ -173,7 +171,6 @@ https://opencode.ai/zen/v1/models - MiniMax M2.5 Free 目前在 OpenCode 上限时免费提供。团队正在利用这段时间收集反馈并改进模型。 - Ling 2.6 Flash Free 目前在 OpenCode 上限时免费提供。团队正在利用这段时间收集反馈并改进模型。 -- Hy3 Preview Flash Free 目前在 OpenCode 上限时免费提供。团队正在利用这段时间收集反馈并改进模型。 - Nemotron 3 Super Free 目前在 OpenCode 上限时免费提供。团队正在利用这段时间收集反馈并改进模型。 - Big Pickle 是一个隐身模型,目前在 OpenCode 上限时免费提供。团队正在利用这段时间收集反馈并改进模型。 @@ -226,7 +223,6 @@ https://opencode.ai/zen/v1/models - Big Pickle:在免费期间,收集的数据可能会被用于改进模型。 - MiniMax M2.5 Free:在免费期间,收集的数据可能会被用于改进模型。 - Ling 2.6 Flash Free:在免费期间,收集的数据可能会被用于改进模型。 -- Hy3 Preview Free:在免费期间,收集的数据可能会被用于改进模型。 - Nemotron 3 Super Free(NVIDIA 免费端点):根据 [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf) 提供。仅供试用,不适用于生产环境或敏感数据。NVIDIA 会记录提示词和输出内容,以改进其模型和服务。请勿提交个人或机密数据。 - OpenAI APIs:请求会根据 [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data) 保留 30 天。 - Anthropic APIs:请求会根据 [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage) 保留 30 天。 diff --git a/packages/web/src/content/docs/zh-tw/go.mdx b/packages/web/src/content/docs/zh-tw/go.mdx index c4589716f2..0a11d8a05a 100644 --- a/packages/web/src/content/docs/zh-tw/go.mdx +++ b/packages/web/src/content/docs/zh-tw/go.mdx @@ -65,6 +65,7 @@ OpenCode Go 的運作方式與 OpenCode 中的任何其他供應商相同。 - **MiniMax M2.7** - **DeepSeek V4 Pro** - **DeepSeek V4 Flash** +- **Hy3 preview** 隨著我們測試並加入新模型,模型清單可能會有所變動。 @@ -96,6 +97,7 @@ OpenCode Go 包含以下限制: | Qwen3.5 Plus | 10,200 | 25,200 | 50,500 | | DeepSeek V4 Pro | 3,450 | 8,550 | 17,150 | | DeepSeek V4 Flash | 31,650 | 79,050 | 158,150 | +| Hy3 preview | 1,875 | 4,690 | 9,375 | 預估值是基於觀察到的平均請求模式: @@ -108,6 +110,7 @@ OpenCode Go 包含以下限制: - Qwen3.6 Plus — 每次請求 500 個輸入 token、57,000 個快取 token、190 個輸出 token - MiMo-V2.5 — 每次請求 1000 個輸入 token、60,000 個快取 token、140 個輸出 token - MiMo-V2.5-Pro — 每次請求 350 個輸入 token、41,000 個快取 token、250 個輸出 token +- Hy3 preview — 800 input, 50,000 cached, 400 output tokens per request 您可以在 **console** 中追蹤您目前的使用量。 @@ -143,6 +146,7 @@ OpenCode Go 包含以下限制: | MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` | | Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` | +| Hy3 preview | hy3-preview | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | 您的 OpenCode 設定中的 [model id](/docs/config/#models) 使用 `opencode-go/` 格式。例如,Kimi K2.6 在設定中應使用 `opencode-go/kimi-k2.6`。 diff --git a/packages/web/src/content/docs/zh-tw/zen.mdx b/packages/web/src/content/docs/zh-tw/zen.mdx index 9511bd9e24..a81f835237 100644 --- a/packages/web/src/content/docs/zh-tw/zen.mdx +++ b/packages/web/src/content/docs/zh-tw/zen.mdx @@ -98,7 +98,6 @@ OpenCode Zen 的運作方式和 OpenCode 中的其他供應商一樣。 | Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Ling 2.6 Flash | ling-2.6-flash | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Hy3 Preview Free | hy3-preview-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Nemotron 3 Super Free | nemotron-3-super-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | OpenCode 設定中的 [模型 ID](/docs/config/#models) 會使用 `opencode/` @@ -125,7 +124,6 @@ https://opencode.ai/zen/v1/models | Big Pickle | Free | Free | Free | - | | MiniMax M2.5 Free | Free | Free | Free | - | | Ling 2.6 Flash Free | Free | Free | Free | - | -| Hy3 Preview Free | Free | Free | Free | - | | Nemotron 3 Super Free | Free | Free | Free | - | | MiniMax M2.7 | $0.30 | $1.20 | $0.06 | $0.375 | | MiniMax M2.5 | $0.30 | $1.20 | $0.06 | $0.375 | @@ -179,7 +177,6 @@ https://opencode.ai/zen/v1/models - MiniMax M2.5 Free 在 OpenCode 上限時提供。團隊正在利用這段時間收集回饋並改進模型。 - Ling 2.6 Flash Free 在 OpenCode 上限時提供。團隊正在利用這段時間收集回饋並改進模型。 -- Hy3 Preview Free 在 OpenCode 上限時提供。團隊正在利用這段時間收集回饋並改進模型。 - Nemotron 3 Super Free 在 OpenCode 上限時提供。團隊正在利用這段時間收集回饋並改進模型。 - Big Pickle 是一個隱身模型,在 OpenCode 上限時免費提供。團隊正在利用這段時間收集回饋並改進模型。 @@ -233,7 +230,6 @@ https://opencode.ai/zen/v1/models - Big Pickle: 在免費期間,收集到的資料可能會用於改進模型。 - MiniMax M2.5 Free: 在免費期間,收集到的資料可能會用於改進模型。 - Ling 2.6 Flash Free: 在免費期間,收集到的資料可能會用於改進模型。 -- Hy3 Preview Free: 在免費期間,收集到的資料可能會用於改進模型。 - Nemotron 3 Super Free(NVIDIA 免費端點):依據 [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf) 提供。僅供試用,不適用於正式環境或敏感資料。NVIDIA 會記錄提示詞與輸出內容,以改進其模型與服務。請勿提交個人或機密資料。 - OpenAI APIs: 請求會依據 [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data) 保留 30 天。 - Anthropic APIs: 請求會依據 [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage) 保留 30 天。 From d9150413cb941d07787fc10d3df10a806a144721 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sun, 10 May 2026 06:24:35 +0000 Subject: [PATCH 117/189] chore: generate --- packages/web/src/content/docs/ar/go.mdx | 2 +- packages/web/src/content/docs/bs/go.mdx | 2 +- packages/web/src/content/docs/da/go.mdx | 2 +- packages/web/src/content/docs/de/go.mdx | 2 +- packages/web/src/content/docs/es/go.mdx | 2 +- packages/web/src/content/docs/fr/go.mdx | 2 +- packages/web/src/content/docs/go.mdx | 2 +- packages/web/src/content/docs/it/go.mdx | 2 +- packages/web/src/content/docs/ja/go.mdx | 2 +- packages/web/src/content/docs/ko/go.mdx | 2 +- packages/web/src/content/docs/nb/go.mdx | 2 +- packages/web/src/content/docs/pl/go.mdx | 2 +- packages/web/src/content/docs/pt-br/go.mdx | 2 +- packages/web/src/content/docs/ru/go.mdx | 2 +- packages/web/src/content/docs/th/go.mdx | 2 +- packages/web/src/content/docs/tr/go.mdx | 2 +- packages/web/src/content/docs/zh-cn/go.mdx | 2 +- packages/web/src/content/docs/zh-tw/go.mdx | 2 +- 18 files changed, 18 insertions(+), 18 deletions(-) diff --git a/packages/web/src/content/docs/ar/go.mdx b/packages/web/src/content/docs/ar/go.mdx index d65e63f9b6..5eb0e477c9 100644 --- a/packages/web/src/content/docs/ar/go.mdx +++ b/packages/web/src/content/docs/ar/go.mdx @@ -97,7 +97,7 @@ OpenCode Go حاليًا في المرحلة التجريبية. | Qwen3.5 Plus | 10,200 | 25,200 | 50,500 | | DeepSeek V4 Pro | 3,450 | 8,550 | 17,150 | | DeepSeek V4 Flash | 31,650 | 79,050 | 158,150 | -| Hy3 preview | 1,875 | 4,690 | 9,375 | +| Hy3 preview | 1,875 | 4,690 | 9,375 | تستند التقديرات إلى متوسطات أنماط الطلبات المرصودة: diff --git a/packages/web/src/content/docs/bs/go.mdx b/packages/web/src/content/docs/bs/go.mdx index 9886a10840..d5b46cd660 100644 --- a/packages/web/src/content/docs/bs/go.mdx +++ b/packages/web/src/content/docs/bs/go.mdx @@ -107,7 +107,7 @@ Tabela ispod pruža procijenjeni broj zahtjeva na osnovu tipičnih obrazaca kori | Qwen3.5 Plus | 10,200 | 25,200 | 50,500 | | DeepSeek V4 Pro | 3,450 | 8,550 | 17,150 | | DeepSeek V4 Flash | 31,650 | 79,050 | 158,150 | -| Hy3 preview | 1,875 | 4,690 | 9,375 | +| Hy3 preview | 1,875 | 4,690 | 9,375 | Procjene se zasnivaju na zapaženim prosječnim obrascima zahtjeva: diff --git a/packages/web/src/content/docs/da/go.mdx b/packages/web/src/content/docs/da/go.mdx index d57c44b71b..bc40325437 100644 --- a/packages/web/src/content/docs/da/go.mdx +++ b/packages/web/src/content/docs/da/go.mdx @@ -107,7 +107,7 @@ Tabellen nedenfor giver et estimeret antal anmodninger baseret på typiske Go-fo | Qwen3.5 Plus | 10,200 | 25,200 | 50,500 | | DeepSeek V4 Pro | 3,450 | 8,550 | 17,150 | | DeepSeek V4 Flash | 31,650 | 79,050 | 158,150 | -| Hy3 preview | 1,875 | 4,690 | 9,375 | +| Hy3 preview | 1,875 | 4,690 | 9,375 | Estimaterne er baseret på observerede gennemsnitlige anmodningsmønstre: diff --git a/packages/web/src/content/docs/de/go.mdx b/packages/web/src/content/docs/de/go.mdx index 087f2bbaa0..9434d39bcd 100644 --- a/packages/web/src/content/docs/de/go.mdx +++ b/packages/web/src/content/docs/de/go.mdx @@ -99,7 +99,7 @@ Die folgende Tabelle zeigt eine geschätzte Anzahl von Anfragen basierend auf ty | Qwen3.5 Plus | 10,200 | 25,200 | 50,500 | | DeepSeek V4 Pro | 3,450 | 8,550 | 17,150 | | DeepSeek V4 Flash | 31,650 | 79,050 | 158,150 | -| Hy3 preview | 1,875 | 4,690 | 9,375 | +| Hy3 preview | 1,875 | 4,690 | 9,375 | Die Schätzungen basieren auf beobachteten durchschnittlichen Anfragemustern: diff --git a/packages/web/src/content/docs/es/go.mdx b/packages/web/src/content/docs/es/go.mdx index 5eff59afc2..ba8d100981 100644 --- a/packages/web/src/content/docs/es/go.mdx +++ b/packages/web/src/content/docs/es/go.mdx @@ -107,7 +107,7 @@ La siguiente tabla proporciona una cantidad estimada de peticiones basada en los | Qwen3.5 Plus | 10,200 | 25,200 | 50,500 | | DeepSeek V4 Pro | 3,450 | 8,550 | 17,150 | | DeepSeek V4 Flash | 31,650 | 79,050 | 158,150 | -| Hy3 preview | 1,875 | 4,690 | 9,375 | +| Hy3 preview | 1,875 | 4,690 | 9,375 | Las estimaciones se basan en los patrones de peticiones promedio observados: diff --git a/packages/web/src/content/docs/fr/go.mdx b/packages/web/src/content/docs/fr/go.mdx index 97caa705db..ebf034f210 100644 --- a/packages/web/src/content/docs/fr/go.mdx +++ b/packages/web/src/content/docs/fr/go.mdx @@ -97,7 +97,7 @@ Le tableau ci-dessous fournit une estimation du nombre de requêtes basée sur d | Qwen3.5 Plus | 10,200 | 25,200 | 50,500 | | DeepSeek V4 Pro | 3,450 | 8,550 | 17,150 | | DeepSeek V4 Flash | 31,650 | 79,050 | 158,150 | -| Hy3 preview | 1,875 | 4,690 | 9,375 | +| Hy3 preview | 1,875 | 4,690 | 9,375 | Les estimations sont basées sur les modèles de requêtes moyens observés : diff --git a/packages/web/src/content/docs/go.mdx b/packages/web/src/content/docs/go.mdx index a99c67cf30..54ba49ff2e 100644 --- a/packages/web/src/content/docs/go.mdx +++ b/packages/web/src/content/docs/go.mdx @@ -107,7 +107,7 @@ The table below provides an estimated request count based on typical Go usage pa | Qwen3.5 Plus | 10,200 | 25,200 | 50,500 | | DeepSeek V4 Pro | 3,450 | 8,550 | 17,150 | | DeepSeek V4 Flash | 31,650 | 79,050 | 158,150 | -| Hy3 preview | 1,875 | 4,690 | 9,375 | +| Hy3 preview | 1,875 | 4,690 | 9,375 | Estimates are based on observed average request patterns: diff --git a/packages/web/src/content/docs/it/go.mdx b/packages/web/src/content/docs/it/go.mdx index 9561874dc2..c6d3bf3301 100644 --- a/packages/web/src/content/docs/it/go.mdx +++ b/packages/web/src/content/docs/it/go.mdx @@ -105,7 +105,7 @@ La tabella seguente fornisce una stima del conteggio delle richieste in base a p | Qwen3.5 Plus | 10,200 | 25,200 | 50,500 | | DeepSeek V4 Pro | 3,450 | 8,550 | 17,150 | | DeepSeek V4 Flash | 31,650 | 79,050 | 158,150 | -| Hy3 preview | 1,875 | 4,690 | 9,375 | +| Hy3 preview | 1,875 | 4,690 | 9,375 | Le stime si basano sui pattern medi di richieste osservati: diff --git a/packages/web/src/content/docs/ja/go.mdx b/packages/web/src/content/docs/ja/go.mdx index 795905b337..f13c0680f7 100644 --- a/packages/web/src/content/docs/ja/go.mdx +++ b/packages/web/src/content/docs/ja/go.mdx @@ -97,7 +97,7 @@ OpenCode Goには以下の制限が含まれています: | Qwen3.5 Plus | 10,200 | 25,200 | 50,500 | | DeepSeek V4 Pro | 3,450 | 8,550 | 17,150 | | DeepSeek V4 Flash | 31,650 | 79,050 | 158,150 | -| Hy3 preview | 1,875 | 4,690 | 9,375 | +| Hy3 preview | 1,875 | 4,690 | 9,375 | 推定値は、観測された平均的なリクエストパターンに基づいています: diff --git a/packages/web/src/content/docs/ko/go.mdx b/packages/web/src/content/docs/ko/go.mdx index 3d63387173..e21ae11826 100644 --- a/packages/web/src/content/docs/ko/go.mdx +++ b/packages/web/src/content/docs/ko/go.mdx @@ -97,7 +97,7 @@ OpenCode Go에는 다음과 같은 한도가 포함됩니다. | Qwen3.5 Plus | 10,200 | 25,200 | 50,500 | | DeepSeek V4 Pro | 3,450 | 8,550 | 17,150 | | DeepSeek V4 Flash | 31,650 | 79,050 | 158,150 | -| Hy3 preview | 1,875 | 4,690 | 9,375 | +| Hy3 preview | 1,875 | 4,690 | 9,375 | 예상치는 관찰된 평균 요청 패턴을 기준으로 합니다. diff --git a/packages/web/src/content/docs/nb/go.mdx b/packages/web/src/content/docs/nb/go.mdx index eefc224d92..a358da75fc 100644 --- a/packages/web/src/content/docs/nb/go.mdx +++ b/packages/web/src/content/docs/nb/go.mdx @@ -107,7 +107,7 @@ Tabellen nedenfor gir et estimert antall forespørsler basert på typiske bruksm | Qwen3.5 Plus | 10,200 | 25,200 | 50,500 | | DeepSeek V4 Pro | 3,450 | 8,550 | 17,150 | | DeepSeek V4 Flash | 31,650 | 79,050 | 158,150 | -| Hy3 preview | 1,875 | 4,690 | 9,375 | +| Hy3 preview | 1,875 | 4,690 | 9,375 | Estimatene er basert på observerte gjennomsnittlige forespørselsmønstre: diff --git a/packages/web/src/content/docs/pl/go.mdx b/packages/web/src/content/docs/pl/go.mdx index 0d9d7884d0..3e6624a240 100644 --- a/packages/web/src/content/docs/pl/go.mdx +++ b/packages/web/src/content/docs/pl/go.mdx @@ -101,7 +101,7 @@ Poniższa tabela przedstawia szacunkową liczbę żądań na podstawie typowych | Qwen3.5 Plus | 10,200 | 25,200 | 50,500 | | DeepSeek V4 Pro | 3,450 | 8,550 | 17,150 | | DeepSeek V4 Flash | 31,650 | 79,050 | 158,150 | -| Hy3 preview | 1,875 | 4,690 | 9,375 | +| Hy3 preview | 1,875 | 4,690 | 9,375 | Szacunki opierają się na zaobserwowanych średnich wzorcach żądań: diff --git a/packages/web/src/content/docs/pt-br/go.mdx b/packages/web/src/content/docs/pt-br/go.mdx index 247f5b54ff..f4d2912c9e 100644 --- a/packages/web/src/content/docs/pt-br/go.mdx +++ b/packages/web/src/content/docs/pt-br/go.mdx @@ -107,7 +107,7 @@ A tabela abaixo fornece uma contagem estimada de requisições com base nos padr | Qwen3.5 Plus | 10,200 | 25,200 | 50,500 | | DeepSeek V4 Pro | 3,450 | 8,550 | 17,150 | | DeepSeek V4 Flash | 31,650 | 79,050 | 158,150 | -| Hy3 preview | 1,875 | 4,690 | 9,375 | +| Hy3 preview | 1,875 | 4,690 | 9,375 | As estimativas baseiam-se nos padrões médios de requisições observados: diff --git a/packages/web/src/content/docs/ru/go.mdx b/packages/web/src/content/docs/ru/go.mdx index 258deecc7e..6fccf9120e 100644 --- a/packages/web/src/content/docs/ru/go.mdx +++ b/packages/web/src/content/docs/ru/go.mdx @@ -107,7 +107,7 @@ OpenCode Go включает следующие лимиты: | Qwen3.5 Plus | 10,200 | 25,200 | 50,500 | | DeepSeek V4 Pro | 3,450 | 8,550 | 17,150 | | DeepSeek V4 Flash | 31,650 | 79,050 | 158,150 | -| Hy3 preview | 1,875 | 4,690 | 9,375 | +| Hy3 preview | 1,875 | 4,690 | 9,375 | Оценки основаны на наблюдаемых средних показателях запросов: diff --git a/packages/web/src/content/docs/th/go.mdx b/packages/web/src/content/docs/th/go.mdx index ed46f70526..5e461baf7c 100644 --- a/packages/web/src/content/docs/th/go.mdx +++ b/packages/web/src/content/docs/th/go.mdx @@ -97,7 +97,7 @@ OpenCode Go มีขีดจำกัดดังต่อไปนี้: | Qwen3.5 Plus | 10,200 | 25,200 | 50,500 | | DeepSeek V4 Pro | 3,450 | 8,550 | 17,150 | | DeepSeek V4 Flash | 31,650 | 79,050 | 158,150 | -| Hy3 preview | 1,875 | 4,690 | 9,375 | +| Hy3 preview | 1,875 | 4,690 | 9,375 | การประมาณการอ้างอิงจากรูปแบบการใช้งาน request โดยเฉลี่ยที่สังเกตพบ: diff --git a/packages/web/src/content/docs/tr/go.mdx b/packages/web/src/content/docs/tr/go.mdx index 57829d50c4..b065658d09 100644 --- a/packages/web/src/content/docs/tr/go.mdx +++ b/packages/web/src/content/docs/tr/go.mdx @@ -97,7 +97,7 @@ Aşağıdaki tablo, tipik Go kullanım modellerine dayalı tahmini bir istek say | Qwen3.5 Plus | 10,200 | 25,200 | 50,500 | | DeepSeek V4 Pro | 3,450 | 8,550 | 17,150 | | DeepSeek V4 Flash | 31,650 | 79,050 | 158,150 | -| Hy3 preview | 1,875 | 4,690 | 9,375 | +| Hy3 preview | 1,875 | 4,690 | 9,375 | Tahminler, gözlemlenen ortalama istek modellerine dayanmaktadır: diff --git a/packages/web/src/content/docs/zh-cn/go.mdx b/packages/web/src/content/docs/zh-cn/go.mdx index 91287d3747..195073a998 100644 --- a/packages/web/src/content/docs/zh-cn/go.mdx +++ b/packages/web/src/content/docs/zh-cn/go.mdx @@ -97,7 +97,7 @@ OpenCode Go 包含以下限制: | Qwen3.5 Plus | 10,200 | 25,200 | 50,500 | | DeepSeek V4 Pro | 3,450 | 8,550 | 17,150 | | DeepSeek V4 Flash | 31,650 | 79,050 | 158,150 | -| Hy3 preview | 1,875 | 4,690 | 9,375 | +| Hy3 preview | 1,875 | 4,690 | 9,375 | 预估值基于观察到的平均请求模式: diff --git a/packages/web/src/content/docs/zh-tw/go.mdx b/packages/web/src/content/docs/zh-tw/go.mdx index 0a11d8a05a..37a24caf8c 100644 --- a/packages/web/src/content/docs/zh-tw/go.mdx +++ b/packages/web/src/content/docs/zh-tw/go.mdx @@ -97,7 +97,7 @@ OpenCode Go 包含以下限制: | Qwen3.5 Plus | 10,200 | 25,200 | 50,500 | | DeepSeek V4 Pro | 3,450 | 8,550 | 17,150 | | DeepSeek V4 Flash | 31,650 | 79,050 | 158,150 | -| Hy3 preview | 1,875 | 4,690 | 9,375 | +| Hy3 preview | 1,875 | 4,690 | 9,375 | 預估值是基於觀察到的平均請求模式: From 3a7f617098e89076fd22ef2b6bfe777912ce43dd Mon Sep 17 00:00:00 2001 From: Frank Date: Sun, 10 May 2026 02:37:48 -0400 Subject: [PATCH 118/189] go: add tencent icon --- packages/console/app/src/component/icon.tsx | 14 ++++++++++++++ packages/console/app/src/routes/go/index.tsx | 6 ++++-- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/packages/console/app/src/component/icon.tsx b/packages/console/app/src/component/icon.tsx index ed83a1934b..bf38b93447 100644 --- a/packages/console/app/src/component/icon.tsx +++ b/packages/console/app/src/component/icon.tsx @@ -212,6 +212,20 @@ export function IconDeepSeek(props: JSX.SvgSVGAttributes) { ) } +export function IconHunyuan(props: JSX.SvgSVGAttributes) { + return ( + + + + ) +} + export function IconMiMo(props: JSX.SvgSVGAttributes) { return ( diff --git a/packages/console/app/src/routes/go/index.tsx b/packages/console/app/src/routes/go/index.tsx index 06529907dc..aea2ef61a9 100644 --- a/packages/console/app/src/routes/go/index.tsx +++ b/packages/console/app/src/routes/go/index.tsx @@ -12,7 +12,7 @@ import { Footer } from "~/component/footer" import { Header } from "~/component/header" import { config } from "~/config" import { getLastSeenWorkspaceID } from "../workspace/common" -import { IconMiniMax, IconMiMo, IconZai, IconAlibaba, IconDeepSeek } from "~/component/icon" +import { IconMiniMax, IconMiMo, IconZai, IconAlibaba, IconDeepSeek, IconHunyuan } from "~/component/icon" import { useI18n } from "~/context/i18n" import { useLanguage } from "~/context/language" import { LocaleLinks } from "~/component/locale-links" @@ -64,7 +64,6 @@ function LimitsGraph(props: { href: string }) { { id: "glm-5.1", name: "GLM-5.1", req: 880, d: "100ms" }, { id: "kimi-k2.6", name: "Kimi K2.6", req: 1150, d: "150ms" }, { id: "mimo-v2.5-pro", name: "MiMo-V2.5-Pro", req: 1290, d: "150ms" }, - { id: "hy3-preview", name: "Hy3 preview", req: 1875, d: "180ms" }, { id: "qwen3.6-plus", name: "Qwen3.6 Plus", req: 3300, d: "280ms" }, { id: "minimax-m2.7", name: "MiniMax M2.7", req: 3400, d: "300ms" }, { id: "deepseek-v4-pro", name: "DeepSeek V4 Pro", req: 3450, d: "200ms" }, @@ -328,6 +327,9 @@ export default function Home() {
    +
    + +
    {/*
    From 5217e6c1af3a4ecb77eb3e727596f20f40534c9b Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sun, 10 May 2026 06:39:09 +0000 Subject: [PATCH 119/189] chore: generate --- packages/console/app/src/component/icon.tsx | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/packages/console/app/src/component/icon.tsx b/packages/console/app/src/component/icon.tsx index bf38b93447..5ce96a0028 100644 --- a/packages/console/app/src/component/icon.tsx +++ b/packages/console/app/src/component/icon.tsx @@ -214,13 +214,7 @@ export function IconDeepSeek(props: JSX.SvgSVGAttributes) { export function IconHunyuan(props: JSX.SvgSVGAttributes) { return ( - + ) From 85ce6a5f95e47fe0c683100113c495b55e6c069c Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Sun, 10 May 2026 01:48:19 -0500 Subject: [PATCH 120/189] feat: better image handling (auto resize & max size constraints) (#26401) --- AGENTS.md | 1 + bun.lock | 4 + package.json | 1 + packages/opencode/package.json | 1 + packages/opencode/src/audio.d.ts | 5 + packages/opencode/src/config/attachment.ts | 30 +++ packages/opencode/src/config/config.ts | 4 + packages/opencode/src/image/image.ts | 180 ++++++++++++++++++ .../instance/httpapi/handlers/session.ts | 42 ++-- packages/opencode/src/session/processor.ts | 40 +++- packages/opencode/src/session/prompt.ts | 21 +- packages/opencode/test/image/image.test.ts | 78 ++++++++ .../opencode/test/session/compaction.test.ts | 3 +- .../test/session/processor-effect.test.ts | 3 +- packages/opencode/test/session/prompt.test.ts | 4 +- .../test/session/snapshot-tool-race.test.ts | 8 +- packages/sdk/js/src/v2/gen/types.gen.ts | 12 ++ .../@silvia-odwyer%2Fphoton-node@0.3.4.patch | 14 ++ 18 files changed, 417 insertions(+), 34 deletions(-) create mode 100644 packages/opencode/src/config/attachment.ts create mode 100644 packages/opencode/src/image/image.ts create mode 100644 packages/opencode/test/image/image.test.ts create mode 100644 patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch diff --git a/AGENTS.md b/AGENTS.md index 44d08ae955..7913ddabd2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,6 +9,7 @@ ### General Principles - Keep things in one function unless composable or reusable +- Do not extract single-use helpers preemptively. Inline the logic at the call site unless the helper is reused, hides a genuinely complex boundary, or has a clear independent name that improves the caller. - Avoid `try`/`catch` where possible - Avoid using the `any` type - Use Bun APIs when possible, like `Bun.file()` diff --git a/bun.lock b/bun.lock index 9dcad8f126..d98f1b2456 100644 --- a/bun.lock +++ b/bun.lock @@ -412,6 +412,7 @@ "@opentui/solid": "catalog:", "@parcel/watcher": "2.5.1", "@pierre/diffs": "catalog:", + "@silvia-odwyer/photon-node": "0.3.4", "@solid-primitives/event-bus": "1.1.2", "@solid-primitives/scheduled": "1.5.2", "@standard-schema/spec": "1.0.0", @@ -677,6 +678,7 @@ "solid-js@1.9.10": "patches/solid-js@1.9.10.patch", "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch", "@npmcli/agent@4.0.0": "patches/@npmcli%2Fagent@4.0.0.patch", + "@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch", }, "overrides": { "@types/bun": "catalog:", @@ -2035,6 +2037,8 @@ "@sigstore/verify": ["@sigstore/verify@3.1.0", "", { "dependencies": { "@sigstore/bundle": "^4.0.0", "@sigstore/core": "^3.1.0", "@sigstore/protobuf-specs": "^0.5.0" } }, "sha512-mNe0Iigql08YupSOGv197YdHpPPr+EzDZmfCgMc7RPNaZTw5aLN01nBl6CHJOh3BGtnMIj83EeN4butBchc8Ag=="], + "@silvia-odwyer/photon-node": ["@silvia-odwyer/photon-node@0.3.4", "", {}, "sha512-bnly4BKB3KDTFxrUIcgCLbaeVVS8lrAkri1pEzskpmxu9MdfGQTy8b8EgcD83ywD3RPMsIulY8xJH5Awa+t9fA=="], + "@sindresorhus/is": ["@sindresorhus/is@4.6.0", "", {}, "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw=="], "@slack/bolt": ["@slack/bolt@3.22.0", "", { "dependencies": { "@slack/logger": "^4.0.0", "@slack/oauth": "^2.6.3", "@slack/socket-mode": "^1.3.6", "@slack/types": "^2.13.0", "@slack/web-api": "^6.13.0", "@types/express": "^4.16.1", "@types/promise.allsettled": "^1.0.3", "@types/tsscmp": "^1.0.0", "axios": "^1.7.4", "express": "^4.21.0", "path-to-regexp": "^8.1.0", "promise.allsettled": "^1.0.2", "raw-body": "^2.3.3", "tsscmp": "^1.0.6" } }, "sha512-iKDqGPEJDnrVwxSVlFW6OKTkijd7s4qLBeSufoBsTM0reTyfdp/5izIQVkxNfzjHi3o6qjdYbRXkYad5HBsBog=="], diff --git a/package.json b/package.json index 27a3597553..5faf8be920 100644 --- a/package.json +++ b/package.json @@ -133,6 +133,7 @@ }, "patchedDependencies": { "@npmcli/agent@4.0.0": "patches/@npmcli%2Fagent@4.0.0.patch", + "@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch", "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch", "solid-js@1.9.10": "patches/solid-js@1.9.10.patch" } diff --git a/packages/opencode/package.json b/packages/opencode/package.json index dbd766f934..f6977296f9 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -119,6 +119,7 @@ "@opentui/solid": "catalog:", "@parcel/watcher": "2.5.1", "@pierre/diffs": "catalog:", + "@silvia-odwyer/photon-node": "0.3.4", "@solid-primitives/event-bus": "1.1.2", "@solid-primitives/scheduled": "1.5.2", "@standard-schema/spec": "1.0.0", diff --git a/packages/opencode/src/audio.d.ts b/packages/opencode/src/audio.d.ts index 54a86efa30..c7c947450d 100644 --- a/packages/opencode/src/audio.d.ts +++ b/packages/opencode/src/audio.d.ts @@ -2,3 +2,8 @@ declare module "*.wav" { const file: string export default file } + +declare module "*.wasm" { + const file: string + export default file +} diff --git a/packages/opencode/src/config/attachment.ts b/packages/opencode/src/config/attachment.ts new file mode 100644 index 0000000000..7af429afde --- /dev/null +++ b/packages/opencode/src/config/attachment.ts @@ -0,0 +1,30 @@ +export * as ConfigAttachment from "./attachment" + +import { Schema } from "effect" +import { zod } from "@opencode-ai/core/effect-zod" +import { PositiveInt, withStatics } from "@opencode-ai/core/schema" + +export const Image = Schema.Struct({ + auto_resize: Schema.optional(Schema.Boolean).annotate({ + description: "Resize images before sending them to the model when they exceed configured limits (default: true)", + }), + max_width: Schema.optional(PositiveInt).annotate({ + description: "Maximum image width before resizing or rejecting the attachment (default: 2000)", + }), + max_height: Schema.optional(PositiveInt).annotate({ + description: "Maximum image height before resizing or rejecting the attachment (default: 2000)", + }), + max_base64_bytes: Schema.optional(PositiveInt).annotate({ + description: "Maximum base64 payload bytes for an image attachment (default: 4718592)", + }), +}) + .annotate({ identifier: "ImageAttachmentConfig" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type Image = Schema.Schema.Type + +export const Info = Schema.Struct({ + image: Schema.optional(Image).annotate({ description: "Image attachment configuration" }), +}) + .annotate({ identifier: "AttachmentConfig" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type Info = Schema.Schema.Type diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 073001e191..41ccac7492 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -25,6 +25,7 @@ import { containsPath } from "../project/instance-context" import { zod } from "@opencode-ai/core/effect-zod" import { NonNegativeInt, PositiveInt, withStatics, type DeepMutable } from "@opencode-ai/core/schema" import { ConfigAgent } from "./agent" +import { ConfigAttachment } from "./attachment" import { ConfigCommand } from "./command" import { ConfigFormatter } from "./formatter" import { ConfigLayout } from "./layout" @@ -241,6 +242,9 @@ export const Info = Schema.Struct({ layout: Schema.optional(ConfigLayout.Layout).annotate({ description: "@deprecated Always uses stretch layout." }), permission: Schema.optional(ConfigPermission.Info), tools: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)), + attachment: Schema.optional(ConfigAttachment.Info).annotate({ + description: "Attachment processing configuration, including image size limits and resizing behavior", + }), enterprise: Schema.optional( Schema.Struct({ url: Schema.optional(Schema.String).annotate({ description: "Enterprise URL" }), diff --git a/packages/opencode/src/image/image.ts b/packages/opencode/src/image/image.ts new file mode 100644 index 0000000000..2115e19198 --- /dev/null +++ b/packages/opencode/src/image/image.ts @@ -0,0 +1,180 @@ +import { Config } from "@/config/config" +import type { MessageV2 } from "@/session/message-v2" +import * as Log from "@opencode-ai/core/util/log" +import { Context, Effect, Layer, Schema } from "effect" + +const MAX_BASE64_BYTES = 4.5 * 1024 * 1024 +const MAX_WIDTH = 2000 +const MAX_HEIGHT = 2000 +const AUTO_RESIZE = true +const JPEG_QUALITIES = [80, 85, 70, 55, 40] +const log = Log.create({ service: "image" }) + +export class PhotonUnavailableError extends Schema.TaggedErrorClass()( + "ImagePhotonUnavailableError", + {}, +) { + override get message() { + return "Photon image processor is unavailable" + } +} + +export class InvalidDataUrlError extends Schema.TaggedErrorClass()("ImageInvalidDataUrlError", { + url: Schema.String, +}) { + override get message() { + return "Image URL must be a base64 data URL" + } +} + +export class DecodeError extends Schema.TaggedErrorClass()("ImageDecodeError", {}) { + override get message() { + return "Image could not be decoded" + } +} + +export class SizeError extends Schema.TaggedErrorClass()("ImageSizeError", { + bytes: Schema.Number, + max: Schema.Number, + width: Schema.Number, + height: Schema.Number, + max_width: Schema.Number, + max_height: Schema.Number, +}) { + override get message() { + return `Image ${this.width}x${this.height} with base64 size ${this.bytes} exceeds configured limits and could not be resized below ${this.max_width}x${this.max_height}/${this.max} bytes` + } +} + +export type Error = PhotonUnavailableError | InvalidDataUrlError | DecodeError | SizeError + +export interface Interface { + readonly normalize: (input: MessageV2.FilePart) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/Image") {} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const config = yield* Config.Service + const loadPhoton = yield* Effect.cached( + Effect.promise(async () => { + try { + const photonWasm = (await import("@silvia-odwyer/photon-node/photon_rs_bg.wasm", { with: { type: "file" } })) + .default + // Patched photon-node reads this during module init so Bun compiled binaries use the embedded wasm path. + ;(globalThis as typeof globalThis & { __OPENCODE_PHOTON_WASM_PATH?: string }).__OPENCODE_PHOTON_WASM_PATH = + photonWasm + return await import("@silvia-odwyer/photon-node") + } catch { + return null + } + }), + ) + + const normalize = Effect.fn("Image.normalize")(function* (input: MessageV2.FilePart) { + const image = (yield* config.get()).attachment?.image + const info = { + autoResize: image?.auto_resize ?? AUTO_RESIZE, + maxWidth: image?.max_width ?? MAX_WIDTH, + maxHeight: image?.max_height ?? MAX_HEIGHT, + maxBase64Bytes: image?.max_base64_bytes ?? MAX_BASE64_BYTES, + } + if (!input.url.startsWith("data:") || !input.url.includes(";base64,")) + return yield* new InvalidDataUrlError({ url: input.url }) + + const base64 = input.url.slice(input.url.indexOf(";base64,") + ";base64,".length) + const photon = yield* loadPhoton + if (!photon) return yield* new PhotonUnavailableError() + + const decoded = yield* Effect.sync(() => { + try { + return photon.PhotonImage.new_from_byteslice(Buffer.from(base64, "base64")) + } catch { + return undefined + } + }) + if (!decoded) return yield* new DecodeError() + + try { + const originalWidth = decoded.get_width() + const originalHeight = decoded.get_height() + if ( + originalWidth <= info.maxWidth && + originalHeight <= info.maxHeight && + Buffer.byteLength(base64, "utf8") <= info.maxBase64Bytes + ) + return input + if (!info.autoResize) + return yield* new SizeError({ + bytes: Buffer.byteLength(base64, "utf8"), + max: info.maxBase64Bytes, + width: originalWidth, + height: originalHeight, + max_width: info.maxWidth, + max_height: info.maxHeight, + }) + + const scale = Math.min(1, info.maxWidth / originalWidth, info.maxHeight / originalHeight) + for (const size of Array.from({ length: 32 }).reduce>((acc) => { + const previous = acc.at(-1) ?? { + width: Math.max(1, Math.round(originalWidth * scale)), + height: Math.max(1, Math.round(originalHeight * scale)), + } + const next = + acc.length === 0 + ? previous + : { + width: previous.width === 1 ? 1 : Math.max(1, Math.floor(previous.width * 0.75)), + height: previous.height === 1 ? 1 : Math.max(1, Math.floor(previous.height * 0.75)), + } + return acc.some((item) => item.width === next.width && item.height === next.height) ? acc : [...acc, next] + }, [])) { + const resized = photon.resize(decoded, size.width, size.height, photon.SamplingFilter.Lanczos3) + const candidate = [ + { data: Buffer.from(resized.get_bytes()).toString("base64"), mime: "image/png" }, + ...JPEG_QUALITIES.map((quality) => ({ + data: Buffer.from(resized.get_bytes_jpeg(quality)).toString("base64"), + mime: "image/jpeg", + })), + ] + .map((item) => ({ ...item, bytes: Buffer.byteLength(item.data, "utf8") })) + .find((item) => item.bytes <= info.maxBase64Bytes) + resized.free() + + if (candidate) { + log.info("using resized image", { + from_mime: input.mime, + to_mime: candidate.mime, + from: `${originalWidth}x${originalHeight}`, + to: `${size.width}x${size.height}`, + }) + return { + ...input, + mime: candidate.mime, + url: `data:${candidate.mime};base64,${candidate.data}`, + } + } + } + + return yield* new SizeError({ + bytes: Buffer.byteLength(base64, "utf8"), + max: info.maxBase64Bytes, + width: originalWidth, + height: originalHeight, + max_width: info.maxWidth, + max_height: info.maxHeight, + }) + } finally { + decoded.free() + } + }) + + return Service.of({ normalize }) + }), +) + +export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer)) + +export * as Image from "./image" diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts index 2328375b97..e64ad6d504 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts @@ -203,13 +203,15 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", params: { sessionID: SessionID } payload: typeof InitPayload.Type }) { - yield* promptSvc.command({ - sessionID: ctx.params.sessionID, - messageID: ctx.payload.messageID, - model: `${ctx.payload.providerID}/${ctx.payload.modelID}`, - command: Command.Default.INIT, - arguments: "", - }) + yield* promptSvc + .command({ + sessionID: ctx.params.sessionID, + messageID: ctx.payload.messageID, + model: `${ctx.payload.providerID}/${ctx.payload.modelID}`, + command: Command.Default.INIT, + arguments: "", + }) + .pipe(Effect.mapError(() => new HttpApiError.BadRequest({}))) return true }) @@ -258,18 +260,18 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", }) { const instance = yield* InstanceState.context const workspace = yield* InstanceState.workspaceID + const message = yield* promptSvc + .prompt({ + ...ctx.payload, + sessionID: ctx.params.sessionID, + }) + .pipe( + Effect.provideService(InstanceRef, instance), + Effect.provideService(WorkspaceRef, workspace), + Effect.mapError(() => new HttpApiError.BadRequest({})), + ) return HttpServerResponse.stream( - Stream.fromEffect( - promptSvc - .prompt({ - ...ctx.payload, - sessionID: ctx.params.sessionID, - }) - .pipe(Effect.provideService(InstanceRef, instance), Effect.provideService(WorkspaceRef, workspace)), - ).pipe( - Stream.map((message) => JSON.stringify(message)), - Stream.encodeText, - ), + Stream.make(JSON.stringify(message)).pipe(Stream.encodeText), { contentType: "application/json" }, ) }) @@ -297,7 +299,9 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", params: { sessionID: SessionID } payload: typeof CommandPayload.Type }) { - return yield* promptSvc.command({ ...ctx.payload, sessionID: ctx.params.sessionID }) + return yield* promptSvc + .command({ ...ctx.payload, sessionID: ctx.params.sessionID }) + .pipe(Effect.mapError(() => new HttpApiError.BadRequest({}))) }) const shell = Effect.fn("SessionHttpApi.shell")(function* (ctx: { diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 6e84db16e2..d87f04f888 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -1,4 +1,4 @@ -import { Cause, Deferred, Effect, Layer, Context, Scope } from "effect" +import { Cause, Deferred, Effect, Exit, Layer, Context, Scope } from "effect" import * as Stream from "effect/Stream" import { Agent } from "@/agent/agent" import { Bus } from "@/bus" @@ -9,6 +9,7 @@ import { Snapshot } from "@/snapshot" import * as Session from "./session" import { LLM } from "./llm" import { MessageV2 } from "./message-v2" +import { Image } from "@/image/image" import { isOverflow } from "./overflow" import { PartID } from "./schema" import type { SessionID } from "./schema" @@ -92,6 +93,7 @@ export const layer: Layer.Layer< | LLM.Service | Permission.Service | Plugin.Service + | Image.Service | SessionSummary.Service | SessionStatus.Service > = Layer.effect( @@ -108,6 +110,7 @@ export const layer: Layer.Layer< const summary = yield* SessionSummary.Service const scope = yield* Scope.Scope const status = yield* SessionStatus.Service + const image = yield* Image.Service const create = Effect.fn("SessionProcessor.create")(function* (input: Input) { // Pre-capture snapshot before the LLM stream starts. The AI SDK @@ -377,17 +380,43 @@ export const layer: Layer.Layer< case "tool-result": { const toolCall = yield* readToolCall(value.toolCallId) + const toolAttachments: MessageV2.FilePart[] = ( + Array.isArray(value.output.attachments) ? value.output.attachments : [] + ).filter( + (attachment: unknown): attachment is MessageV2.FilePart => + isRecord(attachment) && + attachment.type === "file" && + typeof attachment.mime === "string" && + typeof attachment.url === "string", + ) + const normalized = yield* Effect.forEach( + toolAttachments, + (attachment) => + attachment.mime.startsWith("image/") + ? image.normalize(attachment).pipe(Effect.exit) + : Effect.succeed(Exit.succeed(attachment)), + ) + const omitted = normalized.filter(Exit.isFailure).length + const attachments = normalized.filter(Exit.isSuccess).map((item) => item.value) + const output = { + ...value.output, + output: + omitted === 0 + ? value.output.output + : `${value.output.output}\n\n[${omitted} image${omitted === 1 ? "" : "s"} omitted: could not be resized below the inline image size limit.]`, + attachments: attachments?.length ? attachments : undefined, + } // TODO(v2): Temporary dual-write while migrating session messages to v2 events. EventV2.run(SessionEvent.Tool.Success.Sync, { sessionID: ctx.sessionID, callID: value.toolCallId, - structured: value.output.metadata, + structured: output.metadata, content: [ { type: "text", - text: value.output.output, + text: output.output, }, - ...(value.output.attachments?.map((item: MessageV2.FilePart) => ({ + ...(output.attachments?.map((item: MessageV2.FilePart) => ({ type: "file", uri: item.url, mime: item.mime, @@ -399,7 +428,7 @@ export const layer: Layer.Layer< }, timestamp: DateTime.makeUnsafe(Date.now()), }) - yield* completeToolCall(value.toolCallId, value.output) + yield* completeToolCall(value.toolCallId, output) return } @@ -758,6 +787,7 @@ export const defaultLayer = Layer.suspend(() => Layer.provide(Plugin.defaultLayer), Layer.provide(SessionSummary.defaultLayer), Layer.provide(SessionStatus.defaultLayer), + Layer.provide(Image.defaultLayer), Layer.provide(Bus.layer), Layer.provide(Config.defaultLayer), ), diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 1d5e4055c8..1fd61d23e0 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -43,6 +43,7 @@ import { Shell } from "@/shell/shell" import { ShellID } from "@/tool/shell/id" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Truncate } from "@/tool/truncate" +import { Image } from "@/image/image" import { decodeDataUrl } from "@/util/data-url" import { Process } from "@/util/process" import { Cause, Effect, Exit, Latch, Layer, Option, Scope, Context, Schema, Types } from "effect" @@ -80,10 +81,10 @@ const elog = EffectLogger.create({ service: "session.prompt" }) export interface Interface { readonly cancel: (sessionID: SessionID) => Effect.Effect - readonly prompt: (input: PromptInput) => Effect.Effect + readonly prompt: (input: PromptInput) => Effect.Effect readonly loop: (input: LoopInput) => Effect.Effect readonly shell: (input: ShellInput) => Effect.Effect - readonly command: (input: CommandInput) => Effect.Effect + readonly command: (input: CommandInput) => Effect.Effect readonly resolvePromptParts: (template: string) => Effect.Effect } @@ -108,6 +109,7 @@ export const layer = Layer.effect( const lsp = yield* LSP.Service const registry = yield* ToolRegistry.Service const truncate = yield* Truncate.Service + const image = yield* Image.Service const spawner = yield* ChildProcessSpawner.ChildProcessSpawner const scope = yield* Scope.Scope const instruction = yield* Instruction.Service @@ -123,7 +125,7 @@ export const layer = Layer.effect( return { cancel: (sessionID: SessionID) => cancel(sessionID), resolvePromptParts: (template: string) => resolvePromptParts(template), - prompt: (input: PromptInput) => prompt(input), + prompt: (input: PromptInput) => prompt(input).pipe(Effect.catch(Effect.die)), } satisfies TaskPromptOps }) @@ -1259,7 +1261,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the return [{ ...part, messageID: info.id, sessionID: input.sessionID }] }) - const parts = yield* Effect.forEach(input.parts, resolvePart, { concurrency: "unbounded" }).pipe( + const resolvedParts = yield* Effect.forEach(input.parts, resolvePart, { concurrency: "unbounded" }).pipe( Effect.map((x) => x.flat().map(assign)), ) @@ -1272,7 +1274,13 @@ NOTE: At any point in time through this workflow you should feel free to ask the messageID: input.messageID, variant: input.variant, }, - { message: info, parts }, + { message: info, parts: resolvedParts }, + ) + + const parts = yield* Effect.forEach(resolvedParts, (part) => + part.type === "file" && part.mime.startsWith("image/") + ? image.normalize(part) + : Effect.succeed(part), ) const parsed = MessageV2.Info.zod.safeParse(info) @@ -1368,7 +1376,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the return { info, parts } }, Effect.scoped) - const prompt: (input: PromptInput) => Effect.Effect = Effect.fn("SessionPrompt.prompt")( + const prompt: (input: PromptInput) => Effect.Effect = Effect.fn("SessionPrompt.prompt")( function* (input: PromptInput) { const session = yield* sessions.get(input.sessionID).pipe(Effect.orDie) yield* revert.cleanup(session) @@ -1788,6 +1796,7 @@ export const defaultLayer = Layer.suspend(() => Layer.provide(Session.defaultLayer), Layer.provide(SessionRevert.defaultLayer), Layer.provide(SessionSummary.defaultLayer), + Layer.provide(Image.defaultLayer), Layer.provide( Layer.mergeAll( Agent.defaultLayer, diff --git a/packages/opencode/test/image/image.test.ts b/packages/opencode/test/image/image.test.ts new file mode 100644 index 0000000000..67e6977e38 --- /dev/null +++ b/packages/opencode/test/image/image.test.ts @@ -0,0 +1,78 @@ +import { describe, expect } from "bun:test" +import { Cause, Effect, Exit, Layer } from "effect" +import { Image } from "@/image/image" +import { MessageID, PartID, SessionID } from "@/session/schema" +import { TestConfig } from "../fixture/config" +import { testEffect } from "../lib/effect" + +const it = testEffect(Layer.mergeAll(Image.layer.pipe(Layer.provide(TestConfig.layer())))) +const tiny = testEffect( + Layer.mergeAll( + Image.layer.pipe( + Layer.provide(TestConfig.layer({ get: () => Effect.succeed({ attachment: { image: { max_base64_bytes: 1 } } }) })), + ), + ), +) + +function part(mime: string, data: string) { + return { + id: PartID.ascending(), + messageID: MessageID.ascending(), + sessionID: SessionID.make("ses_test"), + type: "file" as const, + mime, + url: `data:${mime};base64,${data}`, + } +} + +describe("Image", () => { + it.effect("normalizes generated png and jpeg attachments", () => + Effect.gen(function* () { + const photon = yield* Effect.promise(() => import("@silvia-odwyer/photon-node")) + const source = new photon.PhotonImage( + new Uint8Array(Array.from({ length: 64 * 64 * 4 }, (_, index) => (index % 4 === 3 ? 255 : index % 251))), + 64, + 64, + ) + const image = yield* Image.Service + const results = yield* Effect.all([ + image.normalize(part("image/png", Buffer.from(source.get_bytes()).toString("base64"))), + image.normalize(part("image/jpeg", Buffer.from(source.get_bytes_jpeg(90)).toString("base64"))), + ]) + + source.free() + expect(results.map((result) => result.url.startsWith(`data:${result.mime};base64,`))).toEqual([true, true]) + expect(results.every((result) => result.mime === "image/png" || result.mime === "image/jpeg")).toBe(true) + }), + ) + + it.effect("accepts webp attachments that are already within limits", () => + Effect.gen(function* () { + const image = yield* Image.Service + const input = part("image/webp", "UklGRiIAAABXRUJQVlA4IBYAAAAwAQCdASoBAAEADsD+JaQAA3AAAAAA") + + expect(yield* image.normalize(input)).toEqual(input) + }), + ) + + tiny.effect("fails with a typed size error when no resized candidate fits", () => + Effect.gen(function* () { + const photon = yield* Effect.promise(() => import("@silvia-odwyer/photon-node")) + const source = new photon.PhotonImage(new Uint8Array(Array.from({ length: 4 }, () => 255)), 1, 1) + const image = yield* Image.Service + const exit = yield* image.normalize(part("image/png", Buffer.from(source.get_bytes()).toString("base64"))).pipe(Effect.exit) + + source.free() + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) { + const error = Cause.squash(exit.cause) + expect(error).toBeInstanceOf(Image.SizeError) + if (error instanceof Image.SizeError) { + expect(error.width).toBe(1) + expect(error.height).toBe(1) + expect(error.max).toBe(1) + } + } + }), + ) +}) diff --git a/packages/opencode/test/session/compaction.test.ts b/packages/opencode/test/session/compaction.test.ts index cde9c1397f..03b2576154 100644 --- a/packages/opencode/test/session/compaction.test.ts +++ b/packages/opencode/test/session/compaction.test.ts @@ -5,6 +5,7 @@ import * as Stream from "effect/Stream" import z from "zod" import { Bus } from "../../src/bus" import { Config } from "@/config/config" +import { Image } from "@/image/image" import { Agent } from "../../src/agent/agent" import { LLM } from "../../src/session/llm" import { SessionCompaction } from "../../src/session/compaction" @@ -278,7 +279,7 @@ function llm() { function liveRuntime(layer: Layer.Layer, provider = ProviderTest.fake(), config = Config.defaultLayer) { const bus = Bus.layer const status = SessionStatus.layer.pipe(Layer.provide(bus)) - const processor = SessionProcessorModule.SessionProcessor.layer.pipe(Layer.provide(summary)) + const processor = SessionProcessorModule.SessionProcessor.layer.pipe(Layer.provide(summary), Layer.provide(Image.defaultLayer)) return ManagedRuntime.make( Layer.mergeAll(SessionCompaction.layer.pipe(Layer.provide(processor)), processor, bus, status).pipe( Layer.provide(provider.layer), diff --git a/packages/opencode/test/session/processor-effect.test.ts b/packages/opencode/test/session/processor-effect.test.ts index 226bab9864..a0736b459b 100644 --- a/packages/opencode/test/session/processor-effect.test.ts +++ b/packages/opencode/test/session/processor-effect.test.ts @@ -6,6 +6,7 @@ import type { Agent } from "../../src/agent/agent" import { Agent as AgentSvc } from "../../src/agent/agent" import { Bus } from "../../src/bus" import { Config } from "@/config/config" +import { Image } from "@/image/image" import { Permission } from "../../src/permission" import { Plugin } from "../../src/plugin" import { Provider } from "@/provider/provider" @@ -168,7 +169,7 @@ const deps = Layer.mergeAll( ).pipe(Layer.provideMerge(infra)) const env = Layer.mergeAll( TestLLMServer.layer, - SessionProcessor.layer.pipe(Layer.provide(summary), Layer.provideMerge(deps)), + SessionProcessor.layer.pipe(Layer.provide(summary), Layer.provide(Image.defaultLayer), Layer.provideMerge(deps)), ) const it = testEffect(env) diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index 3b0009d2b3..bf3811113c 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -16,6 +16,7 @@ import { Plugin } from "../../src/plugin" import { Provider as ProviderSvc } from "@/provider/provider" import { Env } from "../../src/env" import { Git } from "../../src/git" +import { Image } from "../../src/image/image" import { ModelID, ProviderID } from "../../src/provider/schema" import { Question } from "../../src/question" import { Todo } from "../../src/session/todo" @@ -187,12 +188,13 @@ function makeHttp() { Layer.provideMerge(deps), ) const trunc = Truncate.layer.pipe(Layer.provideMerge(deps)) - const proc = SessionProcessor.layer.pipe(Layer.provide(summary), Layer.provideMerge(deps)) + const proc = SessionProcessor.layer.pipe(Layer.provide(summary), Layer.provide(Image.defaultLayer), Layer.provideMerge(deps)) const compact = SessionCompaction.layer.pipe(Layer.provideMerge(proc), Layer.provideMerge(deps)) return Layer.mergeAll( TestLLMServer.layer, SessionPrompt.layer.pipe( Layer.provide(SessionRevert.defaultLayer), + Layer.provide(Image.defaultLayer), Layer.provide(summary), Layer.provideMerge(run), Layer.provideMerge(compact), diff --git a/packages/opencode/test/session/snapshot-tool-race.test.ts b/packages/opencode/test/session/snapshot-tool-race.test.ts index 671f62145c..727b75cfee 100644 --- a/packages/opencode/test/session/snapshot-tool-race.test.ts +++ b/packages/opencode/test/session/snapshot-tool-race.test.ts @@ -41,6 +41,7 @@ import { Plugin } from "../../src/plugin" import { Provider as ProviderSvc } from "@/provider/provider" import { Env } from "../../src/env" import { Question } from "../../src/question" +import { Image } from "../../src/image/image" import { Skill } from "../../src/skill" import { SystemPrompt } from "../../src/session/system" import { Todo } from "../../src/session/todo" @@ -137,13 +138,18 @@ function makeHttp() { Layer.provideMerge(deps), ) const trunc = Truncate.layer.pipe(Layer.provideMerge(deps)) - const proc = SessionProcessor.layer.pipe(Layer.provide(SessionSummary.defaultLayer), Layer.provideMerge(deps)) + const proc = SessionProcessor.layer.pipe( + Layer.provide(SessionSummary.defaultLayer), + Layer.provide(Image.defaultLayer), + Layer.provideMerge(deps), + ) const compact = SessionCompaction.layer.pipe(Layer.provideMerge(proc), Layer.provideMerge(deps)) return Layer.mergeAll( TestLLMServer.layer, SessionSummary.defaultLayer, SessionPrompt.layer.pipe( Layer.provide(SessionRevert.defaultLayer), + Layer.provide(Image.defaultLayer), Layer.provide(SessionSummary.defaultLayer), Layer.provideMerge(run), Layer.provideMerge(compact), diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 4779f7cebd..ae7e9767ce 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1132,6 +1132,17 @@ export type McpRemoteConfig = { */ export type LayoutConfig = "auto" | "stretch" +export type ImageAttachmentConfig = { + auto_resize?: boolean + max_width?: number + max_height?: number + max_base64_bytes?: number +} + +export type AttachmentConfig = { + image?: ImageAttachmentConfig +} + export type Config = { $schema?: string shell?: string @@ -1246,6 +1257,7 @@ export type Config = { tools?: { [key: string]: boolean } + attachment?: AttachmentConfig enterprise?: { url?: string } diff --git a/patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch b/patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch new file mode 100644 index 0000000000..2e43225562 --- /dev/null +++ b/patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch @@ -0,0 +1,14 @@ +diff --git a/photon_rs.js b/photon_rs.js +index 8f4144d..b83e9a9 100644 +--- a/photon_rs.js ++++ b/photon_rs.js +@@ -4509,7 +4509,8 @@ module.exports.__wbindgen_init_externref_table = function() { + ; + }; + +-const path = require('path').join(__dirname, 'photon_rs_bg.wasm'); ++// Allow opencode's Bun compiled binary to point photon-node at its embedded wasm asset. ++const path = globalThis.__OPENCODE_PHOTON_WASM_PATH || require('path').join(__dirname, 'photon_rs_bg.wasm'); + const bytes = require('fs').readFileSync(path); + + const wasmModule = new WebAssembly.Module(bytes); From 3a78fb1f42fe52ae6aaf20d5047c1cc312430fe4 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sun, 10 May 2026 06:49:21 +0000 Subject: [PATCH 121/189] chore: generate --- .../instance/httpapi/handlers/session.ts | 7 ++-- packages/opencode/src/session/processor.ts | 10 ++--- packages/opencode/src/session/prompt.ts | 40 +++++++++---------- packages/opencode/test/image/image.test.ts | 8 +++- .../opencode/test/session/compaction.test.ts | 5 ++- packages/opencode/test/session/prompt.test.ts | 6 ++- packages/sdk/openapi.json | 33 +++++++++++++++ 7 files changed, 74 insertions(+), 35 deletions(-) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts index e64ad6d504..6aa87ee84e 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts @@ -270,10 +270,9 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", Effect.provideService(WorkspaceRef, workspace), Effect.mapError(() => new HttpApiError.BadRequest({})), ) - return HttpServerResponse.stream( - Stream.make(JSON.stringify(message)).pipe(Stream.encodeText), - { contentType: "application/json" }, - ) + return HttpServerResponse.stream(Stream.make(JSON.stringify(message)).pipe(Stream.encodeText), { + contentType: "application/json", + }) }) const promptAsync = Effect.fn("SessionHttpApi.promptAsync")(function* (ctx: { diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index d87f04f888..7df54d5451 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -389,12 +389,10 @@ export const layer: Layer.Layer< typeof attachment.mime === "string" && typeof attachment.url === "string", ) - const normalized = yield* Effect.forEach( - toolAttachments, - (attachment) => - attachment.mime.startsWith("image/") - ? image.normalize(attachment).pipe(Effect.exit) - : Effect.succeed(Exit.succeed(attachment)), + const normalized = yield* Effect.forEach(toolAttachments, (attachment) => + attachment.mime.startsWith("image/") + ? image.normalize(attachment).pipe(Effect.exit) + : Effect.succeed(Exit.succeed(attachment)), ) const omitted = normalized.filter(Exit.isFailure).length const attachments = normalized.filter(Exit.isSuccess).map((item) => item.value) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 1fd61d23e0..5cf04719e5 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1278,9 +1278,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the ) const parts = yield* Effect.forEach(resolvedParts, (part) => - part.type === "file" && part.mime.startsWith("image/") - ? image.normalize(part) - : Effect.succeed(part), + part.type === "file" && part.mime.startsWith("image/") ? image.normalize(part) : Effect.succeed(part), ) const parsed = MessageV2.Info.zod.safeParse(info) @@ -1376,26 +1374,26 @@ NOTE: At any point in time through this workflow you should feel free to ask the return { info, parts } }, Effect.scoped) - const prompt: (input: PromptInput) => Effect.Effect = Effect.fn("SessionPrompt.prompt")( - function* (input: PromptInput) { - const session = yield* sessions.get(input.sessionID).pipe(Effect.orDie) - yield* revert.cleanup(session) - const message = yield* createUserMessage(input) - yield* sessions.touch(input.sessionID) + const prompt: (input: PromptInput) => Effect.Effect = Effect.fn( + "SessionPrompt.prompt", + )(function* (input: PromptInput) { + const session = yield* sessions.get(input.sessionID).pipe(Effect.orDie) + yield* revert.cleanup(session) + const message = yield* createUserMessage(input) + yield* sessions.touch(input.sessionID) - const permissions: Permission.Ruleset = [] - for (const [t, enabled] of Object.entries(input.tools ?? {})) { - permissions.push({ permission: t, action: enabled ? "allow" : "deny", pattern: "*" }) - } - if (permissions.length > 0) { - session.permission = permissions - yield* sessions.setPermission({ sessionID: session.id, permission: permissions }) - } + const permissions: Permission.Ruleset = [] + for (const [t, enabled] of Object.entries(input.tools ?? {})) { + permissions.push({ permission: t, action: enabled ? "allow" : "deny", pattern: "*" }) + } + if (permissions.length > 0) { + session.permission = permissions + yield* sessions.setPermission({ sessionID: session.id, permission: permissions }) + } - if (input.noReply === true) return message - return yield* loop({ sessionID: input.sessionID }) - }, - ) + if (input.noReply === true) return message + return yield* loop({ sessionID: input.sessionID }) + }) const lastAssistant = Effect.fnUntraced(function* (sessionID: SessionID) { const match = yield* sessions.findMessage(sessionID, (m) => m.info.role !== "user") diff --git a/packages/opencode/test/image/image.test.ts b/packages/opencode/test/image/image.test.ts index 67e6977e38..bf5c0b3948 100644 --- a/packages/opencode/test/image/image.test.ts +++ b/packages/opencode/test/image/image.test.ts @@ -9,7 +9,9 @@ const it = testEffect(Layer.mergeAll(Image.layer.pipe(Layer.provide(TestConfig.l const tiny = testEffect( Layer.mergeAll( Image.layer.pipe( - Layer.provide(TestConfig.layer({ get: () => Effect.succeed({ attachment: { image: { max_base64_bytes: 1 } } }) })), + Layer.provide( + TestConfig.layer({ get: () => Effect.succeed({ attachment: { image: { max_base64_bytes: 1 } } }) }), + ), ), ), ) @@ -60,7 +62,9 @@ describe("Image", () => { const photon = yield* Effect.promise(() => import("@silvia-odwyer/photon-node")) const source = new photon.PhotonImage(new Uint8Array(Array.from({ length: 4 }, () => 255)), 1, 1) const image = yield* Image.Service - const exit = yield* image.normalize(part("image/png", Buffer.from(source.get_bytes()).toString("base64"))).pipe(Effect.exit) + const exit = yield* image + .normalize(part("image/png", Buffer.from(source.get_bytes()).toString("base64"))) + .pipe(Effect.exit) source.free() expect(Exit.isFailure(exit)).toBe(true) diff --git a/packages/opencode/test/session/compaction.test.ts b/packages/opencode/test/session/compaction.test.ts index 03b2576154..cb0bd2b9cd 100644 --- a/packages/opencode/test/session/compaction.test.ts +++ b/packages/opencode/test/session/compaction.test.ts @@ -279,7 +279,10 @@ function llm() { function liveRuntime(layer: Layer.Layer, provider = ProviderTest.fake(), config = Config.defaultLayer) { const bus = Bus.layer const status = SessionStatus.layer.pipe(Layer.provide(bus)) - const processor = SessionProcessorModule.SessionProcessor.layer.pipe(Layer.provide(summary), Layer.provide(Image.defaultLayer)) + const processor = SessionProcessorModule.SessionProcessor.layer.pipe( + Layer.provide(summary), + Layer.provide(Image.defaultLayer), + ) return ManagedRuntime.make( Layer.mergeAll(SessionCompaction.layer.pipe(Layer.provide(processor)), processor, bus, status).pipe( Layer.provide(provider.layer), diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index bf3811113c..821b900754 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -188,7 +188,11 @@ function makeHttp() { Layer.provideMerge(deps), ) const trunc = Truncate.layer.pipe(Layer.provideMerge(deps)) - const proc = SessionProcessor.layer.pipe(Layer.provide(summary), Layer.provide(Image.defaultLayer), Layer.provideMerge(deps)) + const proc = SessionProcessor.layer.pipe( + Layer.provide(summary), + Layer.provide(Image.defaultLayer), + Layer.provideMerge(deps), + ) const compact = SessionCompaction.layer.pipe(Layer.provideMerge(proc), Layer.provideMerge(deps)) return Layer.mergeAll( TestLLMServer.layer, diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index a428a3179c..db3705688d 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -12010,6 +12010,36 @@ "enum": ["auto", "stretch"], "description": "@deprecated Always uses stretch layout." }, + "ImageAttachmentConfig": { + "type": "object", + "properties": { + "auto_resize": { + "type": "boolean" + }, + "max_width": { + "type": "integer", + "exclusiveMinimum": 0 + }, + "max_height": { + "type": "integer", + "exclusiveMinimum": 0 + }, + "max_base64_bytes": { + "type": "integer", + "exclusiveMinimum": 0 + } + }, + "additionalProperties": false + }, + "AttachmentConfig": { + "type": "object", + "properties": { + "image": { + "$ref": "#/components/schemas/ImageAttachmentConfig" + } + }, + "additionalProperties": false + }, "Config": { "type": "object", "properties": { @@ -12340,6 +12370,9 @@ "type": "boolean" } }, + "attachment": { + "$ref": "#/components/schemas/AttachmentConfig" + }, "enterprise": { "type": "object", "properties": { From c04fa9e2538dc8819bec4e7f25f6217eefba6761 Mon Sep 17 00:00:00 2001 From: Frank Date: Sun, 10 May 2026 02:55:32 -0400 Subject: [PATCH 122/189] sync: revert This reverts commit 3a7f617098e89076fd22ef2b6bfe777912ce43dd. --- packages/console/app/src/component/icon.tsx | 8 -------- packages/console/app/src/i18n/ar.ts | 8 ++++---- packages/console/app/src/i18n/br.ts | 8 ++++---- packages/console/app/src/i18n/da.ts | 8 ++++---- packages/console/app/src/i18n/de.ts | 8 ++++---- packages/console/app/src/i18n/en.ts | 8 ++++---- packages/console/app/src/i18n/es.ts | 8 ++++---- packages/console/app/src/i18n/fr.ts | 8 ++++---- packages/console/app/src/i18n/it.ts | 8 ++++---- packages/console/app/src/i18n/ja.ts | 8 ++++---- packages/console/app/src/i18n/ko.ts | 8 ++++---- packages/console/app/src/i18n/no.ts | 8 ++++---- packages/console/app/src/i18n/pl.ts | 8 ++++---- packages/console/app/src/i18n/ru.ts | 8 ++++---- packages/console/app/src/i18n/th.ts | 8 ++++---- packages/console/app/src/i18n/tr.ts | 8 ++++---- packages/console/app/src/i18n/zh.ts | 8 ++++---- packages/console/app/src/i18n/zht.ts | 8 ++++---- packages/console/app/src/routes/go/index.tsx | 6 +----- .../app/src/routes/workspace/[id]/go/lite-section.tsx | 1 - packages/web/src/content/docs/ar/go.mdx | 4 ---- packages/web/src/content/docs/ar/zen.mdx | 4 ++++ packages/web/src/content/docs/bs/go.mdx | 4 ---- packages/web/src/content/docs/bs/zen.mdx | 4 ++++ packages/web/src/content/docs/da/go.mdx | 4 ---- packages/web/src/content/docs/da/zen.mdx | 4 ++++ packages/web/src/content/docs/de/go.mdx | 4 ---- packages/web/src/content/docs/de/zen.mdx | 4 ++++ packages/web/src/content/docs/es/go.mdx | 4 ---- packages/web/src/content/docs/es/zen.mdx | 4 ++++ packages/web/src/content/docs/fr/go.mdx | 4 ---- packages/web/src/content/docs/fr/zen.mdx | 4 ++++ packages/web/src/content/docs/go.mdx | 4 ---- packages/web/src/content/docs/it/go.mdx | 4 ---- packages/web/src/content/docs/it/zen.mdx | 4 ++++ packages/web/src/content/docs/ja/go.mdx | 4 ---- packages/web/src/content/docs/ja/zen.mdx | 4 ++++ packages/web/src/content/docs/ko/go.mdx | 4 ---- packages/web/src/content/docs/ko/zen.mdx | 4 ++++ packages/web/src/content/docs/nb/go.mdx | 4 ---- packages/web/src/content/docs/nb/zen.mdx | 4 ++++ packages/web/src/content/docs/pl/go.mdx | 4 ---- packages/web/src/content/docs/pl/zen.mdx | 4 ++++ packages/web/src/content/docs/pt-br/go.mdx | 4 ---- packages/web/src/content/docs/pt-br/zen.mdx | 4 ++++ packages/web/src/content/docs/ru/go.mdx | 4 ---- packages/web/src/content/docs/ru/zen.mdx | 4 ++++ packages/web/src/content/docs/th/go.mdx | 4 ---- packages/web/src/content/docs/th/zen.mdx | 4 ++++ packages/web/src/content/docs/tr/go.mdx | 4 ---- packages/web/src/content/docs/tr/zen.mdx | 4 ++++ packages/web/src/content/docs/zen.mdx | 4 ++++ packages/web/src/content/docs/zh-cn/go.mdx | 4 ---- packages/web/src/content/docs/zh-cn/zen.mdx | 4 ++++ packages/web/src/content/docs/zh-tw/go.mdx | 4 ---- packages/web/src/content/docs/zh-tw/zen.mdx | 4 ++++ 56 files changed, 141 insertions(+), 154 deletions(-) diff --git a/packages/console/app/src/component/icon.tsx b/packages/console/app/src/component/icon.tsx index 5ce96a0028..ed83a1934b 100644 --- a/packages/console/app/src/component/icon.tsx +++ b/packages/console/app/src/component/icon.tsx @@ -212,14 +212,6 @@ export function IconDeepSeek(props: JSX.SvgSVGAttributes) { ) } -export function IconHunyuan(props: JSX.SvgSVGAttributes) { - return ( - - - - ) -} - export function IconMiMo(props: JSX.SvgSVGAttributes) { return ( diff --git a/packages/console/app/src/i18n/ar.ts b/packages/console/app/src/i18n/ar.ts index f2c40b0bbd..f413b5572f 100644 --- a/packages/console/app/src/i18n/ar.ts +++ b/packages/console/app/src/i18n/ar.ts @@ -249,7 +249,7 @@ export const dict = { "go.title": "OpenCode Go | نماذج برمجة منخفضة التكلفة للجميع", "go.meta.description": - "يبدأ Go من $5 للشهر الأول، ثم $10/شهر، مع حدود طلب سخية لمدة 5 ساعات لـ GLM-5.1 وGLM-5 وKimi K2.5 وKimi K2.6 وMiMo-V2.5-Pro وMiMo-V2.5 وQwen3.5 Plus وQwen3.6 Plus وMiniMax M2.5 وMiniMax M2.7 وDeepSeek V4 Pro وDeepSeek V4 Flash وHy3 preview.", + "يبدأ Go من $5 للشهر الأول، ثم $10/شهر، مع حدود طلب سخية لمدة 5 ساعات لـ GLM-5.1 وGLM-5 وKimi K2.5 وKimi K2.6 وMiMo-V2.5-Pro وMiMo-V2.5 وQwen3.5 Plus وQwen3.6 Plus وMiniMax M2.5 وMiniMax M2.7 وDeepSeek V4 Pro وDeepSeek V4 Flash.", "go.hero.title": "نماذج برمجة منخفضة التكلفة للجميع", "go.hero.body": "يجلب Go البرمجة الوكيلة للمبرمجين حول العالم. يوفر حدودًا سخية ووصولًا موثوقًا إلى أقوى النماذج مفتوحة المصدر، حتى تتمكن من البناء باستخدام وكلاء أقوياء دون القلق بشأن التكلفة أو التوفر.", @@ -298,7 +298,7 @@ export const dict = { "go.problem.item2": "حدود سخية ووصول موثوق", "go.problem.item3": "مصمم لأكبر عدد ممكن من المبرمجين", "go.problem.item4": - "يتضمن GLM-5.1 وGLM-5 وKimi K2.5 وKimi K2.6 وMiMo-V2.5-Pro وMiMo-V2.5 وQwen3.5 Plus وQwen3.6 Plus وMiniMax M2.5 وMiniMax M2.7 وDeepSeek V4 Pro وDeepSeek V4 Flash وHy3 preview", + "يتضمن GLM-5.1 وGLM-5 وKimi K2.5 وKimi K2.6 وMiMo-V2.5-Pro وMiMo-V2.5 وQwen3.5 Plus وQwen3.6 Plus وMiniMax M2.5 وMiniMax M2.7 وDeepSeek V4 Pro وDeepSeek V4 Flash", "go.how.title": "كيف يعمل Go", "go.how.body": "يبدأ Go من $5 للشهر الأول، ثم $10/شهر. يمكنك استخدامه مع OpenCode أو أي وكيل.", "go.how.step1.title": "أنشئ حسابًا", @@ -322,7 +322,7 @@ export const dict = { "go.faq.a2": "يتضمن Go النماذج المدرجة أدناه، مع حدود سخية وإتاحة موثوقة.", "go.faq.q3": "هل Go هو نفسه Zen؟", "go.faq.a3": - "لا. Zen هو الدفع حسب الاستخدام، بينما يبدأ Go من $5 للشهر الأول، ثم $10/شهر، مع حدود سخية ووصول موثوق إلى نماذج المصدر المفتوح GLM-5.1 وGLM-5 وKimi K2.5 وKimi K2.6 وMiMo-V2.5-Pro وMiMo-V2.5 وQwen3.5 Plus وQwen3.6 Plus وMiniMax M2.5 وMiniMax M2.7 وDeepSeek V4 Pro وDeepSeek V4 Flash وHy3 preview.", + "لا. Zen هو الدفع حسب الاستخدام، بينما يبدأ Go من $5 للشهر الأول، ثم $10/شهر، مع حدود سخية ووصول موثوق إلى نماذج المصدر المفتوح GLM-5.1 وGLM-5 وKimi K2.5 وKimi K2.6 وMiMo-V2.5-Pro وMiMo-V2.5 وQwen3.5 Plus وQwen3.6 Plus وMiniMax M2.5 وMiniMax M2.7 وDeepSeek V4 Pro وDeepSeek V4 Flash.", "go.faq.q4": "كم تكلفة Go؟", "go.faq.a4.p1.beforePricing": "تكلفة Go", "go.faq.a4.p1.pricingLink": "$5 للشهر الأول", @@ -345,7 +345,7 @@ export const dict = { "go.faq.q9": "ما الفرق بين النماذج المجانية وGo؟", "go.faq.a9": - "تشمل النماذج المجانية Big Pickle بالإضافة إلى النماذج الترويجية المتاحة في ذلك الوقت، مع حصة 200 طلب/يوم. يتضمن Go نماذج GLM-5.1 وGLM-5 وKimi K2.5 وKimi K2.6 وMiMo-V2.5-Pro وMiMo-V2.5 وQwen3.5 Plus وQwen3.6 Plus وMiniMax M2.5 وMiniMax M2.7 وDeepSeek V4 Pro وDeepSeek V4 Flash وHy3 preview مع حصص طلبات أعلى مطبقة عبر نوافذ متجددة (5 ساعات، أسبوعيًا، وشهريًا)، تعادل تقريبًا 12 دولارًا كل 5 ساعات، و30 دولارًا في الأسبوع، و60 دولارًا في الشهر (تختلف أعداد الطلبات الفعلية حسب النموذج والاستخدام).", + "تشمل النماذج المجانية Big Pickle بالإضافة إلى النماذج الترويجية المتاحة في ذلك الوقت، مع حصة 200 طلب/يوم. يتضمن Go نماذج GLM-5.1 وGLM-5 وKimi K2.5 وKimi K2.6 وMiMo-V2.5-Pro وMiMo-V2.5 وQwen3.5 Plus وQwen3.6 Plus وMiniMax M2.5 وMiniMax M2.7 وDeepSeek V4 Pro وDeepSeek V4 Flash مع حصص طلبات أعلى مطبقة عبر نوافذ متجددة (5 ساعات، أسبوعيًا، وشهريًا)، تعادل تقريبًا 12 دولارًا كل 5 ساعات، و30 دولارًا في الأسبوع، و60 دولارًا في الشهر (تختلف أعداد الطلبات الفعلية حسب النموذج والاستخدام).", "zen.api.error.rateLimitExceeded": "تم تجاوز حد الطلبات. يرجى المحاولة مرة أخرى لاحقًا.", "zen.api.error.modelNotSupported": "النموذج {{model}} غير مدعوم", diff --git a/packages/console/app/src/i18n/br.ts b/packages/console/app/src/i18n/br.ts index 120210acf4..8466acc5fd 100644 --- a/packages/console/app/src/i18n/br.ts +++ b/packages/console/app/src/i18n/br.ts @@ -253,7 +253,7 @@ export const dict = { "go.title": "OpenCode Go | Modelos de codificação de baixo custo para todos", "go.meta.description": - "O Go começa em $5 no primeiro mês, depois $10/mês, com limites generosos de solicitação de 5 horas para GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, DeepSeek V4 Flash e Hy3 preview.", + "O Go começa em $5 no primeiro mês, depois $10/mês, com limites generosos de solicitação de 5 horas para GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro e DeepSeek V4 Flash.", "go.hero.title": "Modelos de codificação de baixo custo para todos", "go.hero.body": "O Go traz a codificação com agentes para programadores em todo o mundo. Oferecendo limites generosos e acesso confiável aos modelos de código aberto mais capazes, para que você possa construir com agentes poderosos sem se preocupar com custos ou disponibilidade.", @@ -303,7 +303,7 @@ export const dict = { "go.problem.item2": "Limites generosos e acesso confiável", "go.problem.item3": "Feito para o maior número possível de programadores", "go.problem.item4": - "Inclui GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, DeepSeek V4 Flash e Hy3 preview", + "Inclui GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro e DeepSeek V4 Flash", "go.how.title": "Como o Go funciona", "go.how.body": "O Go começa em $5 no primeiro mês, depois $10/mês. Você pode usá-lo com o OpenCode ou qualquer agente.", @@ -329,7 +329,7 @@ export const dict = { "go.faq.a2": "O Go inclui os modelos listados abaixo, com limites generosos e acesso confiável.", "go.faq.q3": "O Go é o mesmo que o Zen?", "go.faq.a3": - "Não. Zen é pay-as-you-go, enquanto o Go começa em $5 no primeiro mês, depois $10/mês, com limites generosos e acesso confiável aos modelos open source GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, DeepSeek V4 Flash e Hy3 preview.", + "Não. Zen é pay-as-you-go, enquanto o Go começa em $5 no primeiro mês, depois $10/mês, com limites generosos e acesso confiável aos modelos open source GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro e DeepSeek V4 Flash.", "go.faq.q4": "Quanto custa o Go?", "go.faq.a4.p1.beforePricing": "O Go custa", "go.faq.a4.p1.pricingLink": "$5 no primeiro mês", @@ -353,7 +353,7 @@ export const dict = { "go.faq.q9": "Qual a diferença entre os modelos gratuitos e o Go?", "go.faq.a9": - "Os modelos gratuitos incluem Big Pickle e modelos promocionais disponíveis no momento, com uma cota de 200 requisições/dia. O Go inclui GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, DeepSeek V4 Flash e Hy3 preview com cotas de requisição mais altas aplicadas em janelas móveis (5 horas, semanal e mensal), aproximadamente equivalentes a $12 por 5 horas, $30 por semana e $60 por mês (as contagens reais de requisições variam de acordo com o modelo e o uso).", + "Os modelos gratuitos incluem Big Pickle e modelos promocionais disponíveis no momento, com uma cota de 200 requisições/dia. O Go inclui GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro e DeepSeek V4 Flash com cotas de requisição mais altas aplicadas em janelas móveis (5 horas, semanal e mensal), aproximadamente equivalentes a $12 por 5 horas, $30 por semana e $60 por mês (as contagens reais de requisições variam de acordo com o modelo e o uso).", "zen.api.error.rateLimitExceeded": "Limite de taxa excedido. Por favor, tente novamente mais tarde.", "zen.api.error.modelNotSupported": "Modelo {{model}} não suportado", diff --git a/packages/console/app/src/i18n/da.ts b/packages/console/app/src/i18n/da.ts index a6e55ea343..9338e3add5 100644 --- a/packages/console/app/src/i18n/da.ts +++ b/packages/console/app/src/i18n/da.ts @@ -251,7 +251,7 @@ export const dict = { "go.title": "OpenCode Go | Kodningsmodeller til lav pris for alle", "go.meta.description": - "Go starter ved $5 for den første måned, derefter $10/måned, med generøse 5-timers anmodningsgrænser for GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, DeepSeek V4 Flash og Hy3 preview.", + "Go starter ved $5 for den første måned, derefter $10/måned, med generøse 5-timers anmodningsgrænser for GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro og DeepSeek V4 Flash.", "go.hero.title": "Kodningsmodeller til lav pris for alle", "go.hero.body": "Go bringer agentisk kodning til programmører over hele verden. Med generøse grænser og pålidelig adgang til de mest kapable open source-modeller, så du kan bygge med kraftfulde agenter uden at bekymre dig om omkostninger eller tilgængelighed.", @@ -300,7 +300,7 @@ export const dict = { "go.problem.item2": "Generøse grænser og pålidelig adgang", "go.problem.item3": "Bygget til så mange programmører som muligt", "go.problem.item4": - "Inkluderer GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, DeepSeek V4 Flash og Hy3 preview", + "Inkluderer GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro og DeepSeek V4 Flash", "go.how.title": "Hvordan Go virker", "go.how.body": "Go starter ved $5 for den første måned, derefter $10/måned. Du kan bruge det med OpenCode eller enhver agent.", @@ -326,7 +326,7 @@ export const dict = { "go.faq.a2": "Go inkluderer modellerne nedenfor med generøse grænser og pålidelig adgang.", "go.faq.q3": "Er Go det samme som Zen?", "go.faq.a3": - "Nej. Zen er pay-as-you-go, mens Go starter ved $5 for den første måned, derefter $10/måned, med generøse grænser og pålidelig adgang til open source-modellerne GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, DeepSeek V4 Flash og Hy3 preview.", + "Nej. Zen er pay-as-you-go, mens Go starter ved $5 for den første måned, derefter $10/måned, med generøse grænser og pålidelig adgang til open source-modellerne GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro og DeepSeek V4 Flash.", "go.faq.q4": "Hvad koster Go?", "go.faq.a4.p1.beforePricing": "Go koster", "go.faq.a4.p1.pricingLink": "$5 første måned", @@ -349,7 +349,7 @@ export const dict = { "go.faq.q9": "Hvad er forskellen på gratis modeller og Go?", "go.faq.a9": - "Gratis modeller inkluderer Big Pickle plus salgsfremmende modeller tilgængelige på det tidspunkt, med en kvote på 200 forespørgsler/dag. Go inkluderer GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, DeepSeek V4 Flash og Hy3 preview med højere anmodningskvoter håndhævet over rullende vinduer (5-timers, ugentlig og månedlig), nogenlunde svarende til $12 pr. 5 timer, $30 pr. uge og $60 pr. måned (faktiske anmodningstal varierer efter model og brug).", + "Gratis modeller inkluderer Big Pickle plus salgsfremmende modeller tilgængelige på det tidspunkt, med en kvote på 200 forespørgsler/dag. Go inkluderer GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro og DeepSeek V4 Flash med højere anmodningskvoter håndhævet over rullende vinduer (5-timers, ugentlig og månedlig), nogenlunde svarende til $12 pr. 5 timer, $30 pr. uge og $60 pr. måned (faktiske anmodningstal varierer efter model og brug).", "zen.api.error.rateLimitExceeded": "Hastighedsgrænse overskredet. Prøv venligst igen senere.", "zen.api.error.modelNotSupported": "Model {{model}} understøttes ikke", diff --git a/packages/console/app/src/i18n/de.ts b/packages/console/app/src/i18n/de.ts index 185c4fa202..7a2d3e91b4 100644 --- a/packages/console/app/src/i18n/de.ts +++ b/packages/console/app/src/i18n/de.ts @@ -253,7 +253,7 @@ export const dict = { "go.title": "OpenCode Go | Kostengünstige Coding-Modelle für alle", "go.meta.description": - "Go beginnt bei $5 für den ersten Monat, danach $10/Monat, mit großzügigen 5-Stunden-Anfragelimits für GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, DeepSeek V4 Flash und Hy3 preview.", + "Go beginnt bei $5 für den ersten Monat, danach $10/Monat, mit großzügigen 5-Stunden-Anfragelimits für GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro und DeepSeek V4 Flash.", "go.hero.title": "Kostengünstige Coding-Modelle für alle", "go.hero.body": "Go bringt Agentic Coding zu Programmierern auf der ganzen Welt. Mit großzügigen Limits und zuverlässigem Zugang zu den leistungsfähigsten Open-Source-Modellen, damit du mit leistungsstarken Agenten entwickeln kannst, ohne dir Gedanken über Kosten oder Verfügbarkeit zu machen.", @@ -302,7 +302,7 @@ export const dict = { "go.problem.item2": "Großzügige Limits und zuverlässiger Zugang", "go.problem.item3": "Für so viele Programmierer wie möglich gebaut", "go.problem.item4": - "Beinhaltet GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, DeepSeek V4 Flash und Hy3 preview", + "Beinhaltet GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro und DeepSeek V4 Flash", "go.how.title": "Wie Go funktioniert", "go.how.body": "Go beginnt bei $5 für den ersten Monat, danach $10/Monat. Du kannst es mit OpenCode oder jedem Agenten nutzen.", @@ -328,7 +328,7 @@ export const dict = { "go.faq.a2": "Go umfasst die unten aufgeführten Modelle mit großzügigen Limits und zuverlässigem Zugriff.", "go.faq.q3": "Ist Go dasselbe wie Zen?", "go.faq.a3": - "Nein. Zen ist Pay-as-you-go, während Go bei $5 für den ersten Monat beginnt, danach $10/Monat, mit großzügigen Limits und zuverlässigem Zugang zu den Open-Source-Modellen GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, DeepSeek V4 Flash und Hy3 preview.", + "Nein. Zen ist Pay-as-you-go, während Go bei $5 für den ersten Monat beginnt, danach $10/Monat, mit großzügigen Limits und zuverlässigem Zugang zu den Open-Source-Modellen GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro und DeepSeek V4 Flash.", "go.faq.q4": "Wie viel kostet Go?", "go.faq.a4.p1.beforePricing": "Go kostet", "go.faq.a4.p1.pricingLink": "$5 im ersten Monat", @@ -352,7 +352,7 @@ export const dict = { "go.faq.q9": "Was ist der Unterschied zwischen kostenlosen Modellen und Go?", "go.faq.a9": - "Kostenlose Modelle beinhalten Big Pickle sowie Werbemodelle, die zum jeweiligen Zeitpunkt verfügbar sind, mit einem Kontingent von 200 Anfragen/Tag. Go beinhaltet GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, DeepSeek V4 Flash und Hy3 preview mit höheren Anfragekontingenten, die über rollierende Zeitfenster (5 Stunden, wöchentlich und monatlich) durchgesetzt werden, grob äquivalent zu $12 pro 5 Stunden, $30 pro Woche und $60 pro Monat (tatsächliche Anfragezahlen variieren je nach Modell und Nutzung).", + "Kostenlose Modelle beinhalten Big Pickle sowie Werbemodelle, die zum jeweiligen Zeitpunkt verfügbar sind, mit einem Kontingent von 200 Anfragen/Tag. Go beinhaltet GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro und DeepSeek V4 Flash mit höheren Anfragekontingenten, die über rollierende Zeitfenster (5 Stunden, wöchentlich und monatlich) durchgesetzt werden, grob äquivalent zu $12 pro 5 Stunden, $30 pro Woche und $60 pro Monat (tatsächliche Anfragezahlen variieren je nach Modell und Nutzung).", "zen.api.error.rateLimitExceeded": "Ratenlimit überschritten. Bitte versuche es später erneut.", "zen.api.error.modelNotSupported": "Modell {{model}} wird nicht unterstützt", diff --git a/packages/console/app/src/i18n/en.ts b/packages/console/app/src/i18n/en.ts index 0aa4b2ee4c..b7ef397be6 100644 --- a/packages/console/app/src/i18n/en.ts +++ b/packages/console/app/src/i18n/en.ts @@ -248,7 +248,7 @@ export const dict = { "go.title": "OpenCode Go | Low cost coding models for everyone", "go.meta.description": - "Go starts at $5 for your first month, then $10/month, with generous 5-hour request limits for GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, DeepSeek V4 Flash, and Hy3 preview.", + "Go starts at $5 for your first month, then $10/month, with generous 5-hour request limits for GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, and DeepSeek V4 Flash.", "go.hero.title": "Low cost coding models for everyone", "go.hero.body": "Go brings agentic coding to programmers around the world. Offering generous limits and reliable access to the most capable open-source models, so you can build with powerful agents without worrying about cost or availability.", @@ -296,7 +296,7 @@ export const dict = { "go.problem.item2": "Generous limits and reliable access", "go.problem.item3": "Built for as many programmers as possible", "go.problem.item4": - "Includes GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, DeepSeek V4 Flash, and Hy3 preview", + "Includes GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, and DeepSeek V4 Flash", "go.how.title": "How Go works", "go.how.body": "Go starts at $5 for your first month, then $10/month. You can use it with OpenCode or any agent.", "go.how.step1.title": "Create an account", @@ -321,7 +321,7 @@ export const dict = { "go.faq.a2": "Go includes the models listed below, with generous limits and reliable access.", "go.faq.q3": "Is Go the same as Zen?", "go.faq.a3": - "No. Zen is pay-as-you-go, while Go starts at $5 for your first month, then $10/month, with generous limits and reliable access to open-source models GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, DeepSeek V4 Flash, and Hy3 preview.", + "No. Zen is pay-as-you-go, while Go starts at $5 for your first month, then $10/month, with generous limits and reliable access to open-source models GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, and DeepSeek V4 Flash.", "go.faq.q4": "How much does Go cost?", "go.faq.a4.p1.beforePricing": "Go costs", "go.faq.a4.p1.pricingLink": "$5 first month", @@ -345,7 +345,7 @@ export const dict = { "go.faq.q9": "What is the difference between free models and Go?", "go.faq.a9": - "Free models include Big Pickle plus promotional models available at the time, with a quota of 200 requests/day. Go includes GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, DeepSeek V4 Flash, and Hy3 preview with higher request quotas enforced across rolling windows (5-hour, weekly, and monthly), roughly equivalent to $12 per 5 hours, $30 per week, and $60 per month (actual request counts vary by model and usage).", + "Free models include Big Pickle plus promotional models available at the time, with a quota of 200 requests/day. Go includes GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, and DeepSeek V4 Flash with higher request quotas enforced across rolling windows (5-hour, weekly, and monthly), roughly equivalent to $12 per 5 hours, $30 per week, and $60 per month (actual request counts vary by model and usage).", "zen.api.error.rateLimitExceeded": "Rate limit exceeded. Please try again later.", "zen.api.error.modelNotSupported": "Model {{model}} not supported", diff --git a/packages/console/app/src/i18n/es.ts b/packages/console/app/src/i18n/es.ts index e90d3b9f46..f6347d3b52 100644 --- a/packages/console/app/src/i18n/es.ts +++ b/packages/console/app/src/i18n/es.ts @@ -254,7 +254,7 @@ export const dict = { "go.title": "OpenCode Go | Modelos de programación de bajo coste para todos", "go.meta.description": - "Go comienza en $5 el primer mes, luego 10 $/mes, con generosos límites de solicitudes de 5 horas para GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, DeepSeek V4 Flash y Hy3 preview.", + "Go comienza en $5 el primer mes, luego 10 $/mes, con generosos límites de solicitudes de 5 horas para GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro y DeepSeek V4 Flash.", "go.hero.title": "Modelos de programación de bajo coste para todos", "go.hero.body": "Go lleva la programación agéntica a programadores de todo el mundo. Ofrece límites generosos y acceso fiable a los modelos de código abierto más capaces, para que puedas crear con agentes potentes sin preocuparte por el coste o la disponibilidad.", @@ -304,7 +304,7 @@ export const dict = { "go.problem.item2": "Límites generosos y acceso fiable", "go.problem.item3": "Creado para tantos programadores como sea posible", "go.problem.item4": - "Incluye GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, DeepSeek V4 Flash y Hy3 preview", + "Incluye GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro y DeepSeek V4 Flash", "go.how.title": "Cómo funciona Go", "go.how.body": "Go comienza en $5 el primer mes, luego 10 $/mes. Puedes usarlo con OpenCode o cualquier agente.", "go.how.step1.title": "Crear una cuenta", @@ -329,7 +329,7 @@ export const dict = { "go.faq.a2": "Go incluye los modelos que se indican abajo, con límites generosos y acceso confiable.", "go.faq.q3": "¿Es Go lo mismo que Zen?", "go.faq.a3": - "No. Zen es pago por uso, mientras que Go comienza en $5 el primer mes, luego 10 $/mes, con límites generosos y acceso fiable a los modelos de código abierto GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, DeepSeek V4 Flash y Hy3 preview.", + "No. Zen es pago por uso, mientras que Go comienza en $5 el primer mes, luego 10 $/mes, con límites generosos y acceso fiable a los modelos de código abierto GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro y DeepSeek V4 Flash.", "go.faq.q4": "¿Cuánto cuesta Go?", "go.faq.a4.p1.beforePricing": "Go cuesta", "go.faq.a4.p1.pricingLink": "$5 el primer mes", @@ -353,7 +353,7 @@ export const dict = { "go.faq.q9": "¿Cuál es la diferencia entre los modelos gratuitos y Go?", "go.faq.a9": - "Los modelos gratuitos incluyen Big Pickle más modelos promocionales disponibles en el momento, con una cuota de 200 solicitudes/día. Go incluye GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, DeepSeek V4 Flash y Hy3 preview con cuotas de solicitud más altas aplicadas a través de ventanas móviles (5 horas, semanal y mensual), aproximadamente equivalente a 12 $ por 5 horas, 30 $ por semana y 60 $ por mes (los recuentos reales de solicitudes varían según el modelo y el uso).", + "Los modelos gratuitos incluyen Big Pickle más modelos promocionales disponibles en el momento, con una cuota de 200 solicitudes/día. Go incluye GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro y DeepSeek V4 Flash con cuotas de solicitud más altas aplicadas a través de ventanas móviles (5 horas, semanal y mensual), aproximadamente equivalente a 12 $ por 5 horas, 30 $ por semana y 60 $ por mes (los recuentos reales de solicitudes varían según el modelo y el uso).", "zen.api.error.rateLimitExceeded": "Límite de tasa excedido. Por favor, inténtalo de nuevo más tarde.", "zen.api.error.modelNotSupported": "Modelo {{model}} no soportado", diff --git a/packages/console/app/src/i18n/fr.ts b/packages/console/app/src/i18n/fr.ts index c9bb02d4ee..5d1cd0fab7 100644 --- a/packages/console/app/src/i18n/fr.ts +++ b/packages/console/app/src/i18n/fr.ts @@ -255,7 +255,7 @@ export const dict = { "go.title": "OpenCode Go | Modèles de code à faible coût pour tous", "go.meta.description": - "Go commence à $5 pour le premier mois, puis 10 $/mois, avec des limites de requêtes généreuses sur 5 heures pour GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, DeepSeek V4 Flash et Hy3 preview.", + "Go commence à $5 pour le premier mois, puis 10 $/mois, avec des limites de requêtes généreuses sur 5 heures pour GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro et DeepSeek V4 Flash.", "go.hero.title": "Modèles de code à faible coût pour tous", "go.hero.body": "Go apporte le codage agentique aux programmeurs du monde entier. Offrant des limites généreuses et un accès fiable aux modèles open source les plus capables, pour que vous puissiez construire avec des agents puissants sans vous soucier du coût ou de la disponibilité.", @@ -304,7 +304,7 @@ export const dict = { "go.problem.item2": "Limites généreuses et accès fiable", "go.problem.item3": "Conçu pour autant de programmeurs que possible", "go.problem.item4": - "Inclut GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, DeepSeek V4 Flash et Hy3 preview", + "Inclut GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro et DeepSeek V4 Flash", "go.how.title": "Comment fonctionne Go", "go.how.body": "Go commence à $5 pour le premier mois, puis 10 $/mois. Vous pouvez l'utiliser avec OpenCode ou n'importe quel agent.", @@ -330,7 +330,7 @@ export const dict = { "go.faq.a2": "Go inclut les modèles ci-dessous, avec des limites généreuses et un accès fiable.", "go.faq.q3": "Est-ce que Go est la même chose que Zen ?", "go.faq.a3": - "Non. Zen est un paiement à l'utilisation, tandis que Go commence à $5 pour le premier mois, puis 10 $/mois, avec des limites généreuses et un accès fiable aux modèles open source GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, DeepSeek V4 Flash et Hy3 preview.", + "Non. Zen est un paiement à l'utilisation, tandis que Go commence à $5 pour le premier mois, puis 10 $/mois, avec des limites généreuses et un accès fiable aux modèles open source GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro et DeepSeek V4 Flash.", "go.faq.q4": "Combien coûte Go ?", "go.faq.a4.p1.beforePricing": "Go coûte", "go.faq.a4.p1.pricingLink": "$5 le premier mois", @@ -353,7 +353,7 @@ export const dict = { "Oui, vous pouvez utiliser Go avec n'importe quel agent. Suivez les instructions de configuration dans votre agent de code préféré.", "go.faq.q9": "Quelle est la différence entre les modèles gratuits et Go ?", "go.faq.a9": - "Les modèles gratuits incluent Big Pickle ainsi que des modèles promotionnels disponibles à ce moment-là, avec un quota de 200 requêtes/jour. Go inclut GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, DeepSeek V4 Flash et Hy3 preview avec des quotas de requêtes plus élevés appliqués sur des fenêtres glissantes (5 heures, hebdomadaire et mensuelle), à peu près équivalent à 12 $ par 5 heures, 30 $ par semaine et 60 $ par mois (le nombre réel de requêtes varie selon le modèle et l'utilisation).", + "Les modèles gratuits incluent Big Pickle ainsi que des modèles promotionnels disponibles à ce moment-là, avec un quota de 200 requêtes/jour. Go inclut GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro et DeepSeek V4 Flash avec des quotas de requêtes plus élevés appliqués sur des fenêtres glissantes (5 heures, hebdomadaire et mensuelle), à peu près équivalent à 12 $ par 5 heures, 30 $ par semaine et 60 $ par mois (le nombre réel de requêtes varie selon le modèle et l'utilisation).", "zen.api.error.rateLimitExceeded": "Limite de débit dépassée. Veuillez réessayer plus tard.", "zen.api.error.modelNotSupported": "Modèle {{model}} non pris en charge", diff --git a/packages/console/app/src/i18n/it.ts b/packages/console/app/src/i18n/it.ts index 68a6756f99..07da9434eb 100644 --- a/packages/console/app/src/i18n/it.ts +++ b/packages/console/app/src/i18n/it.ts @@ -251,7 +251,7 @@ export const dict = { "go.title": "OpenCode Go | Modelli di coding a basso costo per tutti", "go.meta.description": - "Go inizia a $5 per il primo mese, poi $10/mese, con generosi limiti di richiesta di 5 ore per GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, DeepSeek V4 Flash e Hy3 preview.", + "Go inizia a $5 per il primo mese, poi $10/mese, con generosi limiti di richiesta di 5 ore per GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro e DeepSeek V4 Flash.", "go.hero.title": "Modelli di coding a basso costo per tutti", "go.hero.body": "Go porta il coding agentico ai programmatori di tutto il mondo. Offrendo limiti generosi e un accesso affidabile ai modelli open source più capaci, in modo da poter costruire con agenti potenti senza preoccuparsi dei costi o della disponibilità.", @@ -300,7 +300,7 @@ export const dict = { "go.problem.item2": "Limiti generosi e accesso affidabile", "go.problem.item3": "Costruito per il maggior numero possibile di programmatori", "go.problem.item4": - "Include GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, DeepSeek V4 Flash e Hy3 preview", + "Include GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro e DeepSeek V4 Flash", "go.how.title": "Come funziona Go", "go.how.body": "Go inizia a $5 per il primo mese, poi $10/mese. Puoi usarlo con OpenCode o qualsiasi agente.", "go.how.step1.title": "Crea un account", @@ -325,7 +325,7 @@ export const dict = { "go.faq.a2": "Go include i modelli elencati di seguito, con limiti generosi e accesso affidabile.", "go.faq.q3": "Go è lo stesso di Zen?", "go.faq.a3": - "No. Zen è a consumo, mentre Go inizia a $5 per il primo mese, poi $10/mese, con limiti generosi e accesso affidabile ai modelli open source GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, DeepSeek V4 Flash e Hy3 preview.", + "No. Zen è a consumo, mentre Go inizia a $5 per il primo mese, poi $10/mese, con limiti generosi e accesso affidabile ai modelli open source GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro e DeepSeek V4 Flash.", "go.faq.q4": "Quanto costa Go?", "go.faq.a4.p1.beforePricing": "Go costa", "go.faq.a4.p1.pricingLink": "$5 il primo mese", @@ -349,7 +349,7 @@ export const dict = { "go.faq.q9": "Qual è la differenza tra i modelli gratuiti e Go?", "go.faq.a9": - "I modelli gratuiti includono Big Pickle più modelli promozionali disponibili al momento, con una quota di 200 richieste/giorno. Go include GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, DeepSeek V4 Flash e Hy3 preview con quote di richiesta più elevate applicate su finestre mobili (5 ore, settimanale e mensile), approssimativamente equivalenti a $12 ogni 5 ore, $30 a settimana e $60 al mese (il conteggio effettivo delle richieste varia in base al modello e all'utilizzo).", + "I modelli gratuiti includono Big Pickle più modelli promozionali disponibili al momento, con una quota di 200 richieste/giorno. Go include GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro e DeepSeek V4 Flash con quote di richiesta più elevate applicate su finestre mobili (5 ore, settimanale e mensile), approssimativamente equivalenti a $12 ogni 5 ore, $30 a settimana e $60 al mese (il conteggio effettivo delle richieste varia in base al modello e all'utilizzo).", "zen.api.error.rateLimitExceeded": "Limite di richieste superato. Riprova più tardi.", "zen.api.error.modelNotSupported": "Modello {{model}} non supportato", diff --git a/packages/console/app/src/i18n/ja.ts b/packages/console/app/src/i18n/ja.ts index 1918045e14..975728fe7e 100644 --- a/packages/console/app/src/i18n/ja.ts +++ b/packages/console/app/src/i18n/ja.ts @@ -250,7 +250,7 @@ export const dict = { "go.title": "OpenCode Go | すべての人のための低価格なコーディングモデル", "go.meta.description": - "Goは最初の月$5、その後$10/月で、GLM-5.1、GLM-5、Kimi K2.5、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.5 Plus、Qwen3.6 Plus、MiniMax M2.5、MiniMax M2.7、DeepSeek V4 Pro、DeepSeek V4 Flash、Hy3 previewに対して5時間のゆとりあるリクエスト上限があります。", + "Goは最初の月$5、その後$10/月で、GLM-5.1、GLM-5、Kimi K2.5、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.5 Plus、Qwen3.6 Plus、MiniMax M2.5、MiniMax M2.7、DeepSeek V4 Pro、DeepSeek V4 Flashに対して5時間のゆとりあるリクエスト上限があります。", "go.hero.title": "すべての人のための低価格なコーディングモデル", "go.hero.body": "Goは、世界中のプログラマーにエージェント型コーディングをもたらします。最も高性能なオープンソースモデルへの十分な制限と安定したアクセスを提供し、コストや可用性を気にすることなく強力なエージェントで構築できます。", @@ -300,7 +300,7 @@ export const dict = { "go.problem.item2": "十分な制限と安定したアクセス", "go.problem.item3": "できるだけ多くのプログラマーのために構築", "go.problem.item4": - "GLM-5.1、GLM-5、Kimi K2.5、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.5 Plus、Qwen3.6 Plus、MiniMax M2.5、MiniMax M2.7、DeepSeek V4 Pro、DeepSeek V4 Flash、Hy3 previewを含む", + "GLM-5.1、GLM-5、Kimi K2.5、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.5 Plus、Qwen3.6 Plus、MiniMax M2.5、MiniMax M2.7、DeepSeek V4 Pro、DeepSeek V4 Flashを含む", "go.how.title": "Goの仕組み", "go.how.body": "Goは最初の月$5、その後$10/月で始まります。OpenCodeまたは任意のエージェントで使えます。", "go.how.step1.title": "アカウントを作成", @@ -325,7 +325,7 @@ export const dict = { "go.faq.a2": "Go には、十分な利用上限と安定したアクセスを備えた、以下のモデルが含まれます。", "go.faq.q3": "GoはZenと同じですか?", "go.faq.a3": - "いいえ。Zenは従量課金制ですが、Goは最初の月$5、その後$10/月で始まり、GLM-5.1、GLM-5、Kimi K2.5、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.5 Plus、Qwen3.6 Plus、MiniMax M2.5、MiniMax M2.7、DeepSeek V4 Pro、DeepSeek V4 Flash、Hy3 previewのオープンソースモデルに対して、ゆとりある上限と信頼できるアクセスを提供します。", + "いいえ。Zenは従量課金制ですが、Goは最初の月$5、その後$10/月で始まり、GLM-5.1、GLM-5、Kimi K2.5、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.5 Plus、Qwen3.6 Plus、MiniMax M2.5、MiniMax M2.7、DeepSeek V4 Pro、DeepSeek V4 Flashのオープンソースモデルに対して、ゆとりある上限と信頼できるアクセスを提供します。", "go.faq.q4": "Goの料金は?", "go.faq.a4.p1.beforePricing": "Goは", "go.faq.a4.p1.pricingLink": "最初の月$5", @@ -349,7 +349,7 @@ export const dict = { "go.faq.q9": "無料モデルとGoの違いは何ですか?", "go.faq.a9": - "無料モデルにはBig Pickleと、その時点で利用可能なプロモーションモデルが含まれ、1日200リクエストの制限があります。GoにはGLM-5.1、GLM-5、Kimi K2.5、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.5 Plus、Qwen3.6 Plus、MiniMax M2.5、MiniMax M2.7、DeepSeek V4 Pro、DeepSeek V4 Flash、Hy3 previewが含まれ、ローリングウィンドウ(5時間、週間、月間)全体でより高いリクエスト制限が適用されます。これは概算で5時間あたり$12、週間$30、月間$60相当です(実際のリクエスト数はモデルと使用状況により異なります)。", + "無料モデルにはBig Pickleと、その時点で利用可能なプロモーションモデルが含まれ、1日200リクエストの制限があります。GoにはGLM-5.1、GLM-5、Kimi K2.5、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.5 Plus、Qwen3.6 Plus、MiniMax M2.5、MiniMax M2.7、DeepSeek V4 Pro、DeepSeek V4 Flashが含まれ、ローリングウィンドウ(5時間、週間、月間)全体でより高いリクエスト制限が適用されます。これは概算で5時間あたり$12、週間$30、月間$60相当です(実際のリクエスト数はモデルと使用状況により異なります)。", "zen.api.error.rateLimitExceeded": "レート制限を超えました。後でもう一度お試しください。", "zen.api.error.modelNotSupported": "モデル {{model}} はサポートされていません", diff --git a/packages/console/app/src/i18n/ko.ts b/packages/console/app/src/i18n/ko.ts index 66428a1e77..293c3eb7d9 100644 --- a/packages/console/app/src/i18n/ko.ts +++ b/packages/console/app/src/i18n/ko.ts @@ -247,7 +247,7 @@ export const dict = { "go.title": "OpenCode Go | 모두를 위한 저비용 코딩 모델", "go.meta.description": - "Go는 첫 달 $5, 이후 $10/월로 시작하며, GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, DeepSeek V4 Flash, Hy3 preview에 대해 넉넉한 5시간 요청 한도를 제공합니다.", + "Go는 첫 달 $5, 이후 $10/월로 시작하며, GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, DeepSeek V4 Flash에 대해 넉넉한 5시간 요청 한도를 제공합니다.", "go.hero.title": "모두를 위한 저비용 코딩 모델", "go.hero.body": "Go는 전 세계 프로그래머들에게 에이전트 코딩을 제공합니다. 가장 유능한 오픈 소스 모델에 대한 넉넉한 한도와 안정적인 액세스를 제공하므로, 비용이나 가용성 걱정 없이 강력한 에이전트로 빌드할 수 있습니다.", @@ -297,7 +297,7 @@ export const dict = { "go.problem.item2": "넉넉한 한도와 안정적인 액세스", "go.problem.item3": "가능한 한 많은 프로그래머를 위해 제작됨", "go.problem.item4": - "GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, DeepSeek V4 Flash, Hy3 preview 포함", + "GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, DeepSeek V4 Flash 포함", "go.how.title": "Go 작동 방식", "go.how.body": "Go는 첫 달 $5, 이후 $10/월로 시작합니다. OpenCode 또는 어떤 에이전트와도 함께 사용할 수 있습니다.", "go.how.step1.title": "계정 생성", @@ -321,7 +321,7 @@ export const dict = { "go.faq.a2": "Go에는 넉넉한 한도와 안정적인 액세스를 제공하는 아래 모델이 포함됩니다.", "go.faq.q3": "Go는 Zen과 같은가요?", "go.faq.a3": - "아니요. Zen은 종량제인 반면, Go는 첫 달 $5, 이후 $10/월로 시작하며, GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, DeepSeek V4 Flash, Hy3 preview 오픈 소스 모델에 대한 넉넉한 한도와 안정적인 액세스를 제공합니다.", + "아니요. Zen은 종량제인 반면, Go는 첫 달 $5, 이후 $10/월로 시작하며, GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, DeepSeek V4 Flash 오픈 소스 모델에 대한 넉넉한 한도와 안정적인 액세스를 제공합니다.", "go.faq.q4": "Go 비용은 얼마인가요?", "go.faq.a4.p1.beforePricing": "Go 비용은", "go.faq.a4.p1.pricingLink": "첫 달 $5", @@ -344,7 +344,7 @@ export const dict = { "go.faq.q9": "무료 모델과 Go의 차이점은 무엇인가요?", "go.faq.a9": - "무료 모델에는 Big Pickle과 당시 사용 가능한 프로모션 모델이 포함되며, 하루 200회 요청 할당량이 적용됩니다. Go는 GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, DeepSeek V4 Flash, Hy3 preview를 포함하며, 롤링 윈도우(5시간, 주간, 월간)에 걸쳐 더 높은 요청 할당량을 적용합니다. 이는 대략 5시간당 $12, 주당 $30, 월 $60에 해당합니다(실제 요청 수는 모델 및 사용량에 따라 다름).", + "무료 모델에는 Big Pickle과 당시 사용 가능한 프로모션 모델이 포함되며, 하루 200회 요청 할당량이 적용됩니다. Go는 GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, DeepSeek V4 Flash를 포함하며, 롤링 윈도우(5시간, 주간, 월간)에 걸쳐 더 높은 요청 할당량을 적용합니다. 이는 대략 5시간당 $12, 주당 $30, 월 $60에 해당합니다(실제 요청 수는 모델 및 사용량에 따라 다름).", "zen.api.error.rateLimitExceeded": "속도 제한을 초과했습니다. 나중에 다시 시도해 주세요.", "zen.api.error.modelNotSupported": "{{model}} 모델은 지원되지 않습니다", diff --git a/packages/console/app/src/i18n/no.ts b/packages/console/app/src/i18n/no.ts index 2a9e78aaa2..27b5522e32 100644 --- a/packages/console/app/src/i18n/no.ts +++ b/packages/console/app/src/i18n/no.ts @@ -251,7 +251,7 @@ export const dict = { "go.title": "OpenCode Go | Rimelige kodemodeller for alle", "go.meta.description": - "Go starter på $5 for den første måneden, deretter $10/måned, med sjenerøse 5-timers forespørselsgrenser for GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, DeepSeek V4 Flash og Hy3 preview.", + "Go starter på $5 for den første måneden, deretter $10/måned, med sjenerøse 5-timers forespørselsgrenser for GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro og DeepSeek V4 Flash.", "go.hero.title": "Rimelige kodemodeller for alle", "go.hero.body": "Go bringer agent-koding til programmerere over hele verden. Med rause grenser og pålitelig tilgang til de mest kapable åpen kildekode-modellene, kan du bygge med kraftige agenter uten å bekymre deg for kostnader eller tilgjengelighet.", @@ -300,7 +300,7 @@ export const dict = { "go.problem.item2": "Rause grenser og pålitelig tilgang", "go.problem.item3": "Bygget for så mange programmerere som mulig", "go.problem.item4": - "Inkluderer GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, DeepSeek V4 Flash og Hy3 preview", + "Inkluderer GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro og DeepSeek V4 Flash", "go.how.title": "Hvordan Go fungerer", "go.how.body": "Go starter på $5 for den første måneden, deretter $10/måned. Du kan bruke det med OpenCode eller hvilken som helst agent.", @@ -326,7 +326,7 @@ export const dict = { "go.faq.a2": "Go inkluderer modellene nedenfor, med høye grenser og pålitelig tilgang.", "go.faq.q3": "Er Go det samme som Zen?", "go.faq.a3": - "Nei. Zen er betaling etter bruk, mens Go starter på $5 for den første måneden, deretter $10/måned, med sjenerøse grenser og pålitelig tilgang til åpen kildekode-modellene GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, DeepSeek V4 Flash og Hy3 preview.", + "Nei. Zen er betaling etter bruk, mens Go starter på $5 for den første måneden, deretter $10/måned, med sjenerøse grenser og pålitelig tilgang til åpen kildekode-modellene GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro og DeepSeek V4 Flash.", "go.faq.q4": "Hva koster Go?", "go.faq.a4.p1.beforePricing": "Go koster", "go.faq.a4.p1.pricingLink": "$5 første måned", @@ -350,7 +350,7 @@ export const dict = { "go.faq.q9": "Hva er forskjellen mellom gratis modeller og Go?", "go.faq.a9": - "Gratis modeller inkluderer Big Pickle pluss kampanjemodeller tilgjengelig på det tidspunktet, med en kvote på 200 forespørsler/dag. Go inkluderer GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, DeepSeek V4 Flash og Hy3 preview med høyere kvoter håndhevet over rullerende vinduer (5 timer, ukentlig og månedlig), omtrent tilsvarende $12 per 5 timer, $30 per uke og $60 per måned (faktiske forespørselsantall varierer etter modell og bruk).", + "Gratis modeller inkluderer Big Pickle pluss kampanjemodeller tilgjengelig på det tidspunktet, med en kvote på 200 forespørsler/dag. Go inkluderer GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro og DeepSeek V4 Flash med høyere kvoter håndhevet over rullerende vinduer (5 timer, ukentlig og månedlig), omtrent tilsvarende $12 per 5 timer, $30 per uke og $60 per måned (faktiske forespørselsantall varierer etter modell og bruk).", "zen.api.error.rateLimitExceeded": "Rate limit overskredet. Vennligst prøv igjen senere.", "zen.api.error.modelNotSupported": "Modell {{model}} støttes ikke", diff --git a/packages/console/app/src/i18n/pl.ts b/packages/console/app/src/i18n/pl.ts index c21fc87630..7f8c849156 100644 --- a/packages/console/app/src/i18n/pl.ts +++ b/packages/console/app/src/i18n/pl.ts @@ -252,7 +252,7 @@ export const dict = { "go.title": "OpenCode Go | Niskokosztowe modele do kodowania dla każdego", "go.meta.description": - "Go zaczyna się od $5 za pierwszy miesiąc, potem $10/miesiąc, z hojnymi 5-godzinnymi limitami zapytań dla GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, DeepSeek V4 Flash i Hy3 preview.", + "Go zaczyna się od $5 za pierwszy miesiąc, potem $10/miesiąc, z hojnymi 5-godzinnymi limitami zapytań dla GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro i DeepSeek V4 Flash.", "go.hero.title": "Niskokosztowe modele do kodowania dla każdego", "go.hero.body": "Go udostępnia programowanie z agentami programistom na całym świecie. Oferuje hojne limity i niezawodny dostęp do najzdolniejszych modeli open source, dzięki czemu możesz budować za pomocą potężnych agentów, nie martwiąc się o koszty czy dostępność.", @@ -301,7 +301,7 @@ export const dict = { "go.problem.item2": "Hojne limity i niezawodny dostęp", "go.problem.item3": "Stworzony dla jak największej liczby programistów", "go.problem.item4": - "Zawiera GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, DeepSeek V4 Flash i Hy3 preview", + "Zawiera GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro i DeepSeek V4 Flash", "go.how.title": "Jak działa Go", "go.how.body": "Go zaczyna się od $5 za pierwszy miesiąc, potem $10/miesiąc. Możesz go używać z OpenCode lub dowolnym agentem.", @@ -327,7 +327,7 @@ export const dict = { "go.faq.a2": "Go obejmuje poniższe modele z wysokimi limitami i niezawodnym dostępem.", "go.faq.q3": "Czy Go to to samo co Zen?", "go.faq.a3": - "Nie. Zen to model płatności za użycie, podczas gdy Go zaczyna się od $5 za pierwszy miesiąc, potem $10/miesiąc, z hojnymi limitami i niezawodnym dostępem do modeli open source GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, DeepSeek V4 Flash i Hy3 preview.", + "Nie. Zen to model płatności za użycie, podczas gdy Go zaczyna się od $5 za pierwszy miesiąc, potem $10/miesiąc, z hojnymi limitami i niezawodnym dostępem do modeli open source GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro i DeepSeek V4 Flash.", "go.faq.q4": "Ile kosztuje Go?", "go.faq.a4.p1.beforePricing": "Go kosztuje", "go.faq.a4.p1.pricingLink": "$5 za pierwszy miesiąc", @@ -351,7 +351,7 @@ export const dict = { "go.faq.q9": "Jaka jest różnica między darmowymi modelami a Go?", "go.faq.a9": - "Darmowe modele obejmują Big Pickle oraz modele promocyjne dostępne w danym momencie, z limitem 200 zapytań/dzień. Go zawiera GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, DeepSeek V4 Flash i Hy3 preview z wyższymi limitami zapytań egzekwowanymi w oknach kroczących (5-godzinnych, tygodniowych i miesięcznych), w przybliżeniu równoważnymi $12 na 5 godzin, $30 tygodniowo i $60 miesięcznie (rzeczywista liczba zapytań zależy od modelu i użycia).", + "Darmowe modele obejmują Big Pickle oraz modele promocyjne dostępne w danym momencie, z limitem 200 zapytań/dzień. Go zawiera GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro i DeepSeek V4 Flash z wyższymi limitami zapytań egzekwowanymi w oknach kroczących (5-godzinnych, tygodniowych i miesięcznych), w przybliżeniu równoważnymi $12 na 5 godzin, $30 tygodniowo i $60 miesięcznie (rzeczywista liczba zapytań zależy od modelu i użycia).", "zen.api.error.rateLimitExceeded": "Przekroczono limit zapytań. Spróbuj ponownie później.", "zen.api.error.modelNotSupported": "Model {{model}} nie jest obsługiwany", diff --git a/packages/console/app/src/i18n/ru.ts b/packages/console/app/src/i18n/ru.ts index df096db032..4ac54c2ac0 100644 --- a/packages/console/app/src/i18n/ru.ts +++ b/packages/console/app/src/i18n/ru.ts @@ -255,7 +255,7 @@ export const dict = { "go.title": "OpenCode Go | Недорогие модели для кодинга для всех", "go.meta.description": - "Go начинается с $5 за первый месяц, затем $10/месяц, с щедрыми лимитами запросов за 5 часов для GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, DeepSeek V4 Flash и Hy3 preview.", + "Go начинается с $5 за первый месяц, затем $10/месяц, с щедрыми лимитами запросов за 5 часов для GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro и DeepSeek V4 Flash.", "go.hero.title": "Недорогие модели для кодинга для всех", "go.hero.body": "Go открывает доступ к агентам-программистам разработчикам по всему миру. Предлагая щедрые лимиты и надежный доступ к наиболее способным моделям с открытым исходным кодом, вы можете создавать проекты с мощными агентами, не беспокоясь о затратах или доступности.", @@ -305,7 +305,7 @@ export const dict = { "go.problem.item2": "Щедрые лимиты и надежный доступ", "go.problem.item3": "Создан для максимального числа программистов", "go.problem.item4": - "Включает GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, DeepSeek V4 Flash и Hy3 preview", + "Включает GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro и DeepSeek V4 Flash", "go.how.title": "Как работает Go", "go.how.body": "Go начинается с $5 за первый месяц, затем $10/месяц. Вы можете использовать его с OpenCode или любым агентом.", @@ -331,7 +331,7 @@ export const dict = { "go.faq.a2": "Go включает перечисленные ниже модели с щедрыми лимитами и надежным доступом.", "go.faq.q3": "Go — это то же самое, что и Zen?", "go.faq.a3": - "Нет. Zen - это оплата по мере использования, в то время как Go начинается с $5 за первый месяц, затем $10/месяц, с щедрыми лимитами и надежным доступом к моделям с открытым исходным кодом GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, DeepSeek V4 Flash и Hy3 preview.", + "Нет. Zen - это оплата по мере использования, в то время как Go начинается с $5 за первый месяц, затем $10/месяц, с щедрыми лимитами и надежным доступом к моделям с открытым исходным кодом GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro и DeepSeek V4 Flash.", "go.faq.q4": "Сколько стоит Go?", "go.faq.a4.p1.beforePricing": "Go стоит", "go.faq.a4.p1.pricingLink": "$5 за первый месяц", @@ -355,7 +355,7 @@ export const dict = { "go.faq.q9": "В чем разница между бесплатными моделями и Go?", "go.faq.a9": - "Бесплатные модели включают Big Pickle плюс промо-модели, доступные на данный момент, с квотой 200 запросов/день. Go включает GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, DeepSeek V4 Flash и Hy3 preview с более высокими квотами запросов, применяемыми в скользящих окнах (5 часов, неделя и месяц), что примерно эквивалентно $12 за 5 часов, $30 в неделю и $60 в месяц (фактическое количество запросов зависит от модели и использования).", + "Бесплатные модели включают Big Pickle плюс промо-модели, доступные на данный момент, с квотой 200 запросов/день. Go включает GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro и DeepSeek V4 Flash с более высокими квотами запросов, применяемыми в скользящих окнах (5 часов, неделя и месяц), что примерно эквивалентно $12 за 5 часов, $30 в неделю и $60 в месяц (фактическое количество запросов зависит от модели и использования).", "zen.api.error.rateLimitExceeded": "Превышен лимит запросов. Пожалуйста, попробуйте позже.", "zen.api.error.modelNotSupported": "Модель {{model}} не поддерживается", diff --git a/packages/console/app/src/i18n/th.ts b/packages/console/app/src/i18n/th.ts index 2f71846978..280b9d9fa8 100644 --- a/packages/console/app/src/i18n/th.ts +++ b/packages/console/app/src/i18n/th.ts @@ -250,7 +250,7 @@ export const dict = { "go.title": "OpenCode Go | โมเดลเขียนโค้ดราคาประหยัดสำหรับทุกคน", "go.meta.description": - "Go เริ่มต้นที่ $5 สำหรับเดือนแรก จากนั้น $10/เดือน พร้อมขีดจำกัดคำขอ 5 ชั่วโมงที่เอื้อเฟื้อสำหรับ GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, DeepSeek V4 Flash และ Hy3 preview", + "Go เริ่มต้นที่ $5 สำหรับเดือนแรก จากนั้น $10/เดือน พร้อมขีดจำกัดคำขอ 5 ชั่วโมงที่เอื้อเฟื้อสำหรับ GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro และ DeepSeek V4 Flash", "go.hero.title": "โมเดลเขียนโค้ดราคาประหยัดสำหรับทุกคน", "go.hero.body": "Go นำการเขียนโค้ดแบบเอเจนต์มาสู่นักเขียนโปรแกรมทั่วโลก เสนอขีดจำกัดที่กว้างขวางและการเข้าถึงโมเดลโอเพนซอร์สที่มีความสามารถสูงสุดได้อย่างน่าเชื่อถือ เพื่อให้คุณสามารถสร้างสรรค์ด้วยเอเจนต์ที่ทรงพลังโดยไม่ต้องกังวลเรื่องค่าใช้จ่ายหรือความพร้อมใช้งาน", @@ -298,7 +298,7 @@ export const dict = { "go.problem.item2": "ขีดจำกัดที่กว้างขวางและการเข้าถึงที่เชื่อถือได้", "go.problem.item3": "สร้างขึ้นเพื่อโปรแกรมเมอร์จำนวนมากที่สุดเท่าที่จะเป็นไปได้", "go.problem.item4": - "รวมถึง GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, DeepSeek V4 Flash และ Hy3 preview", + "รวมถึง GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro และ DeepSeek V4 Flash", "go.how.title": "Go ทำงานอย่างไร", "go.how.body": "Go เริ่มต้นที่ $5 สำหรับเดือนแรก จากนั้น $10/เดือน คุณสามารถใช้กับ OpenCode หรือเอเจนต์ใดก็ได้", "go.how.step1.title": "สร้างบัญชี", @@ -323,7 +323,7 @@ export const dict = { "go.faq.a2": "Go รวมโมเดลด้านล่างนี้ พร้อมขีดจำกัดที่มากและการเข้าถึงที่เชื่อถือได้", "go.faq.q3": "Go เหมือนกับ Zen หรือไม่?", "go.faq.a3": - "ไม่ Zen เป็นแบบจ่ายตามการใช้งาน ในขณะที่ Go เริ่มต้นที่ $5 สำหรับเดือนแรก จากนั้น $10/เดือน พร้อมขีดจำกัดที่เอื้อเฟื้อและการเข้าถึงโมเดลโอเพนซอร์ส GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, DeepSeek V4 Flash และ Hy3 preview อย่างเชื่อถือได้", + "ไม่ Zen เป็นแบบจ่ายตามการใช้งาน ในขณะที่ Go เริ่มต้นที่ $5 สำหรับเดือนแรก จากนั้น $10/เดือน พร้อมขีดจำกัดที่เอื้อเฟื้อและการเข้าถึงโมเดลโอเพนซอร์ส GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro และ DeepSeek V4 Flash อย่างเชื่อถือได้", "go.faq.q4": "Go ราคาเท่าไหร่?", "go.faq.a4.p1.beforePricing": "Go ราคา", "go.faq.a4.p1.pricingLink": "$5 เดือนแรก", @@ -346,7 +346,7 @@ export const dict = { "go.faq.q9": "ความแตกต่างระหว่างโมเดลฟรีและ Go คืออะไร?", "go.faq.a9": - "โมเดลฟรีรวมถึง Big Pickle บวกกับโมเดลโปรโมชั่นที่มีให้ในขณะนั้น ด้วยโควต้า 200 คำขอ/วัน Go รวมถึง GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, DeepSeek V4 Flash และ Hy3 preview ที่มีโควต้าคำขอสูงกว่า ซึ่งบังคับใช้ผ่านช่วงเวลาหมุนเวียน (5 ชั่วโมง, รายสัปดาห์ และรายเดือน) เทียบเท่าประมาณ $12 ต่อ 5 ชั่วโมง, $30 ต่อสัปดาห์ และ $60 ต่อเดือน (จำนวนคำขอจริงจะแตกต่างกันไปตามโมเดลและการใช้งาน)", + "โมเดลฟรีรวมถึง Big Pickle บวกกับโมเดลโปรโมชั่นที่มีให้ในขณะนั้น ด้วยโควต้า 200 คำขอ/วัน Go รวมถึง GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro และ DeepSeek V4 Flash ที่มีโควต้าคำขอสูงกว่า ซึ่งบังคับใช้ผ่านช่วงเวลาหมุนเวียน (5 ชั่วโมง, รายสัปดาห์ และรายเดือน) เทียบเท่าประมาณ $12 ต่อ 5 ชั่วโมง, $30 ต่อสัปดาห์ และ $60 ต่อเดือน (จำนวนคำขอจริงจะแตกต่างกันไปตามโมเดลและการใช้งาน)", "zen.api.error.rateLimitExceeded": "เกินขีดจำกัดอัตราการใช้งาน กรุณาลองใหม่ในภายหลัง", "zen.api.error.modelNotSupported": "ไม่รองรับโมเดล {{model}}", diff --git a/packages/console/app/src/i18n/tr.ts b/packages/console/app/src/i18n/tr.ts index 732a304e85..a8f449dc47 100644 --- a/packages/console/app/src/i18n/tr.ts +++ b/packages/console/app/src/i18n/tr.ts @@ -253,7 +253,7 @@ export const dict = { "go.title": "OpenCode Go | Herkes için düşük maliyetli kodlama modelleri", "go.meta.description": - "Go ilk ay $5, sonrasında ayda 10$ fiyatıyla başlar; GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, DeepSeek V4 Flash ve Hy3 preview için cömert 5 saatlik istek limitleri sunar.", + "Go ilk ay $5, sonrasında ayda 10$ fiyatıyla başlar; GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro ve DeepSeek V4 Flash için cömert 5 saatlik istek limitleri sunar.", "go.hero.title": "Herkes için düşük maliyetli kodlama modelleri", "go.hero.body": "Go, dünya çapındaki programcılara ajan tabanlı kodlama getiriyor. En yetenekli açık kaynaklı modellere cömert limitler ve güvenilir erişim sunarak, maliyet veya erişilebilirlik konusunda endişelenmeden güçlü ajanlarla geliştirme yapmanızı sağlar.", @@ -303,7 +303,7 @@ export const dict = { "go.problem.item2": "Cömert limitler ve güvenilir erişim", "go.problem.item3": "Mümkün olduğunca çok programcı için geliştirildi", "go.problem.item4": - "GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, DeepSeek V4 Flash ve Hy3 preview içerir", + "GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro ve DeepSeek V4 Flash içerir", "go.how.title": "Go nasıl çalışır?", "go.how.body": "Go ilk ay $5, sonrasında ayda 10$ fiyatıyla başlar. OpenCode veya herhangi bir ajanla kullanabilirsiniz.", @@ -329,7 +329,7 @@ export const dict = { "go.faq.a2": "Go, aşağıda listelenen modelleri cömert limitler ve güvenilir erişimle sunar.", "go.faq.q3": "Go, Zen ile aynı mı?", "go.faq.a3": - "Hayır. Zen kullandıkça öde modelidir, Go ise ilk ay $5, sonrasında ayda 10$ fiyatıyla başlar; GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, DeepSeek V4 Flash ve Hy3 preview açık kaynak modellerine cömert limitler ve güvenilir erişim sunar.", + "Hayır. Zen kullandıkça öde modelidir, Go ise ilk ay $5, sonrasında ayda 10$ fiyatıyla başlar; GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro ve DeepSeek V4 Flash açık kaynak modellerine cömert limitler ve güvenilir erişim sunar.", "go.faq.q4": "Go ne kadar?", "go.faq.a4.p1.beforePricing": "Go'nun maliyeti", "go.faq.a4.p1.pricingLink": "İlk ay $5", @@ -353,7 +353,7 @@ export const dict = { "go.faq.q9": "Ücretsiz modeller ve Go arasındaki fark nedir?", "go.faq.a9": - "Ücretsiz modeller, günlük 200 istek kotası ile Big Pickle ve o sırada mevcut olan promosyonel modelleri içerir. Go ise GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro, DeepSeek V4 Flash ve Hy3 preview modellerini; yuvarlanan pencereler (5 saatlik, haftalık ve aylık) üzerinden uygulanan daha yüksek istek kotalarıyla içerir. Bu kotalar kabaca her 5 saatte 12$, haftada 30$ ve ayda 60$ değerine eşdeğerdir (gerçek istek sayıları modele ve kullanıma göre değişir).", + "Ücretsiz modeller, günlük 200 istek kotası ile Big Pickle ve o sırada mevcut olan promosyonel modelleri içerir. Go ise GLM-5.1, GLM-5, Kimi K2.5, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5, MiniMax M2.7, DeepSeek V4 Pro ve DeepSeek V4 Flash modellerini; yuvarlanan pencereler (5 saatlik, haftalık ve aylık) üzerinden uygulanan daha yüksek istek kotalarıyla içerir. Bu kotalar kabaca her 5 saatte 12$, haftada 30$ ve ayda 60$ değerine eşdeğerdir (gerçek istek sayıları modele ve kullanıma göre değişir).", "zen.api.error.rateLimitExceeded": "İstek limiti aşıldı. Lütfen daha sonra tekrar deneyin.", "zen.api.error.modelNotSupported": "{{model}} modeli desteklenmiyor", diff --git a/packages/console/app/src/i18n/zh.ts b/packages/console/app/src/i18n/zh.ts index 70623a9264..ced0060ca0 100644 --- a/packages/console/app/src/i18n/zh.ts +++ b/packages/console/app/src/i18n/zh.ts @@ -241,7 +241,7 @@ export const dict = { "go.title": "OpenCode Go | 人人可用的低成本编程模型", "go.meta.description": - "Go 首月 $5,之后 $10/月,提供对 GLM-5.1、GLM-5、Kimi K2.5、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.5 Plus、Qwen3.6 Plus、MiniMax M2.5、MiniMax M2.7、DeepSeek V4 Pro, DeepSeek V4 Flash 和 Hy3 preview 的 5 小时充裕请求额度。", + "Go 首月 $5,之后 $10/月,提供对 GLM-5.1、GLM-5、Kimi K2.5、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.5 Plus、Qwen3.6 Plus、MiniMax M2.5、MiniMax M2.7、DeepSeek V4 Pro 和 DeepSeek V4 Flash 的 5 小时充裕请求额度。", "go.hero.title": "人人可用的低成本编程模型", "go.hero.body": "Go 将代理编程带给全世界的程序员。提供充裕的限额和对最强大的开源模型的可靠访问,让您可以利用强大的代理进行构建,而无需担心成本或可用性。", @@ -289,7 +289,7 @@ export const dict = { "go.problem.item2": "充裕的限额和可靠的访问", "go.problem.item3": "为尽可能多的程序员打造", "go.problem.item4": - "包含 GLM-5.1, GLM-5, Kimi K2.5、Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5、MiniMax M2.7、DeepSeek V4 Pro, DeepSeek V4 Flash 和 Hy3 preview", + "包含 GLM-5.1, GLM-5, Kimi K2.5、Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5、MiniMax M2.7、DeepSeek V4 Pro 和 DeepSeek V4 Flash", "go.how.title": "Go 如何工作", "go.how.body": "Go 起价为首月 $5,之后 $10/月。您可以将其与 OpenCode 或任何代理搭配使用。", "go.how.step1.title": "创建账户", @@ -311,7 +311,7 @@ export const dict = { "go.faq.a2": "Go 包含下方列出的模型,提供充足的限额和可靠的访问。", "go.faq.q3": "Go 和 Zen 一样吗?", "go.faq.a3": - "不。Zen 是按量付费,而 Go 首月 $5,之后 $10/月,提供充裕的额度,并可可靠地访问 GLM-5.1、GLM-5、Kimi K2.5、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.5 Plus、Qwen3.6 Plus、MiniMax M2.5、MiniMax M2.7、DeepSeek V4 Pro, DeepSeek V4 Flash 和 Hy3 preview 等开源模型。", + "不。Zen 是按量付费,而 Go 首月 $5,之后 $10/月,提供充裕的额度,并可可靠地访问 GLM-5.1、GLM-5、Kimi K2.5、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.5 Plus、Qwen3.6 Plus、MiniMax M2.5、MiniMax M2.7、DeepSeek V4 Pro 和 DeepSeek V4 Flash 等开源模型。", "go.faq.q4": "Go 多少钱?", "go.faq.a4.p1.beforePricing": "Go 费用为", "go.faq.a4.p1.pricingLink": "首月 $5", @@ -333,7 +333,7 @@ export const dict = { "go.faq.q9": "免费模型和 Go 之间的区别是什么?", "go.faq.a9": - "免费模型包含 Big Pickle 加上当时可用的促销模型,每天有 200 次请求的配额。Go 包含 GLM-5.1, GLM-5, Kimi K2.5、Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5、MiniMax M2.7、DeepSeek V4 Pro, DeepSeek V4 Flash 和 Hy3 preview,并在滚动窗口(5 小时、每周和每月)内执行更高的请求配额,大致相当于每 5 小时 $12、每周 $30 和每月 $60(实际请求计数因模型和使用情况而异)。", + "免费模型包含 Big Pickle 加上当时可用的促销模型,每天有 200 次请求的配额。Go 包含 GLM-5.1, GLM-5, Kimi K2.5、Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.5 Plus, Qwen3.6 Plus, MiniMax M2.5、MiniMax M2.7、DeepSeek V4 Pro 和 DeepSeek V4 Flash,并在滚动窗口(5 小时、每周和每月)内执行更高的请求配额,大致相当于每 5 小时 $12、每周 $30 和每月 $60(实际请求计数因模型和使用情况而异)。", "zen.api.error.rateLimitExceeded": "超出速率限制。请稍后重试。", "zen.api.error.modelNotSupported": "不支持模型 {{model}}", diff --git a/packages/console/app/src/i18n/zht.ts b/packages/console/app/src/i18n/zht.ts index aeb45c601c..e3e374a329 100644 --- a/packages/console/app/src/i18n/zht.ts +++ b/packages/console/app/src/i18n/zht.ts @@ -241,7 +241,7 @@ export const dict = { "go.title": "OpenCode Go | 低成本全民編碼模型", "go.meta.description": - "Go 首月 $5,之後 $10/月,提供對 GLM-5.1、GLM-5、Kimi K2.5、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.5 Plus、Qwen3.6 Plus、MiniMax M2.5、MiniMax M2.7、DeepSeek V4 Pro, DeepSeek V4 Flash 和 Hy3 preview 的 5 小時充裕請求額度。", + "Go 首月 $5,之後 $10/月,提供對 GLM-5.1、GLM-5、Kimi K2.5、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.5 Plus、Qwen3.6 Plus、MiniMax M2.5、MiniMax M2.7、DeepSeek V4 Pro 和 DeepSeek V4 Flash 的 5 小時充裕請求額度。", "go.hero.title": "低成本全民編碼模型", "go.hero.body": "Go 將代理編碼帶給全世界的程式設計師。提供寬裕的限額以及對最強大開源模型的穩定存取,讓你可以使用強大的代理進行構建,而無需擔心成本或可用性。", @@ -289,7 +289,7 @@ export const dict = { "go.problem.item2": "寬裕的限額與穩定存取", "go.problem.item3": "專為盡可能多的程式設計師打造", "go.problem.item4": - "包含 GLM-5.1、GLM-5、Kimi K2.5、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.5 Plus、Qwen3.6 Plus、MiniMax M2.5、MiniMax M2.7、DeepSeek V4 Pro, DeepSeek V4 Flash 與 Hy3 preview", + "包含 GLM-5.1、GLM-5、Kimi K2.5、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.5 Plus、Qwen3.6 Plus、MiniMax M2.5、MiniMax M2.7、DeepSeek V4 Pro 與 DeepSeek V4 Flash", "go.how.title": "Go 如何運作", "go.how.body": "Go 起價為首月 $5,之後 $10/月。您可以將其與 OpenCode 或任何代理搭配使用。", "go.how.step1.title": "建立帳號", @@ -311,7 +311,7 @@ export const dict = { "go.faq.a2": "Go 包含下方列出的模型,提供充足的額度與穩定的存取。", "go.faq.q3": "Go 與 Zen 一樣嗎?", "go.faq.a3": - "不。Zen 是按量付費,而 Go 首月 $5,之後 $10/月,提供充裕的額度,並可可靠地存取 GLM-5.1、GLM-5、Kimi K2.5、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.5 Plus、Qwen3.6 Plus、MiniMax M2.5、MiniMax M2.7、DeepSeek V4 Pro, DeepSeek V4 Flash 和 Hy3 preview 等開源模型。", + "不。Zen 是按量付費,而 Go 首月 $5,之後 $10/月,提供充裕的額度,並可可靠地存取 GLM-5.1、GLM-5、Kimi K2.5、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.5 Plus、Qwen3.6 Plus、MiniMax M2.5、MiniMax M2.7、DeepSeek V4 Pro 和 DeepSeek V4 Flash 等開源模型。", "go.faq.q4": "Go 費用是多少?", "go.faq.a4.p1.beforePricing": "Go 費用為", "go.faq.a4.p1.pricingLink": "首月 $5", @@ -333,7 +333,7 @@ export const dict = { "go.faq.q9": "免費模型與 Go 有什麼區別?", "go.faq.a9": - "免費模型包括 Big Pickle 以及當時可用的促銷模型,配額為 200 次請求/天。Go 包括 GLM-5.1、GLM-5、Kimi K2.5、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.5 Plus、Qwen3.6 Plus、MiniMax M2.5、MiniMax M2.7、DeepSeek V4 Pro, DeepSeek V4 Flash 與 Hy3 preview,並在滾動視窗(5 小時、每週和每月)內執行更高的請求配額,大約相當於每 5 小時 $12、每週 $30 和每月 $60(實際請求數因模型和使用情況而異)。", + "免費模型包括 Big Pickle 以及當時可用的促銷模型,配額為 200 次請求/天。Go 包括 GLM-5.1、GLM-5、Kimi K2.5、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.5 Plus、Qwen3.6 Plus、MiniMax M2.5、MiniMax M2.7、DeepSeek V4 Pro 與 DeepSeek V4 Flash,並在滾動視窗(5 小時、每週和每月)內執行更高的請求配額,大約相當於每 5 小時 $12、每週 $30 和每月 $60(實際請求數因模型和使用情況而異)。", "zen.api.error.rateLimitExceeded": "超出頻率限制。請稍後再試。", "zen.api.error.modelNotSupported": "不支援模型 {{model}}", diff --git a/packages/console/app/src/routes/go/index.tsx b/packages/console/app/src/routes/go/index.tsx index aea2ef61a9..71102c7227 100644 --- a/packages/console/app/src/routes/go/index.tsx +++ b/packages/console/app/src/routes/go/index.tsx @@ -12,7 +12,7 @@ import { Footer } from "~/component/footer" import { Header } from "~/component/header" import { config } from "~/config" import { getLastSeenWorkspaceID } from "../workspace/common" -import { IconMiniMax, IconMiMo, IconZai, IconAlibaba, IconDeepSeek, IconHunyuan } from "~/component/icon" +import { IconMiniMax, IconMiMo, IconZai, IconAlibaba, IconDeepSeek } from "~/component/icon" import { useI18n } from "~/context/i18n" import { useLanguage } from "~/context/language" import { LocaleLinks } from "~/component/locale-links" @@ -35,7 +35,6 @@ const models = [ { name: "MiniMax M2.5", provider: "MiniMax" }, { name: "DeepSeek V4 Pro", provider: "DeepSeek" }, { name: "DeepSeek V4 Flash", provider: "DeepSeek" }, - { name: "Hy3 preview", provider: "Tencent" }, ] function LimitsGraph(props: { href: string }) { @@ -327,9 +326,6 @@ export default function Home() {
    -
    - -
    {/*
    diff --git a/packages/console/app/src/routes/workspace/[id]/go/lite-section.tsx b/packages/console/app/src/routes/workspace/[id]/go/lite-section.tsx index d12bc6ce0b..eba52b0e17 100644 --- a/packages/console/app/src/routes/workspace/[id]/go/lite-section.tsx +++ b/packages/console/app/src/routes/workspace/[id]/go/lite-section.tsx @@ -297,7 +297,6 @@ export function LiteSection() {
  • Qwen3.6 Plus
  • DeepSeek V4 Pro
  • DeepSeek V4 Flash
  • -
  • Hy3 preview
  • {i18n.t("workspace.lite.promo.footer")}

    diff --git a/packages/web/src/content/docs/ar/go.mdx b/packages/web/src/content/docs/ar/go.mdx index 5eb0e477c9..81f885335c 100644 --- a/packages/web/src/content/docs/ar/go.mdx +++ b/packages/web/src/content/docs/ar/go.mdx @@ -65,7 +65,6 @@ OpenCode Go حاليًا في المرحلة التجريبية. - **MiniMax M2.7** - **DeepSeek V4 Pro** - **DeepSeek V4 Flash** -- **Hy3 preview** قد تتغير قائمة النماذج مع استمرارنا في اختبار نماذج جديدة وإضافتها. @@ -97,7 +96,6 @@ OpenCode Go حاليًا في المرحلة التجريبية. | Qwen3.5 Plus | 10,200 | 25,200 | 50,500 | | DeepSeek V4 Pro | 3,450 | 8,550 | 17,150 | | DeepSeek V4 Flash | 31,650 | 79,050 | 158,150 | -| Hy3 preview | 1,875 | 4,690 | 9,375 | تستند التقديرات إلى متوسطات أنماط الطلبات المرصودة: @@ -110,7 +108,6 @@ OpenCode Go حاليًا في المرحلة التجريبية. - Qwen3.6 Plus — ‏500 input، و57,000 cached، و190 output tokens لكل طلب - MiMo-V2.5 — ‏1000 input، و60,000 cached، و140 output tokens لكل طلب - MiMo-V2.5-Pro — ‏350 input، و41,000 cached، و250 output tokens لكل طلب -- Hy3 preview — 800 input, 50,000 cached, 400 output tokens per request يمكنك تتبّع استخدامك الحالي في **console**. @@ -146,7 +143,6 @@ OpenCode Go حاليًا في المرحلة التجريبية. | MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` | | Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` | -| Hy3 preview | hy3-preview | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | يستخدم [model id](/docs/config/#models) في إعدادات OpenCode لديك التنسيق `opencode-go/`. على سبيل المثال، بالنسبة إلى Kimi K2.6، ستستخدم `opencode-go/kimi-k2.6` في إعداداتك. diff --git a/packages/web/src/content/docs/ar/zen.mdx b/packages/web/src/content/docs/ar/zen.mdx index e73c56b0aa..33fd9493ba 100644 --- a/packages/web/src/content/docs/ar/zen.mdx +++ b/packages/web/src/content/docs/ar/zen.mdx @@ -98,6 +98,7 @@ OpenCode Zen هي بوابة AI تتيح لك الوصول إلى هذه الن | Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Ling 2.6 Flash | ling-2.6-flash | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Hy3 Preview Free | hy3-preview-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Nemotron 3 Super Free | nemotron-3-super-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | يستخدم [معرّف النموذج](/docs/config/#models) في إعدادات OpenCode الصيغة `opencode/`. على سبيل المثال، بالنسبة إلى GPT 5.5، ستستخدم `opencode/gpt-5.5` في إعداداتك. @@ -123,6 +124,7 @@ https://opencode.ai/zen/v1/models | Big Pickle | Free | Free | Free | - | | MiniMax M2.5 Free | Free | Free | Free | - | | Ling 2.6 Flash Free | Free | Free | Free | - | +| Hy3 Preview Free | Free | Free | Free | - | | Nemotron 3 Super Free | Free | Free | Free | - | | MiniMax M2.7 | $0.30 | $1.20 | $0.06 | $0.375 | | MiniMax M2.5 | $0.30 | $1.20 | $0.06 | $0.375 | @@ -175,6 +177,7 @@ https://opencode.ai/zen/v1/models - MiniMax M2.5 Free متاح على OpenCode لفترة محدودة. يستخدم الفريق هذه الفترة لجمع الملاحظات وتحسين النموذج. - Ling 2.6 Flash Free متاح على OpenCode لفترة محدودة. يستخدم الفريق هذه الفترة لجمع الملاحظات وتحسين النموذج. +- Hy3 Preview Flash Free متاح على OpenCode لفترة محدودة. يستخدم الفريق هذه الفترة لجمع الملاحظات وتحسين النموذج. - Nemotron 3 Super Free متاح على OpenCode لفترة محدودة. يستخدم الفريق هذه الفترة لجمع الملاحظات وتحسين النموذج. - Big Pickle نموذج خفي ومتاح مجانا على OpenCode لفترة محدودة. يستخدم الفريق هذه الفترة لجمع الملاحظات وتحسين النموذج. @@ -227,6 +230,7 @@ https://opencode.ai/zen/v1/models - Big Pickle: خلال فترته المجانية، قد تُستخدم البيانات المجمعة لتحسين النموذج. - MiniMax M2.5 Free: خلال فترته المجانية، قد تُستخدم البيانات المجمعة لتحسين النموذج. - Ling 2.6 Flash Free: خلال فترته المجانية، قد تُستخدم البيانات المجمعة لتحسين النموذج. +- Hy3 Preview Free: خلال فترته المجانية، قد تُستخدم البيانات المجمعة لتحسين النموذج. - Nemotron 3 Super Free (نقاط نهاية NVIDIA المجانية): يُقدَّم بموجب [شروط خدمة النسخة التجريبية من واجهة NVIDIA API](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). للاستخدام التجريبي فقط، وليس للإنتاج أو البيانات الحساسة. تقوم NVIDIA بتسجيل المطالبات والمخرجات لتحسين نماذجها وخدماتها. لا ترسل بيانات شخصية أو سرية. - OpenAI APIs: يتم الاحتفاظ بالطلبات لمدة 30 يوما وفقا لـ [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data). - Anthropic APIs: يتم الاحتفاظ بالطلبات لمدة 30 يوما وفقا لـ [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage). diff --git a/packages/web/src/content/docs/bs/go.mdx b/packages/web/src/content/docs/bs/go.mdx index d5b46cd660..d2df6aaad8 100644 --- a/packages/web/src/content/docs/bs/go.mdx +++ b/packages/web/src/content/docs/bs/go.mdx @@ -75,7 +75,6 @@ Trenutna lista modela uključuje: - **MiniMax M2.7** - **DeepSeek V4 Pro** - **DeepSeek V4 Flash** -- **Hy3 preview** Lista modela se može mijenjati dok testiramo i dodajemo nove. @@ -107,7 +106,6 @@ Tabela ispod pruža procijenjeni broj zahtjeva na osnovu tipičnih obrazaca kori | Qwen3.5 Plus | 10,200 | 25,200 | 50,500 | | DeepSeek V4 Pro | 3,450 | 8,550 | 17,150 | | DeepSeek V4 Flash | 31,650 | 79,050 | 158,150 | -| Hy3 preview | 1,875 | 4,690 | 9,375 | Procjene se zasnivaju na zapaženim prosječnim obrascima zahtjeva: @@ -120,7 +118,6 @@ Procjene se zasnivaju na zapaženim prosječnim obrascima zahtjeva: - Qwen3.6 Plus — 500 ulaznih, 57,000 keširanih, 190 izlaznih tokena po zahtjevu - MiMo-V2.5 — 1000 ulaznih, 60,000 keširanih, 140 izlaznih tokena po zahtjevu - MiMo-V2.5-Pro — 350 ulaznih, 41,000 keširanih, 250 izlaznih tokena po zahtjevu -- Hy3 preview — 800 input, 50,000 cached, 400 output tokens per request Svoju trenutnu potrošnju možete pratiti u **konzoli**. @@ -158,7 +155,6 @@ Također možete pristupiti Go modelima putem sljedećih API endpointa. | MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` | | Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` | -| Hy3 preview | hy3-preview | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | [Model id](/docs/config/#models) u vašoj OpenCode konfiguraciji koristi format `opencode-go/`. Na primjer, za Kimi K2.6, koristili biste diff --git a/packages/web/src/content/docs/bs/zen.mdx b/packages/web/src/content/docs/bs/zen.mdx index a5487ade7f..3723cbaa3c 100644 --- a/packages/web/src/content/docs/bs/zen.mdx +++ b/packages/web/src/content/docs/bs/zen.mdx @@ -103,6 +103,7 @@ Našim modelima možete pristupiti i preko sljedećih API endpointa. | Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Ling 2.6 Flash | ling-2.6-flash | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Hy3 Preview Free | hy3-preview-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Nemotron 3 Super Free | nemotron-3-super-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | [model id](/docs/config/#models) u vašoj OpenCode konfiguraciji koristi format @@ -130,6 +131,7 @@ Podržavamo pay-as-you-go model. Ispod su cijene **po 1M tokena**. | Big Pickle | Free | Free | Free | - | | MiniMax M2.5 Free | Free | Free | Free | - | | Ling 2.6 Flash Free | Free | Free | Free | - | +| Hy3 Preview Free | Free | Free | Free | - | | Nemotron 3 Super Free | Free | Free | Free | - | | MiniMax M2.7 | $0.30 | $1.20 | $0.06 | $0.375 | | MiniMax M2.5 | $0.30 | $1.20 | $0.06 | $0.375 | @@ -182,6 +184,7 @@ Besplatni modeli: - MiniMax M2.5 Free je dostupan na OpenCode ograničeno vrijeme. Tim koristi ovo vrijeme da prikupi povratne informacije i poboljša model. - Ling 2.6 Flash Free je dostupan na OpenCode ograničeno vrijeme. Tim koristi ovo vrijeme da prikupi povratne informacije i poboljša model. +- Hy3 Preview Free je dostupan na OpenCode ograničeno vrijeme. Tim koristi ovo vrijeme da prikupi povratne informacije i poboljša model. - Nemotron 3 Super Free je dostupan na OpenCode ograničeno vrijeme. Tim koristi ovo vrijeme da prikupi povratne informacije i poboljša model. - Big Pickle je stealth model koji je besplatan na OpenCode ograničeno vrijeme. Tim koristi ovo vrijeme da prikupi povratne informacije i poboljša model. @@ -239,6 +242,7 @@ i ne koriste vaše podatke za treniranje modela, uz sljedeće izuzetke: - Big Pickle: Tokom besplatnog perioda, prikupljeni podaci mogu se koristiti za poboljšanje modela. - MiniMax M2.5 Free: Tokom besplatnog perioda, prikupljeni podaci mogu se koristiti za poboljšanje modela. - Ling 2.6 Flash Free: Tokom besplatnog perioda, prikupljeni podaci mogu se koristiti za poboljšanje modela. +- Hy3 Preview Free: Tokom besplatnog perioda, prikupljeni podaci mogu se koristiti za poboljšanje modela. - Nemotron 3 Super Free (besplatni NVIDIA endpointi): Dostupan je prema [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). Samo za probnu upotrebu, nije za produkciju niti osjetljive podatke. NVIDIA bilježi promptove i izlaze radi poboljšanja svojih modela i usluga. Nemojte slati lične ili povjerljive podatke. - OpenAI APIs: Requests are retained for 30 days in accordance with [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data). - Anthropic APIs: Requests are retained for 30 days in accordance with [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage). diff --git a/packages/web/src/content/docs/da/go.mdx b/packages/web/src/content/docs/da/go.mdx index bc40325437..6891e6d579 100644 --- a/packages/web/src/content/docs/da/go.mdx +++ b/packages/web/src/content/docs/da/go.mdx @@ -75,7 +75,6 @@ Den nuværende liste over modeller inkluderer: - **MiniMax M2.7** - **DeepSeek V4 Pro** - **DeepSeek V4 Flash** -- **Hy3 preview** Listen over modeller kan ændre sig, efterhånden som vi tester og tilføjer nye. @@ -107,7 +106,6 @@ Tabellen nedenfor giver et estimeret antal anmodninger baseret på typiske Go-fo | Qwen3.5 Plus | 10,200 | 25,200 | 50,500 | | DeepSeek V4 Pro | 3,450 | 8,550 | 17,150 | | DeepSeek V4 Flash | 31,650 | 79,050 | 158,150 | -| Hy3 preview | 1,875 | 4,690 | 9,375 | Estimaterne er baseret på observerede gennemsnitlige anmodningsmønstre: @@ -120,7 +118,6 @@ Estimaterne er baseret på observerede gennemsnitlige anmodningsmønstre: - Qwen3.6 Plus — 500 input, 57.000 cachelagrede, 190 output-tokens pr. anmodning - MiMo-V2.5 — 1000 input, 60.000 cachelagrede, 140 output-tokens pr. anmodning - MiMo-V2.5-Pro — 350 input, 41.000 cachelagrede, 250 output-tokens pr. anmodning -- Hy3 preview — 800 input, 50,000 cached, 400 output tokens per request Du kan spore dit nuværende forbrug i **konsollen**. @@ -158,7 +155,6 @@ Du kan også få adgang til Go-modeller gennem følgende API-endpoints. | MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` | | Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` | -| Hy3 preview | hy3-preview | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | Dit [model id](/docs/config/#models) i din OpenCode config bruger formatet `opencode-go/`. For eksempel for Kimi K2.6, vil du diff --git a/packages/web/src/content/docs/da/zen.mdx b/packages/web/src/content/docs/da/zen.mdx index b0004cb2e0..d45f785a59 100644 --- a/packages/web/src/content/docs/da/zen.mdx +++ b/packages/web/src/content/docs/da/zen.mdx @@ -103,6 +103,7 @@ Du kan også få adgang til vores modeller gennem følgende API-endpoints. | Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Ling 2.6 Flash | ling-2.6-flash | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Hy3 Preview Free | hy3-preview-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Nemotron 3 Super Free | nemotron-3-super-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | [model id](/docs/config/#models) i din OpenCode-konfiguration @@ -130,6 +131,7 @@ Vi understøtter en pay-as-you-go-model. Nedenfor er priserne **pr. 1M tokens**. | Big Pickle | Free | Free | Free | - | | MiniMax M2.5 Free | Free | Free | Free | - | | Ling 2.6 Flash Free | Free | Free | Free | - | +| Hy3 Preview Free | Free | Free | Free | - | | Nemotron 3 Super Free | Free | Free | Free | - | | MiniMax M2.7 | $0.30 | $1.20 | $0.06 | $0.375 | | MiniMax M2.5 | $0.30 | $1.20 | $0.06 | $0.375 | @@ -182,6 +184,7 @@ De gratis modeller: - MiniMax M2.5 Free er tilgængelig på OpenCode i en begrænset periode. Teamet bruger denne tid til at indsamle feedback og forbedre modellen. - Ling 2.6 Flash Free er tilgængelig på OpenCode i en begrænset periode. Teamet bruger denne tid til at indsamle feedback og forbedre modellen. +- Hy3 Preview Free er tilgængelig på OpenCode i en begrænset periode. Teamet bruger denne tid til at indsamle feedback og forbedre modellen. - Nemotron 3 Super Free er tilgængelig på OpenCode i en begrænset periode. Teamet bruger denne tid til at indsamle feedback og forbedre modellen. - Big Pickle er en stealth-model, som er gratis på OpenCode i en begrænset periode. Teamet bruger denne tid til at indsamle feedback og forbedre modellen. @@ -237,6 +240,7 @@ Alle vores modeller hostes i US. Vores udbydere følger en nul-opbevaringspoliti - Big Pickle: I den gratis periode kan indsamlede data blive brugt til at forbedre modellen. - MiniMax M2.5 Free: I den gratis periode kan indsamlede data blive brugt til at forbedre modellen. - Ling 2.6 Flash Free: I den gratis periode kan indsamlede data blive brugt til at forbedre modellen. +- Hy3 Preview Free: I den gratis periode kan indsamlede data blive brugt til at forbedre modellen. - Nemotron 3 Super Free (gratis NVIDIA-endpoints): Leveres under [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). Kun til prøvebrug, ikke til produktion eller følsomme data. Prompts og outputs logges af NVIDIA for at forbedre deres modeller og tjenester. Indsend ikke personlige eller fortrolige data. - OpenAI APIs: Anmodninger opbevares i 30 dage i overensstemmelse med [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data). - Anthropic APIs: Anmodninger opbevares i 30 dage i overensstemmelse med [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage). diff --git a/packages/web/src/content/docs/de/go.mdx b/packages/web/src/content/docs/de/go.mdx index 9434d39bcd..917ea340ef 100644 --- a/packages/web/src/content/docs/de/go.mdx +++ b/packages/web/src/content/docs/de/go.mdx @@ -67,7 +67,6 @@ Die aktuelle Liste der Modelle umfasst: - **MiniMax M2.7** - **DeepSeek V4 Pro** - **DeepSeek V4 Flash** -- **Hy3 preview** Die Liste der Modelle kann sich ändern, während wir neue testen und hinzufügen. @@ -99,7 +98,6 @@ Die folgende Tabelle zeigt eine geschätzte Anzahl von Anfragen basierend auf ty | Qwen3.5 Plus | 10,200 | 25,200 | 50,500 | | DeepSeek V4 Pro | 3,450 | 8,550 | 17,150 | | DeepSeek V4 Flash | 31,650 | 79,050 | 158,150 | -| Hy3 preview | 1,875 | 4,690 | 9,375 | Die Schätzungen basieren auf beobachteten durchschnittlichen Anfragemustern: @@ -112,7 +110,6 @@ Die Schätzungen basieren auf beobachteten durchschnittlichen Anfragemustern: - Qwen3.6 Plus — 500 Input-, 57.000 Cached-, 190 Output-Tokens pro Anfrage - MiMo-V2.5 — 1.000 Input-, 60.000 Cached-, 140 Output-Tokens pro Anfrage - MiMo-V2.5-Pro — 350 Input-, 41.000 Cached-, 250 Output-Tokens pro Anfrage -- Hy3 preview — 800 input, 50,000 cached, 400 output tokens per request Du kannst deine aktuelle Nutzung in der **Console** verfolgen. @@ -148,7 +145,6 @@ Du kannst auf die Go-Modelle auch über die folgenden API-Endpunkte zugreifen. | MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` | | Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` | -| Hy3 preview | hy3-preview | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | Die [Modell-ID](/docs/config/#models) in deiner OpenCode Config verwendet das Format `opencode-go/`. Für Kimi K2.6 würdest du beispielsweise `opencode-go/kimi-k2.6` in deiner Config verwenden. diff --git a/packages/web/src/content/docs/de/zen.mdx b/packages/web/src/content/docs/de/zen.mdx index 0439159e77..5e6c8eee80 100644 --- a/packages/web/src/content/docs/de/zen.mdx +++ b/packages/web/src/content/docs/de/zen.mdx @@ -94,6 +94,7 @@ Du kannst auch über die folgenden API-Endpunkte auf unsere Modelle zugreifen. | Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Ling 2.6 Flash | ling-2.6-flash | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Hy3 Preview Free | hy3-preview-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Nemotron 3 Super Free | nemotron-3-super-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | Die [Model-ID](/docs/config/#models) in deiner OpenCode-Konfiguration verwendet das Format `opencode/`. Für GPT 5.5 würdest du zum Beispiel `opencode/gpt-5.5` in deiner Konfiguration verwenden. @@ -119,6 +120,7 @@ Wir unterstützen ein Pay-as-you-go-Modell. Unten findest du die Preise **pro 1M | Big Pickle | Free | Free | Free | - | | MiniMax M2.5 Free | Free | Free | Free | - | | Ling 2.6 Flash Free | Free | Free | Free | - | +| Hy3 Preview Free | Free | Free | Free | - | | Nemotron 3 Super Free | Free | Free | Free | - | | MiniMax M2.7 | $0.30 | $1.20 | $0.06 | $0.375 | | MiniMax M2.5 | $0.30 | $1.20 | $0.06 | $0.375 | @@ -171,6 +173,7 @@ Die kostenlosen Modelle: - MiniMax M2.5 Free ist für begrenzte Zeit auf OpenCode verfügbar. Das Team nutzt diese Zeit, um Feedback zu sammeln und das Modell zu verbessern. - Ling 2.6 Flash Free ist für begrenzte Zeit auf OpenCode verfügbar. Das Team nutzt diese Zeit, um Feedback zu sammeln und das Modell zu verbessern. +- Hy3 Preview Flash Free ist für begrenzte Zeit auf OpenCode verfügbar. Das Team nutzt diese Zeit, um Feedback zu sammeln und das Modell zu verbessern. - Nemotron 3 Super Free ist für begrenzte Zeit auf OpenCode verfügbar. Das Team nutzt diese Zeit, um Feedback zu sammeln und das Modell zu verbessern. - Big Pickle ist ein Stealth-Modell, das für begrenzte Zeit kostenlos auf OpenCode verfügbar ist. Das Team nutzt diese Zeit, um Feedback zu sammeln und das Modell zu verbessern. @@ -223,6 +226,7 @@ Alle unsere Modelle werden in den USA gehostet. Unsere Provider folgen einer Zer - Big Pickle: Während des kostenlosen Zeitraums können gesammelte Daten zur Verbesserung des Modells verwendet werden. - MiniMax M2.5 Free: Während des kostenlosen Zeitraums können gesammelte Daten zur Verbesserung des Modells verwendet werden. - Ling 2.6 Flash Free: Während des kostenlosen Zeitraums können gesammelte Daten zur Verbesserung des Modells verwendet werden. +- Hy3 Preview Free: Während des kostenlosen Zeitraums können gesammelte Daten zur Verbesserung des Modells verwendet werden. - Nemotron 3 Super Free (kostenlose NVIDIA-Endpunkte): Bereitgestellt gemäß den [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). Nur für Testzwecke, nicht für Produktion oder sensible Daten. Eingaben und Ausgaben werden von NVIDIA protokolliert, um seine Modelle und Dienste zu verbessern. Übermitteln Sie keine personenbezogenen oder vertraulichen Daten. - OpenAI APIs: Anfragen werden in Übereinstimmung mit [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data) 30 Tage lang gespeichert. - Anthropic APIs: Anfragen werden in Übereinstimmung mit [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage) 30 Tage lang gespeichert. diff --git a/packages/web/src/content/docs/es/go.mdx b/packages/web/src/content/docs/es/go.mdx index ba8d100981..0be23b3fa4 100644 --- a/packages/web/src/content/docs/es/go.mdx +++ b/packages/web/src/content/docs/es/go.mdx @@ -75,7 +75,6 @@ La lista actual de modelos incluye: - **MiniMax M2.7** - **DeepSeek V4 Pro** - **DeepSeek V4 Flash** -- **Hy3 preview** La lista de modelos puede cambiar a medida que probamos y agregamos otros nuevos. @@ -107,7 +106,6 @@ La siguiente tabla proporciona una cantidad estimada de peticiones basada en los | Qwen3.5 Plus | 10,200 | 25,200 | 50,500 | | DeepSeek V4 Pro | 3,450 | 8,550 | 17,150 | | DeepSeek V4 Flash | 31,650 | 79,050 | 158,150 | -| Hy3 preview | 1,875 | 4,690 | 9,375 | Las estimaciones se basan en los patrones de peticiones promedio observados: @@ -120,7 +118,6 @@ Las estimaciones se basan en los patrones de peticiones promedio observados: - Qwen3.6 Plus — 500 tokens de entrada, 57,000 en caché, 190 tokens de salida por petición - MiMo-V2.5 — 1000 tokens de entrada, 60,000 en caché, 140 tokens de salida por petición - MiMo-V2.5-Pro — 350 tokens de entrada, 41,000 en caché, 250 tokens de salida por petición -- Hy3 preview — 800 input, 50,000 cached, 400 output tokens per request Puedes realizar un seguimiento de tu uso actual en la **consola**. @@ -158,7 +155,6 @@ También puedes acceder a los modelos de Go a través de los siguientes endpoint | MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` | | Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` | -| Hy3 preview | hy3-preview | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | El [ID del modelo](/docs/config/#models) en tu configuración de OpenCode usa el formato `opencode-go/`. Por ejemplo, para Kimi K2.6, usarías diff --git a/packages/web/src/content/docs/es/zen.mdx b/packages/web/src/content/docs/es/zen.mdx index 377ad2df05..15436226a5 100644 --- a/packages/web/src/content/docs/es/zen.mdx +++ b/packages/web/src/content/docs/es/zen.mdx @@ -103,6 +103,7 @@ También puedes acceder a nuestros modelos a través de los siguientes endpoints | Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Ling 2.6 Flash | ling-2.6-flash | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Hy3 Preview Free | hy3-preview-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Nemotron 3 Super Free | nemotron-3-super-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | El [identificador del modelo](/docs/config/#models) en tu configuración de OpenCode @@ -130,6 +131,7 @@ Admitimos un modelo de pago por uso. A continuación se muestran los precios **p | Big Pickle | Free | Free | Free | - | | MiniMax M2.5 Free | Free | Free | Free | - | | Ling 2.6 Flash Free | Free | Free | Free | - | +| Hy3 Preview Free | Free | Free | Free | - | | Nemotron 3 Super Free | Free | Free | Free | - | | MiniMax M2.7 | $0.30 | $1.20 | $0.06 | $0.375 | | MiniMax M2.5 | $0.30 | $1.20 | $0.06 | $0.375 | @@ -182,6 +184,7 @@ Los modelos gratuitos: - MiniMax M2.5 Free está disponible en OpenCode por tiempo limitado. El equipo está usando este tiempo para recopilar comentarios y mejorar el modelo. - Ling 2.6 Flash Free está disponible en OpenCode por tiempo limitado. El equipo está usando este tiempo para recopilar comentarios y mejorar el modelo. +- Hy3 Preview Flash Free está disponible en OpenCode por tiempo limitado. El equipo está usando este tiempo para recopilar comentarios y mejorar el modelo. - Nemotron 3 Super Free está disponible en OpenCode por tiempo limitado. El equipo está usando este tiempo para recopilar comentarios y mejorar el modelo. - Big Pickle es un modelo stealth que es gratuito en OpenCode por tiempo limitado. El equipo está usando este tiempo para recopilar comentarios y mejorar el modelo. @@ -237,6 +240,7 @@ Todos nuestros modelos están alojados en US. Nuestros proveedores siguen una po - Big Pickle: Durante su período gratuito, los datos recopilados pueden usarse para mejorar el modelo. - MiniMax M2.5 Free: Durante su período gratuito, los datos recopilados pueden usarse para mejorar el modelo. - Ling 2.6 Flash Free: Durante su período gratuito, los datos recopilados pueden usarse para mejorar el modelo. +- Hy3 Preview Free: Durante su período gratuito, los datos recopilados pueden usarse para mejorar el modelo. - Nemotron 3 Super Free (endpoints gratuitos de NVIDIA): Se ofrece bajo los [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). Solo para uso de prueba, no para producción ni datos sensibles. NVIDIA registra los prompts y las salidas para mejorar sus modelos y servicios. No envíes datos personales ni confidenciales. - OpenAI APIs: Las solicitudes se conservan durante 30 días de acuerdo con [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data). - Anthropic APIs: Las solicitudes se conservan durante 30 días de acuerdo con [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage). diff --git a/packages/web/src/content/docs/fr/go.mdx b/packages/web/src/content/docs/fr/go.mdx index ebf034f210..3dd9c25f32 100644 --- a/packages/web/src/content/docs/fr/go.mdx +++ b/packages/web/src/content/docs/fr/go.mdx @@ -65,7 +65,6 @@ La liste actuelle des modèles comprend : - **MiniMax M2.7** - **DeepSeek V4 Pro** - **DeepSeek V4 Flash** -- **Hy3 preview** La liste des modèles peut changer au fur et à mesure que nous en testons et en ajoutons de nouveaux. @@ -97,7 +96,6 @@ Le tableau ci-dessous fournit une estimation du nombre de requêtes basée sur d | Qwen3.5 Plus | 10,200 | 25,200 | 50,500 | | DeepSeek V4 Pro | 3,450 | 8,550 | 17,150 | | DeepSeek V4 Flash | 31,650 | 79,050 | 158,150 | -| Hy3 preview | 1,875 | 4,690 | 9,375 | Les estimations sont basées sur les modèles de requêtes moyens observés : @@ -110,7 +108,6 @@ Les estimations sont basées sur les modèles de requêtes moyens observés : - Qwen3.6 Plus — 500 tokens en entrée, 57,000 en cache, 190 tokens en sortie par requête - MiMo-V2.5 — 1000 tokens en entrée, 60,000 en cache, 140 tokens en sortie par requête - MiMo-V2.5-Pro — 350 tokens en entrée, 41,000 en cache, 250 tokens en sortie par requête -- Hy3 preview — 800 input, 50,000 cached, 400 output tokens per request Vous pouvez suivre votre utilisation actuelle dans la **console**. @@ -146,7 +143,6 @@ Vous pouvez également accéder aux modèles Go via les points de terminaison d' | MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` | | Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` | -| Hy3 preview | hy3-preview | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | L'[ID de modèle](/docs/config/#models) dans votre configuration OpenCode utilise le format `opencode-go/`. Par exemple, pour Kimi K2.6, vous utiliseriez `opencode-go/kimi-k2.6` dans votre configuration. diff --git a/packages/web/src/content/docs/fr/zen.mdx b/packages/web/src/content/docs/fr/zen.mdx index 95157792c4..fdf14e8fb0 100644 --- a/packages/web/src/content/docs/fr/zen.mdx +++ b/packages/web/src/content/docs/fr/zen.mdx @@ -94,6 +94,7 @@ Vous pouvez également accéder à nos modèles via les points de terminaison AP | Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Ling 2.6 Flash | ling-2.6-flash | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Hy3 Preview Free | hy3-preview-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Nemotron 3 Super Free | nemotron-3-super-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | Le [model id](/docs/config/#models) dans votre configuration OpenCode utilise le format `opencode/`. Par exemple, pour GPT 5.5, vous utiliseriez `opencode/gpt-5.5` dans votre configuration. @@ -119,6 +120,7 @@ Nous prenons en charge un modèle de paiement à l'utilisation. Vous trouverez c | Big Pickle | Free | Free | Free | - | | MiniMax M2.5 Free | Free | Free | Free | - | | Ling 2.6 Flash Free | Free | Free | Free | - | +| Hy3 Preview Free | Free | Free | Free | - | | Nemotron 3 Super Free | Free | Free | Free | - | | MiniMax M2.7 | $0.30 | $1.20 | $0.06 | $0.375 | | MiniMax M2.5 | $0.30 | $1.20 | $0.06 | $0.375 | @@ -171,6 +173,7 @@ Les modèles gratuits : - MiniMax M2.5 Free est disponible sur OpenCode pour une durée limitée. L'équipe utilise cette période pour recueillir des retours et améliorer le modèle. - Ling 2.6 Flash Free est disponible sur OpenCode pour une durée limitée. L'équipe utilise cette période pour recueillir des retours et améliorer le modèle. +- Hy3 Preview Free est disponible sur OpenCode pour une durée limitée. L'équipe utilise cette période pour recueillir des retours et améliorer le modèle. - Nemotron 3 Super Free est disponible sur OpenCode pour une durée limitée. L'équipe utilise cette période pour recueillir des retours et améliorer le modèle. - Big Pickle est un modèle stealth gratuit sur OpenCode pour une durée limitée. L'équipe utilise cette période pour recueillir des retours et améliorer le modèle. @@ -223,6 +226,7 @@ Tous nos modèles sont hébergés aux US. Nos fournisseurs suivent une politique - Big Pickle : Pendant sa période gratuite, les données collectées peuvent être utilisées pour améliorer le modèle. - MiniMax M2.5 Free : Pendant sa période gratuite, les données collectées peuvent être utilisées pour améliorer le modèle. - Ling 2.6 Flash Free : Pendant sa période gratuite, les données collectées peuvent être utilisées pour améliorer le modèle. +- Hy3 Preview Free : Pendant sa période gratuite, les données collectées peuvent être utilisées pour améliorer le modèle. - Nemotron 3 Super Free (endpoints NVIDIA gratuits) : Fourni dans le cadre des [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). Réservé à un usage d'essai, pas à la production ni aux données sensibles. Les prompts et les sorties sont journalisés par NVIDIA pour améliorer ses modèles et services. N'envoyez pas de données personnelles ou confidentielles. - OpenAI APIs : Les requêtes sont conservées pendant 30 jours conformément à [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data). - Anthropic APIs : Les requêtes sont conservées pendant 30 jours conformément à [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage). diff --git a/packages/web/src/content/docs/go.mdx b/packages/web/src/content/docs/go.mdx index 54ba49ff2e..237d1c4b84 100644 --- a/packages/web/src/content/docs/go.mdx +++ b/packages/web/src/content/docs/go.mdx @@ -75,7 +75,6 @@ The current list of models includes: - **Qwen3.6 Plus** - **DeepSeek V4 Pro** - **DeepSeek V4 Flash** -- **Hy3 preview** The list of models may change as we test and add new ones. @@ -107,7 +106,6 @@ The table below provides an estimated request count based on typical Go usage pa | Qwen3.5 Plus | 10,200 | 25,200 | 50,500 | | DeepSeek V4 Pro | 3,450 | 8,550 | 17,150 | | DeepSeek V4 Flash | 31,650 | 79,050 | 158,150 | -| Hy3 preview | 1,875 | 4,690 | 9,375 | Estimates are based on observed average request patterns: @@ -120,7 +118,6 @@ Estimates are based on observed average request patterns: - MiMo-V2.5-Pro — 350 input, 41,000 cached, 250 output tokens per request - Qwen3.5 Plus — 410 input, 47,000 cached, 140 output tokens per request - Qwen3.6 Plus — 500 input, 57,000 cached, 190 output tokens per request -- Hy3 preview — 800 input, 50,000 cached, 400 output tokens per request You can track your current usage in the **console**. @@ -158,7 +155,6 @@ You can also access Go models through the following API endpoints. | MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` | | Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` | -| Hy3 preview | hy3-preview | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | The [model id](/docs/config/#models) in your OpenCode config uses the format `opencode-go/`. For example, for Kimi K2.6, you would diff --git a/packages/web/src/content/docs/it/go.mdx b/packages/web/src/content/docs/it/go.mdx index c6d3bf3301..df4f6dd1ca 100644 --- a/packages/web/src/content/docs/it/go.mdx +++ b/packages/web/src/content/docs/it/go.mdx @@ -73,7 +73,6 @@ L'elenco attuale dei modelli include: - **MiniMax M2.7** - **DeepSeek V4 Pro** - **DeepSeek V4 Flash** -- **Hy3 preview** L'elenco dei modelli potrebbe cambiare man mano che ne testiamo e aggiungiamo di nuovi. @@ -105,7 +104,6 @@ La tabella seguente fornisce una stima del conteggio delle richieste in base a p | Qwen3.5 Plus | 10,200 | 25,200 | 50,500 | | DeepSeek V4 Pro | 3,450 | 8,550 | 17,150 | | DeepSeek V4 Flash | 31,650 | 79,050 | 158,150 | -| Hy3 preview | 1,875 | 4,690 | 9,375 | Le stime si basano sui pattern medi di richieste osservati: @@ -118,7 +116,6 @@ Le stime si basano sui pattern medi di richieste osservati: - Qwen3.6 Plus — 500 di input, 57.000 in cache, 190 token di output per richiesta - MiMo-V2.5 — 1000 di input, 60.000 in cache, 140 token di output per richiesta - MiMo-V2.5-Pro — 350 di input, 41.000 in cache, 250 token di output per richiesta -- Hy3 preview — 800 input, 50,000 cached, 400 output tokens per request Puoi monitorare il tuo utilizzo attuale nella **console**. @@ -156,7 +153,6 @@ Puoi anche accedere ai modelli Go tramite i seguenti endpoint API. | MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` | | Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` | -| Hy3 preview | hy3-preview | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | Il [model id](/docs/config/#models) nella tua OpenCode config utilizza il formato `opencode-go/`. Ad esempio, per Kimi K2.6, useresti diff --git a/packages/web/src/content/docs/it/zen.mdx b/packages/web/src/content/docs/it/zen.mdx index 43fb7756da..a53d6a2ba1 100644 --- a/packages/web/src/content/docs/it/zen.mdx +++ b/packages/web/src/content/docs/it/zen.mdx @@ -103,6 +103,7 @@ Puoi anche accedere ai nostri modelli tramite i seguenti endpoint API. | Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Ling 2.6 Flash | ling-2.6-flash | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Hy3 Preview Free | hy3-preview-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Nemotron 3 Super Free | nemotron-3-super-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | Il [model id](/docs/config/#models) nella config di OpenCode @@ -130,6 +131,7 @@ Supportiamo un modello pay-as-you-go. Qui sotto trovi i prezzi **per 1M token**. | Big Pickle | Free | Free | Free | - | | MiniMax M2.5 Free | Free | Free | Free | - | | Ling 2.6 Flash Free | Free | Free | Free | - | +| Hy3 Preview Free | Free | Free | Free | - | | Nemotron 3 Super Free | Free | Free | Free | - | | MiniMax M2.7 | $0.30 | $1.20 | $0.06 | $0.375 | | MiniMax M2.5 | $0.30 | $1.20 | $0.06 | $0.375 | @@ -182,6 +184,7 @@ I modelli gratuiti: - MiniMax M2.5 Free è disponibile su OpenCode per un periodo limitato. Il team usa questo periodo per raccogliere feedback e migliorare il modello. - Ling 2.6 Flash Free è disponibile su OpenCode per un periodo limitato. Il team usa questo periodo per raccogliere feedback e migliorare il modello. +- Hy3 Preview Flash Free è disponibile su OpenCode per un periodo limitato. Il team usa questo periodo per raccogliere feedback e migliorare il modello. - Nemotron 3 Super Free è disponibile su OpenCode per un periodo limitato. Il team usa questo periodo per raccogliere feedback e migliorare il modello. - Big Pickle è un modello stealth che è gratuito su OpenCode per un periodo limitato. Il team usa questo periodo per raccogliere feedback e migliorare il modello. @@ -237,6 +240,7 @@ Tutti i nostri modelli sono ospitati negli US. I nostri provider seguono una pol - Big Pickle: durante il periodo gratuito, i dati raccolti possono essere usati per migliorare il modello. - MiniMax M2.5 Free: durante il periodo gratuito, i dati raccolti possono essere usati per migliorare il modello. - Ling 2.6 Flash Free: durante il periodo gratuito, i dati raccolti possono essere usati per migliorare il modello. +- Hy3 Preview Free: durante il periodo gratuito, i dati raccolti possono essere usati per migliorare il modello. - Nemotron 3 Super Free (endpoint NVIDIA gratuiti): fornito secondo i [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). Solo per uso di prova, non per produzione o dati sensibili. NVIDIA registra prompt e output per migliorare i propri modelli e servizi. Non inviare dati personali o riservati. - OpenAI APIs: le richieste vengono conservate per 30 giorni in conformità con [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data). - Anthropic APIs: le richieste vengono conservate per 30 giorni in conformità con [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage). diff --git a/packages/web/src/content/docs/ja/go.mdx b/packages/web/src/content/docs/ja/go.mdx index f13c0680f7..0cb294754f 100644 --- a/packages/web/src/content/docs/ja/go.mdx +++ b/packages/web/src/content/docs/ja/go.mdx @@ -65,7 +65,6 @@ OpenCode Goをサブスクライブできるのは、1つのワークスペー - **MiniMax M2.7** - **DeepSeek V4 Pro** - **DeepSeek V4 Flash** -- **Hy3 preview** 新しいモデルをテストして追加するにつれて、モデルのリストは変更される場合があります。 @@ -97,7 +96,6 @@ OpenCode Goには以下の制限が含まれています: | Qwen3.5 Plus | 10,200 | 25,200 | 50,500 | | DeepSeek V4 Pro | 3,450 | 8,550 | 17,150 | | DeepSeek V4 Flash | 31,650 | 79,050 | 158,150 | -| Hy3 preview | 1,875 | 4,690 | 9,375 | 推定値は、観測された平均的なリクエストパターンに基づいています: @@ -110,7 +108,6 @@ OpenCode Goには以下の制限が含まれています: - Qwen3.6 Plus — リクエストあたり 入力 500トークン、キャッシュ 57,000トークン、出力 190トークン - MiMo-V2.5 — リクエストあたり 入力 1000トークン、キャッシュ 60,000トークン、出力 140トークン - MiMo-V2.5-Pro — リクエストあたり 入力 350トークン、キャッシュ 41,000トークン、出力 250トークン -- Hy3 preview — 800 input, 50,000 cached, 400 output tokens per request 現在の利用状況は**コンソール**で追跡できます。 @@ -146,7 +143,6 @@ Zen残高にクレジットがある場合は、コンソールで**Use balance* | MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` | | Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` | -| Hy3 preview | hy3-preview | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | OpenCode設定の[model id](/docs/config/#models)は、`opencode-go/`という形式を使用します。たとえば、Kimi K2.6の場合は、設定で`opencode-go/kimi-k2.6`を使用します。 diff --git a/packages/web/src/content/docs/ja/zen.mdx b/packages/web/src/content/docs/ja/zen.mdx index 15c4cd3688..64427a72ec 100644 --- a/packages/web/src/content/docs/ja/zen.mdx +++ b/packages/web/src/content/docs/ja/zen.mdx @@ -94,6 +94,7 @@ OpenCode Zen は、OpenCode のほかのプロバイダーと同じように動 | Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Ling 2.6 Flash | ling-2.6-flash | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Hy3 Preview Free | hy3-preview-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Nemotron 3 Super Free | nemotron-3-super-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | OpenCode 設定で使う [model id](/docs/config/#models) は `opencode/` 形式です。たとえば、GPT 5.5 では設定に `opencode/gpt-5.5` を使用します。 @@ -119,6 +120,7 @@ https://opencode.ai/zen/v1/models | Big Pickle | Free | Free | Free | - | | MiniMax M2.5 Free | Free | Free | Free | - | | Ling 2.6 Flash Free | Free | Free | Free | - | +| Hy3 Preview Free | Free | Free | Free | - | | Nemotron 3 Super Free | Free | Free | Free | - | | MiniMax M2.7 | $0.30 | $1.20 | $0.06 | $0.375 | | MiniMax M2.5 | $0.30 | $1.20 | $0.06 | $0.375 | @@ -171,6 +173,7 @@ https://opencode.ai/zen/v1/models - MiniMax M2.5 Free は期間限定で OpenCode で利用できます。チームはこの期間中にフィードバックを集め、モデルを改善しています。 - Ling 2.6 Flash Free は期間限定で OpenCode で利用できます。チームはこの期間中にフィードバックを集め、モデルを改善しています。 +- Hy3 Preview Flash Free は期間限定で OpenCode で利用できます。チームはこの期間中にフィードバックを集め、モデルを改善しています。 - Nemotron 3 Super Free は期間限定で OpenCode で利用できます。チームはこの期間中にフィードバックを集め、モデルを改善しています。 - Big Pickle はステルスモデルで、期間限定で OpenCode で無料提供されています。チームはこの期間中にフィードバックを集め、モデルを改善しています。 @@ -223,6 +226,7 @@ https://opencode.ai/zen/v1/models - Big Pickle: 無料提供期間中、収集されたデータがモデル改善に使われる場合があります。 - MiniMax M2.5 Free: 無料提供期間中、収集されたデータがモデル改善に使われる場合があります。 - Ling 2.6 Flash Free: 無料提供期間中、収集されたデータがモデル改善に使われる場合があります。 +- Hy3 Preview Free: 無料提供期間中、収集されたデータがモデル改善に使われる場合があります。 - Nemotron 3 Super Free(NVIDIA の無料エンドポイント): [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf) に基づいて提供されます。試用専用であり、本番環境や機密性の高いデータには使用しないでください。プロンプトと出力は、NVIDIA が自社のモデルとサービスを改善するために記録します。個人情報や機密データは送信しないでください。 - OpenAI APIs: リクエストは [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data) に従って 30 日間保持されます。 - Anthropic APIs: リクエストは [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage) に従って 30 日間保持されます。 diff --git a/packages/web/src/content/docs/ko/go.mdx b/packages/web/src/content/docs/ko/go.mdx index e21ae11826..d0a3b9d0d1 100644 --- a/packages/web/src/content/docs/ko/go.mdx +++ b/packages/web/src/content/docs/ko/go.mdx @@ -65,7 +65,6 @@ workspace당 한 명의 멤버만 OpenCode Go를 구독할 수 있습니다. - **MiniMax M2.7** - **DeepSeek V4 Pro** - **DeepSeek V4 Flash** -- **Hy3 preview** 새로운 모델을 테스트하고 추가함에 따라 이 목록은 변경될 수 있습니다. @@ -97,7 +96,6 @@ OpenCode Go에는 다음과 같은 한도가 포함됩니다. | Qwen3.5 Plus | 10,200 | 25,200 | 50,500 | | DeepSeek V4 Pro | 3,450 | 8,550 | 17,150 | | DeepSeek V4 Flash | 31,650 | 79,050 | 158,150 | -| Hy3 preview | 1,875 | 4,690 | 9,375 | 예상치는 관찰된 평균 요청 패턴을 기준으로 합니다. @@ -110,7 +108,6 @@ OpenCode Go에는 다음과 같은 한도가 포함됩니다. - Qwen3.6 Plus — 요청당 입력 500, 캐시 57,000, 출력 토큰 190 - MiMo-V2.5 — 요청당 입력 1000, 캐시 60,000, 출력 토큰 140 - MiMo-V2.5-Pro — 요청당 입력 350, 캐시 41,000, 출력 토큰 250 -- Hy3 preview — 800 input, 50,000 cached, 400 output tokens per request 현재 사용량은 **console**에서 확인할 수 있습니다. @@ -146,7 +143,6 @@ Zen 잔액에 크레딧도 있다면, console에서 **Use balance** 옵션을 | MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` | | Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` | -| Hy3 preview | hy3-preview | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | OpenCode config의 [model id](/docs/config/#models)는 `opencode-go/` 형식을 사용합니다. 예를 들어 Kimi K2.6의 경우 config에서 `opencode-go/kimi-k2.6`를 사용하면 됩니다. diff --git a/packages/web/src/content/docs/ko/zen.mdx b/packages/web/src/content/docs/ko/zen.mdx index b0e7d49136..e80a5e8710 100644 --- a/packages/web/src/content/docs/ko/zen.mdx +++ b/packages/web/src/content/docs/ko/zen.mdx @@ -94,6 +94,7 @@ OpenCode Zen은 OpenCode의 다른 provider와 똑같이 작동합니다. | Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Ling 2.6 Flash | ling-2.6-flash | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Hy3 Preview Free | hy3-preview-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Nemotron 3 Super Free | nemotron-3-super-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | OpenCode config에서 사용하는 [모델 ID](/docs/config/#models)는 `opencode/` 형식입니다. 예를 들어 GPT 5.5를 사용하려면 config에서 `opencode/gpt-5.5`를 사용하면 됩니다. @@ -119,6 +120,7 @@ https://opencode.ai/zen/v1/models | Big Pickle | Free | Free | Free | - | | MiniMax M2.5 Free | Free | Free | Free | - | | Ling 2.6 Flash Free | Free | Free | Free | - | +| Hy3 Preview Free | Free | Free | Free | - | | Nemotron 3 Super Free | Free | Free | Free | - | | MiniMax M2.7 | $0.30 | $1.20 | $0.06 | $0.375 | | MiniMax M2.5 | $0.30 | $1.20 | $0.06 | $0.375 | @@ -171,6 +173,7 @@ https://opencode.ai/zen/v1/models - MiniMax M2.5 Free는 한정된 기간 동안 OpenCode에서 제공됩니다. 팀은 이 기간에 피드백을 수집하고 모델을 개선합니다. - Ling 2.6 Flash Free는 한정된 기간 동안 OpenCode에서 제공됩니다. 팀은 이 기간에 피드백을 수집하고 모델을 개선합니다. +- Hy3 Preview Flash Free는 한정된 기간 동안 OpenCode에서 제공됩니다. 팀은 이 기간에 피드백을 수집하고 모델을 개선합니다. - Nemotron 3 Super Free는 한정된 기간 동안 OpenCode에서 제공됩니다. 팀은 이 기간에 피드백을 수집하고 모델을 개선합니다. - Big Pickle은 한정된 기간 동안 OpenCode에서 무료로 제공되는 stealth model입니다. 팀은 이 기간에 피드백을 수집하고 모델을 개선합니다. @@ -223,6 +226,7 @@ https://opencode.ai/zen/v1/models - Big Pickle: 무료 제공 기간에는 수집된 데이터가 모델 개선에 사용될 수 있습니다. - MiniMax M2.5 Free: 무료 제공 기간에는 수집된 데이터가 모델 개선에 사용될 수 있습니다. - Ling 2.6 Flash Free: 무료 제공 기간에는 수집된 데이터가 모델 개선에 사용될 수 있습니다. +- Hy3 Preview Free: 무료 제공 기간에는 수집된 데이터가 모델 개선에 사용될 수 있습니다. - Nemotron 3 Super Free(NVIDIA 무료 엔드포인트): [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf)에 따라 제공됩니다. 평가판 전용이며 프로덕션 환경이나 민감한 데이터에는 사용할 수 없습니다. NVIDIA는 자사 모델과 서비스를 개선하기 위해 프롬프트와 출력을 기록합니다. 개인 정보나 기밀 데이터는 제출하지 마세요. - OpenAI APIs: 요청은 [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data)에 따라 30일 동안 보관됩니다. - Anthropic APIs: 요청은 [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage)에 따라 30일 동안 보관됩니다. diff --git a/packages/web/src/content/docs/nb/go.mdx b/packages/web/src/content/docs/nb/go.mdx index a358da75fc..e19b6ccce1 100644 --- a/packages/web/src/content/docs/nb/go.mdx +++ b/packages/web/src/content/docs/nb/go.mdx @@ -75,7 +75,6 @@ Den nåværende listen over modeller inkluderer: - **MiniMax M2.7** - **DeepSeek V4 Pro** - **DeepSeek V4 Flash** -- **Hy3 preview** Listen over modeller kan endres etter hvert som vi tester og legger til nye. @@ -107,7 +106,6 @@ Tabellen nedenfor gir et estimert antall forespørsler basert på typiske bruksm | Qwen3.5 Plus | 10,200 | 25,200 | 50,500 | | DeepSeek V4 Pro | 3,450 | 8,550 | 17,150 | | DeepSeek V4 Flash | 31,650 | 79,050 | 158,150 | -| Hy3 preview | 1,875 | 4,690 | 9,375 | Estimatene er basert på observerte gjennomsnittlige forespørselsmønstre: @@ -120,7 +118,6 @@ Estimatene er basert på observerte gjennomsnittlige forespørselsmønstre: - Qwen3.6 Plus — 500 input, 57 000 bufret, 190 output-tokens per forespørsel - MiMo-V2.5 — 1000 input, 60 000 bufret, 140 output-tokens per forespørsel - MiMo-V2.5-Pro — 350 input, 41 000 bufret, 250 output-tokens per forespørsel -- Hy3 preview — 800 input, 50,000 cached, 400 output tokens per request Du kan spore din nåværende bruk i **konsollen**. @@ -158,7 +155,6 @@ Du kan også få tilgang til Go-modeller gjennom følgende API-endepunkter. | MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` | | Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` | -| Hy3 preview | hy3-preview | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | [Modell-ID-en](/docs/config/#models) i din OpenCode-konfigurasjon bruker formatet `opencode-go/`. For eksempel, for Kimi K2.6, vil du diff --git a/packages/web/src/content/docs/nb/zen.mdx b/packages/web/src/content/docs/nb/zen.mdx index 6b5847df8e..4bd1e6115e 100644 --- a/packages/web/src/content/docs/nb/zen.mdx +++ b/packages/web/src/content/docs/nb/zen.mdx @@ -103,6 +103,7 @@ Du kan også få tilgang til modellene våre gjennom følgende API-endepunkter. | Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Ling 2.6 Flash | ling-2.6-flash | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Hy3 Preview Free | hy3-preview-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Nemotron 3 Super Free | nemotron-3-super-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | [modell-id](/docs/config/#models) i OpenCode-konfigurasjonen din @@ -130,6 +131,7 @@ Vi støtter en pay-as-you-go-modell. Nedenfor er prisene **per 1M tokens**. | Big Pickle | Free | Free | Free | - | | MiniMax M2.5 Free | Free | Free | Free | - | | Ling 2.6 Flash Free | Free | Free | Free | - | +| Hy3 Preview Free | Free | Free | Free | - | | Nemotron 3 Super Free | Free | Free | Free | - | | MiniMax M2.7 | $0.30 | $1.20 | $0.06 | $0.375 | | MiniMax M2.5 | $0.30 | $1.20 | $0.06 | $0.375 | @@ -182,6 +184,7 @@ Gratis-modellene: - MiniMax M2.5 Free er tilgjengelig på OpenCode i en begrenset periode. Teamet bruker denne tiden til å samle inn tilbakemeldinger og forbedre modellen. - Ling 2.6 Flash Free er tilgjengelig på OpenCode i en begrenset periode. Teamet bruker denne tiden til å samle inn tilbakemeldinger og forbedre modellen. +- Hy3 Preview Free er tilgjengelig på OpenCode i en begrenset periode. Teamet bruker denne tiden til å samle inn tilbakemeldinger og forbedre modellen. - Nemotron 3 Super Free er tilgjengelig på OpenCode i en begrenset periode. Teamet bruker denne tiden til å samle inn tilbakemeldinger og forbedre modellen. - Big Pickle er en stealth-modell som er gratis på OpenCode i en begrenset periode. Teamet bruker denne tiden til å samle inn tilbakemeldinger og forbedre modellen. @@ -237,6 +240,7 @@ Alle modellene våre hostes i US. Leverandørene våre følger en policy for zer - Big Pickle: I gratisperioden kan innsamlede data brukes til å forbedre modellen. - MiniMax M2.5 Free: I gratisperioden kan innsamlede data brukes til å forbedre modellen. - Ling 2.6 Flash Free: I gratisperioden kan innsamlede data brukes til å forbedre modellen. +- Hy3 Preview Free: I gratisperioden kan innsamlede data brukes til å forbedre modellen. - Nemotron 3 Super Free (gratis NVIDIA-endepunkter): Leveres under [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). Kun for prøvebruk, ikke for produksjon eller sensitive data. Prompter og svar logges av NVIDIA for å forbedre modellene og tjenestene deres. Ikke send inn personopplysninger eller konfidensielle data. - OpenAI APIs: Forespørsler lagres i 30 dager i samsvar med [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data). - Anthropic APIs: Forespørsler lagres i 30 dager i samsvar med [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage). diff --git a/packages/web/src/content/docs/pl/go.mdx b/packages/web/src/content/docs/pl/go.mdx index 3e6624a240..00f76a103f 100644 --- a/packages/web/src/content/docs/pl/go.mdx +++ b/packages/web/src/content/docs/pl/go.mdx @@ -69,7 +69,6 @@ Obecna lista modeli obejmuje: - **MiniMax M2.7** - **DeepSeek V4 Pro** - **DeepSeek V4 Flash** -- **Hy3 preview** Lista modeli może ulec zmianie w miarę testowania i dodawania nowych. @@ -101,7 +100,6 @@ Poniższa tabela przedstawia szacunkową liczbę żądań na podstawie typowych | Qwen3.5 Plus | 10,200 | 25,200 | 50,500 | | DeepSeek V4 Pro | 3,450 | 8,550 | 17,150 | | DeepSeek V4 Flash | 31,650 | 79,050 | 158,150 | -| Hy3 preview | 1,875 | 4,690 | 9,375 | Szacunki opierają się na zaobserwowanych średnich wzorcach żądań: @@ -114,7 +112,6 @@ Szacunki opierają się na zaobserwowanych średnich wzorcach żądań: - Qwen3.6 Plus — 500 tokenów wejściowych, 57 000 w pamięci podręcznej, 190 tokenów wyjściowych na żądanie - MiMo-V2.5 — 1000 tokenów wejściowych, 60 000 w pamięci podręcznej, 140 tokenów wyjściowych na żądanie - MiMo-V2.5-Pro — 350 tokenów wejściowych, 41 000 w pamięci podręcznej, 250 tokenów wyjściowych na żądanie -- Hy3 preview — 800 input, 50,000 cached, 400 output tokens per request Możesz śledzić swoje bieżące zużycie w **konsoli**. @@ -150,7 +147,6 @@ Możesz również uzyskać dostęp do modeli Go za pośrednictwem następującyc | MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` | | Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` | -| Hy3 preview | hy3-preview | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | [ID modelu](/docs/config/#models) w Twojej konfiguracji OpenCode używa formatu `opencode-go/`. Na przykład dla Kimi K2.6 należy użyć diff --git a/packages/web/src/content/docs/pl/zen.mdx b/packages/web/src/content/docs/pl/zen.mdx index 7b9fb25ce4..ebd16d7856 100644 --- a/packages/web/src/content/docs/pl/zen.mdx +++ b/packages/web/src/content/docs/pl/zen.mdx @@ -103,6 +103,7 @@ Możesz też uzyskać dostęp do naszych modeli przez poniższe endpointy API. | Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Ling 2.6 Flash | ling-2.6-flash | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Hy3 Preview Free | hy3-preview-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Nemotron 3 Super Free | nemotron-3-super-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | [ID modelu](/docs/config/#models) w Twojej konfiguracji OpenCode używa formatu @@ -130,6 +131,7 @@ Obsługujemy model pay-as-you-go. Poniżej znajdują się ceny **za 1M tokenów* | Big Pickle | Free | Free | Free | - | | MiniMax M2.5 Free | Free | Free | Free | - | | Ling 2.6 Flash Free | Free | Free | Free | - | +| Hy3 Preview Free | Free | Free | Free | - | | Nemotron 3 Super Free | Free | Free | Free | - | | MiniMax M2.7 | $0.30 | $1.20 | $0.06 | $0.375 | | MiniMax M2.5 | $0.30 | $1.20 | $0.06 | $0.375 | @@ -183,6 +185,7 @@ Darmowe modele: - MiniMax M2.5 Free jest dostępny w OpenCode przez ograniczony czas. Zespół wykorzystuje ten czas do zbierania opinii i ulepszania modelu. - Ling 2.6 Flash Free jest dostępny w OpenCode przez ograniczony czas. Zespół wykorzystuje ten czas do zbierania opinii i ulepszania modelu. +- Hy3 Preview Flash Free jest dostępny w OpenCode przez ograniczony czas. Zespół wykorzystuje ten czas do zbierania opinii i ulepszania modelu. - Nemotron 3 Super Free jest dostępny w OpenCode przez ograniczony czas. Zespół wykorzystuje ten czas do zbierania opinii i ulepszania modelu. - Big Pickle to stealth model, który jest darmowy w OpenCode przez ograniczony czas. Zespół wykorzystuje ten czas do zbierania opinii i ulepszania modelu. @@ -238,6 +241,7 @@ Wszystkie nasze modele są hostowane w US. Nasi dostawcy stosują politykę zero - Big Pickle: W czasie darmowego okresu zebrane dane mogą być wykorzystywane do ulepszania modelu. - MiniMax M2.5 Free: W czasie darmowego okresu zebrane dane mogą być wykorzystywane do ulepszania modelu. - Ling 2.6 Flash Free: W czasie darmowego okresu zebrane dane mogą być wykorzystywane do ulepszania modelu. +- Hy3 Preview Free: W czasie darmowego okresu zebrane dane mogą być wykorzystywane do ulepszania modelu. - Nemotron 3 Super Free (darmowe endpointy NVIDIA): Udostępniany zgodnie z [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). Tylko do użytku próbnego, nie do produkcji ani danych wrażliwych. NVIDIA rejestruje prompty i odpowiedzi, aby ulepszać swoje modele i usługi. Nie przesyłaj danych osobowych ani poufnych. - OpenAI APIs: Żądania są przechowywane przez 30 dni zgodnie z [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data). - Anthropic APIs: Żądania są przechowywane przez 30 dni zgodnie z [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage). diff --git a/packages/web/src/content/docs/pt-br/go.mdx b/packages/web/src/content/docs/pt-br/go.mdx index f4d2912c9e..44c5092a00 100644 --- a/packages/web/src/content/docs/pt-br/go.mdx +++ b/packages/web/src/content/docs/pt-br/go.mdx @@ -75,7 +75,6 @@ A lista atual de modelos inclui: - **MiniMax M2.7** - **DeepSeek V4 Pro** - **DeepSeek V4 Flash** -- **Hy3 preview** A lista de modelos pode mudar conforme testamos e adicionamos novos. @@ -107,7 +106,6 @@ A tabela abaixo fornece uma contagem estimada de requisições com base nos padr | Qwen3.5 Plus | 10,200 | 25,200 | 50,500 | | DeepSeek V4 Pro | 3,450 | 8,550 | 17,150 | | DeepSeek V4 Flash | 31,650 | 79,050 | 158,150 | -| Hy3 preview | 1,875 | 4,690 | 9,375 | As estimativas baseiam-se nos padrões médios de requisições observados: @@ -120,7 +118,6 @@ As estimativas baseiam-se nos padrões médios de requisições observados: - Qwen3.6 Plus — 500 tokens de entrada, 57.000 em cache, 190 tokens de saída por requisição - MiMo-V2.5 — 1000 tokens de entrada, 60.000 em cache, 140 tokens de saída por requisição - MiMo-V2.5-Pro — 350 tokens de entrada, 41.000 em cache, 250 tokens de saída por requisição -- Hy3 preview — 800 input, 50,000 cached, 400 output tokens per request Você pode acompanhar o seu uso atual no **console**. @@ -158,7 +155,6 @@ Você também pode acessar os modelos do Go através dos seguintes endpoints de | MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` | | Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` | -| Hy3 preview | hy3-preview | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | O [ID do modelo](/docs/config/#models) na sua configuração do OpenCode usa o formato `opencode-go/`. Por exemplo, para o Kimi K2.6, você usaria diff --git a/packages/web/src/content/docs/pt-br/zen.mdx b/packages/web/src/content/docs/pt-br/zen.mdx index af813cb659..1dcc98c5d5 100644 --- a/packages/web/src/content/docs/pt-br/zen.mdx +++ b/packages/web/src/content/docs/pt-br/zen.mdx @@ -94,6 +94,7 @@ Você também pode acessar nossos modelos pelos seguintes endpoints de API. | Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Ling 2.6 Flash | ling-2.6-flash | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Hy3 Preview Free | hy3-preview-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Nemotron 3 Super Free | nemotron-3-super-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | O [model id](/docs/config/#models) na sua configuração do OpenCode usa o formato `opencode/`. Por exemplo, para GPT 5.5, você usaria `opencode/gpt-5.5` na sua configuração. @@ -119,6 +120,7 @@ Oferecemos um modelo pay-as-you-go. Abaixo estão os preços **por 1M tokens**. | Big Pickle | Free | Free | Free | - | | MiniMax M2.5 Free | Free | Free | Free | - | | Ling 2.6 Flash Free | Free | Free | Free | - | +| Hy3 Preview Free | Free | Free | Free | - | | Nemotron 3 Super Free | Free | Free | Free | - | | MiniMax M2.7 | $0.30 | $1.20 | $0.06 | $0.375 | | MiniMax M2.5 | $0.30 | $1.20 | $0.06 | $0.375 | @@ -171,6 +173,7 @@ Os modelos gratuitos: - MiniMax M2.5 Free está disponível no OpenCode por tempo limitado. A equipe está usando esse período para coletar feedback e melhorar o modelo. - Ling 2.6 Flash Free está disponível no OpenCode por tempo limitado. A equipe está usando esse período para coletar feedback e melhorar o modelo. +- Hy3 Preview Free está disponível no OpenCode por tempo limitado. A equipe está usando esse período para coletar feedback e melhorar o modelo. - Nemotron 3 Super Free está disponível no OpenCode por tempo limitado. A equipe está usando esse período para coletar feedback e melhorar o modelo. - Big Pickle é um modelo stealth que está gratuito no OpenCode por tempo limitado. A equipe está usando esse período para coletar feedback e melhorar o modelo. @@ -223,6 +226,7 @@ Todos os nossos modelos são hospedados nos US. Nossos provedores seguem uma pol - Big Pickle: Durante seu período gratuito, os dados coletados podem ser usados para melhorar o modelo. - MiniMax M2.5 Free: Durante seu período gratuito, os dados coletados podem ser usados para melhorar o modelo. - Ling 2.6 Flash Free: Durante seu período gratuito, os dados coletados podem ser usados para melhorar o modelo. +- Hy3 Preview Free: Durante seu período gratuito, os dados coletados podem ser usados para melhorar o modelo. - Nemotron 3 Super Free (endpoints gratuitos da NVIDIA): Fornecido sob os [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). Apenas para uso de avaliação, não para produção nem dados sensíveis. A NVIDIA registra prompts e saídas para melhorar seus modelos e serviços. Não envie dados pessoais ou confidenciais. - OpenAI APIs: As solicitações são retidas por 30 dias de acordo com [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data). - Anthropic APIs: As solicitações são retidas por 30 dias de acordo com [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage). diff --git a/packages/web/src/content/docs/ru/go.mdx b/packages/web/src/content/docs/ru/go.mdx index 6fccf9120e..66e929c5f4 100644 --- a/packages/web/src/content/docs/ru/go.mdx +++ b/packages/web/src/content/docs/ru/go.mdx @@ -75,7 +75,6 @@ OpenCode Go работает так же, как и любой другой пр - **MiniMax M2.7** - **DeepSeek V4 Pro** - **DeepSeek V4 Flash** -- **Hy3 preview** Список моделей может меняться по мере того, как мы тестируем и добавляем новые. @@ -107,7 +106,6 @@ OpenCode Go включает следующие лимиты: | Qwen3.5 Plus | 10,200 | 25,200 | 50,500 | | DeepSeek V4 Pro | 3,450 | 8,550 | 17,150 | | DeepSeek V4 Flash | 31,650 | 79,050 | 158,150 | -| Hy3 preview | 1,875 | 4,690 | 9,375 | Оценки основаны на наблюдаемых средних показателях запросов: @@ -120,7 +118,6 @@ OpenCode Go включает следующие лимиты: - Qwen3.6 Plus — 500 входных, 57,000 кешированных, 190 выходных токенов на запрос - MiMo-V2.5 — 1000 входных, 60,000 кешированных, 140 выходных токенов на запрос - MiMo-V2.5-Pro — 350 входных, 41,000 кешированных, 250 выходных токенов на запрос -- Hy3 preview — 800 input, 50,000 cached, 400 output tokens per request Вы можете отслеживать текущее использование в **консоли**. @@ -158,7 +155,6 @@ OpenCode Go включает следующие лимиты: | MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` | | Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` | -| Hy3 preview | hy3-preview | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | [ID модели](/docs/config/#models) в вашем конфиге OpenCode использует формат `opencode-go/`. Например, для Kimi K2.6 вам нужно diff --git a/packages/web/src/content/docs/ru/zen.mdx b/packages/web/src/content/docs/ru/zen.mdx index b7ba2ee256..10c55fc4dd 100644 --- a/packages/web/src/content/docs/ru/zen.mdx +++ b/packages/web/src/content/docs/ru/zen.mdx @@ -103,6 +103,7 @@ OpenCode Zen работает как любой другой провайдер | Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Ling 2.6 Flash | ling-2.6-flash | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Hy3 Preview Free | hy3-preview-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Nemotron 3 Super Free | nemotron-3-super-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | [идентификатор модели](/docs/config/#models) в вашей конфигурации OpenCode @@ -130,6 +131,7 @@ https://opencode.ai/zen/v1/models | Big Pickle | Free | Free | Free | - | | MiniMax M2.5 Free | Free | Free | Free | - | | Ling 2.6 Flash Free | Free | Free | Free | - | +| Hy3 Preview Free | Free | Free | Free | - | | Nemotron 3 Super Free | Free | Free | Free | - | | MiniMax M2.7 | $0.30 | $1.20 | $0.06 | $0.375 | | MiniMax M2.5 | $0.30 | $1.20 | $0.06 | $0.375 | @@ -182,6 +184,7 @@ https://opencode.ai/zen/v1/models - MiniMax M2.5 Free доступна в OpenCode ограниченное время. Команда использует это время, чтобы собирать отзывы и улучшать модель. - Ling 2.6 Flash Free доступна в OpenCode ограниченное время. Команда использует это время, чтобы собирать отзывы и улучшать модель. +- Hy3 Preview Flash Free доступна в OpenCode ограниченное время. Команда использует это время, чтобы собирать отзывы и улучшать модель. - Nemotron 3 Super Free доступна в OpenCode ограниченное время. Команда использует это время, чтобы собирать отзывы и улучшать модель. - Big Pickle — это скрытая модель, которая доступна бесплатно в OpenCode ограниченное время. Команда использует это время, чтобы собирать отзывы и улучшать модель. @@ -237,6 +240,7 @@ https://opencode.ai/zen/v1/models - Big Pickle: во время бесплатного периода собранные данные могут использоваться для улучшения модели. - MiniMax M2.5 Free: во время бесплатного периода собранные данные могут использоваться для улучшения модели. - Ling 2.6 Flash Free: во время бесплатного периода собранные данные могут использоваться для улучшения модели. +- Hy3 Preview Free: во время бесплатного периода собранные данные могут использоваться для улучшения модели. - Nemotron 3 Super Free (бесплатные эндпоинты NVIDIA): предоставляется в соответствии с [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). Только для пробного использования, не для продакшена и не для чувствительных данных. NVIDIA логирует запросы и ответы, чтобы улучшать свои модели и сервисы. Не отправляйте персональные или конфиденциальные данные. - OpenAI APIs: запросы хранятся 30 дней в соответствии с [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data). - Anthropic APIs: запросы хранятся 30 дней в соответствии с [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage). diff --git a/packages/web/src/content/docs/th/go.mdx b/packages/web/src/content/docs/th/go.mdx index 5e461baf7c..1fa0f8cc2a 100644 --- a/packages/web/src/content/docs/th/go.mdx +++ b/packages/web/src/content/docs/th/go.mdx @@ -65,7 +65,6 @@ OpenCode Go ทำงานเหมือนกับผู้ให้บร - **MiniMax M2.7** - **DeepSeek V4 Pro** - **DeepSeek V4 Flash** -- **Hy3 preview** รายชื่อโมเดลอาจมีการเปลี่ยนแปลงเมื่อเราทำการทดสอบและเพิ่มโมเดลใหม่ๆ @@ -97,7 +96,6 @@ OpenCode Go มีขีดจำกัดดังต่อไปนี้: | Qwen3.5 Plus | 10,200 | 25,200 | 50,500 | | DeepSeek V4 Pro | 3,450 | 8,550 | 17,150 | | DeepSeek V4 Flash | 31,650 | 79,050 | 158,150 | -| Hy3 preview | 1,875 | 4,690 | 9,375 | การประมาณการอ้างอิงจากรูปแบบการใช้งาน request โดยเฉลี่ยที่สังเกตพบ: @@ -110,7 +108,6 @@ OpenCode Go มีขีดจำกัดดังต่อไปนี้: - Qwen3.6 Plus — 500 input, 57,000 cached, 190 output tokens ต่อ request - MiMo-V2.5 — 1000 input, 60,000 cached, 140 output tokens ต่อ request - MiMo-V2.5-Pro — 350 input, 41,000 cached, 250 output tokens ต่อ request -- Hy3 preview — 800 input, 50,000 cached, 400 output tokens per request คุณสามารถติดตามการใช้งานปัจจุบันของคุณได้ใน **console** @@ -146,7 +143,6 @@ OpenCode Go มีขีดจำกัดดังต่อไปนี้: | MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` | | Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` | -| Hy3 preview | hy3-preview | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | [model id](/docs/config/#models) ใน OpenCode config ของคุณจะใช้รูปแบบ `opencode-go/` ตัวอย่างเช่น สำหรับ Kimi K2.6 คุณจะใช้ `opencode-go/kimi-k2.6` ใน config ของคุณ diff --git a/packages/web/src/content/docs/th/zen.mdx b/packages/web/src/content/docs/th/zen.mdx index 3808ed2ab6..cb2556ef63 100644 --- a/packages/web/src/content/docs/th/zen.mdx +++ b/packages/web/src/content/docs/th/zen.mdx @@ -96,6 +96,7 @@ OpenCode Zen ทำงานเหมือน provider อื่น ๆ ใน | Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Ling 2.6 Flash | ling-2.6-flash | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Hy3 Preview Free | hy3-preview-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Nemotron 3 Super Free | nemotron-3-super-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | [model id](/docs/config/#models) ใน OpenCode config ของคุณใช้รูปแบบ `opencode/` ตัวอย่างเช่น สำหรับ GPT 5.5 คุณจะใช้ `opencode/gpt-5.5` ใน config ของคุณ @@ -121,6 +122,7 @@ https://opencode.ai/zen/v1/models | Big Pickle | Free | Free | Free | - | | MiniMax M2.5 Free | Free | Free | Free | - | | Ling 2.6 Flash Free | Free | Free | Free | - | +| Hy3 Preview Free | Free | Free | Free | - | | Nemotron 3 Super Free | Free | Free | Free | - | | MiniMax M2.7 | $0.30 | $1.20 | $0.06 | $0.375 | | MiniMax M2.5 | $0.30 | $1.20 | $0.06 | $0.375 | @@ -173,6 +175,7 @@ https://opencode.ai/zen/v1/models - MiniMax M2.5 Free เปิดให้ใช้บน OpenCode ในช่วงเวลาจำกัด ทีมกำลังใช้ช่วงเวลานี้เพื่อเก็บ feedback และปรับปรุงโมเดล - Ling 2.6 Flash Free เปิดให้ใช้บน OpenCode ในช่วงเวลาจำกัด ทีมกำลังใช้ช่วงเวลานี้เพื่อเก็บ feedback และปรับปรุงโมเดล +- Hy3 Preview Free เปิดให้ใช้บน OpenCode ในช่วงเวลาจำกัด ทีมกำลังใช้ช่วงเวลานี้เพื่อเก็บ feedback และปรับปรุงโมเดล - Nemotron 3 Super Free เปิดให้ใช้บน OpenCode ในช่วงเวลาจำกัด ทีมกำลังใช้ช่วงเวลานี้เพื่อเก็บ feedback และปรับปรุงโมเดล - Big Pickle เป็น stealth model ที่ใช้งานฟรีบน OpenCode ในช่วงเวลาจำกัด ทีมกำลังใช้ช่วงเวลานี้เพื่อเก็บ feedback และปรับปรุงโมเดล @@ -225,6 +228,7 @@ https://opencode.ai/zen/v1/models - Big Pickle: ระหว่างช่วงที่เปิดให้ใช้ฟรี ข้อมูลที่เก็บรวบรวมอาจถูกนำไปใช้เพื่อปรับปรุงโมเดล - MiniMax M2.5 Free: ระหว่างช่วงที่เปิดให้ใช้ฟรี ข้อมูลที่เก็บรวบรวมอาจถูกนำไปใช้เพื่อปรับปรุงโมเดล - Ling 2.6 Flash Free: ระหว่างช่วงที่เปิดให้ใช้ฟรี ข้อมูลที่เก็บรวบรวมอาจถูกนำไปใช้เพื่อปรับปรุงโมเดล +- Hy3 Preview Free: ระหว่างช่วงที่เปิดให้ใช้ฟรี ข้อมูลที่เก็บรวบรวมอาจถูกนำไปใช้เพื่อปรับปรุงโมเดล - Nemotron 3 Super Free (endpoint ฟรีของ NVIDIA): ให้บริการภายใต้ [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf) ใช้สำหรับการทดลองเท่านั้น ไม่เหมาะสำหรับ production หรือข้อมูลที่อ่อนไหว NVIDIA จะบันทึก prompt และ output เพื่อนำไปปรับปรุงโมเดลและบริการของตน โปรดอย่าส่งข้อมูลส่วนบุคคลหรือข้อมูลลับ. - OpenAI APIs: คำขอจะถูกเก็บไว้เป็นเวลา 30 วันตาม [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data). - Anthropic APIs: คำขอจะถูกเก็บไว้เป็นเวลา 30 วันตาม [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage). diff --git a/packages/web/src/content/docs/tr/go.mdx b/packages/web/src/content/docs/tr/go.mdx index b065658d09..367be5a750 100644 --- a/packages/web/src/content/docs/tr/go.mdx +++ b/packages/web/src/content/docs/tr/go.mdx @@ -65,7 +65,6 @@ Mevcut model listesi şunları içerir: - **MiniMax M2.7** - **DeepSeek V4 Pro** - **DeepSeek V4 Flash** -- **Hy3 preview** Test edip yenilerini ekledikçe model listesi değişebilir. @@ -97,7 +96,6 @@ Aşağıdaki tablo, tipik Go kullanım modellerine dayalı tahmini bir istek say | Qwen3.5 Plus | 10,200 | 25,200 | 50,500 | | DeepSeek V4 Pro | 3,450 | 8,550 | 17,150 | | DeepSeek V4 Flash | 31,650 | 79,050 | 158,150 | -| Hy3 preview | 1,875 | 4,690 | 9,375 | Tahminler, gözlemlenen ortalama istek modellerine dayanmaktadır: @@ -110,7 +108,6 @@ Tahminler, gözlemlenen ortalama istek modellerine dayanmaktadır: - Qwen3.6 Plus — İstek başına 500 girdi, 57.000 önbelleğe alınmış, 190 çıktı token'ı - MiMo-V2.5 — İstek başına 1000 girdi, 60.000 önbelleğe alınmış, 140 çıktı token'ı - MiMo-V2.5-Pro — İstek başına 350 girdi, 41.000 önbelleğe alınmış, 250 çıktı token'ı -- Hy3 preview — 800 input, 50,000 cached, 400 output tokens per request Mevcut kullanımınızı **konsoldan** takip edebilirsiniz. @@ -146,7 +143,6 @@ Go modellerine aşağıdaki API uç noktaları aracılığıyla da erişebilirsi | MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` | | Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` | -| Hy3 preview | hy3-preview | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | OpenCode yapılandırmanızdaki [model id](/docs/config/#models) formatı `opencode-go/` şeklindedir. Örneğin, Kimi K2.6 için yapılandırmanızda `opencode-go/kimi-k2.6` kullanmalısınız. diff --git a/packages/web/src/content/docs/tr/zen.mdx b/packages/web/src/content/docs/tr/zen.mdx index 37885db43b..36c1bfc66e 100644 --- a/packages/web/src/content/docs/tr/zen.mdx +++ b/packages/web/src/content/docs/tr/zen.mdx @@ -94,6 +94,7 @@ Modellerimize aşağıdaki API uç noktaları aracılığıyla da erişebilirsin | Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Ling 2.6 Flash | ling-2.6-flash | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Hy3 Preview Free | hy3-preview-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Nemotron 3 Super Free | nemotron-3-super-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | OpenCode yapılandırmanızdaki [model id](/docs/config/#models) `opencode/` biçimini kullanır. Örneğin, GPT 5.5 için yapılandırmanızda `opencode/gpt-5.5` kullanırsınız. @@ -119,6 +120,7 @@ Kullandıkça öde modelini destekliyoruz. Aşağıda **1M token başına** fiya | Big Pickle | Free | Free | Free | - | | MiniMax M2.5 Free | Free | Free | Free | - | | Ling 2.6 Flash Free | Free | Free | Free | - | +| Hy3 Preview Free | Free | Free | Free | - | | Nemotron 3 Super Free | Free | Free | Free | - | | MiniMax M2.7 | $0.30 | $1.20 | $0.06 | $0.375 | | MiniMax M2.5 | $0.30 | $1.20 | $0.06 | $0.375 | @@ -171,6 +173,7 @@ Kredi kartı ücretleri maliyet üzerinden yansıtılır (%4.4 + işlem başına - MiniMax M2.5 Free, sınırlı bir süre için OpenCode'da ücretsizdir. Ekip bu süreyi geri bildirim toplamak ve modeli iyileştirmek için kullanıyor. - Ling 2.6 Flash Free, sınırlı bir süre için OpenCode'da ücretsizdir. Ekip bu süreyi geri bildirim toplamak ve modeli iyileştirmek için kullanıyor. +- Hy3 Preview Free, sınırlı bir süre için OpenCode'da ücretsizdir. Ekip bu süreyi geri bildirim toplamak ve modeli iyileştirmek için kullanıyor. - Nemotron 3 Super Free, sınırlı bir süre için OpenCode'da ücretsizdir. Ekip bu süreyi geri bildirim toplamak ve modeli iyileştirmek için kullanıyor. - Big Pickle, sınırlı bir süre için OpenCode'da ücretsiz olan gizli bir modeldir. Ekip bu süreyi geri bildirim toplamak ve modeli iyileştirmek için kullanıyor. @@ -223,6 +226,7 @@ Tüm modellerimiz US'de barındırılıyor. Sağlayıcılarımız zero-retention - Big Pickle: Ücretsiz döneminde toplanan veriler modeli iyileştirmek için kullanılabilir. - MiniMax M2.5 Free: Ücretsiz döneminde toplanan veriler modeli iyileştirmek için kullanılabilir. - Ling 2.6 Flash Free: Ücretsiz döneminde toplanan veriler modeli iyileştirmek için kullanılabilir. +- Hy3 Preview Free: Ücretsiz döneminde toplanan veriler modeli iyileştirmek için kullanılabilir. - Nemotron 3 Super Free (ücretsiz NVIDIA uç noktaları): [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf) kapsamında sunulur. Yalnızca deneme amaçlıdır; üretim veya hassas veriler için uygun değildir. NVIDIA, modellerini ve hizmetlerini geliştirmek için promptları ve çıktıları kaydeder. Kişisel veya gizli veri göndermeyin. - OpenAI APIs: İstekler [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data) uyarınca 30 gün boyunca saklanır. - Anthropic APIs: İstekler [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage) uyarınca 30 gün boyunca saklanır. diff --git a/packages/web/src/content/docs/zen.mdx b/packages/web/src/content/docs/zen.mdx index fcec86cfd6..333e74434b 100644 --- a/packages/web/src/content/docs/zen.mdx +++ b/packages/web/src/content/docs/zen.mdx @@ -103,6 +103,7 @@ You can also access our models through the following API endpoints. | Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Ling 2.6 Flash | ling-2.6-flash | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Hy3 Preview Free | hy3-preview-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Nemotron 3 Super Free | nemotron-3-super-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | The [model id](/docs/config/#models) in your OpenCode config @@ -130,6 +131,7 @@ We support a pay-as-you-go model. Below are the prices **per 1M tokens**. | Big Pickle | Free | Free | Free | - | | MiniMax M2.5 Free | Free | Free | Free | - | | Ling 2.6 Flash Free | Free | Free | Free | - | +| Hy3 Preview Free | Free | Free | Free | - | | Nemotron 3 Super Free | Free | Free | Free | - | | MiniMax M2.7 | $0.30 | $1.20 | $0.06 | $0.375 | | MiniMax M2.5 | $0.30 | $1.20 | $0.06 | $0.375 | @@ -182,6 +184,7 @@ The free models: - MiniMax M2.5 Free is available on OpenCode for a limited time. The team is using this time to collect feedback and improve the model. - Ling 2.6 Flash Free is available on OpenCode for a limited time. The team is using this time to collect feedback and improve the model. +- Hy3 Preview Flash Free is available on OpenCode for a limited time. The team is using this time to collect feedback and improve the model. - Nemotron 3 Super Free is available on OpenCode for a limited time. The team is using this time to collect feedback and improve the model. - Big Pickle is a stealth model that's free on OpenCode for a limited time. The team is using this time to collect feedback and improve the model. @@ -237,6 +240,7 @@ All our models are hosted in the US. Our providers follow a zero-retention polic - Big Pickle: During its free period, collected data may be used to improve the model. - MiniMax M2.5 Free: During its free period, collected data may be used to improve the model. - Ling 2.6 Flash Free: During its free period, collected data may be used to improve the model. +- Hy3 Preview Free: During its free period, collected data may be used to improve the model. - Nemotron 3 Super Free (NVIDIA free endpoints): Provided under the [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). Trial use only — not for production or sensitive data. Prompts and outputs are logged by NVIDIA to improve its models and services. Do not submit personal or confidential data. - OpenAI APIs: Requests are retained for 30 days in accordance with [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data). - Anthropic APIs: Requests are retained for 30 days in accordance with [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage). diff --git a/packages/web/src/content/docs/zh-cn/go.mdx b/packages/web/src/content/docs/zh-cn/go.mdx index 195073a998..17934ee2a0 100644 --- a/packages/web/src/content/docs/zh-cn/go.mdx +++ b/packages/web/src/content/docs/zh-cn/go.mdx @@ -65,7 +65,6 @@ OpenCode Go 的工作方式与 OpenCode 中的其他提供商一样。 - **MiniMax M2.7** - **DeepSeek V4 Pro** - **DeepSeek V4 Flash** -- **Hy3 preview** 随着我们进行测试和添加新模型,该列表可能会发生变化。 @@ -97,7 +96,6 @@ OpenCode Go 包含以下限制: | Qwen3.5 Plus | 10,200 | 25,200 | 50,500 | | DeepSeek V4 Pro | 3,450 | 8,550 | 17,150 | | DeepSeek V4 Flash | 31,650 | 79,050 | 158,150 | -| Hy3 preview | 1,875 | 4,690 | 9,375 | 预估值基于观察到的平均请求模式: @@ -110,7 +108,6 @@ OpenCode Go 包含以下限制: - MiniMax M2.7/M2.5 — 每次请求 300 个输入 token,55,000 个缓存 token,125 个输出 token - Qwen3.5 Plus — 每次请求 410 个输入 token,47,000 个缓存 token,140 个输出 token - Qwen3.6 Plus — 每次请求 500 个输入 token,57,000 个缓存 token,190 个输出 token -- Hy3 preview — 800 input, 50,000 cached, 400 output tokens per request 你可以在 **控制台** 中跟踪你当前的使用情况。 @@ -146,7 +143,6 @@ OpenCode Go 包含以下限制: | MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` | | Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` | -| Hy3 preview | hy3-preview | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | 你 OpenCode 配置中的 [模型 ID](/docs/config/#models) 使用 `opencode-go/` 格式。例如,对于 Kimi K2.6,你将在配置中使用 `opencode-go/kimi-k2.6`。 diff --git a/packages/web/src/content/docs/zh-cn/zen.mdx b/packages/web/src/content/docs/zh-cn/zen.mdx index 1ba577ab2a..9ad7e6b53d 100644 --- a/packages/web/src/content/docs/zh-cn/zen.mdx +++ b/packages/web/src/content/docs/zh-cn/zen.mdx @@ -94,6 +94,7 @@ OpenCode Zen 的工作方式与 OpenCode 中的任何其他提供商相同。 | Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Ling 2.6 Flash | ling-2.6-flash | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Hy3 Preview Free | hy3-preview-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Nemotron 3 Super Free | nemotron-3-super-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | 在你的 OpenCode 配置中,[模型 ID](/docs/config/#models) 使用 `opencode/` 格式。例如,对于 GPT 5.5,你需要在配置中使用 `opencode/gpt-5.5`。 @@ -119,6 +120,7 @@ https://opencode.ai/zen/v1/models | Big Pickle | Free | Free | Free | - | | MiniMax M2.5 Free | Free | Free | Free | - | | Ling 2.6 Flash Free | Free | Free | Free | - | +| Hy3 Preview Free | Free | Free | Free | - | | Nemotron 3 Super Free | Free | Free | Free | - | | MiniMax M2.7 | $0.30 | $1.20 | $0.06 | $0.375 | | MiniMax M2.5 | $0.30 | $1.20 | $0.06 | $0.375 | @@ -171,6 +173,7 @@ https://opencode.ai/zen/v1/models - MiniMax M2.5 Free 目前在 OpenCode 上限时免费提供。团队正在利用这段时间收集反馈并改进模型。 - Ling 2.6 Flash Free 目前在 OpenCode 上限时免费提供。团队正在利用这段时间收集反馈并改进模型。 +- Hy3 Preview Flash Free 目前在 OpenCode 上限时免费提供。团队正在利用这段时间收集反馈并改进模型。 - Nemotron 3 Super Free 目前在 OpenCode 上限时免费提供。团队正在利用这段时间收集反馈并改进模型。 - Big Pickle 是一个隐身模型,目前在 OpenCode 上限时免费提供。团队正在利用这段时间收集反馈并改进模型。 @@ -223,6 +226,7 @@ https://opencode.ai/zen/v1/models - Big Pickle:在免费期间,收集的数据可能会被用于改进模型。 - MiniMax M2.5 Free:在免费期间,收集的数据可能会被用于改进模型。 - Ling 2.6 Flash Free:在免费期间,收集的数据可能会被用于改进模型。 +- Hy3 Preview Free:在免费期间,收集的数据可能会被用于改进模型。 - Nemotron 3 Super Free(NVIDIA 免费端点):根据 [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf) 提供。仅供试用,不适用于生产环境或敏感数据。NVIDIA 会记录提示词和输出内容,以改进其模型和服务。请勿提交个人或机密数据。 - OpenAI APIs:请求会根据 [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data) 保留 30 天。 - Anthropic APIs:请求会根据 [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage) 保留 30 天。 diff --git a/packages/web/src/content/docs/zh-tw/go.mdx b/packages/web/src/content/docs/zh-tw/go.mdx index 37a24caf8c..c4589716f2 100644 --- a/packages/web/src/content/docs/zh-tw/go.mdx +++ b/packages/web/src/content/docs/zh-tw/go.mdx @@ -65,7 +65,6 @@ OpenCode Go 的運作方式與 OpenCode 中的任何其他供應商相同。 - **MiniMax M2.7** - **DeepSeek V4 Pro** - **DeepSeek V4 Flash** -- **Hy3 preview** 隨著我們測試並加入新模型,模型清單可能會有所變動。 @@ -97,7 +96,6 @@ OpenCode Go 包含以下限制: | Qwen3.5 Plus | 10,200 | 25,200 | 50,500 | | DeepSeek V4 Pro | 3,450 | 8,550 | 17,150 | | DeepSeek V4 Flash | 31,650 | 79,050 | 158,150 | -| Hy3 preview | 1,875 | 4,690 | 9,375 | 預估值是基於觀察到的平均請求模式: @@ -110,7 +108,6 @@ OpenCode Go 包含以下限制: - Qwen3.6 Plus — 每次請求 500 個輸入 token、57,000 個快取 token、190 個輸出 token - MiMo-V2.5 — 每次請求 1000 個輸入 token、60,000 個快取 token、140 個輸出 token - MiMo-V2.5-Pro — 每次請求 350 個輸入 token、41,000 個快取 token、250 個輸出 token -- Hy3 preview — 800 input, 50,000 cached, 400 output tokens per request 您可以在 **console** 中追蹤您目前的使用量。 @@ -146,7 +143,6 @@ OpenCode Go 包含以下限制: | MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` | | Qwen3.5 Plus | qwen3.5-plus | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/alibaba` | -| Hy3 preview | hy3-preview | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | 您的 OpenCode 設定中的 [model id](/docs/config/#models) 使用 `opencode-go/` 格式。例如,Kimi K2.6 在設定中應使用 `opencode-go/kimi-k2.6`。 diff --git a/packages/web/src/content/docs/zh-tw/zen.mdx b/packages/web/src/content/docs/zh-tw/zen.mdx index a81f835237..9511bd9e24 100644 --- a/packages/web/src/content/docs/zh-tw/zen.mdx +++ b/packages/web/src/content/docs/zh-tw/zen.mdx @@ -98,6 +98,7 @@ OpenCode Zen 的運作方式和 OpenCode 中的其他供應商一樣。 | Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Ling 2.6 Flash | ling-2.6-flash | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Hy3 Preview Free | hy3-preview-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Nemotron 3 Super Free | nemotron-3-super-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | OpenCode 設定中的 [模型 ID](/docs/config/#models) 會使用 `opencode/` @@ -124,6 +125,7 @@ https://opencode.ai/zen/v1/models | Big Pickle | Free | Free | Free | - | | MiniMax M2.5 Free | Free | Free | Free | - | | Ling 2.6 Flash Free | Free | Free | Free | - | +| Hy3 Preview Free | Free | Free | Free | - | | Nemotron 3 Super Free | Free | Free | Free | - | | MiniMax M2.7 | $0.30 | $1.20 | $0.06 | $0.375 | | MiniMax M2.5 | $0.30 | $1.20 | $0.06 | $0.375 | @@ -177,6 +179,7 @@ https://opencode.ai/zen/v1/models - MiniMax M2.5 Free 在 OpenCode 上限時提供。團隊正在利用這段時間收集回饋並改進模型。 - Ling 2.6 Flash Free 在 OpenCode 上限時提供。團隊正在利用這段時間收集回饋並改進模型。 +- Hy3 Preview Free 在 OpenCode 上限時提供。團隊正在利用這段時間收集回饋並改進模型。 - Nemotron 3 Super Free 在 OpenCode 上限時提供。團隊正在利用這段時間收集回饋並改進模型。 - Big Pickle 是一個隱身模型,在 OpenCode 上限時免費提供。團隊正在利用這段時間收集回饋並改進模型。 @@ -230,6 +233,7 @@ https://opencode.ai/zen/v1/models - Big Pickle: 在免費期間,收集到的資料可能會用於改進模型。 - MiniMax M2.5 Free: 在免費期間,收集到的資料可能會用於改進模型。 - Ling 2.6 Flash Free: 在免費期間,收集到的資料可能會用於改進模型。 +- Hy3 Preview Free: 在免費期間,收集到的資料可能會用於改進模型。 - Nemotron 3 Super Free(NVIDIA 免費端點):依據 [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf) 提供。僅供試用,不適用於正式環境或敏感資料。NVIDIA 會記錄提示詞與輸出內容,以改進其模型與服務。請勿提交個人或機密資料。 - OpenAI APIs: 請求會依據 [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data) 保留 30 天。 - Anthropic APIs: 請求會依據 [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage) 保留 30 天。 From 472f9e64a69643310df1cb1ca8f242d918f55606 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sun, 10 May 2026 07:06:30 +0000 Subject: [PATCH 123/189] chore: update nix node_modules hashes --- nix/hashes.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/nix/hashes.json b/nix/hashes.json index 558264474a..4244e0c0e7 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-LTo0ohJN5hBOubqFLVL45unVEIwBDkACNVv64k2nkq4=", - "aarch64-linux": "sha256-oYKY2UJRWG2fhufW4aGujX/Poou93023ZF2Fu7oyYOw=", - "aarch64-darwin": "sha256-618c9vqKN5I+no1nzylctAiWvqw7Bsa+bzSTNwXmSQA=", - "x86_64-darwin": "sha256-1ro3/gH0FC0TWXwWT+k675xR396GE98HpnBEeuD4t6k=" + "x86_64-linux": "sha256-baGxh+hk/rPhg0xI/OdMDz6dPwncgercYNBdTPnLX9o=", + "aarch64-linux": "sha256-VTWKq679B3Q4ZnAoQzC4VSCYA09wWecNJ+JajvjNB1U=", + "aarch64-darwin": "sha256-orf2zIBMTiiQrt/6qCzE+o0oKhv6u8zXF9DH1Bo3lbo=", + "x86_64-darwin": "sha256-1MZC1fadRoY4lhkmjlcUQTLYH9Q8pDI1bxd5f94f1xU=" } } From 903d81819de23a4ef76e28e327c2400676718f62 Mon Sep 17 00:00:00 2001 From: Frank Date: Sun, 10 May 2026 03:51:32 -0400 Subject: [PATCH 124/189] Zen: add Ring 2.6 1T --- packages/web/src/content/docs/ar/zen.mdx | 12 ++++-------- packages/web/src/content/docs/bs/zen.mdx | 12 ++++-------- packages/web/src/content/docs/da/zen.mdx | 12 ++++-------- packages/web/src/content/docs/de/zen.mdx | 12 ++++-------- packages/web/src/content/docs/es/zen.mdx | 12 ++++-------- packages/web/src/content/docs/fr/zen.mdx | 12 ++++-------- packages/web/src/content/docs/it/zen.mdx | 12 ++++-------- packages/web/src/content/docs/ja/zen.mdx | 12 ++++-------- packages/web/src/content/docs/ko/zen.mdx | 12 ++++-------- packages/web/src/content/docs/nb/zen.mdx | 12 ++++-------- packages/web/src/content/docs/pl/zen.mdx | 12 ++++-------- packages/web/src/content/docs/pt-br/zen.mdx | 12 ++++-------- packages/web/src/content/docs/ru/zen.mdx | 12 ++++-------- packages/web/src/content/docs/th/zen.mdx | 12 ++++-------- packages/web/src/content/docs/tr/zen.mdx | 12 ++++-------- packages/web/src/content/docs/zen.mdx | 12 ++++-------- packages/web/src/content/docs/zh-cn/zen.mdx | 12 ++++-------- packages/web/src/content/docs/zh-tw/zen.mdx | 12 ++++-------- 18 files changed, 72 insertions(+), 144 deletions(-) diff --git a/packages/web/src/content/docs/ar/zen.mdx b/packages/web/src/content/docs/ar/zen.mdx index 33fd9493ba..748384c211 100644 --- a/packages/web/src/content/docs/ar/zen.mdx +++ b/packages/web/src/content/docs/ar/zen.mdx @@ -97,8 +97,7 @@ OpenCode Zen هي بوابة AI تتيح لك الوصول إلى هذه الن | Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Ling 2.6 Flash | ling-2.6-flash | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Hy3 Preview Free | hy3-preview-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Ring 2.6 1T | ring-2.6-1t-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Nemotron 3 Super Free | nemotron-3-super-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | يستخدم [معرّف النموذج](/docs/config/#models) في إعدادات OpenCode الصيغة `opencode/`. على سبيل المثال، بالنسبة إلى GPT 5.5، ستستخدم `opencode/gpt-5.5` في إعداداتك. @@ -123,8 +122,7 @@ https://opencode.ai/zen/v1/models | --------------------------------- | ------- | ------- | --------------- | --------------- | | Big Pickle | Free | Free | Free | - | | MiniMax M2.5 Free | Free | Free | Free | - | -| Ling 2.6 Flash Free | Free | Free | Free | - | -| Hy3 Preview Free | Free | Free | Free | - | +| Ring 2.6 1T Free | Free | Free | Free | - | | Nemotron 3 Super Free | Free | Free | Free | - | | MiniMax M2.7 | $0.30 | $1.20 | $0.06 | $0.375 | | MiniMax M2.5 | $0.30 | $1.20 | $0.06 | $0.375 | @@ -176,8 +174,7 @@ https://opencode.ai/zen/v1/models النماذج المجانية: - MiniMax M2.5 Free متاح على OpenCode لفترة محدودة. يستخدم الفريق هذه الفترة لجمع الملاحظات وتحسين النموذج. -- Ling 2.6 Flash Free متاح على OpenCode لفترة محدودة. يستخدم الفريق هذه الفترة لجمع الملاحظات وتحسين النموذج. -- Hy3 Preview Flash Free متاح على OpenCode لفترة محدودة. يستخدم الفريق هذه الفترة لجمع الملاحظات وتحسين النموذج. +- Ring 2.6 1T Free متاح على OpenCode لفترة محدودة. يستخدم الفريق هذه الفترة لجمع الملاحظات وتحسين النموذج. - Nemotron 3 Super Free متاح على OpenCode لفترة محدودة. يستخدم الفريق هذه الفترة لجمع الملاحظات وتحسين النموذج. - Big Pickle نموذج خفي ومتاح مجانا على OpenCode لفترة محدودة. يستخدم الفريق هذه الفترة لجمع الملاحظات وتحسين النموذج. @@ -229,8 +226,7 @@ https://opencode.ai/zen/v1/models - Big Pickle: خلال فترته المجانية، قد تُستخدم البيانات المجمعة لتحسين النموذج. - MiniMax M2.5 Free: خلال فترته المجانية، قد تُستخدم البيانات المجمعة لتحسين النموذج. -- Ling 2.6 Flash Free: خلال فترته المجانية، قد تُستخدم البيانات المجمعة لتحسين النموذج. -- Hy3 Preview Free: خلال فترته المجانية، قد تُستخدم البيانات المجمعة لتحسين النموذج. +- Ring 2.6 1T Free: خلال فترته المجانية، قد تُستخدم البيانات المجمعة لتحسين النموذج. - Nemotron 3 Super Free (نقاط نهاية NVIDIA المجانية): يُقدَّم بموجب [شروط خدمة النسخة التجريبية من واجهة NVIDIA API](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). للاستخدام التجريبي فقط، وليس للإنتاج أو البيانات الحساسة. تقوم NVIDIA بتسجيل المطالبات والمخرجات لتحسين نماذجها وخدماتها. لا ترسل بيانات شخصية أو سرية. - OpenAI APIs: يتم الاحتفاظ بالطلبات لمدة 30 يوما وفقا لـ [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data). - Anthropic APIs: يتم الاحتفاظ بالطلبات لمدة 30 يوما وفقا لـ [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage). diff --git a/packages/web/src/content/docs/bs/zen.mdx b/packages/web/src/content/docs/bs/zen.mdx index 3723cbaa3c..22299b12c9 100644 --- a/packages/web/src/content/docs/bs/zen.mdx +++ b/packages/web/src/content/docs/bs/zen.mdx @@ -102,8 +102,7 @@ Našim modelima možete pristupiti i preko sljedećih API endpointa. | Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Ling 2.6 Flash | ling-2.6-flash | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Hy3 Preview Free | hy3-preview-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Ring 2.6 1T | ring-2.6-1t-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Nemotron 3 Super Free | nemotron-3-super-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | [model id](/docs/config/#models) u vašoj OpenCode konfiguraciji koristi format @@ -130,8 +129,7 @@ Podržavamo pay-as-you-go model. Ispod su cijene **po 1M tokena**. | --------------------------------- | ------ | ------- | ----------- | ------------ | | Big Pickle | Free | Free | Free | - | | MiniMax M2.5 Free | Free | Free | Free | - | -| Ling 2.6 Flash Free | Free | Free | Free | - | -| Hy3 Preview Free | Free | Free | Free | - | +| Ring 2.6 1T Free | Free | Free | Free | - | | Nemotron 3 Super Free | Free | Free | Free | - | | MiniMax M2.7 | $0.30 | $1.20 | $0.06 | $0.375 | | MiniMax M2.5 | $0.30 | $1.20 | $0.06 | $0.375 | @@ -183,8 +181,7 @@ Naknade za kreditne kartice prosljeđujemo po stvarnom trošku (4.4% + $0.30 po Besplatni modeli: - MiniMax M2.5 Free je dostupan na OpenCode ograničeno vrijeme. Tim koristi ovo vrijeme da prikupi povratne informacije i poboljša model. -- Ling 2.6 Flash Free je dostupan na OpenCode ograničeno vrijeme. Tim koristi ovo vrijeme da prikupi povratne informacije i poboljša model. -- Hy3 Preview Free je dostupan na OpenCode ograničeno vrijeme. Tim koristi ovo vrijeme da prikupi povratne informacije i poboljša model. +- Ring 2.6 1T Free je dostupan na OpenCode ograničeno vrijeme. Tim koristi ovo vrijeme da prikupi povratne informacije i poboljša model. - Nemotron 3 Super Free je dostupan na OpenCode ograničeno vrijeme. Tim koristi ovo vrijeme da prikupi povratne informacije i poboljša model. - Big Pickle je stealth model koji je besplatan na OpenCode ograničeno vrijeme. Tim koristi ovo vrijeme da prikupi povratne informacije i poboljša model. @@ -241,8 +238,7 @@ i ne koriste vaše podatke za treniranje modela, uz sljedeće izuzetke: - Big Pickle: Tokom besplatnog perioda, prikupljeni podaci mogu se koristiti za poboljšanje modela. - MiniMax M2.5 Free: Tokom besplatnog perioda, prikupljeni podaci mogu se koristiti za poboljšanje modela. -- Ling 2.6 Flash Free: Tokom besplatnog perioda, prikupljeni podaci mogu se koristiti za poboljšanje modela. -- Hy3 Preview Free: Tokom besplatnog perioda, prikupljeni podaci mogu se koristiti za poboljšanje modela. +- Ring 2.6 1T Free: Tokom besplatnog perioda, prikupljeni podaci mogu se koristiti za poboljšanje modela. - Nemotron 3 Super Free (besplatni NVIDIA endpointi): Dostupan je prema [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). Samo za probnu upotrebu, nije za produkciju niti osjetljive podatke. NVIDIA bilježi promptove i izlaze radi poboljšanja svojih modela i usluga. Nemojte slati lične ili povjerljive podatke. - OpenAI APIs: Requests are retained for 30 days in accordance with [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data). - Anthropic APIs: Requests are retained for 30 days in accordance with [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage). diff --git a/packages/web/src/content/docs/da/zen.mdx b/packages/web/src/content/docs/da/zen.mdx index d45f785a59..0fb2c9737a 100644 --- a/packages/web/src/content/docs/da/zen.mdx +++ b/packages/web/src/content/docs/da/zen.mdx @@ -102,8 +102,7 @@ Du kan også få adgang til vores modeller gennem følgende API-endpoints. | Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Ling 2.6 Flash | ling-2.6-flash | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Hy3 Preview Free | hy3-preview-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Ring 2.6 1T | ring-2.6-1t-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Nemotron 3 Super Free | nemotron-3-super-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | [model id](/docs/config/#models) i din OpenCode-konfiguration @@ -130,8 +129,7 @@ Vi understøtter en pay-as-you-go-model. Nedenfor er priserne **pr. 1M tokens**. | --------------------------------- | ------ | ------- | ----------- | ------------ | | Big Pickle | Free | Free | Free | - | | MiniMax M2.5 Free | Free | Free | Free | - | -| Ling 2.6 Flash Free | Free | Free | Free | - | -| Hy3 Preview Free | Free | Free | Free | - | +| Ring 2.6 1T Free | Free | Free | Free | - | | Nemotron 3 Super Free | Free | Free | Free | - | | MiniMax M2.7 | $0.30 | $1.20 | $0.06 | $0.375 | | MiniMax M2.5 | $0.30 | $1.20 | $0.06 | $0.375 | @@ -183,8 +181,7 @@ Kreditkortgebyrer videregives til kostpris (4.4% + $0.30 pr. transaktion); vi op De gratis modeller: - MiniMax M2.5 Free er tilgængelig på OpenCode i en begrænset periode. Teamet bruger denne tid til at indsamle feedback og forbedre modellen. -- Ling 2.6 Flash Free er tilgængelig på OpenCode i en begrænset periode. Teamet bruger denne tid til at indsamle feedback og forbedre modellen. -- Hy3 Preview Free er tilgængelig på OpenCode i en begrænset periode. Teamet bruger denne tid til at indsamle feedback og forbedre modellen. +- Ring 2.6 1T Free er tilgængelig på OpenCode i en begrænset periode. Teamet bruger denne tid til at indsamle feedback og forbedre modellen. - Nemotron 3 Super Free er tilgængelig på OpenCode i en begrænset periode. Teamet bruger denne tid til at indsamle feedback og forbedre modellen. - Big Pickle er en stealth-model, som er gratis på OpenCode i en begrænset periode. Teamet bruger denne tid til at indsamle feedback og forbedre modellen. @@ -239,8 +236,7 @@ Alle vores modeller hostes i US. Vores udbydere følger en nul-opbevaringspoliti - Big Pickle: I den gratis periode kan indsamlede data blive brugt til at forbedre modellen. - MiniMax M2.5 Free: I den gratis periode kan indsamlede data blive brugt til at forbedre modellen. -- Ling 2.6 Flash Free: I den gratis periode kan indsamlede data blive brugt til at forbedre modellen. -- Hy3 Preview Free: I den gratis periode kan indsamlede data blive brugt til at forbedre modellen. +- Ring 2.6 1T Free: I den gratis periode kan indsamlede data blive brugt til at forbedre modellen. - Nemotron 3 Super Free (gratis NVIDIA-endpoints): Leveres under [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). Kun til prøvebrug, ikke til produktion eller følsomme data. Prompts og outputs logges af NVIDIA for at forbedre deres modeller og tjenester. Indsend ikke personlige eller fortrolige data. - OpenAI APIs: Anmodninger opbevares i 30 dage i overensstemmelse med [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data). - Anthropic APIs: Anmodninger opbevares i 30 dage i overensstemmelse med [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage). diff --git a/packages/web/src/content/docs/de/zen.mdx b/packages/web/src/content/docs/de/zen.mdx index 5e6c8eee80..425d9b6510 100644 --- a/packages/web/src/content/docs/de/zen.mdx +++ b/packages/web/src/content/docs/de/zen.mdx @@ -93,8 +93,7 @@ Du kannst auch über die folgenden API-Endpunkte auf unsere Modelle zugreifen. | Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Ling 2.6 Flash | ling-2.6-flash | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Hy3 Preview Free | hy3-preview-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Ring 2.6 1T | ring-2.6-1t-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Nemotron 3 Super Free | nemotron-3-super-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | Die [Model-ID](/docs/config/#models) in deiner OpenCode-Konfiguration verwendet das Format `opencode/`. Für GPT 5.5 würdest du zum Beispiel `opencode/gpt-5.5` in deiner Konfiguration verwenden. @@ -119,8 +118,7 @@ Wir unterstützen ein Pay-as-you-go-Modell. Unten findest du die Preise **pro 1M | --------------------------------- | ------ | ------- | ----------- | ------------ | | Big Pickle | Free | Free | Free | - | | MiniMax M2.5 Free | Free | Free | Free | - | -| Ling 2.6 Flash Free | Free | Free | Free | - | -| Hy3 Preview Free | Free | Free | Free | - | +| Ring 2.6 1T Free | Free | Free | Free | - | | Nemotron 3 Super Free | Free | Free | Free | - | | MiniMax M2.7 | $0.30 | $1.20 | $0.06 | $0.375 | | MiniMax M2.5 | $0.30 | $1.20 | $0.06 | $0.375 | @@ -172,8 +170,7 @@ Kreditkartengebühren werden zum Selbstkostenpreis weitergegeben (4.4% + $0.30 p Die kostenlosen Modelle: - MiniMax M2.5 Free ist für begrenzte Zeit auf OpenCode verfügbar. Das Team nutzt diese Zeit, um Feedback zu sammeln und das Modell zu verbessern. -- Ling 2.6 Flash Free ist für begrenzte Zeit auf OpenCode verfügbar. Das Team nutzt diese Zeit, um Feedback zu sammeln und das Modell zu verbessern. -- Hy3 Preview Flash Free ist für begrenzte Zeit auf OpenCode verfügbar. Das Team nutzt diese Zeit, um Feedback zu sammeln und das Modell zu verbessern. +- Ring 2.6 1T Free ist für begrenzte Zeit auf OpenCode verfügbar. Das Team nutzt diese Zeit, um Feedback zu sammeln und das Modell zu verbessern. - Nemotron 3 Super Free ist für begrenzte Zeit auf OpenCode verfügbar. Das Team nutzt diese Zeit, um Feedback zu sammeln und das Modell zu verbessern. - Big Pickle ist ein Stealth-Modell, das für begrenzte Zeit kostenlos auf OpenCode verfügbar ist. Das Team nutzt diese Zeit, um Feedback zu sammeln und das Modell zu verbessern. @@ -225,8 +222,7 @@ Alle unsere Modelle werden in den USA gehostet. Unsere Provider folgen einer Zer - Big Pickle: Während des kostenlosen Zeitraums können gesammelte Daten zur Verbesserung des Modells verwendet werden. - MiniMax M2.5 Free: Während des kostenlosen Zeitraums können gesammelte Daten zur Verbesserung des Modells verwendet werden. -- Ling 2.6 Flash Free: Während des kostenlosen Zeitraums können gesammelte Daten zur Verbesserung des Modells verwendet werden. -- Hy3 Preview Free: Während des kostenlosen Zeitraums können gesammelte Daten zur Verbesserung des Modells verwendet werden. +- Ring 2.6 1T Free: Während des kostenlosen Zeitraums können gesammelte Daten zur Verbesserung des Modells verwendet werden. - Nemotron 3 Super Free (kostenlose NVIDIA-Endpunkte): Bereitgestellt gemäß den [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). Nur für Testzwecke, nicht für Produktion oder sensible Daten. Eingaben und Ausgaben werden von NVIDIA protokolliert, um seine Modelle und Dienste zu verbessern. Übermitteln Sie keine personenbezogenen oder vertraulichen Daten. - OpenAI APIs: Anfragen werden in Übereinstimmung mit [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data) 30 Tage lang gespeichert. - Anthropic APIs: Anfragen werden in Übereinstimmung mit [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage) 30 Tage lang gespeichert. diff --git a/packages/web/src/content/docs/es/zen.mdx b/packages/web/src/content/docs/es/zen.mdx index 15436226a5..8e0e51293d 100644 --- a/packages/web/src/content/docs/es/zen.mdx +++ b/packages/web/src/content/docs/es/zen.mdx @@ -102,8 +102,7 @@ También puedes acceder a nuestros modelos a través de los siguientes endpoints | Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Ling 2.6 Flash | ling-2.6-flash | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Hy3 Preview Free | hy3-preview-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Ring 2.6 1T | ring-2.6-1t-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Nemotron 3 Super Free | nemotron-3-super-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | El [identificador del modelo](/docs/config/#models) en tu configuración de OpenCode @@ -130,8 +129,7 @@ Admitimos un modelo de pago por uso. A continuación se muestran los precios **p | --------------------------------- | ------- | ------- | ---------------- | ------------------ | | Big Pickle | Free | Free | Free | - | | MiniMax M2.5 Free | Free | Free | Free | - | -| Ling 2.6 Flash Free | Free | Free | Free | - | -| Hy3 Preview Free | Free | Free | Free | - | +| Ring 2.6 1T Free | Free | Free | Free | - | | Nemotron 3 Super Free | Free | Free | Free | - | | MiniMax M2.7 | $0.30 | $1.20 | $0.06 | $0.375 | | MiniMax M2.5 | $0.30 | $1.20 | $0.06 | $0.375 | @@ -183,8 +181,7 @@ Las comisiones de tarjeta de crédito se trasladan al costo (4.4% + $0.30 por tr Los modelos gratuitos: - MiniMax M2.5 Free está disponible en OpenCode por tiempo limitado. El equipo está usando este tiempo para recopilar comentarios y mejorar el modelo. -- Ling 2.6 Flash Free está disponible en OpenCode por tiempo limitado. El equipo está usando este tiempo para recopilar comentarios y mejorar el modelo. -- Hy3 Preview Flash Free está disponible en OpenCode por tiempo limitado. El equipo está usando este tiempo para recopilar comentarios y mejorar el modelo. +- Ring 2.6 1T Free está disponible en OpenCode por tiempo limitado. El equipo está usando este tiempo para recopilar comentarios y mejorar el modelo. - Nemotron 3 Super Free está disponible en OpenCode por tiempo limitado. El equipo está usando este tiempo para recopilar comentarios y mejorar el modelo. - Big Pickle es un modelo stealth que es gratuito en OpenCode por tiempo limitado. El equipo está usando este tiempo para recopilar comentarios y mejorar el modelo. @@ -239,8 +236,7 @@ Todos nuestros modelos están alojados en US. Nuestros proveedores siguen una po - Big Pickle: Durante su período gratuito, los datos recopilados pueden usarse para mejorar el modelo. - MiniMax M2.5 Free: Durante su período gratuito, los datos recopilados pueden usarse para mejorar el modelo. -- Ling 2.6 Flash Free: Durante su período gratuito, los datos recopilados pueden usarse para mejorar el modelo. -- Hy3 Preview Free: Durante su período gratuito, los datos recopilados pueden usarse para mejorar el modelo. +- Ring 2.6 1T Free: Durante su período gratuito, los datos recopilados pueden usarse para mejorar el modelo. - Nemotron 3 Super Free (endpoints gratuitos de NVIDIA): Se ofrece bajo los [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). Solo para uso de prueba, no para producción ni datos sensibles. NVIDIA registra los prompts y las salidas para mejorar sus modelos y servicios. No envíes datos personales ni confidenciales. - OpenAI APIs: Las solicitudes se conservan durante 30 días de acuerdo con [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data). - Anthropic APIs: Las solicitudes se conservan durante 30 días de acuerdo con [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage). diff --git a/packages/web/src/content/docs/fr/zen.mdx b/packages/web/src/content/docs/fr/zen.mdx index fdf14e8fb0..0d07ee3144 100644 --- a/packages/web/src/content/docs/fr/zen.mdx +++ b/packages/web/src/content/docs/fr/zen.mdx @@ -93,8 +93,7 @@ Vous pouvez également accéder à nos modèles via les points de terminaison AP | Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Ling 2.6 Flash | ling-2.6-flash | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Hy3 Preview Free | hy3-preview-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Ring 2.6 1T | ring-2.6-1t-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Nemotron 3 Super Free | nemotron-3-super-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | Le [model id](/docs/config/#models) dans votre configuration OpenCode utilise le format `opencode/`. Par exemple, pour GPT 5.5, vous utiliseriez `opencode/gpt-5.5` dans votre configuration. @@ -119,8 +118,7 @@ Nous prenons en charge un modèle de paiement à l'utilisation. Vous trouverez c | --------------------------------- | ------ | ------- | ----------- | ------------ | | Big Pickle | Free | Free | Free | - | | MiniMax M2.5 Free | Free | Free | Free | - | -| Ling 2.6 Flash Free | Free | Free | Free | - | -| Hy3 Preview Free | Free | Free | Free | - | +| Ring 2.6 1T Free | Free | Free | Free | - | | Nemotron 3 Super Free | Free | Free | Free | - | | MiniMax M2.7 | $0.30 | $1.20 | $0.06 | $0.375 | | MiniMax M2.5 | $0.30 | $1.20 | $0.06 | $0.375 | @@ -172,8 +170,7 @@ Les frais de carte de crédit sont répercutés au prix coûtant (4.4% + $0.30 p Les modèles gratuits : - MiniMax M2.5 Free est disponible sur OpenCode pour une durée limitée. L'équipe utilise cette période pour recueillir des retours et améliorer le modèle. -- Ling 2.6 Flash Free est disponible sur OpenCode pour une durée limitée. L'équipe utilise cette période pour recueillir des retours et améliorer le modèle. -- Hy3 Preview Free est disponible sur OpenCode pour une durée limitée. L'équipe utilise cette période pour recueillir des retours et améliorer le modèle. +- Ring 2.6 1T Free est disponible sur OpenCode pour une durée limitée. L'équipe utilise cette période pour recueillir des retours et améliorer le modèle. - Nemotron 3 Super Free est disponible sur OpenCode pour une durée limitée. L'équipe utilise cette période pour recueillir des retours et améliorer le modèle. - Big Pickle est un modèle stealth gratuit sur OpenCode pour une durée limitée. L'équipe utilise cette période pour recueillir des retours et améliorer le modèle. @@ -225,8 +222,7 @@ Tous nos modèles sont hébergés aux US. Nos fournisseurs suivent une politique - Big Pickle : Pendant sa période gratuite, les données collectées peuvent être utilisées pour améliorer le modèle. - MiniMax M2.5 Free : Pendant sa période gratuite, les données collectées peuvent être utilisées pour améliorer le modèle. -- Ling 2.6 Flash Free : Pendant sa période gratuite, les données collectées peuvent être utilisées pour améliorer le modèle. -- Hy3 Preview Free : Pendant sa période gratuite, les données collectées peuvent être utilisées pour améliorer le modèle. +- Ring 2.6 1T Free : Pendant sa période gratuite, les données collectées peuvent être utilisées pour améliorer le modèle. - Nemotron 3 Super Free (endpoints NVIDIA gratuits) : Fourni dans le cadre des [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). Réservé à un usage d'essai, pas à la production ni aux données sensibles. Les prompts et les sorties sont journalisés par NVIDIA pour améliorer ses modèles et services. N'envoyez pas de données personnelles ou confidentielles. - OpenAI APIs : Les requêtes sont conservées pendant 30 jours conformément à [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data). - Anthropic APIs : Les requêtes sont conservées pendant 30 jours conformément à [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage). diff --git a/packages/web/src/content/docs/it/zen.mdx b/packages/web/src/content/docs/it/zen.mdx index a53d6a2ba1..df24babb26 100644 --- a/packages/web/src/content/docs/it/zen.mdx +++ b/packages/web/src/content/docs/it/zen.mdx @@ -102,8 +102,7 @@ Puoi anche accedere ai nostri modelli tramite i seguenti endpoint API. | Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Ling 2.6 Flash | ling-2.6-flash | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Hy3 Preview Free | hy3-preview-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Ring 2.6 1T | ring-2.6-1t-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Nemotron 3 Super Free | nemotron-3-super-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | Il [model id](/docs/config/#models) nella config di OpenCode @@ -130,8 +129,7 @@ Supportiamo un modello pay-as-you-go. Qui sotto trovi i prezzi **per 1M token**. | --------------------------------- | ------ | ------- | ----------- | ------------ | | Big Pickle | Free | Free | Free | - | | MiniMax M2.5 Free | Free | Free | Free | - | -| Ling 2.6 Flash Free | Free | Free | Free | - | -| Hy3 Preview Free | Free | Free | Free | - | +| Ring 2.6 1T Free | Free | Free | Free | - | | Nemotron 3 Super Free | Free | Free | Free | - | | MiniMax M2.7 | $0.30 | $1.20 | $0.06 | $0.375 | | MiniMax M2.5 | $0.30 | $1.20 | $0.06 | $0.375 | @@ -183,8 +181,7 @@ Le commissioni della carta di credito vengono trasferite al costo (4.4% + $0.30 I modelli gratuiti: - MiniMax M2.5 Free è disponibile su OpenCode per un periodo limitato. Il team usa questo periodo per raccogliere feedback e migliorare il modello. -- Ling 2.6 Flash Free è disponibile su OpenCode per un periodo limitato. Il team usa questo periodo per raccogliere feedback e migliorare il modello. -- Hy3 Preview Flash Free è disponibile su OpenCode per un periodo limitato. Il team usa questo periodo per raccogliere feedback e migliorare il modello. +- Ring 2.6 1T Free è disponibile su OpenCode per un periodo limitato. Il team usa questo periodo per raccogliere feedback e migliorare il modello. - Nemotron 3 Super Free è disponibile su OpenCode per un periodo limitato. Il team usa questo periodo per raccogliere feedback e migliorare il modello. - Big Pickle è un modello stealth che è gratuito su OpenCode per un periodo limitato. Il team usa questo periodo per raccogliere feedback e migliorare il modello. @@ -239,8 +236,7 @@ Tutti i nostri modelli sono ospitati negli US. I nostri provider seguono una pol - Big Pickle: durante il periodo gratuito, i dati raccolti possono essere usati per migliorare il modello. - MiniMax M2.5 Free: durante il periodo gratuito, i dati raccolti possono essere usati per migliorare il modello. -- Ling 2.6 Flash Free: durante il periodo gratuito, i dati raccolti possono essere usati per migliorare il modello. -- Hy3 Preview Free: durante il periodo gratuito, i dati raccolti possono essere usati per migliorare il modello. +- Ring 2.6 1T Free: durante il periodo gratuito, i dati raccolti possono essere usati per migliorare il modello. - Nemotron 3 Super Free (endpoint NVIDIA gratuiti): fornito secondo i [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). Solo per uso di prova, non per produzione o dati sensibili. NVIDIA registra prompt e output per migliorare i propri modelli e servizi. Non inviare dati personali o riservati. - OpenAI APIs: le richieste vengono conservate per 30 giorni in conformità con [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data). - Anthropic APIs: le richieste vengono conservate per 30 giorni in conformità con [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage). diff --git a/packages/web/src/content/docs/ja/zen.mdx b/packages/web/src/content/docs/ja/zen.mdx index 64427a72ec..05d6c8b812 100644 --- a/packages/web/src/content/docs/ja/zen.mdx +++ b/packages/web/src/content/docs/ja/zen.mdx @@ -93,8 +93,7 @@ OpenCode Zen は、OpenCode のほかのプロバイダーと同じように動 | Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Ling 2.6 Flash | ling-2.6-flash | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Hy3 Preview Free | hy3-preview-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Ring 2.6 1T | ring-2.6-1t-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Nemotron 3 Super Free | nemotron-3-super-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | OpenCode 設定で使う [model id](/docs/config/#models) は `opencode/` 形式です。たとえば、GPT 5.5 では設定に `opencode/gpt-5.5` を使用します。 @@ -119,8 +118,7 @@ https://opencode.ai/zen/v1/models | --------------------------------- | ------ | ------- | ----------- | ------------ | | Big Pickle | Free | Free | Free | - | | MiniMax M2.5 Free | Free | Free | Free | - | -| Ling 2.6 Flash Free | Free | Free | Free | - | -| Hy3 Preview Free | Free | Free | Free | - | +| Ring 2.6 1T Free | Free | Free | Free | - | | Nemotron 3 Super Free | Free | Free | Free | - | | MiniMax M2.7 | $0.30 | $1.20 | $0.06 | $0.375 | | MiniMax M2.5 | $0.30 | $1.20 | $0.06 | $0.375 | @@ -172,8 +170,7 @@ https://opencode.ai/zen/v1/models 無料モデル: - MiniMax M2.5 Free は期間限定で OpenCode で利用できます。チームはこの期間中にフィードバックを集め、モデルを改善しています。 -- Ling 2.6 Flash Free は期間限定で OpenCode で利用できます。チームはこの期間中にフィードバックを集め、モデルを改善しています。 -- Hy3 Preview Flash Free は期間限定で OpenCode で利用できます。チームはこの期間中にフィードバックを集め、モデルを改善しています。 +- Ring 2.6 1T Free は期間限定で OpenCode で利用できます。チームはこの期間中にフィードバックを集め、モデルを改善しています。 - Nemotron 3 Super Free は期間限定で OpenCode で利用できます。チームはこの期間中にフィードバックを集め、モデルを改善しています。 - Big Pickle はステルスモデルで、期間限定で OpenCode で無料提供されています。チームはこの期間中にフィードバックを集め、モデルを改善しています。 @@ -225,8 +222,7 @@ https://opencode.ai/zen/v1/models - Big Pickle: 無料提供期間中、収集されたデータがモデル改善に使われる場合があります。 - MiniMax M2.5 Free: 無料提供期間中、収集されたデータがモデル改善に使われる場合があります。 -- Ling 2.6 Flash Free: 無料提供期間中、収集されたデータがモデル改善に使われる場合があります。 -- Hy3 Preview Free: 無料提供期間中、収集されたデータがモデル改善に使われる場合があります。 +- Ring 2.6 1T Free: 無料提供期間中、収集されたデータがモデル改善に使われる場合があります。 - Nemotron 3 Super Free(NVIDIA の無料エンドポイント): [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf) に基づいて提供されます。試用専用であり、本番環境や機密性の高いデータには使用しないでください。プロンプトと出力は、NVIDIA が自社のモデルとサービスを改善するために記録します。個人情報や機密データは送信しないでください。 - OpenAI APIs: リクエストは [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data) に従って 30 日間保持されます。 - Anthropic APIs: リクエストは [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage) に従って 30 日間保持されます。 diff --git a/packages/web/src/content/docs/ko/zen.mdx b/packages/web/src/content/docs/ko/zen.mdx index e80a5e8710..513093a2c6 100644 --- a/packages/web/src/content/docs/ko/zen.mdx +++ b/packages/web/src/content/docs/ko/zen.mdx @@ -93,8 +93,7 @@ OpenCode Zen은 OpenCode의 다른 provider와 똑같이 작동합니다. | Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Ling 2.6 Flash | ling-2.6-flash | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Hy3 Preview Free | hy3-preview-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Ring 2.6 1T | ring-2.6-1t-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Nemotron 3 Super Free | nemotron-3-super-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | OpenCode config에서 사용하는 [모델 ID](/docs/config/#models)는 `opencode/` 형식입니다. 예를 들어 GPT 5.5를 사용하려면 config에서 `opencode/gpt-5.5`를 사용하면 됩니다. @@ -119,8 +118,7 @@ https://opencode.ai/zen/v1/models | --------------------------------- | ------ | ------- | ----------- | ------------ | | Big Pickle | Free | Free | Free | - | | MiniMax M2.5 Free | Free | Free | Free | - | -| Ling 2.6 Flash Free | Free | Free | Free | - | -| Hy3 Preview Free | Free | Free | Free | - | +| Ring 2.6 1T Free | Free | Free | Free | - | | Nemotron 3 Super Free | Free | Free | Free | - | | MiniMax M2.7 | $0.30 | $1.20 | $0.06 | $0.375 | | MiniMax M2.5 | $0.30 | $1.20 | $0.06 | $0.375 | @@ -172,8 +170,7 @@ https://opencode.ai/zen/v1/models 무료 모델: - MiniMax M2.5 Free는 한정된 기간 동안 OpenCode에서 제공됩니다. 팀은 이 기간에 피드백을 수집하고 모델을 개선합니다. -- Ling 2.6 Flash Free는 한정된 기간 동안 OpenCode에서 제공됩니다. 팀은 이 기간에 피드백을 수집하고 모델을 개선합니다. -- Hy3 Preview Flash Free는 한정된 기간 동안 OpenCode에서 제공됩니다. 팀은 이 기간에 피드백을 수집하고 모델을 개선합니다. +- Ring 2.6 1T Free는 한정된 기간 동안 OpenCode에서 제공됩니다. 팀은 이 기간에 피드백을 수집하고 모델을 개선합니다. - Nemotron 3 Super Free는 한정된 기간 동안 OpenCode에서 제공됩니다. 팀은 이 기간에 피드백을 수집하고 모델을 개선합니다. - Big Pickle은 한정된 기간 동안 OpenCode에서 무료로 제공되는 stealth model입니다. 팀은 이 기간에 피드백을 수집하고 모델을 개선합니다. @@ -225,8 +222,7 @@ https://opencode.ai/zen/v1/models - Big Pickle: 무료 제공 기간에는 수집된 데이터가 모델 개선에 사용될 수 있습니다. - MiniMax M2.5 Free: 무료 제공 기간에는 수집된 데이터가 모델 개선에 사용될 수 있습니다. -- Ling 2.6 Flash Free: 무료 제공 기간에는 수집된 데이터가 모델 개선에 사용될 수 있습니다. -- Hy3 Preview Free: 무료 제공 기간에는 수집된 데이터가 모델 개선에 사용될 수 있습니다. +- Ring 2.6 1T Free: 무료 제공 기간에는 수집된 데이터가 모델 개선에 사용될 수 있습니다. - Nemotron 3 Super Free(NVIDIA 무료 엔드포인트): [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf)에 따라 제공됩니다. 평가판 전용이며 프로덕션 환경이나 민감한 데이터에는 사용할 수 없습니다. NVIDIA는 자사 모델과 서비스를 개선하기 위해 프롬프트와 출력을 기록합니다. 개인 정보나 기밀 데이터는 제출하지 마세요. - OpenAI APIs: 요청은 [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data)에 따라 30일 동안 보관됩니다. - Anthropic APIs: 요청은 [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage)에 따라 30일 동안 보관됩니다. diff --git a/packages/web/src/content/docs/nb/zen.mdx b/packages/web/src/content/docs/nb/zen.mdx index 4bd1e6115e..c5726f4b4b 100644 --- a/packages/web/src/content/docs/nb/zen.mdx +++ b/packages/web/src/content/docs/nb/zen.mdx @@ -102,8 +102,7 @@ Du kan også få tilgang til modellene våre gjennom følgende API-endepunkter. | Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Ling 2.6 Flash | ling-2.6-flash | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Hy3 Preview Free | hy3-preview-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Ring 2.6 1T | ring-2.6-1t-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Nemotron 3 Super Free | nemotron-3-super-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | [modell-id](/docs/config/#models) i OpenCode-konfigurasjonen din @@ -130,8 +129,7 @@ Vi støtter en pay-as-you-go-modell. Nedenfor er prisene **per 1M tokens**. | --------------------------------- | ------- | ------- | ------------- | --------------- | | Big Pickle | Free | Free | Free | - | | MiniMax M2.5 Free | Free | Free | Free | - | -| Ling 2.6 Flash Free | Free | Free | Free | - | -| Hy3 Preview Free | Free | Free | Free | - | +| Ring 2.6 1T Free | Free | Free | Free | - | | Nemotron 3 Super Free | Free | Free | Free | - | | MiniMax M2.7 | $0.30 | $1.20 | $0.06 | $0.375 | | MiniMax M2.5 | $0.30 | $1.20 | $0.06 | $0.375 | @@ -183,8 +181,7 @@ Kredittkortgebyrer videreføres til kostpris (4.4% + $0.30 per transaction); vi Gratis-modellene: - MiniMax M2.5 Free er tilgjengelig på OpenCode i en begrenset periode. Teamet bruker denne tiden til å samle inn tilbakemeldinger og forbedre modellen. -- Ling 2.6 Flash Free er tilgjengelig på OpenCode i en begrenset periode. Teamet bruker denne tiden til å samle inn tilbakemeldinger og forbedre modellen. -- Hy3 Preview Free er tilgjengelig på OpenCode i en begrenset periode. Teamet bruker denne tiden til å samle inn tilbakemeldinger og forbedre modellen. +- Ring 2.6 1T Free er tilgjengelig på OpenCode i en begrenset periode. Teamet bruker denne tiden til å samle inn tilbakemeldinger og forbedre modellen. - Nemotron 3 Super Free er tilgjengelig på OpenCode i en begrenset periode. Teamet bruker denne tiden til å samle inn tilbakemeldinger og forbedre modellen. - Big Pickle er en stealth-modell som er gratis på OpenCode i en begrenset periode. Teamet bruker denne tiden til å samle inn tilbakemeldinger og forbedre modellen. @@ -239,8 +236,7 @@ Alle modellene våre hostes i US. Leverandørene våre følger en policy for zer - Big Pickle: I gratisperioden kan innsamlede data brukes til å forbedre modellen. - MiniMax M2.5 Free: I gratisperioden kan innsamlede data brukes til å forbedre modellen. -- Ling 2.6 Flash Free: I gratisperioden kan innsamlede data brukes til å forbedre modellen. -- Hy3 Preview Free: I gratisperioden kan innsamlede data brukes til å forbedre modellen. +- Ring 2.6 1T Free: I gratisperioden kan innsamlede data brukes til å forbedre modellen. - Nemotron 3 Super Free (gratis NVIDIA-endepunkter): Leveres under [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). Kun for prøvebruk, ikke for produksjon eller sensitive data. Prompter og svar logges av NVIDIA for å forbedre modellene og tjenestene deres. Ikke send inn personopplysninger eller konfidensielle data. - OpenAI APIs: Forespørsler lagres i 30 dager i samsvar med [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data). - Anthropic APIs: Forespørsler lagres i 30 dager i samsvar med [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage). diff --git a/packages/web/src/content/docs/pl/zen.mdx b/packages/web/src/content/docs/pl/zen.mdx index ebd16d7856..f22d10b3ad 100644 --- a/packages/web/src/content/docs/pl/zen.mdx +++ b/packages/web/src/content/docs/pl/zen.mdx @@ -102,8 +102,7 @@ Możesz też uzyskać dostęp do naszych modeli przez poniższe endpointy API. | Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Ling 2.6 Flash | ling-2.6-flash | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Hy3 Preview Free | hy3-preview-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Ring 2.6 1T | ring-2.6-1t-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Nemotron 3 Super Free | nemotron-3-super-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | [ID modelu](/docs/config/#models) w Twojej konfiguracji OpenCode używa formatu @@ -130,8 +129,7 @@ Obsługujemy model pay-as-you-go. Poniżej znajdują się ceny **za 1M tokenów* | --------------------------------- | ------- | ------- | -------------- | -------------- | | Big Pickle | Free | Free | Free | - | | MiniMax M2.5 Free | Free | Free | Free | - | -| Ling 2.6 Flash Free | Free | Free | Free | - | -| Hy3 Preview Free | Free | Free | Free | - | +| Ring 2.6 1T Free | Free | Free | Free | - | | Nemotron 3 Super Free | Free | Free | Free | - | | MiniMax M2.7 | $0.30 | $1.20 | $0.06 | $0.375 | | MiniMax M2.5 | $0.30 | $1.20 | $0.06 | $0.375 | @@ -184,8 +182,7 @@ Opłaty za karty kredytowe są przenoszone po kosztach (4.4% + $0.30 per transac Darmowe modele: - MiniMax M2.5 Free jest dostępny w OpenCode przez ograniczony czas. Zespół wykorzystuje ten czas do zbierania opinii i ulepszania modelu. -- Ling 2.6 Flash Free jest dostępny w OpenCode przez ograniczony czas. Zespół wykorzystuje ten czas do zbierania opinii i ulepszania modelu. -- Hy3 Preview Flash Free jest dostępny w OpenCode przez ograniczony czas. Zespół wykorzystuje ten czas do zbierania opinii i ulepszania modelu. +- Ring 2.6 1T Free jest dostępny w OpenCode przez ograniczony czas. Zespół wykorzystuje ten czas do zbierania opinii i ulepszania modelu. - Nemotron 3 Super Free jest dostępny w OpenCode przez ograniczony czas. Zespół wykorzystuje ten czas do zbierania opinii i ulepszania modelu. - Big Pickle to stealth model, który jest darmowy w OpenCode przez ograniczony czas. Zespół wykorzystuje ten czas do zbierania opinii i ulepszania modelu. @@ -240,8 +237,7 @@ Wszystkie nasze modele są hostowane w US. Nasi dostawcy stosują politykę zero - Big Pickle: W czasie darmowego okresu zebrane dane mogą być wykorzystywane do ulepszania modelu. - MiniMax M2.5 Free: W czasie darmowego okresu zebrane dane mogą być wykorzystywane do ulepszania modelu. -- Ling 2.6 Flash Free: W czasie darmowego okresu zebrane dane mogą być wykorzystywane do ulepszania modelu. -- Hy3 Preview Free: W czasie darmowego okresu zebrane dane mogą być wykorzystywane do ulepszania modelu. +- Ring 2.6 1T Free: W czasie darmowego okresu zebrane dane mogą być wykorzystywane do ulepszania modelu. - Nemotron 3 Super Free (darmowe endpointy NVIDIA): Udostępniany zgodnie z [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). Tylko do użytku próbnego, nie do produkcji ani danych wrażliwych. NVIDIA rejestruje prompty i odpowiedzi, aby ulepszać swoje modele i usługi. Nie przesyłaj danych osobowych ani poufnych. - OpenAI APIs: Żądania są przechowywane przez 30 dni zgodnie z [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data). - Anthropic APIs: Żądania są przechowywane przez 30 dni zgodnie z [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage). diff --git a/packages/web/src/content/docs/pt-br/zen.mdx b/packages/web/src/content/docs/pt-br/zen.mdx index 1dcc98c5d5..55678781aa 100644 --- a/packages/web/src/content/docs/pt-br/zen.mdx +++ b/packages/web/src/content/docs/pt-br/zen.mdx @@ -93,8 +93,7 @@ Você também pode acessar nossos modelos pelos seguintes endpoints de API. | Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Ling 2.6 Flash | ling-2.6-flash | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Hy3 Preview Free | hy3-preview-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Ring 2.6 1T | ring-2.6-1t-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Nemotron 3 Super Free | nemotron-3-super-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | O [model id](/docs/config/#models) na sua configuração do OpenCode usa o formato `opencode/`. Por exemplo, para GPT 5.5, você usaria `opencode/gpt-5.5` na sua configuração. @@ -119,8 +118,7 @@ Oferecemos um modelo pay-as-you-go. Abaixo estão os preços **por 1M tokens**. | --------------------------------- | ------- | ------- | ---------------- | ---------------- | | Big Pickle | Free | Free | Free | - | | MiniMax M2.5 Free | Free | Free | Free | - | -| Ling 2.6 Flash Free | Free | Free | Free | - | -| Hy3 Preview Free | Free | Free | Free | - | +| Ring 2.6 1T Free | Free | Free | Free | - | | Nemotron 3 Super Free | Free | Free | Free | - | | MiniMax M2.7 | $0.30 | $1.20 | $0.06 | $0.375 | | MiniMax M2.5 | $0.30 | $1.20 | $0.06 | $0.375 | @@ -172,8 +170,7 @@ As taxas de cartão de crédito são repassadas a preço de custo (4.4% + $0.30 Os modelos gratuitos: - MiniMax M2.5 Free está disponível no OpenCode por tempo limitado. A equipe está usando esse período para coletar feedback e melhorar o modelo. -- Ling 2.6 Flash Free está disponível no OpenCode por tempo limitado. A equipe está usando esse período para coletar feedback e melhorar o modelo. -- Hy3 Preview Free está disponível no OpenCode por tempo limitado. A equipe está usando esse período para coletar feedback e melhorar o modelo. +- Ring 2.6 1T Free está disponível no OpenCode por tempo limitado. A equipe está usando esse período para coletar feedback e melhorar o modelo. - Nemotron 3 Super Free está disponível no OpenCode por tempo limitado. A equipe está usando esse período para coletar feedback e melhorar o modelo. - Big Pickle é um modelo stealth que está gratuito no OpenCode por tempo limitado. A equipe está usando esse período para coletar feedback e melhorar o modelo. @@ -225,8 +222,7 @@ Todos os nossos modelos são hospedados nos US. Nossos provedores seguem uma pol - Big Pickle: Durante seu período gratuito, os dados coletados podem ser usados para melhorar o modelo. - MiniMax M2.5 Free: Durante seu período gratuito, os dados coletados podem ser usados para melhorar o modelo. -- Ling 2.6 Flash Free: Durante seu período gratuito, os dados coletados podem ser usados para melhorar o modelo. -- Hy3 Preview Free: Durante seu período gratuito, os dados coletados podem ser usados para melhorar o modelo. +- Ring 2.6 1T Free: Durante seu período gratuito, os dados coletados podem ser usados para melhorar o modelo. - Nemotron 3 Super Free (endpoints gratuitos da NVIDIA): Fornecido sob os [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). Apenas para uso de avaliação, não para produção nem dados sensíveis. A NVIDIA registra prompts e saídas para melhorar seus modelos e serviços. Não envie dados pessoais ou confidenciais. - OpenAI APIs: As solicitações são retidas por 30 dias de acordo com [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data). - Anthropic APIs: As solicitações são retidas por 30 dias de acordo com [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage). diff --git a/packages/web/src/content/docs/ru/zen.mdx b/packages/web/src/content/docs/ru/zen.mdx index 10c55fc4dd..c685d2dacc 100644 --- a/packages/web/src/content/docs/ru/zen.mdx +++ b/packages/web/src/content/docs/ru/zen.mdx @@ -102,8 +102,7 @@ OpenCode Zen работает как любой другой провайдер | Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Ling 2.6 Flash | ling-2.6-flash | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Hy3 Preview Free | hy3-preview-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Ring 2.6 1T | ring-2.6-1t-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Nemotron 3 Super Free | nemotron-3-super-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | [идентификатор модели](/docs/config/#models) в вашей конфигурации OpenCode @@ -130,8 +129,7 @@ https://opencode.ai/zen/v1/models | --------------------------------- | ------ | ------- | ----------- | ------------ | | Big Pickle | Free | Free | Free | - | | MiniMax M2.5 Free | Free | Free | Free | - | -| Ling 2.6 Flash Free | Free | Free | Free | - | -| Hy3 Preview Free | Free | Free | Free | - | +| Ring 2.6 1T Free | Free | Free | Free | - | | Nemotron 3 Super Free | Free | Free | Free | - | | MiniMax M2.7 | $0.30 | $1.20 | $0.06 | $0.375 | | MiniMax M2.5 | $0.30 | $1.20 | $0.06 | $0.375 | @@ -183,8 +181,7 @@ https://opencode.ai/zen/v1/models Бесплатные модели: - MiniMax M2.5 Free доступна в OpenCode ограниченное время. Команда использует это время, чтобы собирать отзывы и улучшать модель. -- Ling 2.6 Flash Free доступна в OpenCode ограниченное время. Команда использует это время, чтобы собирать отзывы и улучшать модель. -- Hy3 Preview Flash Free доступна в OpenCode ограниченное время. Команда использует это время, чтобы собирать отзывы и улучшать модель. +- Ring 2.6 1T Free доступна в OpenCode ограниченное время. Команда использует это время, чтобы собирать отзывы и улучшать модель. - Nemotron 3 Super Free доступна в OpenCode ограниченное время. Команда использует это время, чтобы собирать отзывы и улучшать модель. - Big Pickle — это скрытая модель, которая доступна бесплатно в OpenCode ограниченное время. Команда использует это время, чтобы собирать отзывы и улучшать модель. @@ -239,8 +236,7 @@ https://opencode.ai/zen/v1/models - Big Pickle: во время бесплатного периода собранные данные могут использоваться для улучшения модели. - MiniMax M2.5 Free: во время бесплатного периода собранные данные могут использоваться для улучшения модели. -- Ling 2.6 Flash Free: во время бесплатного периода собранные данные могут использоваться для улучшения модели. -- Hy3 Preview Free: во время бесплатного периода собранные данные могут использоваться для улучшения модели. +- Ring 2.6 1T Free: во время бесплатного периода собранные данные могут использоваться для улучшения модели. - Nemotron 3 Super Free (бесплатные эндпоинты NVIDIA): предоставляется в соответствии с [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). Только для пробного использования, не для продакшена и не для чувствительных данных. NVIDIA логирует запросы и ответы, чтобы улучшать свои модели и сервисы. Не отправляйте персональные или конфиденциальные данные. - OpenAI APIs: запросы хранятся 30 дней в соответствии с [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data). - Anthropic APIs: запросы хранятся 30 дней в соответствии с [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage). diff --git a/packages/web/src/content/docs/th/zen.mdx b/packages/web/src/content/docs/th/zen.mdx index cb2556ef63..68139d9629 100644 --- a/packages/web/src/content/docs/th/zen.mdx +++ b/packages/web/src/content/docs/th/zen.mdx @@ -95,8 +95,7 @@ OpenCode Zen ทำงานเหมือน provider อื่น ๆ ใน | Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Ling 2.6 Flash | ling-2.6-flash | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Hy3 Preview Free | hy3-preview-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Ring 2.6 1T | ring-2.6-1t-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Nemotron 3 Super Free | nemotron-3-super-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | [model id](/docs/config/#models) ใน OpenCode config ของคุณใช้รูปแบบ `opencode/` ตัวอย่างเช่น สำหรับ GPT 5.5 คุณจะใช้ `opencode/gpt-5.5` ใน config ของคุณ @@ -121,8 +120,7 @@ https://opencode.ai/zen/v1/models | --------------------------------- | ------ | ------- | ----------- | ------------ | | Big Pickle | Free | Free | Free | - | | MiniMax M2.5 Free | Free | Free | Free | - | -| Ling 2.6 Flash Free | Free | Free | Free | - | -| Hy3 Preview Free | Free | Free | Free | - | +| Ring 2.6 1T Free | Free | Free | Free | - | | Nemotron 3 Super Free | Free | Free | Free | - | | MiniMax M2.7 | $0.30 | $1.20 | $0.06 | $0.375 | | MiniMax M2.5 | $0.30 | $1.20 | $0.06 | $0.375 | @@ -174,8 +172,7 @@ https://opencode.ai/zen/v1/models โมเดลฟรี: - MiniMax M2.5 Free เปิดให้ใช้บน OpenCode ในช่วงเวลาจำกัด ทีมกำลังใช้ช่วงเวลานี้เพื่อเก็บ feedback และปรับปรุงโมเดล -- Ling 2.6 Flash Free เปิดให้ใช้บน OpenCode ในช่วงเวลาจำกัด ทีมกำลังใช้ช่วงเวลานี้เพื่อเก็บ feedback และปรับปรุงโมเดล -- Hy3 Preview Free เปิดให้ใช้บน OpenCode ในช่วงเวลาจำกัด ทีมกำลังใช้ช่วงเวลานี้เพื่อเก็บ feedback และปรับปรุงโมเดล +- Ring 2.6 1T Free เปิดให้ใช้บน OpenCode ในช่วงเวลาจำกัด ทีมกำลังใช้ช่วงเวลานี้เพื่อเก็บ feedback และปรับปรุงโมเดล - Nemotron 3 Super Free เปิดให้ใช้บน OpenCode ในช่วงเวลาจำกัด ทีมกำลังใช้ช่วงเวลานี้เพื่อเก็บ feedback และปรับปรุงโมเดล - Big Pickle เป็น stealth model ที่ใช้งานฟรีบน OpenCode ในช่วงเวลาจำกัด ทีมกำลังใช้ช่วงเวลานี้เพื่อเก็บ feedback และปรับปรุงโมเดล @@ -227,8 +224,7 @@ https://opencode.ai/zen/v1/models - Big Pickle: ระหว่างช่วงที่เปิดให้ใช้ฟรี ข้อมูลที่เก็บรวบรวมอาจถูกนำไปใช้เพื่อปรับปรุงโมเดล - MiniMax M2.5 Free: ระหว่างช่วงที่เปิดให้ใช้ฟรี ข้อมูลที่เก็บรวบรวมอาจถูกนำไปใช้เพื่อปรับปรุงโมเดล -- Ling 2.6 Flash Free: ระหว่างช่วงที่เปิดให้ใช้ฟรี ข้อมูลที่เก็บรวบรวมอาจถูกนำไปใช้เพื่อปรับปรุงโมเดล -- Hy3 Preview Free: ระหว่างช่วงที่เปิดให้ใช้ฟรี ข้อมูลที่เก็บรวบรวมอาจถูกนำไปใช้เพื่อปรับปรุงโมเดล +- Ring 2.6 1T Free: ระหว่างช่วงที่เปิดให้ใช้ฟรี ข้อมูลที่เก็บรวบรวมอาจถูกนำไปใช้เพื่อปรับปรุงโมเดล - Nemotron 3 Super Free (endpoint ฟรีของ NVIDIA): ให้บริการภายใต้ [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf) ใช้สำหรับการทดลองเท่านั้น ไม่เหมาะสำหรับ production หรือข้อมูลที่อ่อนไหว NVIDIA จะบันทึก prompt และ output เพื่อนำไปปรับปรุงโมเดลและบริการของตน โปรดอย่าส่งข้อมูลส่วนบุคคลหรือข้อมูลลับ. - OpenAI APIs: คำขอจะถูกเก็บไว้เป็นเวลา 30 วันตาม [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data). - Anthropic APIs: คำขอจะถูกเก็บไว้เป็นเวลา 30 วันตาม [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage). diff --git a/packages/web/src/content/docs/tr/zen.mdx b/packages/web/src/content/docs/tr/zen.mdx index 36c1bfc66e..5d4ed76ad8 100644 --- a/packages/web/src/content/docs/tr/zen.mdx +++ b/packages/web/src/content/docs/tr/zen.mdx @@ -93,8 +93,7 @@ Modellerimize aşağıdaki API uç noktaları aracılığıyla da erişebilirsin | Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Ling 2.6 Flash | ling-2.6-flash | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Hy3 Preview Free | hy3-preview-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Ring 2.6 1T | ring-2.6-1t-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Nemotron 3 Super Free | nemotron-3-super-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | OpenCode yapılandırmanızdaki [model id](/docs/config/#models) `opencode/` biçimini kullanır. Örneğin, GPT 5.5 için yapılandırmanızda `opencode/gpt-5.5` kullanırsınız. @@ -119,8 +118,7 @@ Kullandıkça öde modelini destekliyoruz. Aşağıda **1M token başına** fiya | --------------------------------- | ------ | ------- | ----------- | ------------ | | Big Pickle | Free | Free | Free | - | | MiniMax M2.5 Free | Free | Free | Free | - | -| Ling 2.6 Flash Free | Free | Free | Free | - | -| Hy3 Preview Free | Free | Free | Free | - | +| Ring 2.6 1T Free | Free | Free | Free | - | | Nemotron 3 Super Free | Free | Free | Free | - | | MiniMax M2.7 | $0.30 | $1.20 | $0.06 | $0.375 | | MiniMax M2.5 | $0.30 | $1.20 | $0.06 | $0.375 | @@ -172,8 +170,7 @@ Kredi kartı ücretleri maliyet üzerinden yansıtılır (%4.4 + işlem başına Ücretsiz modeller: - MiniMax M2.5 Free, sınırlı bir süre için OpenCode'da ücretsizdir. Ekip bu süreyi geri bildirim toplamak ve modeli iyileştirmek için kullanıyor. -- Ling 2.6 Flash Free, sınırlı bir süre için OpenCode'da ücretsizdir. Ekip bu süreyi geri bildirim toplamak ve modeli iyileştirmek için kullanıyor. -- Hy3 Preview Free, sınırlı bir süre için OpenCode'da ücretsizdir. Ekip bu süreyi geri bildirim toplamak ve modeli iyileştirmek için kullanıyor. +- Ring 2.6 1T Free, sınırlı bir süre için OpenCode'da ücretsizdir. Ekip bu süreyi geri bildirim toplamak ve modeli iyileştirmek için kullanıyor. - Nemotron 3 Super Free, sınırlı bir süre için OpenCode'da ücretsizdir. Ekip bu süreyi geri bildirim toplamak ve modeli iyileştirmek için kullanıyor. - Big Pickle, sınırlı bir süre için OpenCode'da ücretsiz olan gizli bir modeldir. Ekip bu süreyi geri bildirim toplamak ve modeli iyileştirmek için kullanıyor. @@ -225,8 +222,7 @@ Tüm modellerimiz US'de barındırılıyor. Sağlayıcılarımız zero-retention - Big Pickle: Ücretsiz döneminde toplanan veriler modeli iyileştirmek için kullanılabilir. - MiniMax M2.5 Free: Ücretsiz döneminde toplanan veriler modeli iyileştirmek için kullanılabilir. -- Ling 2.6 Flash Free: Ücretsiz döneminde toplanan veriler modeli iyileştirmek için kullanılabilir. -- Hy3 Preview Free: Ücretsiz döneminde toplanan veriler modeli iyileştirmek için kullanılabilir. +- Ring 2.6 1T Free: Ücretsiz döneminde toplanan veriler modeli iyileştirmek için kullanılabilir. - Nemotron 3 Super Free (ücretsiz NVIDIA uç noktaları): [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf) kapsamında sunulur. Yalnızca deneme amaçlıdır; üretim veya hassas veriler için uygun değildir. NVIDIA, modellerini ve hizmetlerini geliştirmek için promptları ve çıktıları kaydeder. Kişisel veya gizli veri göndermeyin. - OpenAI APIs: İstekler [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data) uyarınca 30 gün boyunca saklanır. - Anthropic APIs: İstekler [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage) uyarınca 30 gün boyunca saklanır. diff --git a/packages/web/src/content/docs/zen.mdx b/packages/web/src/content/docs/zen.mdx index 333e74434b..2d180b30be 100644 --- a/packages/web/src/content/docs/zen.mdx +++ b/packages/web/src/content/docs/zen.mdx @@ -102,8 +102,7 @@ You can also access our models through the following API endpoints. | Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Ling 2.6 Flash | ling-2.6-flash | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Hy3 Preview Free | hy3-preview-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Ring 2.6 1T | ring-2.6-1t-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Nemotron 3 Super Free | nemotron-3-super-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | The [model id](/docs/config/#models) in your OpenCode config @@ -130,8 +129,7 @@ We support a pay-as-you-go model. Below are the prices **per 1M tokens**. | --------------------------------- | ------ | ------- | ----------- | ------------ | | Big Pickle | Free | Free | Free | - | | MiniMax M2.5 Free | Free | Free | Free | - | -| Ling 2.6 Flash Free | Free | Free | Free | - | -| Hy3 Preview Free | Free | Free | Free | - | +| Ring 2.6 1T Free | Free | Free | Free | - | | Nemotron 3 Super Free | Free | Free | Free | - | | MiniMax M2.7 | $0.30 | $1.20 | $0.06 | $0.375 | | MiniMax M2.5 | $0.30 | $1.20 | $0.06 | $0.375 | @@ -183,8 +181,7 @@ Credit card fees are passed along at cost (4.4% + $0.30 per transaction); we don The free models: - MiniMax M2.5 Free is available on OpenCode for a limited time. The team is using this time to collect feedback and improve the model. -- Ling 2.6 Flash Free is available on OpenCode for a limited time. The team is using this time to collect feedback and improve the model. -- Hy3 Preview Flash Free is available on OpenCode for a limited time. The team is using this time to collect feedback and improve the model. +- Ring 2.6 1T Free is available on OpenCode for a limited time. The team is using this time to collect feedback and improve the model. - Nemotron 3 Super Free is available on OpenCode for a limited time. The team is using this time to collect feedback and improve the model. - Big Pickle is a stealth model that's free on OpenCode for a limited time. The team is using this time to collect feedback and improve the model. @@ -239,8 +236,7 @@ All our models are hosted in the US. Our providers follow a zero-retention polic - Big Pickle: During its free period, collected data may be used to improve the model. - MiniMax M2.5 Free: During its free period, collected data may be used to improve the model. -- Ling 2.6 Flash Free: During its free period, collected data may be used to improve the model. -- Hy3 Preview Free: During its free period, collected data may be used to improve the model. +- Ring 2.6 1T Free: During its free period, collected data may be used to improve the model. - Nemotron 3 Super Free (NVIDIA free endpoints): Provided under the [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). Trial use only — not for production or sensitive data. Prompts and outputs are logged by NVIDIA to improve its models and services. Do not submit personal or confidential data. - OpenAI APIs: Requests are retained for 30 days in accordance with [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data). - Anthropic APIs: Requests are retained for 30 days in accordance with [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage). diff --git a/packages/web/src/content/docs/zh-cn/zen.mdx b/packages/web/src/content/docs/zh-cn/zen.mdx index 9ad7e6b53d..e81e51a67c 100644 --- a/packages/web/src/content/docs/zh-cn/zen.mdx +++ b/packages/web/src/content/docs/zh-cn/zen.mdx @@ -93,8 +93,7 @@ OpenCode Zen 的工作方式与 OpenCode 中的任何其他提供商相同。 | Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Ling 2.6 Flash | ling-2.6-flash | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Hy3 Preview Free | hy3-preview-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Ring 2.6 1T | ring-2.6-1t-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Nemotron 3 Super Free | nemotron-3-super-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | 在你的 OpenCode 配置中,[模型 ID](/docs/config/#models) 使用 `opencode/` 格式。例如,对于 GPT 5.5,你需要在配置中使用 `opencode/gpt-5.5`。 @@ -119,8 +118,7 @@ https://opencode.ai/zen/v1/models | --------------------------------- | ------ | ------- | -------- | -------- | | Big Pickle | Free | Free | Free | - | | MiniMax M2.5 Free | Free | Free | Free | - | -| Ling 2.6 Flash Free | Free | Free | Free | - | -| Hy3 Preview Free | Free | Free | Free | - | +| Ring 2.6 1T Free | Free | Free | Free | - | | Nemotron 3 Super Free | Free | Free | Free | - | | MiniMax M2.7 | $0.30 | $1.20 | $0.06 | $0.375 | | MiniMax M2.5 | $0.30 | $1.20 | $0.06 | $0.375 | @@ -172,8 +170,7 @@ https://opencode.ai/zen/v1/models 免费模型: - MiniMax M2.5 Free 目前在 OpenCode 上限时免费提供。团队正在利用这段时间收集反馈并改进模型。 -- Ling 2.6 Flash Free 目前在 OpenCode 上限时免费提供。团队正在利用这段时间收集反馈并改进模型。 -- Hy3 Preview Flash Free 目前在 OpenCode 上限时免费提供。团队正在利用这段时间收集反馈并改进模型。 +- Ring 2.6 1T Free 目前在 OpenCode 上限时免费提供。团队正在利用这段时间收集反馈并改进模型。 - Nemotron 3 Super Free 目前在 OpenCode 上限时免费提供。团队正在利用这段时间收集反馈并改进模型。 - Big Pickle 是一个隐身模型,目前在 OpenCode 上限时免费提供。团队正在利用这段时间收集反馈并改进模型。 @@ -225,8 +222,7 @@ https://opencode.ai/zen/v1/models - Big Pickle:在免费期间,收集的数据可能会被用于改进模型。 - MiniMax M2.5 Free:在免费期间,收集的数据可能会被用于改进模型。 -- Ling 2.6 Flash Free:在免费期间,收集的数据可能会被用于改进模型。 -- Hy3 Preview Free:在免费期间,收集的数据可能会被用于改进模型。 +- Ring 2.6 1T Free:在免费期间,收集的数据可能会被用于改进模型。 - Nemotron 3 Super Free(NVIDIA 免费端点):根据 [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf) 提供。仅供试用,不适用于生产环境或敏感数据。NVIDIA 会记录提示词和输出内容,以改进其模型和服务。请勿提交个人或机密数据。 - OpenAI APIs:请求会根据 [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data) 保留 30 天。 - Anthropic APIs:请求会根据 [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage) 保留 30 天。 diff --git a/packages/web/src/content/docs/zh-tw/zen.mdx b/packages/web/src/content/docs/zh-tw/zen.mdx index 9511bd9e24..1634543018 100644 --- a/packages/web/src/content/docs/zh-tw/zen.mdx +++ b/packages/web/src/content/docs/zh-tw/zen.mdx @@ -97,8 +97,7 @@ OpenCode Zen 的運作方式和 OpenCode 中的其他供應商一樣。 | Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Ling 2.6 Flash | ling-2.6-flash | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | -| Hy3 Preview Free | hy3-preview-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Ring 2.6 1T | ring-2.6-1t-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Nemotron 3 Super Free | nemotron-3-super-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | OpenCode 設定中的 [模型 ID](/docs/config/#models) 會使用 `opencode/` @@ -124,8 +123,7 @@ https://opencode.ai/zen/v1/models | --------------------------------- | ------ | ------- | -------- | -------- | | Big Pickle | Free | Free | Free | - | | MiniMax M2.5 Free | Free | Free | Free | - | -| Ling 2.6 Flash Free | Free | Free | Free | - | -| Hy3 Preview Free | Free | Free | Free | - | +| Ring 2.6 1T Free | Free | Free | Free | - | | Nemotron 3 Super Free | Free | Free | Free | - | | MiniMax M2.7 | $0.30 | $1.20 | $0.06 | $0.375 | | MiniMax M2.5 | $0.30 | $1.20 | $0.06 | $0.375 | @@ -178,8 +176,7 @@ https://opencode.ai/zen/v1/models 免費模型: - MiniMax M2.5 Free 在 OpenCode 上限時提供。團隊正在利用這段時間收集回饋並改進模型。 -- Ling 2.6 Flash Free 在 OpenCode 上限時提供。團隊正在利用這段時間收集回饋並改進模型。 -- Hy3 Preview Free 在 OpenCode 上限時提供。團隊正在利用這段時間收集回饋並改進模型。 +- Ring 2.6 1T Free 在 OpenCode 上限時提供。團隊正在利用這段時間收集回饋並改進模型。 - Nemotron 3 Super Free 在 OpenCode 上限時提供。團隊正在利用這段時間收集回饋並改進模型。 - Big Pickle 是一個隱身模型,在 OpenCode 上限時免費提供。團隊正在利用這段時間收集回饋並改進模型。 @@ -232,8 +229,7 @@ https://opencode.ai/zen/v1/models - Big Pickle: 在免費期間,收集到的資料可能會用於改進模型。 - MiniMax M2.5 Free: 在免費期間,收集到的資料可能會用於改進模型。 -- Ling 2.6 Flash Free: 在免費期間,收集到的資料可能會用於改進模型。 -- Hy3 Preview Free: 在免費期間,收集到的資料可能會用於改進模型。 +- Ring 2.6 1T Free: 在免費期間,收集到的資料可能會用於改進模型。 - Nemotron 3 Super Free(NVIDIA 免費端點):依據 [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf) 提供。僅供試用,不適用於正式環境或敏感資料。NVIDIA 會記錄提示詞與輸出內容,以改進其模型與服務。請勿提交個人或機密資料。 - OpenAI APIs: 請求會依據 [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data) 保留 30 天。 - Anthropic APIs: 請求會依據 [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage) 保留 30 天。 From 5cf9abe7437b643e1b6e83edf42dd795a81768a3 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Sun, 10 May 2026 17:57:11 +0530 Subject: [PATCH 125/189] feat(scout): materialize configured reference repos (#26692) --- packages/opencode/src/agent/agent.ts | 97 +++---- packages/opencode/src/effect/app-runtime.ts | 2 + packages/opencode/src/project/bootstrap.ts | 5 +- packages/opencode/src/reference/reference.ts | 228 ++++++++++++++++ .../src/reference/repository-cache.ts | 155 +++++++++++ packages/opencode/src/tool/glob.ts | 8 +- packages/opencode/src/tool/grep.ts | 4 + packages/opencode/src/tool/read.ts | 5 +- packages/opencode/src/tool/registry.ts | 3 + packages/opencode/src/tool/repo_clone.ts | 165 ++---------- packages/opencode/src/util/repository.ts | 15 ++ packages/opencode/test/agent/agent.test.ts | 15 +- .../opencode/test/reference/reference.test.ts | 249 ++++++++++++++++++ packages/opencode/test/session/prompt.test.ts | 2 + .../test/session/snapshot-tool-race.test.ts | 2 + packages/opencode/test/tool/glob.test.ts | 2 + packages/opencode/test/tool/grep.test.ts | 2 + packages/opencode/test/tool/read.test.ts | 87 ++++++ packages/opencode/test/tool/registry.test.ts | 2 + 19 files changed, 848 insertions(+), 200 deletions(-) create mode 100644 packages/opencode/src/reference/reference.ts create mode 100644 packages/opencode/src/reference/repository-cache.ts create mode 100644 packages/opencode/test/reference/reference.test.ts diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index a5876ac347..450ec53f8f 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -26,9 +26,7 @@ import * as Option from "effect/Option" import * as OtelTracer from "@effect/opentelemetry/Tracer" import { zod } from "@opencode-ai/core/effect-zod" import { withStatics, type DeepMutable } from "@opencode-ai/core/schema" - -type ReferenceEntry = NonNullable[string] -type ResolvedReference = { kind: "git"; repository: string; branch?: string } | { kind: "local"; path: string } +import { Reference } from "@/reference/reference" export const Info = Schema.Struct({ name: Schema.String, @@ -303,69 +301,72 @@ export const layer = Layer.effect( item.permission = Permission.merge(item.permission, Permission.fromConfig(value.permission ?? {})) } - function referencePath(value: string) { - if (value.startsWith("~/")) return path.join(Global.Path.home, value.slice(2)) - return path.isAbsolute(value) - ? value - : path.resolve(ctx.worktree === "/" ? ctx.directory : ctx.worktree, value) - } - - function resolveReference(reference: ReferenceEntry): ResolvedReference { - if (typeof reference === "string") { - if (reference.startsWith(".") || reference.startsWith("/") || reference.startsWith("~")) { - return { kind: "local", path: referencePath(reference) } - } - return { kind: "git", repository: reference } - } - if ("path" in reference) return { kind: "local", path: referencePath(reference.path) } - return { kind: "git", repository: reference.repository, branch: reference.branch } - } - - function referencePrompt(name: string, reference: ResolvedReference) { + function referencePrompt(reference: Reference.Resolved) { if (reference.kind === "local") { return [ - PROMPT_SCOUT, - `You are Scout reference @${name}. This reference points to a local directory outside or alongside the current workspace.`, + `You are configured reference @${reference.name}, a read-only research agent for external reference material.`, `Local directory: ${reference.path}`, - `When invoked, inspect this directory as the primary reference source. Prefer repo_overview with path ${JSON.stringify(reference.path)} before broader searches. Do not edit files.`, + `Inspect this directory as the primary reference source. Prefer repo_overview with path ${JSON.stringify(reference.path)} before broader searches. Do not edit files.`, + `Return exact absolute file paths for findings whenever possible.`, + ].join("\n\n") + } + + if (reference.kind === "invalid") { + return [ + `You are configured reference @${reference.name}, but this reference is not usable yet.`, + `Configured repository: ${reference.repository}`, + `Problem: ${reference.message}`, + `Explain this configuration problem if invoked. Do not edit files or attempt fallback clones.`, ].join("\n\n") } return [ - PROMPT_SCOUT, - `You are Scout reference @${name}. This reference points to a git repository.`, + `You are configured reference @${reference.name}, a read-only research agent for external reference material.`, `Repository: ${reference.repository}`, ...(reference.branch ? [`Branch/ref: ${reference.branch}`] : []), - `When invoked, clone or refresh this repository with repo_clone, then inspect the cached repository as the primary reference source. Do not edit files.`, + `Cached directory: ${reference.path}`, + `OpenCode materializes this configured repository before use. Do not call repo_clone for this reference.`, + `Inspect the cached directory as the primary reference source. Prefer repo_overview with path ${JSON.stringify(reference.path)} before broader searches, then use Glob, Grep, and Read inside that directory. Do not edit files.`, + `Return exact absolute file paths for findings whenever possible.`, ].join("\n\n") } + function referenceDescription(reference: Reference.Resolved) { + if (reference.kind === "local") return `Scout reference for local directory ${reference.path}` + if (reference.kind === "git") return `Scout reference for repository ${reference.repository}` + return `Invalid Scout reference for repository ${reference.repository}` + } + if (Flag.OPENCODE_EXPERIMENTAL_SCOUT) { - for (const [name, reference] of Object.entries(cfg.reference ?? {})) { - if (agents[name]) continue - const resolved = resolveReference(reference) - const localPath = resolved.kind === "local" ? resolved.path : undefined - agents[name] = { - name, - description: - resolved.kind === "local" - ? `Scout reference for local directory ${resolved.path}` - : `Scout reference for repository ${resolved.repository}`, + const resolvedReferences = Reference.resolveAll({ + references: cfg.reference ?? {}, + directory: ctx.directory, + worktree: ctx.worktree, + }) + for (const resolved of resolvedReferences) { + if (agents[resolved.name]) continue + const localPath = resolved.kind === "invalid" ? undefined : resolved.path + agents[resolved.name] = { + name: resolved.name, + description: referenceDescription(resolved), permission: Permission.merge( agents.scout.permission, Permission.fromConfig( - localPath - ? { - external_directory: { - [localPath]: "allow", - [path.join(localPath, "*")]: "allow", - }, - } - : {}, + { + repo_clone: "deny", + ...(localPath + ? { + external_directory: { + [localPath]: "allow", + [path.join(localPath, "*")]: "allow", + }, + } + : {}), + }, ), ), - prompt: referencePrompt(name, resolved), - options: { reference }, + prompt: referencePrompt(resolved), + options: { reference: cfg.reference?.[resolved.name], resolved }, mode: "subagent", native: false, } diff --git a/packages/opencode/src/effect/app-runtime.ts b/packages/opencode/src/effect/app-runtime.ts index 76ed26d302..a955cb86dc 100644 --- a/packages/opencode/src/effect/app-runtime.ts +++ b/packages/opencode/src/effect/app-runtime.ts @@ -43,6 +43,7 @@ import { Format } from "@/format" import { InstanceLayer } from "@/project/instance-layer" import { Project } from "@/project/project" import { Vcs } from "@/project/vcs" +import { Reference } from "@/reference/reference" import { Workspace } from "@/control-plane/workspace" import { Worktree } from "@/worktree" import { Pty } from "@/pty" @@ -96,6 +97,7 @@ export const AppLayer = Layer.mergeAll( Format.defaultLayer, Project.defaultLayer, Vcs.defaultLayer, + Reference.defaultLayer, Workspace.defaultLayer, Worktree.appLayer, Pty.defaultLayer, diff --git a/packages/opencode/src/project/bootstrap.ts b/packages/opencode/src/project/bootstrap.ts index fb3e1bb32d..6103a9efb4 100644 --- a/packages/opencode/src/project/bootstrap.ts +++ b/packages/opencode/src/project/bootstrap.ts @@ -12,6 +12,7 @@ import { ShareNext } from "@/share/share-next" import { Effect, Layer } from "effect" import { Config } from "@/config/config" import { Service } from "./bootstrap-service" +import { Reference } from "@/reference/reference" export { Service } from "./bootstrap-service" export type { Interface } from "./bootstrap-service" @@ -29,6 +30,7 @@ export const layer = Layer.effect( const lsp = yield* LSP.Service const plugin = yield* Plugin.Service const project = yield* Project.Service + const reference = yield* Reference.Service const shareNext = yield* ShareNext.Service const snapshot = yield* Snapshot.Service const vcs = yield* Vcs.Service @@ -43,7 +45,7 @@ export const layer = Layer.effect( // Each service self-manages its own slow work via Effect.forkScoped against // its per-instance state scope. We just await materialization here. yield* Effect.forEach( - [lsp, shareNext, format, file, fileWatcher, vcs, snapshot, project], + [reference, lsp, shareNext, format, file, fileWatcher, vcs, snapshot, project], (s) => s.init().pipe(Effect.catchCause((cause) => Effect.logWarning("init failed", { cause }))), { concurrency: "unbounded", discard: true }, ).pipe(Effect.withSpan("InstanceBootstrap.init")) @@ -63,6 +65,7 @@ export const defaultLayer: Layer.Layer = layer.pipe( LSP.defaultLayer, Plugin.defaultLayer, Project.defaultLayer, + Reference.defaultLayer, ShareNext.defaultLayer, Snapshot.defaultLayer, Vcs.defaultLayer, diff --git a/packages/opencode/src/reference/reference.ts b/packages/opencode/src/reference/reference.ts new file mode 100644 index 0000000000..b62da96f27 --- /dev/null +++ b/packages/opencode/src/reference/reference.ts @@ -0,0 +1,228 @@ +import path from "path" +import { Effect, Context, Layer, Scope } from "effect" +import { AppFileSystem } from "@opencode-ai/core/filesystem" +import { Flag } from "@opencode-ai/core/flag/flag" +import { Global } from "@opencode-ai/core/global" +import { Config } from "@/config/config" +import { InstanceState } from "@/effect/instance-state" +import { Git } from "@/git" +import { parseRepositoryReference, repositoryCachePath, type Reference as RepositoryReference } from "@/util/repository" +import { RepositoryCache } from "./repository-cache" + +type ReferenceEntry = NonNullable[string] + +export type Resolved = + | { + name: string + kind: "local" + path: string + } + | { + name: string + kind: "git" + repository: string + reference: RepositoryReference + path: string + branch?: string + } + | { + name: string + kind: "invalid" + repository: string + message: string + } + +type State = { + references: Resolved[] + materializeAll: Effect.Effect + materializeByPath: { path: string; run: Effect.Effect }[] +} + +export interface Interface { + readonly init: () => Effect.Effect + readonly list: () => Effect.Effect + readonly get: (name: string) => Effect.Effect + readonly ensure: (target?: string) => Effect.Effect + readonly contains: (target?: string) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/Reference") {} + +export function referencePath(input: { directory: string; worktree: string; value: string }) { + if (input.value.startsWith("~/")) return path.join(Global.Path.home, input.value.slice(2)) + return path.isAbsolute(input.value) + ? input.value + : path.resolve(input.worktree === "/" ? input.directory : input.worktree, input.value) +} + +function resolveGit( + input: { name: string; repository: string } | { name: string; repository: string; branch: string | undefined }, +): Resolved { + const parsed = parseRepositoryReference(input.repository) + if (!parsed || parsed.protocol === "file:") { + return { + name: input.name, + kind: "invalid", + repository: input.repository, + message: "Repository must be a git URL, host/path reference, or GitHub owner/repo shorthand", + } + } + return { + name: input.name, + kind: "git", + repository: input.repository, + reference: parsed, + path: repositoryCachePath(parsed), + ...("branch" in input ? { branch: input.branch } : {}), + } +} + +function branchLabel(branch: string | undefined) { + return branch ?? "default branch" +} + +function normalizedTarget(target?: string) { + if (!target) return + return process.platform === "win32" ? AppFileSystem.normalizePath(target) : target +} + +function containsReferencePath(referencePath: string, target: string) { + return AppFileSystem.contains(normalizedTarget(referencePath) ?? referencePath, target) +} + +export function resolve(input: { name: string; reference: ReferenceEntry; directory: string; worktree: string }): Resolved { + if (typeof input.reference === "string") { + if (input.reference.startsWith(".") || input.reference.startsWith("/") || input.reference.startsWith("~")) { + return { name: input.name, kind: "local", path: referencePath({ ...input, value: input.reference }) } + } + return resolveGit({ name: input.name, repository: input.reference }) + } + + if ("path" in input.reference) { + return { name: input.name, kind: "local", path: referencePath({ ...input, value: input.reference.path }) } + } + + return resolveGit({ name: input.name, repository: input.reference.repository, branch: input.reference.branch }) +} + +export function resolveAll(input: { + references: NonNullable + directory: string + worktree: string +}) { + const seen = new Map() + return Object.entries(input.references).map(([name, reference]) => { + const resolved = resolve({ name, reference, directory: input.directory, worktree: input.worktree }) + if (resolved.kind !== "git") return resolved + + const existing = seen.get(resolved.path) + if (!existing) { + seen.set(resolved.path, { name, branch: resolved.branch }) + return resolved + } + if (existing.branch === resolved.branch) return resolved + + return { + name, + kind: "invalid" as const, + repository: resolved.repository, + message: `Reference conflicts with @${existing.name}: both use ${resolved.path}, but @${existing.name} requests ${branchLabel(existing.branch)} and @${name} requests ${branchLabel(resolved.branch)}`, + } + }) +} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const config = yield* Config.Service + const fs = yield* AppFileSystem.Service + const git = yield* Git.Service + const scope = yield* Scope.Scope + + const state = yield* InstanceState.make( + Effect.fn("Reference.state")(function* (ctx) { + const cfg = yield* config.get() + const references = resolveAll({ references: cfg.reference ?? {}, directory: ctx.directory, worktree: ctx.worktree }) + const seenPath = new Set() + const gitReferences = references.filter((reference): reference is Extract => { + if (reference.kind !== "git") return false + if (seenPath.has(reference.path)) return false + seenPath.add(reference.path) + return true + }) + const materializeByPath = yield* Effect.forEach( + gitReferences, + Effect.fnUntraced(function* (reference) { + const run = yield* Effect.cached( + RepositoryCache.ensure( + { reference: reference.reference, branch: reference.branch, refresh: true }, + { fs, git }, + ).pipe( + Effect.asVoid, + Effect.catchCause((cause) => + Effect.logWarning("failed to materialize reference repository", { name: reference.name, cause }), + ), + ), + ) + return { path: reference.path, run } + }), + { concurrency: "unbounded" }, + ) + + const materializeAll = yield* Effect.cached( + Flag.OPENCODE_EXPERIMENTAL_SCOUT + ? Effect.gen(function* () { + yield* Effect.forEach( + materializeByPath, + Effect.fnUntraced(function* (item) { + yield* item.run + }), + { concurrency: 4, discard: true }, + ) + }) + : Effect.void, + ) + + return { references, materializeAll, materializeByPath } + }), + ) + + return Service.of({ + init: Effect.fn("Reference.init")(function* () { + if (!Flag.OPENCODE_EXPERIMENTAL_SCOUT) return + yield* InstanceState.useEffect(state, (s) => s.materializeAll).pipe(Effect.forkIn(scope), Effect.asVoid) + }), + list: Effect.fn("Reference.list")(function* () { + return yield* InstanceState.use(state, (s) => s.references) + }), + get: Effect.fn("Reference.get")(function* (name: string) { + return yield* InstanceState.use(state, (s) => s.references.find((reference) => reference.name === name)) + }), + ensure: Effect.fn("Reference.ensure")(function* (target?: string) { + if (!Flag.OPENCODE_EXPERIMENTAL_SCOUT) return + const full = normalizedTarget(target) + if (!full) return yield* InstanceState.useEffect(state, (s) => s.materializeAll) + return yield* InstanceState.useEffect( + state, + (s) => s.materializeByPath.find((item) => containsReferencePath(item.path, full))?.run ?? Effect.void, + ) + }), + contains: Effect.fn("Reference.contains")(function* (target?: string) { + if (!Flag.OPENCODE_EXPERIMENTAL_SCOUT) return false + const full = normalizedTarget(target) + if (!full) return false + return yield* InstanceState.use(state, (s) => + s.references.some((reference) => reference.kind === "git" && containsReferencePath(reference.path, full)), + ) + }), + }) + }), +) + +export const defaultLayer = layer.pipe( + Layer.provide(Config.defaultLayer), + Layer.provide(AppFileSystem.defaultLayer), + Layer.provide(Git.defaultLayer), +) + +export * as Reference from "./reference" diff --git a/packages/opencode/src/reference/repository-cache.ts b/packages/opencode/src/reference/repository-cache.ts new file mode 100644 index 0000000000..521f64369e --- /dev/null +++ b/packages/opencode/src/reference/repository-cache.ts @@ -0,0 +1,155 @@ +import path from "path" +import { Effect } from "effect" +import { AppFileSystem } from "@opencode-ai/core/filesystem" +import { Flock } from "@opencode-ai/core/util/flock" +import { Git } from "@/git" +import { + repositoryCachePath, + sameRepositoryReference, + parseRepositoryReference, + validateRepositoryBranch, + type Reference as RepositoryReference, +} from "@/util/repository" + +export type Result = { + repository: string + host: string + remote: string + localPath: string + status: "cached" | "cloned" | "refreshed" + head?: string + branch?: string +} + +function statusForRepository(input: { reuse: boolean; refresh?: boolean; branchMatches?: boolean }) { + if (!input.reuse) return "cloned" as const + if (input.branchMatches === false) return "refreshed" as const + if (input.refresh) return "refreshed" as const + return "cached" as const +} + +function resetTarget(input: { + requestedBranch?: string + remoteHead: { code: number; stdout: string } + branch: { code: number; stdout: string } +}) { + if (input.requestedBranch) return `origin/${input.requestedBranch}` + if (input.remoteHead.code === 0 && input.remoteHead.stdout) { + return input.remoteHead.stdout.replace(/^refs\/remotes\//, "") + } + if (input.branch.code === 0 && input.branch.stdout) { + return `origin/${input.branch.stdout}` + } + return "HEAD" +} + +export const ensure = Effect.fn("RepositoryCache.ensure")(function* ( + input: { + reference: RepositoryReference + refresh?: boolean + branch?: string + }, + services: { + fs: AppFileSystem.Interface + git: Git.Interface + }, +) { + if (input.branch) validateRepositoryBranch(input.branch) + + const repository = input.reference.label + const remote = input.reference.remote + const localPath = repositoryCachePath(input.reference) + const cloneTarget = parseRepositoryReference(remote) ?? input.reference + + return yield* Effect.acquireUseRelease( + Effect.promise((signal) => Flock.acquire(`repo-clone:${localPath}`, { signal })), + () => + Effect.gen(function* () { + yield* services.fs.ensureDir(path.dirname(localPath)).pipe(Effect.orDie) + + const exists = yield* services.fs.existsSafe(localPath) + const hasGitDir = yield* services.fs.existsSafe(path.join(localPath, ".git")) + const origin = hasGitDir + ? yield* services.git.run(["config", "--get", "remote.origin.url"], { cwd: localPath }) + : undefined + const originReference = origin?.exitCode === 0 ? parseRepositoryReference(origin.text().trim()) : undefined + const reuse = hasGitDir && Boolean(originReference && sameRepositoryReference(originReference, cloneTarget)) + if (exists && !reuse) { + yield* services.fs.remove(localPath, { recursive: true }).pipe(Effect.orDie) + } + + const currentBranch = hasGitDir ? yield* services.git.branch(localPath) : undefined + const status = statusForRepository({ + reuse, + refresh: input.refresh, + branchMatches: input.branch ? currentBranch === input.branch : undefined, + }) + + if (status === "cloned") { + const clone = yield* services.git.run( + [ + "clone", + "--depth", + "100", + ...(input.branch ? ["--branch", input.branch] : []), + "--", + remote, + localPath, + ], + { cwd: path.dirname(localPath) }, + ) + if (clone.exitCode !== 0) { + throw new Error(clone.stderr.toString().trim() || clone.text().trim() || `Failed to clone ${repository}`) + } + } + + if (status === "refreshed") { + const fetch = yield* services.git.run(["fetch", "--all", "--prune"], { cwd: localPath }) + if (fetch.exitCode !== 0) { + throw new Error(fetch.stderr.toString().trim() || fetch.text().trim() || `Failed to refresh ${repository}`) + } + + if (input.branch) { + const checkout = yield* services.git.run(["checkout", "-B", input.branch, `origin/${input.branch}`], { + cwd: localPath, + }) + if (checkout.exitCode !== 0) { + throw new Error( + checkout.stderr.toString().trim() || checkout.text().trim() || `Failed to checkout ${input.branch}`, + ) + } + } + + const remoteHead = yield* services.git.run(["symbolic-ref", "refs/remotes/origin/HEAD"], { cwd: localPath }) + const branch = yield* services.git.run(["symbolic-ref", "--quiet", "--short", "HEAD"], { cwd: localPath }) + const target = resetTarget({ + requestedBranch: input.branch, + remoteHead: { code: remoteHead.exitCode, stdout: remoteHead.text().trim() }, + branch: { code: branch.exitCode, stdout: branch.text().trim() }, + }) + + const reset = yield* services.git.run(["reset", "--hard", target], { cwd: localPath }) + if (reset.exitCode !== 0) { + throw new Error(reset.stderr.toString().trim() || reset.text().trim() || `Failed to reset ${repository}`) + } + } + + const head = yield* services.git.run(["rev-parse", "HEAD"], { cwd: localPath }) + const branch = yield* services.git.branch(localPath) + const headText = head.exitCode === 0 ? head.text().trim() : undefined + + return { + repository, + host: input.reference.host, + remote, + localPath, + status, + head: headText, + branch, + } satisfies Result + }), + (lock) => Effect.promise(() => lock.release()).pipe(Effect.ignore), + ) +}) + +export * as RepositoryCache from "./repository-cache" diff --git a/packages/opencode/src/tool/glob.ts b/packages/opencode/src/tool/glob.ts index 0c97b9cdf7..ce58331ea3 100644 --- a/packages/opencode/src/tool/glob.ts +++ b/packages/opencode/src/tool/glob.ts @@ -7,6 +7,7 @@ import { Ripgrep } from "../file/ripgrep" import { assertExternalDirectoryEffect } from "./external-directory" import DESCRIPTION from "./glob.txt" import * as Tool from "./tool" +import { Reference } from "@/reference/reference" export const Parameters = Schema.Struct({ pattern: Schema.String.annotate({ description: "The glob pattern to match files against" }), @@ -20,6 +21,7 @@ export const GlobTool = Tool.define( Effect.gen(function* () { const rg = yield* Ripgrep.Service const fs = yield* AppFileSystem.Service + const reference = yield* Reference.Service return { description: DESCRIPTION, @@ -39,11 +41,15 @@ export const GlobTool = Tool.define( let search = params.path ?? ins.directory search = path.isAbsolute(search) ? search : path.resolve(ins.directory, search) + yield* reference.ensure(search) const info = yield* fs.stat(search).pipe(Effect.catch(() => Effect.succeed(undefined))) if (info?.type === "File") { throw new Error(`glob path must be a directory: ${search}`) } - yield* assertExternalDirectoryEffect(ctx, search, { kind: "directory" }) + yield* assertExternalDirectoryEffect(ctx, search, { + bypass: yield* reference.contains(search), + kind: "directory", + }) const limit = 100 let truncated = false diff --git a/packages/opencode/src/tool/grep.ts b/packages/opencode/src/tool/grep.ts index fb3e70cad2..4e89198dff 100644 --- a/packages/opencode/src/tool/grep.ts +++ b/packages/opencode/src/tool/grep.ts @@ -7,6 +7,7 @@ import { Ripgrep } from "../file/ripgrep" import { assertExternalDirectoryEffect } from "./external-directory" import DESCRIPTION from "./grep.txt" import * as Tool from "./tool" +import { Reference } from "@/reference/reference" const MAX_LINE_LENGTH = 2000 @@ -25,6 +26,7 @@ export const GrepTool = Tool.define( Effect.gen(function* () { const fs = yield* AppFileSystem.Service const rg = yield* Ripgrep.Service + const reference = yield* Reference.Service return { description: DESCRIPTION, @@ -57,10 +59,12 @@ export const GrepTool = Tool.define( ? (params.path ?? ins.directory) : path.join(ins.directory, params.path ?? "."), ) + yield* reference.ensure(search) const info = yield* fs.stat(search).pipe(Effect.catch(() => Effect.succeed(undefined))) const cwd = info?.type === "Directory" ? search : path.dirname(search) const file = info?.type === "Directory" ? undefined : [path.relative(cwd, search)] yield* assertExternalDirectoryEffect(ctx, search, { + bypass: yield* reference.contains(search), kind: info?.type === "Directory" ? "directory" : "file", }) diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts index 7ade166c5f..ad3c33e742 100644 --- a/packages/opencode/src/tool/read.ts +++ b/packages/opencode/src/tool/read.ts @@ -11,6 +11,7 @@ import { InstanceState } from "@/effect/instance-state" import { assertExternalDirectoryEffect } from "./external-directory" import { Instruction } from "../session/instruction" import { isPdfAttachment, sniffAttachmentMime } from "@/util/media" +import { Reference } from "@/reference/reference" const DEFAULT_READ_LIMIT = 2000 const MAX_LINE_LENGTH = 2000 @@ -41,6 +42,7 @@ export const ReadTool = Tool.define( const fs = yield* AppFileSystem.Service const instruction = yield* Instruction.Service const lsp = yield* LSP.Service + const reference = yield* Reference.Service const scope = yield* Scope.Scope const miss = Effect.fn("ReadTool.miss")(function* (filepath: string) { @@ -162,6 +164,7 @@ export const ReadTool = Tool.define( if (process.platform === "win32") { filepath = AppFileSystem.normalizePath(filepath) } + yield* reference.ensure(filepath) const title = path.relative(instance.worktree, filepath) const stat = yield* fs.stat(filepath).pipe( @@ -172,7 +175,7 @@ export const ReadTool = Tool.define( ) yield* assertExternalDirectoryEffect(ctx, filepath, { - bypass: Boolean(ctx.extra?.["bypassCwdCheck"]), + bypass: Boolean(ctx.extra?.["bypassCwdCheck"]) || (yield* reference.contains(filepath)), kind: stat?.type === "Directory" ? "directory" : "file", }) diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 2a5a64033d..68251c342c 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -50,6 +50,7 @@ import { Agent } from "../agent/agent" import { Git } from "@/git" import { Skill } from "../skill" import { Permission } from "@/permission" +import { Reference } from "@/reference/reference" const log = Log.create({ service: "tool.registry" }) @@ -91,6 +92,7 @@ export const layer: Layer.Layer< | Session.Service | Provider.Service | Git.Service + | Reference.Service | LSP.Service | Instruction.Service | AppFileSystem.Service @@ -361,6 +363,7 @@ export const defaultLayer = Layer.suspend(() => Layer.provide(Session.defaultLayer), Layer.provide(Provider.defaultLayer), Layer.provide(Git.defaultLayer), + Layer.provide(Reference.defaultLayer), Layer.provide(LSP.defaultLayer), Layer.provide(Instruction.defaultLayer), Layer.provide(AppFileSystem.defaultLayer), diff --git a/packages/opencode/src/tool/repo_clone.ts b/packages/opencode/src/tool/repo_clone.ts index 969a3e66dd..2b5e41844e 100644 --- a/packages/opencode/src/tool/repo_clone.ts +++ b/packages/opencode/src/tool/repo_clone.ts @@ -1,11 +1,10 @@ -import path from "path" import { Effect, Schema } from "effect" import { AppFileSystem } from "@opencode-ai/core/filesystem" -import { Flock } from "@opencode-ai/core/util/flock" import { Git } from "@/git" import DESCRIPTION from "./repo_clone.txt" import * as Tool from "./tool" -import { parseRepositoryReference, repositoryCachePath, sameRepositoryReference } from "@/util/repository" +import { parseRemoteRepositoryReference, repositoryCachePath, validateRepositoryBranch } from "@/util/repository" +import { RepositoryCache } from "@/reference/repository-cache" export const Parameters = Schema.Struct({ repository: Schema.String.annotate({ @@ -29,36 +28,6 @@ type Metadata = { branch?: string } -function statusForRepository(input: { reuse: boolean; refresh?: boolean; branchMatches?: boolean }) { - if (!input.reuse) return "cloned" as const - if (input.branchMatches === false) return "refreshed" as const - if (input.refresh) return "refreshed" as const - return "cached" as const -} - -function resetTarget(input: { - requestedBranch?: string - remoteHead: { code: number; stdout: string } - branch: { code: number; stdout: string } -}) { - if (input.requestedBranch) return `origin/${input.requestedBranch}` - if (input.remoteHead.code === 0 && input.remoteHead.stdout) { - return input.remoteHead.stdout.replace(/^refs\/remotes\//, "") - } - if (input.branch.code === 0 && input.branch.stdout) { - return `origin/${input.branch.stdout}` - } - return "HEAD" -} - -function validateBranch(branch: string) { - if (!/^[A-Za-z0-9/_.-]+$/.test(branch) || branch.startsWith("-") || branch.includes("..")) { - throw new Error( - "Branch must contain only alphanumeric characters, /, _, ., and -, and cannot start with - or contain ..", - ) - } -} - export const RepoCloneTool = Tool.define( "repo_clone", Effect.gen(function* () { @@ -70,16 +39,12 @@ export const RepoCloneTool = Tool.define, ctx: Tool.Context) => Effect.gen(function* () { - const reference = parseRepositoryReference(params.repository) - if (!reference) - throw new Error("Repository must be a git URL, host/path reference, or GitHub owner/repo shorthand") - if (reference.protocol === "file:") throw new Error("Local file repositories are not supported") - if (params.branch) validateBranch(params.branch) + const reference = parseRemoteRepositoryReference(params.repository) + if (params.branch) validateRepositoryBranch(params.branch) const repository = reference.label const remote = reference.remote const localPath = repositoryCachePath(reference) - const cloneTarget = parseRepositoryReference(remote) ?? reference yield* ctx.ask({ permission: "repo_clone", @@ -94,115 +59,21 @@ export const RepoCloneTool = Tool.define Flock.acquire(`repo-clone:${localPath}`, { signal })), - () => - Effect.gen(function* () { - yield* fs.ensureDir(path.dirname(localPath)).pipe(Effect.orDie) - - const exists = yield* fs.existsSafe(localPath) - const hasGitDir = yield* fs.existsSafe(path.join(localPath, ".git")) - const origin = hasGitDir - ? yield* git.run(["config", "--get", "remote.origin.url"], { cwd: localPath }) - : undefined - const originReference = - origin?.exitCode === 0 ? parseRepositoryReference(origin.text().trim()) : undefined - const reuse = - hasGitDir && Boolean(originReference && sameRepositoryReference(originReference, cloneTarget)) - if (exists && !reuse) { - yield* fs.remove(localPath, { recursive: true }).pipe(Effect.orDie) - } - - const currentBranch = hasGitDir ? yield* git.branch(localPath) : undefined - const status = statusForRepository({ - reuse, - refresh: params.refresh, - branchMatches: params.branch ? currentBranch === params.branch : undefined, - }) - - if (status === "cloned") { - const clone = yield* git.run( - [ - "clone", - "--depth", - "100", - ...(params.branch ? ["--branch", params.branch] : []), - "--", - remote, - localPath, - ], - { cwd: path.dirname(localPath) }, - ) - if (clone.exitCode !== 0) { - throw new Error( - clone.stderr.toString().trim() || clone.text().trim() || `Failed to clone ${repository}`, - ) - } - } - - if (status === "refreshed") { - const fetch = yield* git.run(["fetch", "--all", "--prune"], { cwd: localPath }) - if (fetch.exitCode !== 0) { - throw new Error( - fetch.stderr.toString().trim() || fetch.text().trim() || `Failed to refresh ${repository}`, - ) - } - - if (params.branch) { - const checkout = yield* git.run(["checkout", "-B", params.branch, `origin/${params.branch}`], { - cwd: localPath, - }) - if (checkout.exitCode !== 0) { - throw new Error( - checkout.stderr.toString().trim() || - checkout.text().trim() || - `Failed to checkout ${params.branch}`, - ) - } - } - - const remoteHead = yield* git.run(["symbolic-ref", "refs/remotes/origin/HEAD"], { cwd: localPath }) - const branch = yield* git.run(["symbolic-ref", "--quiet", "--short", "HEAD"], { cwd: localPath }) - const target = resetTarget({ - requestedBranch: params.branch, - remoteHead: { code: remoteHead.exitCode, stdout: remoteHead.text().trim() }, - branch: { code: branch.exitCode, stdout: branch.text().trim() }, - }) - - const reset = yield* git.run(["reset", "--hard", target], { cwd: localPath }) - if (reset.exitCode !== 0) { - throw new Error( - reset.stderr.toString().trim() || reset.text().trim() || `Failed to reset ${repository}`, - ) - } - } - - const head = yield* git.run(["rev-parse", "HEAD"], { cwd: localPath }) - const branch = yield* git.branch(localPath) - const headText = head.exitCode === 0 ? head.text().trim() : undefined - - return { - title: repository, - metadata: { - repository, - host: reference.host, - remote, - localPath, - status, - head: headText, - branch, - }, - output: [ - `Repository ready: ${repository}`, - `Status: ${status}`, - `Local path: ${localPath}`, - ...(branch ? [`Branch: ${branch}`] : []), - ...(headText ? [`HEAD: ${headText}`] : []), - ].join("\n"), - } - }), - (lock) => Effect.promise(() => lock.release()).pipe(Effect.ignore), + const result = yield* RepositoryCache.ensure( + { reference, refresh: params.refresh, branch: params.branch }, + { fs, git }, ) + return { + title: repository, + metadata: result, + output: [ + `Repository ready: ${repository}`, + `Status: ${result.status}`, + `Local path: ${localPath}`, + ...(result.branch ? [`Branch: ${result.branch}`] : []), + ...(result.head ? [`HEAD: ${result.head}`] : []), + ].join("\n"), + } }).pipe(Effect.orDie), } satisfies Tool.DefWithoutID }), diff --git a/packages/opencode/src/util/repository.ts b/packages/opencode/src/util/repository.ts index c7298494f5..71c001255f 100644 --- a/packages/opencode/src/util/repository.ts +++ b/packages/opencode/src/util/repository.ts @@ -125,6 +125,21 @@ export function parseRepositoryReference(input: string) { } } +export function parseRemoteRepositoryReference(input: string) { + const reference = parseRepositoryReference(input) + if (!reference) throw new Error("Repository must be a git URL, host/path reference, or GitHub owner/repo shorthand") + if (reference.protocol === "file:") throw new Error("Local file repositories are not supported") + return reference +} + +export function validateRepositoryBranch(branch: string) { + if (!/^[A-Za-z0-9/_.-]+$/.test(branch) || branch.startsWith("-") || branch.includes("..")) { + throw new Error( + "Branch must contain only alphanumeric characters, /, _, ., and -, and cannot start with - or contain ..", + ) + } +} + export function parseGitHubRemote(input: string) { const cleaned = normalize(input) if (!cleaned.includes("://") && !cleaned.match(/^(?:[^@/\s]+@)?github\.com:/)) return null diff --git a/packages/opencode/test/agent/agent.test.ts b/packages/opencode/test/agent/agent.test.ts index 7f29860cfe..d2d571bbbe 100644 --- a/packages/opencode/test/agent/agent.test.ts +++ b/packages/opencode/test/agent/agent.test.ts @@ -147,6 +147,10 @@ test("reference config creates scout-backed subagents", async () => { config: { reference: { effect: "github.com/effect/effect-smol", + effectDev: { + repository: "https://github.com/effect/effect-smol", + branch: "dev", + }, effectFull: { repository: "Effect-TS/effect", branch: "main", @@ -162,6 +166,7 @@ test("reference config creates scout-backed subagents", async () => { directory: tmp.path, fn: async () => { const effect = await load(tmp.path, (svc) => svc.get("effect")) + const effectDev = await load(tmp.path, (svc) => svc.get("effectDev")) const effectFull = await load(tmp.path, (svc) => svc.get("effectFull")) const local = await load(tmp.path, (svc) => svc.get("localdocs")) const localFull = await load(tmp.path, (svc) => svc.get("localdocsFull")) @@ -169,13 +174,19 @@ test("reference config creates scout-backed subagents", async () => { expect(effect).toBeDefined() expect(effect?.mode).toBe("subagent") expect(effect?.prompt).toContain("Repository: github.com/effect/effect-smol") - expect(evalPerm(effect, "repo_clone")).toBe("allow") + expect(effect?.prompt).toContain(`Cached directory: ${path.join(Global.Path.repos, "github.com", "effect", "effect-smol")}`) + expect(effect?.prompt).toContain("Do not call repo_clone") + expect(evalPerm(effect, "repo_clone")).toBe("deny") + + expect(effectDev).toBeDefined() + expect(effectDev?.prompt).toContain("Problem: Reference conflicts with @effect") + expect(effectDev?.prompt).not.toContain("Cached directory:") expect(effectFull).toBeDefined() expect(effectFull?.mode).toBe("subagent") expect(effectFull?.prompt).toContain("Repository: Effect-TS/effect") expect(effectFull?.prompt).toContain("Branch/ref: main") - expect(evalPerm(effectFull, "repo_clone")).toBe("allow") + expect(evalPerm(effectFull, "repo_clone")).toBe("deny") expect(local).toBeDefined() expect(local?.mode).toBe("subagent") diff --git a/packages/opencode/test/reference/reference.test.ts b/packages/opencode/test/reference/reference.test.ts new file mode 100644 index 0000000000..bd3f08e462 --- /dev/null +++ b/packages/opencode/test/reference/reference.test.ts @@ -0,0 +1,249 @@ +import { afterEach, describe, expect } from "bun:test" +import path from "path" +import { Effect, Layer } from "effect" +import { AppFileSystem } from "@opencode-ai/core/filesystem" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { Flag } from "@opencode-ai/core/flag/flag" +import { Global } from "@opencode-ai/core/global" +import { Git } from "../../src/git" +import { Reference } from "../../src/reference/reference" +import { disposeAllInstances, provideTmpdirInstance, tmpdirScoped } from "../fixture/fixture" +import { testEffect } from "../lib/effect" + +afterEach(async () => { + await disposeAllInstances() +}) + +const it = testEffect( + Layer.mergeAll( + AppFileSystem.defaultLayer, + CrossSpawnSpawner.defaultLayer, + Git.defaultLayer, + Reference.defaultLayer, + ), +) + +const experimentalScout = (self: Effect.Effect) => + Effect.acquireUseRelease( + Effect.sync(() => { + const previous = Flag.OPENCODE_EXPERIMENTAL_SCOUT + Flag.OPENCODE_EXPERIMENTAL_SCOUT = true + return previous + }), + () => self, + (previous) => + Effect.sync(() => { + Flag.OPENCODE_EXPERIMENTAL_SCOUT = previous + }), + ) + +const githubBase = (url: string, self: Effect.Effect) => + Effect.acquireUseRelease( + Effect.sync(() => { + const previous = process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL + process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL = url + return previous + }), + () => self, + (previous) => + Effect.sync(() => { + if (previous) process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL = previous + else delete process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL + }), + ) + +const git = Effect.fn("ReferenceTest.git")(function* (cwd: string, args: string[]) { + return yield* Effect.promise(async () => { + const proc = Bun.spawn(["git", ...args], { + cwd, + stdout: "pipe", + stderr: "pipe", + }) + const [stdout, stderr, code] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]) + if (code !== 0) throw new Error(stderr.trim() || stdout.trim() || `git ${args.join(" ")} failed`) + return stdout.trim() + }) +}) + +const waitForContent = ( + fs: AppFileSystem.Interface, + file: string, + content: string, + attempts = 50, +): Effect.Effect => + Effect.gen(function* () { + if ((yield* fs.readFileStringSafe(file)) === content) return + if (attempts <= 0) throw new Error(`timed out waiting for ${file}`) + yield* Effect.sleep("100 millis") + yield* waitForContent(fs, file, content, attempts - 1) + }) + +describe("reference", () => { + it.live("resolves local and git references", () => + Effect.gen(function* () { + const root = path.resolve("opencode-reference-root") + const local = Reference.resolve({ + name: "docs", + reference: { path: "../docs" }, + directory: path.join(root, "packages", "app"), + worktree: root, + }) + const repo = Reference.resolve({ + name: "effect", + reference: { repository: "Effect-TS/effect", branch: "main" }, + directory: path.join(root, "packages", "app"), + worktree: root, + }) + + expect(local.kind).toBe("local") + if (local.kind === "local") expect(local.path).toBe(path.resolve(root, "../docs")) + expect(repo.kind).toBe("git") + if (repo.kind === "git") { + expect(repo.repository).toBe("Effect-TS/effect") + expect(repo.branch).toBe("main") + expect(repo.path).toBe(path.join(Global.Path.repos, "github.com", "Effect-TS", "effect")) + } + }), + ) + + it.live("marks same-cache references with different branches invalid", () => + Effect.gen(function* () { + const root = path.resolve("opencode-reference-root") + const references = Reference.resolveAll({ + directory: root, + worktree: root, + references: { + main: { repository: "owner/repo", branch: "main" }, + dev: { repository: "github.com/owner/repo", branch: "dev" }, + alsoMain: { repository: "https://github.com/owner/repo", branch: "main" }, + }, + }) + + expect(references.map((reference) => reference.kind)).toEqual(["git", "invalid", "git"]) + expect(references[1]?.kind).toBe("invalid") + if (references[1]?.kind === "invalid") { + expect(references[1].message).toContain("conflicts with @main") + expect(references[1].message).toContain("@dev requests dev") + } + }), + ) + + it.live("materializes configured git references during init", () => + experimentalScout( + provideTmpdirInstance( + (_dir) => + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const cache = path.join(Global.Path.repos, "github.com", "opencode-reference-test", "repo") + yield* fs.remove(cache, { recursive: true }).pipe(Effect.ignore) + yield* Effect.addFinalizer(() => fs.remove(cache, { recursive: true }).pipe(Effect.ignore)) + + const source = yield* tmpdirScoped({ git: true }) + const remoteRoot = yield* tmpdirScoped() + const remoteDir = path.join(remoteRoot, "opencode-reference-test") + const remoteRepo = path.join(remoteDir, "repo.git") + + yield* Effect.promise(() => Bun.write(path.join(source, "README.md"), "configured\n")) + yield* git(source, ["add", "."]) + yield* git(source, ["commit", "-m", "add readme"]) + yield* fs.makeDirectory(remoteDir, { recursive: true }).pipe(Effect.orDie) + yield* git(remoteRoot, ["clone", "--bare", source, remoteRepo]) + + const reference = yield* Reference.Service + yield* githubBase( + `file://${remoteRoot}/`, + Effect.gen(function* () { + yield* reference.init() + yield* waitForContent(fs, path.join(cache, "README.md"), "configured\n") + }), + ) + + expect(yield* fs.existsSafe(path.join(cache, ".git"))).toBe(true) + expect(yield* fs.readFileString(path.join(cache, "README.md"))).toBe("configured\n") + + const resolved = yield* reference.get("docs") + expect(resolved?.kind).toBe("git") + if (resolved?.kind === "git") expect(resolved.path).toBe(cache) + }), + { + config: { + reference: { + docs: "opencode-reference-test/repo", + }, + }, + }, + ), + ), + ) + + it.live("refreshes configured git references on new instance init", () => + experimentalScout( + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const cache = path.join(Global.Path.repos, "github.com", "opencode-reference-refresh", "repo") + yield* fs.remove(cache, { recursive: true }).pipe(Effect.ignore) + yield* Effect.addFinalizer(() => fs.remove(cache, { recursive: true }).pipe(Effect.ignore)) + + const source = yield* tmpdirScoped({ git: true }) + const remoteRoot = yield* tmpdirScoped() + const remoteDir = path.join(remoteRoot, "opencode-reference-refresh") + const remoteRepo = path.join(remoteDir, "repo.git") + + yield* Effect.promise(() => Bun.write(path.join(source, "README.md"), "v1\n")) + yield* git(source, ["add", "."]) + yield* git(source, ["commit", "-m", "add readme"]) + yield* fs.makeDirectory(remoteDir, { recursive: true }).pipe(Effect.orDie) + yield* git(remoteRoot, ["clone", "--bare", source, remoteRepo]) + + yield* githubBase( + `file://${remoteRoot}/`, + provideTmpdirInstance( + (_dir) => + Effect.gen(function* () { + const reference = yield* Reference.Service + yield* reference.init() + yield* waitForContent(fs, path.join(cache, "README.md"), "v1\n") + }), + { + config: { + reference: { + docs: "opencode-reference-refresh/repo", + }, + }, + }, + ), + ) + + const branch = yield* git(source, ["branch", "--show-current"]) + yield* git(source, ["remote", "add", "origin", remoteRepo]) + yield* Effect.promise(() => Bun.write(path.join(source, "README.md"), "v2\n")) + yield* git(source, ["add", "."]) + yield* git(source, ["commit", "-m", "update readme"]) + yield* git(source, ["push", "origin", `${branch}:${branch}`]) + + yield* githubBase( + `file://${remoteRoot}/`, + provideTmpdirInstance( + (_dir) => + Effect.gen(function* () { + const reference = yield* Reference.Service + yield* reference.init() + yield* waitForContent(fs, path.join(cache, "README.md"), "v2\n") + }), + { + config: { + reference: { + docs: "opencode-reference-refresh/repo", + }, + }, + }, + ), + ) + }), + ), + ) +}) diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index 821b900754..98a69fce96 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -46,6 +46,7 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import * as Database from "../../src/storage/db" import { Ripgrep } from "../../src/file/ripgrep" import { Format } from "../../src/format" +import { Reference } from "../../src/reference/reference" import { provideTmpdirInstance, provideTmpdirServer } from "../fixture/fixture" import { testEffect } from "../lib/effect" import { reply, TestLLMServer } from "../lib/llm-server" @@ -181,6 +182,7 @@ function makeHttp() { Layer.provide(FetchHttpClient.layer), Layer.provide(CrossSpawnSpawner.defaultLayer), Layer.provide(Git.defaultLayer), + Layer.provide(Reference.defaultLayer), Layer.provide(Ripgrep.defaultLayer), Layer.provide(Format.defaultLayer), Layer.provideMerge(todo), diff --git a/packages/opencode/test/session/snapshot-tool-race.test.ts b/packages/opencode/test/session/snapshot-tool-race.test.ts index 727b75cfee..251a4acf3f 100644 --- a/packages/opencode/test/session/snapshot-tool-race.test.ts +++ b/packages/opencode/test/session/snapshot-tool-race.test.ts @@ -57,6 +57,7 @@ import { AppFileSystem } from "@opencode-ai/core/filesystem" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Ripgrep } from "../../src/file/ripgrep" import { Format } from "../../src/format" +import { Reference } from "../../src/reference/reference" void Log.init({ print: false }) @@ -131,6 +132,7 @@ function makeHttp() { Layer.provide(FetchHttpClient.layer), Layer.provide(CrossSpawnSpawner.defaultLayer), Layer.provide(Git.defaultLayer), + Layer.provide(Reference.defaultLayer), Layer.provide(Ripgrep.defaultLayer), Layer.provide(Format.defaultLayer), Layer.provideMerge(todo), diff --git a/packages/opencode/test/tool/glob.test.ts b/packages/opencode/test/tool/glob.test.ts index 9ff1f8a99a..45dc0b36a9 100644 --- a/packages/opencode/test/tool/glob.test.ts +++ b/packages/opencode/test/tool/glob.test.ts @@ -10,6 +10,7 @@ import { Truncate } from "@/tool/truncate" import { Agent } from "../../src/agent/agent" import { TestInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" +import { Reference } from "@/reference/reference" const it = testEffect( Layer.mergeAll( @@ -18,6 +19,7 @@ const it = testEffect( Ripgrep.defaultLayer, Truncate.defaultLayer, Agent.defaultLayer, + Reference.defaultLayer, ), ) diff --git a/packages/opencode/test/tool/grep.test.ts b/packages/opencode/test/tool/grep.test.ts index dfb74b8bed..53f5d9a19c 100644 --- a/packages/opencode/test/tool/grep.test.ts +++ b/packages/opencode/test/tool/grep.test.ts @@ -10,6 +10,7 @@ import { Agent } from "../../src/agent/agent" import { Ripgrep } from "../../src/file/ripgrep" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { testEffect } from "../lib/effect" +import { Reference } from "@/reference/reference" const it = testEffect( Layer.mergeAll( @@ -18,6 +19,7 @@ const it = testEffect( Ripgrep.defaultLayer, Truncate.defaultLayer, Agent.defaultLayer, + Reference.defaultLayer, ), ) diff --git a/packages/opencode/test/tool/read.test.ts b/packages/opencode/test/tool/read.test.ts index 9e2cc04895..11bb1513f3 100644 --- a/packages/opencode/test/tool/read.test.ts +++ b/packages/opencode/test/tool/read.test.ts @@ -4,6 +4,8 @@ import path from "path" import { Agent } from "../../src/agent/agent" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { AppFileSystem } from "@opencode-ai/core/filesystem" +import { Flag } from "@opencode-ai/core/flag/flag" +import { Global } from "@opencode-ai/core/global" import { LSP } from "@/lsp/lsp" import { Permission } from "../../src/permission" import { Instance } from "../../src/project/instance" @@ -15,6 +17,7 @@ import { Tool } from "@/tool/tool" import { Filesystem } from "@/util/filesystem" import { disposeAllInstances, provideInstance, TestInstance, tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" +import { Reference } from "@/reference/reference" const FIXTURES_DIR = path.join(import.meta.dir, "fixtures") @@ -40,6 +43,7 @@ const it = testEffect( CrossSpawnSpawner.defaultLayer, Instruction.defaultLayer, LSP.defaultLayer, + Reference.defaultLayer, Truncate.defaultLayer, ), ) @@ -81,6 +85,49 @@ const fail = Effect.fn("ReadToolTest.fail")(function* ( const full = (p: string) => (process.platform === "win32" ? Filesystem.normalizePath(p) : p) const glob = (p: string) => process.platform === "win32" ? Filesystem.normalizePathPattern(p) : p.replaceAll("\\", "/") +const experimentalScout = (self: Effect.Effect) => + Effect.acquireUseRelease( + Effect.sync(() => { + const previous = Flag.OPENCODE_EXPERIMENTAL_SCOUT + Flag.OPENCODE_EXPERIMENTAL_SCOUT = true + return previous + }), + () => self, + (previous) => + Effect.sync(() => { + Flag.OPENCODE_EXPERIMENTAL_SCOUT = previous + }), + ) +const githubBase = (url: string, self: Effect.Effect) => + Effect.acquireUseRelease( + Effect.sync(() => { + const previous = process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL + process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL = url + return previous + }), + () => self, + (previous) => + Effect.sync(() => { + if (previous) process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL = previous + else delete process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL + }), + ) +const git = Effect.fn("ReadToolTest.git")(function* (cwd: string, args: string[]) { + return yield* Effect.promise(async () => { + const proc = Bun.spawn(["git", ...args], { + cwd, + stdout: "pipe", + stderr: "pipe", + }) + const [stdout, stderr, code] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]) + if (code !== 0) throw new Error(stderr.trim() || stdout.trim() || `git ${args.join(" ")} failed`) + return stdout.trim() + }) +}) const put = Effect.fn("ReadToolTest.put")(function* (p: string, content: string | Buffer | Uint8Array) { const fs = yield* AppFileSystem.Service yield* fs.writeWithDirs(p, content) @@ -212,6 +259,46 @@ describe("tool.read external_directory permission", () => { expect(ext).toBeUndefined() }), ) + + it.live("does not ask for external_directory permission when reading configured references", () => + experimentalScout( + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const cache = path.join(Global.Path.repos, "github.com", "opencode-read-reference", "repo") + yield* fs.remove(cache, { recursive: true }).pipe(Effect.ignore) + yield* Effect.addFinalizer(() => fs.remove(cache, { recursive: true }).pipe(Effect.ignore)) + + const source = yield* tmpdirScoped({ git: true }) + const remoteRoot = yield* tmpdirScoped() + const remoteDir = path.join(remoteRoot, "opencode-read-reference") + const remoteRepo = path.join(remoteDir, "repo.git") + yield* put(path.join(source, "notes.md"), "reference notes") + yield* git(source, ["add", "."]) + yield* git(source, ["commit", "-m", "add notes"]) + yield* fs.makeDirectory(remoteDir, { recursive: true }).pipe(Effect.orDie) + yield* git(remoteRoot, ["clone", "--bare", source, remoteRepo]) + + const dir = yield* tmpdirScoped({ + git: true, + config: { + reference: { + docs: "opencode-read-reference/repo", + }, + }, + }) + + const { items, next } = asks() + const result = yield* githubBase( + `file://${remoteRoot}/`, + exec(dir, { filePath: path.join(cache, "notes.md") }, next), + ) + const ext = items.find((item) => item.permission === "external_directory") + + expect(result.output).toContain("reference notes") + expect(ext).toBeUndefined() + }), + ), + ) }) describe("tool.read env file permissions", () => { diff --git a/packages/opencode/test/tool/registry.test.ts b/packages/opencode/test/tool/registry.test.ts index e42d4d59b3..dc66c308ac 100644 --- a/packages/opencode/test/tool/registry.test.ts +++ b/packages/opencode/test/tool/registry.test.ts @@ -25,6 +25,7 @@ import { Format } from "@/format" import { Ripgrep } from "@/file/ripgrep" import * as Truncate from "@/tool/truncate" import { InstanceState } from "@/effect/instance-state" +import { Reference } from "@/reference/reference" const node = CrossSpawnSpawner.defaultLayer const originalExperimentalScout = Flag.OPENCODE_EXPERIMENTAL_SCOUT @@ -42,6 +43,7 @@ const registryLayer = ToolRegistry.layer.pipe( Layer.provide(Session.defaultLayer), Layer.provide(Provider.defaultLayer), Layer.provide(Git.defaultLayer), + Layer.provide(Reference.defaultLayer), Layer.provide(LSP.defaultLayer), Layer.provide(Instruction.defaultLayer), Layer.provide(AppFileSystem.defaultLayer), From 6589a66822c171be39375cc97a3985cca449175a Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sun, 10 May 2026 12:28:11 +0000 Subject: [PATCH 126/189] chore: generate --- packages/opencode/src/agent/agent.ts | 24 +++++++++---------- packages/opencode/src/reference/reference.ts | 13 ++++++++-- .../src/reference/repository-cache.ts | 10 +------- packages/opencode/test/agent/agent.test.ts | 4 +++- .../opencode/test/reference/reference.test.ts | 7 +----- 5 files changed, 27 insertions(+), 31 deletions(-) diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index 450ec53f8f..5917240cdb 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -351,19 +351,17 @@ export const layer = Layer.effect( description: referenceDescription(resolved), permission: Permission.merge( agents.scout.permission, - Permission.fromConfig( - { - repo_clone: "deny", - ...(localPath - ? { - external_directory: { - [localPath]: "allow", - [path.join(localPath, "*")]: "allow", - }, - } - : {}), - }, - ), + Permission.fromConfig({ + repo_clone: "deny", + ...(localPath + ? { + external_directory: { + [localPath]: "allow", + [path.join(localPath, "*")]: "allow", + }, + } + : {}), + }), ), prompt: referencePrompt(resolved), options: { reference: cfg.reference?.[resolved.name], resolved }, diff --git a/packages/opencode/src/reference/reference.ts b/packages/opencode/src/reference/reference.ts index b62da96f27..09e0a825d8 100644 --- a/packages/opencode/src/reference/reference.ts +++ b/packages/opencode/src/reference/reference.ts @@ -90,7 +90,12 @@ function containsReferencePath(referencePath: string, target: string) { return AppFileSystem.contains(normalizedTarget(referencePath) ?? referencePath, target) } -export function resolve(input: { name: string; reference: ReferenceEntry; directory: string; worktree: string }): Resolved { +export function resolve(input: { + name: string + reference: ReferenceEntry + directory: string + worktree: string +}): Resolved { if (typeof input.reference === "string") { if (input.reference.startsWith(".") || input.reference.startsWith("/") || input.reference.startsWith("~")) { return { name: input.name, kind: "local", path: referencePath({ ...input, value: input.reference }) } @@ -142,7 +147,11 @@ export const layer = Layer.effect( const state = yield* InstanceState.make( Effect.fn("Reference.state")(function* (ctx) { const cfg = yield* config.get() - const references = resolveAll({ references: cfg.reference ?? {}, directory: ctx.directory, worktree: ctx.worktree }) + const references = resolveAll({ + references: cfg.reference ?? {}, + directory: ctx.directory, + worktree: ctx.worktree, + }) const seenPath = new Set() const gitReferences = references.filter((reference): reference is Extract => { if (reference.kind !== "git") return false diff --git a/packages/opencode/src/reference/repository-cache.ts b/packages/opencode/src/reference/repository-cache.ts index 521f64369e..d31db8ab5f 100644 --- a/packages/opencode/src/reference/repository-cache.ts +++ b/packages/opencode/src/reference/repository-cache.ts @@ -87,15 +87,7 @@ export const ensure = Effect.fn("RepositoryCache.ensure")(function* ( if (status === "cloned") { const clone = yield* services.git.run( - [ - "clone", - "--depth", - "100", - ...(input.branch ? ["--branch", input.branch] : []), - "--", - remote, - localPath, - ], + ["clone", "--depth", "100", ...(input.branch ? ["--branch", input.branch] : []), "--", remote, localPath], { cwd: path.dirname(localPath) }, ) if (clone.exitCode !== 0) { diff --git a/packages/opencode/test/agent/agent.test.ts b/packages/opencode/test/agent/agent.test.ts index d2d571bbbe..cb6f60503f 100644 --- a/packages/opencode/test/agent/agent.test.ts +++ b/packages/opencode/test/agent/agent.test.ts @@ -174,7 +174,9 @@ test("reference config creates scout-backed subagents", async () => { expect(effect).toBeDefined() expect(effect?.mode).toBe("subagent") expect(effect?.prompt).toContain("Repository: github.com/effect/effect-smol") - expect(effect?.prompt).toContain(`Cached directory: ${path.join(Global.Path.repos, "github.com", "effect", "effect-smol")}`) + expect(effect?.prompt).toContain( + `Cached directory: ${path.join(Global.Path.repos, "github.com", "effect", "effect-smol")}`, + ) expect(effect?.prompt).toContain("Do not call repo_clone") expect(evalPerm(effect, "repo_clone")).toBe("deny") diff --git a/packages/opencode/test/reference/reference.test.ts b/packages/opencode/test/reference/reference.test.ts index bd3f08e462..4717c61d25 100644 --- a/packages/opencode/test/reference/reference.test.ts +++ b/packages/opencode/test/reference/reference.test.ts @@ -15,12 +15,7 @@ afterEach(async () => { }) const it = testEffect( - Layer.mergeAll( - AppFileSystem.defaultLayer, - CrossSpawnSpawner.defaultLayer, - Git.defaultLayer, - Reference.defaultLayer, - ), + Layer.mergeAll(AppFileSystem.defaultLayer, CrossSpawnSpawner.defaultLayer, Git.defaultLayer, Reference.defaultLayer), ) const experimentalScout = (self: Effect.Effect) => From d28b5ad2f484861c7ee0e70ee8f6e7f8b4826075 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sun, 10 May 2026 10:49:22 -0400 Subject: [PATCH 127/189] refactor(http-recorder): Redactor + Recorder seams, README (#26636) --- packages/http-recorder/README.md | 214 ++++++++++++++++++ packages/http-recorder/src/cassette.ts | 30 ++- packages/http-recorder/src/diff.ts | 95 -------- packages/http-recorder/src/effect.ts | 169 ++++---------- packages/http-recorder/src/index.ts | 22 +- packages/http-recorder/src/matching.ts | 90 +++++++- packages/http-recorder/src/recorder.ts | 59 +++++ packages/http-recorder/src/redactor.ts | 76 +++++++ packages/http-recorder/src/storage.ts | 8 +- packages/http-recorder/src/websocket.ts | 143 ++++-------- .../http-recorder/test/record-replay.test.ts | 9 +- .../anthropic-messages.recorded.test.ts | 3 +- .../llm/test/provider/golden.recorded.test.ts | 7 +- 13 files changed, 577 insertions(+), 348 deletions(-) create mode 100644 packages/http-recorder/README.md delete mode 100644 packages/http-recorder/src/diff.ts create mode 100644 packages/http-recorder/src/recorder.ts create mode 100644 packages/http-recorder/src/redactor.ts diff --git a/packages/http-recorder/README.md b/packages/http-recorder/README.md new file mode 100644 index 0000000000..607b39af54 --- /dev/null +++ b/packages/http-recorder/README.md @@ -0,0 +1,214 @@ +# @opencode-ai/http-recorder + +Record and replay HTTP and WebSocket traffic for Effect's `HttpClient`. Tests +exercise real request shapes against deterministic, version-controlled +cassettes — no manual mocks, no flakes from upstream drift. + +## Install + +Internal package; depended on as `@opencode-ai/http-recorder` from another +workspace package. + +```ts +import { HttpRecorder } from "@opencode-ai/http-recorder" +``` + +## Quickstart + +Provide `cassetteLayer(name)` in place of (or layered over) your `HttpClient`. +The first run records to `test/fixtures/recordings/.json`; subsequent +runs replay from it. + +```ts +import { Effect } from "effect" +import { HttpClient, HttpClientRequest } from "effect/unstable/http" +import { HttpRecorder } from "@opencode-ai/http-recorder" + +const program = Effect.gen(function* () { + const http = yield* HttpClient.HttpClient + const response = yield* http.execute(HttpClientRequest.get("https://api.example.com/users/1")) + return yield* response.json +}) + +// Replay (default). Fails if the cassette is missing. +Effect.runPromise(program.pipe(Effect.provide(HttpRecorder.cassetteLayer("users/get-one")))) + +// Record. Hits the upstream and writes the cassette. +Effect.runPromise( + program.pipe(Effect.provide(HttpRecorder.cassetteLayer("users/get-one", { mode: "record" }))), +) +``` + +Set the mode from the environment in your test setup: + +```ts +HttpRecorder.cassetteLayer("users/get-one", { + mode: process.env.RECORD === "true" ? "record" : "replay", +}) +``` + +## Modes + +| Mode | Behavior | +| ------------- | -------------------------------------------------------------------- | +| `replay` | Default. Match the request to a recorded interaction; error if none. | +| `record` | Execute upstream, append the interaction, write the cassette. | +| `passthrough` | Bypass the recorder entirely — just call upstream. | + +## Cassette format + +A cassette is JSON at `test/fixtures/recordings/.json`: + +```json +{ + "version": 1, + "metadata": { "name": "users/get-one", "recordedAt": "2026-05-09T..." }, + "interactions": [ + { + "transport": "http", + "request": { "method": "GET", "url": "...", "headers": {...}, "body": "" }, + "response": { "status": 200, "headers": {...}, "body": "..." } + } + ] +} +``` + +Cassettes are normal source files — review them, diff them, commit them. + +## Request matching + +By default, requests match on canonicalized method, URL, headers, and JSON +body (object keys sorted). Two dispatch strategies are available: + +- **`match`** (default) — find the first recorded interaction whose request + matches the incoming request. Same request twice returns the same response. +- **`sequential`** — return interactions in the order they were recorded, + validating each one matches as the cursor advances. Use for ordered flows + where the same URL is hit multiple times with meaningful state changes + (pagination, retries, polling). + +```ts +HttpRecorder.cassetteLayer("flow/poll-until-done", { dispatch: "sequential" }) +``` + +Supply your own matcher via `match: (incoming, recorded) => boolean` for +custom equivalence (e.g. ignoring a timestamp field in the body). + +## Redaction & secret safety + +Cassettes get checked in, so the recorder is aggressive about not letting +secrets escape. Redaction is configured by composing a `Redactor`: + +```ts +import { HttpRecorder, Redactor } from "@opencode-ai/http-recorder" + +HttpRecorder.cassetteLayer("anthropic/messages", { + mode: process.env.RECORD === "true" ? "record" : "replay", + redactor: Redactor.defaults({ + requestHeaders: { allow: ["content-type", "anthropic-version"] }, + url: { transform: (url) => url.replace(/\/accounts\/[^/]+/, "/accounts/{account}") }, + body: (parsed) => ({ ...(parsed as object), user_id: "{user}" }), + }), +}) +``` + +`Redactor.defaults({ … })` composes the four built-in redactors with your +overrides. For full control, build the stack yourself: + +```ts +const redactor = Redactor.compose( + Redactor.requestHeaders({ allow: ["content-type", "x-custom"] }), + Redactor.responseHeaders(), + Redactor.url({ query: ["session-id"] }), + Redactor.body((parsed) => /* … */), +) +``` + +What each layer does: + +- **`requestHeaders` / `responseHeaders`** — strip headers to a small + allow-list (request default: `content-type`, `accept`, `openai-beta`; + response default: `content-type`). Sensitive headers within the + allow-list (`authorization`, `cookie`, API-key headers, AWS/GCP tokens, + …) are replaced with `[REDACTED]`. +- **`url`** — query parameters matching common secret names (`api_key`, + `token`, `signature`, AWS signing params, …) are replaced with + `[REDACTED]`. URL user/password are replaced. `transform` runs after + built-in redaction for path-level scrubbing. +- **`body`** — receives the parsed JSON request body and returns a redacted + version. No-op for non-JSON bodies. + +After assembling the cassette, the recorder scans every string for known +secret patterns (Bearer tokens, `sk-…`, `sk-ant-…`, Google `AIza…` keys, +AWS access keys, GitHub tokens, PEM blocks) and for values matching any +environment variable named like a credential. If anything is found, the +cassette is **not written** and the request fails with `UnsafeCassetteError` +listing what was detected. + +## WebSocket recording + +WebSocket support records the open frame plus client/server message +streams. It uses the shared `Cassette.Service`, so HTTP and WS interactions +can live in the same cassette. + +```ts +import { HttpRecorder } from "@opencode-ai/http-recorder" +import { Effect } from "effect" + +const program = Effect.gen(function* () { + const cassette = yield* HttpRecorder.Cassette.Service + const executor = yield* HttpRecorder.makeWebSocketExecutor({ + name: "ws/subscribe", + mode: process.env.RECORD === "true" ? "record" : "replay", + cassette, + live: liveExecutor, + }) + // use executor.open(...) +}) +``` + +## Inspecting cassettes programmatically + +`Cassette.Service` exposes `read`, `write`, `append`, `exists`, `list`, and +`scan` (re-running the secret detector over an existing cassette). Useful +for CI checks: + +```ts +import { HttpRecorder } from "@opencode-ai/http-recorder" +import { Effect } from "effect" + +const audit = Effect.gen(function* () { + const cassettes = yield* HttpRecorder.Cassette.Service + const findings = yield* Effect.forEach(yield* cassettes.list(), (entry) => + cassettes.read(entry.name).pipe(Effect.map((c) => ({ entry, findings: cassettes.scan(c) }))), + ) + return findings.filter((r) => r.findings.length > 0) +}) +``` + +## Options reference + +```ts +type RecordReplayOptions = { + mode?: "record" | "replay" | "passthrough" // default: "replay" + directory?: string // default: /test/fixtures/recordings + metadata?: Record // merged into cassette.metadata + redactor?: Redactor // default: Redactor.defaults() + dispatch?: "match" | "sequential" // default: "match" + match?: (incoming, recorded) => boolean // custom matcher +} +``` + +## Layout + +| File | Purpose | +| -------------- | -------------------------------------------------------------------------------- | +| `effect.ts` | `cassetteLayer` / `recordingLayer` — the `HttpClient` adapter. | +| `websocket.ts` | `makeWebSocketExecutor` — WebSocket record/replay. | +| `cassette.ts` | `Cassette.Service` — reads/writes cassette files, accumulates state. | +| `recorder.ts` | Shared transport plumbing: `UnsafeCassetteError`, `appendOrFail`, `ReplayState`. | +| `redactor.ts` | Composable `Redactor` — headers, url, body redaction. | +| `redaction.ts` | Lower-level header/URL primitives + secret pattern detection. | +| `schema.ts` | Effect Schema definitions for the cassette JSON format. | +| `storage.ts` | Path resolution, JSON encode/decode, sync existence check. | +| `matching.ts` | Request matcher, canonicalization, dispatch strategies, mismatch diagnostics. | diff --git a/packages/http-recorder/src/cassette.ts b/packages/http-recorder/src/cassette.ts index 769bcc7c70..4f8d2dd7f3 100644 --- a/packages/http-recorder/src/cassette.ts +++ b/packages/http-recorder/src/cassette.ts @@ -1,6 +1,6 @@ -import { Context, Effect, FileSystem, Layer, PlatformError, Ref } from "effect" +import { Context, Effect, FileSystem, Layer, PlatformError } from "effect" import * as path from "node:path" -import { cassetteSecretFindings, type SecretFinding } from "./redaction" +import { cassetteSecretFindings, secretFindings, type SecretFinding } from "./redaction" import type { Cassette, CassetteMetadata, Interaction } from "./schema" import { cassetteFor, cassettePath, DEFAULT_RECORDINGS_DIR, formatCassette, parseCassette } from "./storage" @@ -37,10 +37,18 @@ export const layer = (options: { readonly directory?: string } = {}) => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem const directory = options.directory ?? DEFAULT_RECORDINGS_DIR - const recorded = yield* Ref.make(new Map>()) + const recorded = new Map() + const directoriesEnsured = new Set() const pathFor = (name: string) => cassettePath(name, directory) + const ensureDirectory = Effect.fn("Cassette.ensureDirectory")(function* (name: string) { + const dir = path.dirname(pathFor(name)) + if (directoriesEnsured.has(dir)) return + yield* fileSystem.makeDirectory(dir, { recursive: true }) + directoriesEnsured.add(dir) + }) + const walk = (directory: string): Effect.Effect, PlatformError.PlatformError> => Effect.gen(function* () { const entries = yield* fileSystem @@ -61,7 +69,7 @@ export const layer = (options: { readonly directory?: string } = {}) => }) const write = Effect.fn("Cassette.write")(function* (name: string, cassette: Cassette) { - yield* fileSystem.makeDirectory(path.dirname(pathFor(name)), { recursive: true }) + yield* ensureDirectory(name) yield* fileSystem.writeFileString(pathFor(name), formatCassette(cassette)) }) @@ -70,11 +78,12 @@ export const layer = (options: { readonly directory?: string } = {}) => interaction: Interaction, metadata: CassetteMetadata | undefined, ) { - const interactions = yield* Ref.updateAndGet(recorded, (previous) => - new Map(previous).set(name, [...(previous.get(name) ?? []), interaction]), - ) - const cassette = cassetteFor(name, interactions.get(name) ?? [], metadata) - const findings = cassetteSecretFindings(cassette) + const entry = recorded.get(name) ?? { interactions: [], findings: [] } + entry.interactions.push(interaction) + entry.findings.push(...secretFindings(interaction)) + recorded.set(name, entry) + const cassette = cassetteFor(name, entry.interactions, metadata) + const findings = [...entry.findings, ...secretFindings(cassette.metadata ?? {})] if (findings.length === 0) yield* write(name, cassette) return { cassette, findings } }) @@ -103,6 +112,3 @@ export const layer = (options: { readonly directory?: string } = {}) => }), ) -export const defaultLayer = layer() - -export * as Cassette from "./cassette" diff --git a/packages/http-recorder/src/diff.ts b/packages/http-recorder/src/diff.ts deleted file mode 100644 index 29517befcb..0000000000 --- a/packages/http-recorder/src/diff.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { Option } from "effect" -import { Headers, HttpBody, HttpClientRequest, UrlParams } from "effect/unstable/http" -import { decodeJson } from "./matching" -import { REDACTED, redactUrl, secretFindings } from "./redaction" -import { httpInteractions, type Cassette, type RequestSnapshot } from "./schema" - -const safeText = (value: unknown) => { - if (value === undefined) return "undefined" - if (secretFindings(value).length > 0) return JSON.stringify(REDACTED) - const text = typeof value === "string" ? JSON.stringify(value) : JSON.stringify(value) - if (!text) return String(value) - return text.length > 300 ? `${text.slice(0, 300)}...` : text -} - -const jsonBody = (body: string) => Option.getOrUndefined(decodeJson(body)) - -const valueDiffs = (expected: unknown, received: unknown, base = "$", limit = 8): ReadonlyArray => { - if (Object.is(expected, received)) return [] - if ( - expected && - received && - typeof expected === "object" && - typeof received === "object" && - !Array.isArray(expected) && - !Array.isArray(received) - ) { - return [...new Set([...Object.keys(expected), ...Object.keys(received)])] - .toSorted() - .flatMap((key) => - valueDiffs( - (expected as Record)[key], - (received as Record)[key], - `${base}.${key}`, - limit, - ), - ) - .slice(0, limit) - } - if (Array.isArray(expected) && Array.isArray(received)) { - return Array.from({ length: Math.max(expected.length, received.length) }, (_, index) => index) - .flatMap((index) => valueDiffs(expected[index], received[index], `${base}[${index}]`, limit)) - .slice(0, limit) - } - return [`${base} expected ${safeText(expected)}, received ${safeText(received)}`] -} - -const headerDiffs = (expected: Record, received: Record) => - [...new Set([...Object.keys(expected), ...Object.keys(received)])].toSorted().flatMap((key) => { - if (expected[key] === received[key]) return [] - if (expected[key] === undefined) return [` ${key} unexpected ${safeText(received[key])}`] - if (received[key] === undefined) return [` ${key} missing expected ${safeText(expected[key])}`] - return [` ${key} expected ${safeText(expected[key])}, received ${safeText(received[key])}`] - }) - -export const requestDiff = (expected: RequestSnapshot, received: RequestSnapshot) => { - const lines = [] - if (expected.method !== received.method) { - lines.push("method:", ` expected ${expected.method}, received ${received.method}`) - } - if (expected.url !== received.url) { - lines.push("url:", ` expected ${expected.url}`, ` received ${received.url}`) - } - const headers = headerDiffs(expected.headers, received.headers) - if (headers.length > 0) lines.push("headers:", ...headers.slice(0, 8)) - const expectedBody = jsonBody(expected.body) - const receivedBody = jsonBody(received.body) - const body = - expectedBody !== undefined && receivedBody !== undefined - ? valueDiffs(expectedBody, receivedBody).map((line) => ` ${line}`) - : expected.body === received.body - ? [] - : [` expected ${safeText(expected.body)}, received ${safeText(received.body)}`] - if (body.length > 0) lines.push("body:", ...body) - return lines -} - -export const mismatchDetail = (cassette: Cassette, incoming: RequestSnapshot) => { - const interactions = httpInteractions(cassette) - if (interactions.length === 0) return "cassette has no recorded HTTP interactions" - const ranked = interactions - .map((interaction, index) => ({ index, lines: requestDiff(interaction.request, incoming) })) - .toSorted((a, b) => a.lines.length - b.lines.length || a.index - b.index) - const best = ranked[0] - return ["no recorded interaction matched", `closest interaction: #${best.index + 1}`, ...best.lines].join("\n") -} - -export const redactedErrorRequest = (request: HttpClientRequest.HttpClientRequest) => - HttpClientRequest.makeWith( - request.method, - redactUrl(request.url), - UrlParams.empty, - Option.none(), - Headers.empty, - HttpBody.empty, - ) diff --git a/packages/http-recorder/src/effect.ts b/packages/http-recorder/src/effect.ts index f103e45dc7..e8e46e6c68 100644 --- a/packages/http-recorder/src/effect.ts +++ b/packages/http-recorder/src/effect.ts @@ -1,26 +1,21 @@ import { NodeFileSystem } from "@effect/platform-node" -import { Effect, Layer, Option, Ref } from "effect" +import { Effect, Layer, Option } from "effect" import { FetchHttpClient, + Headers, + HttpBody, HttpClient, HttpClientError, HttpClientRequest, HttpClientResponse, + UrlParams, } from "effect/unstable/http" -import { redactedErrorRequest, mismatchDetail, requestDiff } from "./diff" -import { defaultMatcher, decodeJson, type RequestMatcher } from "./matching" -import { redactHeaders, redactUrl, type SecretFinding } from "./redaction" -import { - httpInteractions, - type Cassette, - type CassetteMetadata, - type HttpInteraction, - type ResponseSnapshot, -} from "./schema" import * as CassetteService from "./cassette" - -export const DEFAULT_REQUEST_HEADERS: ReadonlyArray = ["content-type", "accept", "openai-beta"] -const DEFAULT_RESPONSE_HEADERS: ReadonlyArray = ["content-type"] +import { defaultMatcher, selectMatch, selectSequential, type RequestMatcher } from "./matching" +import { appendOrFail, makeReplayState } from "./recorder" +import { defaults, type Redactor } from "./redactor" +import { redactUrl } from "./redaction" +import { httpInteractions, type CassetteMetadata, type HttpInteraction, type ResponseSnapshot } from "./schema" export type RecordReplayMode = "record" | "replay" | "passthrough" @@ -28,35 +23,15 @@ export interface RecordReplayOptions { readonly mode?: RecordReplayMode readonly directory?: string readonly metadata?: CassetteMetadata - readonly redact?: { - readonly headers?: ReadonlyArray - readonly query?: ReadonlyArray - readonly url?: (url: string) => string - } - readonly requestHeaders?: ReadonlyArray - readonly responseHeaders?: ReadonlyArray - readonly redactBody?: (body: unknown) => unknown + readonly redactor?: Redactor readonly dispatch?: "match" | "sequential" readonly match?: RequestMatcher } -const responseHeaders = ( - response: HttpClientResponse.HttpClientResponse, - allow: ReadonlyArray, - redact: ReadonlyArray | undefined, -) => { - const merged = redactHeaders(response.headers as Record, allow, redact) - if (!merged["content-type"]) merged["content-type"] = "text/event-stream" - return merged -} - const BINARY_CONTENT_TYPES: ReadonlyArray = ["vnd.amazon.eventstream", "octet-stream"] -const isBinaryContentType = (contentType: string | undefined) => { - if (!contentType) return false - const lower = contentType.toLowerCase() - return BINARY_CONTENT_TYPES.some((token) => lower.includes(token)) -} +const isBinaryContentType = (contentType: string | undefined) => + contentType !== undefined && BINARY_CONTENT_TYPES.some((token) => contentType.toLowerCase().includes(token)) const captureResponseBody = (response: HttpClientResponse.HttpClientResponse, contentType: string | undefined) => isBinaryContentType(contentType) @@ -68,34 +43,12 @@ const captureResponseBody = (response: HttpClientResponse.HttpClientResponse, co const decodeResponseBody = (snapshot: ResponseSnapshot) => snapshot.bodyEncoding === "base64" ? Buffer.from(snapshot.body, "base64") : snapshot.body -const fixtureMissing = (request: HttpClientRequest.HttpClientRequest, name: string) => - new HttpClientError.HttpClientError({ - reason: new HttpClientError.TransportError({ - request: redactedErrorRequest(request), - description: `Fixture "${name}" not found. Run with RECORD=true to create it.`, - }), - }) +export const redactedErrorRequest = (request: HttpClientRequest.HttpClientRequest) => + HttpClientRequest.makeWith(request.method, redactUrl(request.url), UrlParams.empty, Option.none(), Headers.empty, HttpBody.empty) -const fixtureMismatch = (request: HttpClientRequest.HttpClientRequest, name: string, detail: string) => +const transportError = (request: HttpClientRequest.HttpClientRequest, description: string) => new HttpClientError.HttpClientError({ - reason: new HttpClientError.TransportError({ - request: redactedErrorRequest(request), - description: `Fixture "${name}" does not match the current request: ${detail}. Run with RECORD=true to update it.`, - }), - }) - -const unsafeCassette = ( - request: HttpClientRequest.HttpClientRequest, - name: string, - findings: ReadonlyArray, -) => - new HttpClientError.HttpClientError({ - reason: new HttpClientError.TransportError({ - request: redactedErrorRequest(request), - description: `Refusing to write cassette "${name}" because it contains possible secrets: ${findings - .map((item) => `${item.path} (${item.reason})`) - .join(", ")}`, - }), + reason: new HttpClientError.TransportError({ request: redactedErrorRequest(request), description }), }) export const recordingLayer = ( @@ -107,61 +60,21 @@ export const recordingLayer = ( Effect.gen(function* () { const upstream = yield* HttpClient.HttpClient const cassetteService = yield* CassetteService.Service - const requestHeadersAllow = options.requestHeaders ?? DEFAULT_REQUEST_HEADERS - const responseHeadersAllow = options.responseHeaders ?? DEFAULT_RESPONSE_HEADERS + const redactor = options.redactor ?? defaults() const match = options.match ?? defaultMatcher const mode = options.mode ?? "replay" const sequential = options.dispatch === "sequential" - const replay = yield* Ref.make(undefined) - const cursor = yield* Ref.make(0) + const replay = yield* makeReplayState(cassetteService, name, httpInteractions) const snapshotRequest = (request: HttpClientRequest.HttpClientRequest) => Effect.gen(function* () { const web = yield* HttpClientRequest.toWeb(request).pipe(Effect.orDie) - const raw = yield* Effect.promise(() => web.text()) - const body = options.redactBody - ? Option.match(decodeJson(raw), { - onNone: () => raw, - onSome: (parsed) => JSON.stringify(options.redactBody?.(parsed)), - }) - : raw - return { + return redactor.request({ method: web.method, - url: redactUrl(web.url, options.redact?.query, options.redact?.url), - headers: redactHeaders( - Object.fromEntries(web.headers.entries()), - requestHeadersAllow, - options.redact?.headers, - ), - body, - } - }) - - const selectInteraction = (cassette: Cassette, incoming: HttpInteraction["request"]) => - Effect.gen(function* () { - const interactions = httpInteractions(cassette) - if (sequential) { - const index = yield* Ref.get(cursor) - const interaction = interactions[index] - if (!interaction) - return { interaction, detail: `interaction ${index + 1} of ${interactions.length} not recorded` } - if (!match(incoming, interaction.request)) { - return { interaction: undefined, detail: requestDiff(interaction.request, incoming).join("\n") } - } - yield* Ref.update(cursor, (n) => n + 1) - return { interaction, detail: "" } - } - const interaction = interactions.find((candidate) => match(incoming, candidate.request)) - return { interaction, detail: interaction ? "" : mismatchDetail(cassette, incoming) } - }) - - const loadReplay = (request: HttpClientRequest.HttpClientRequest) => - Effect.gen(function* () { - const cached = yield* Ref.get(replay) - if (cached) return cached - const cassette = yield* cassetteService.read(name).pipe(Effect.mapError(() => fixtureMissing(request, name))) - yield* Ref.set(replay, cassette) - return cassette + url: web.url, + headers: Object.fromEntries(web.headers.entries()), + body: yield* Effect.promise(() => web.text()), + }) }) return HttpClient.make((request) => { @@ -169,18 +82,21 @@ export const recordingLayer = ( if (mode === "record") { return Effect.gen(function* () { - const currentRequest = yield* snapshotRequest(request) + const incoming = yield* snapshotRequest(request) const response = yield* upstream.execute(request) - const headers = responseHeaders(response, responseHeadersAllow, options.redact?.headers) - const captured = yield* captureResponseBody(response, headers["content-type"]) + const captured = yield* captureResponseBody(response, response.headers["content-type"]) const interaction: HttpInteraction = { transport: "http", - request: currentRequest, - response: { status: response.status, headers, ...captured }, + request: incoming, + response: redactor.response({ + status: response.status, + headers: response.headers as Record, + ...captured, + }), } - const result = yield* cassetteService.append(name, interaction, options.metadata).pipe(Effect.orDie) - const findings = result.findings - if (findings.length > 0) return yield* unsafeCassette(request, name, findings) + yield* appendOrFail(cassetteService, name, interaction, options.metadata).pipe( + Effect.catchTag("UnsafeCassetteError", (error) => Effect.fail(transportError(request, error.message))), + ) return HttpClientResponse.fromWeb( request, new Response(decodeResponseBody(interaction.response), interaction.response), @@ -189,14 +105,21 @@ export const recordingLayer = ( } return Effect.gen(function* () { - const cassette = yield* loadReplay(request) const incoming = yield* snapshotRequest(request) - const { interaction, detail } = yield* selectInteraction(cassette, incoming) - if (!interaction) return yield* fixtureMismatch(request, name, detail) - + const interactions = yield* replay.load.pipe( + Effect.mapError(() => transportError(request, `Fixture "${name}" not found.`)), + ) + const result = sequential + ? selectSequential(interactions, incoming, match, yield* replay.cursor) + : selectMatch(interactions, incoming, match) + if (!result.interaction) + return yield* Effect.fail( + transportError(request, `Fixture "${name}" does not match the current request: ${result.detail}.`), + ) + if (sequential) yield* replay.advance return HttpClientResponse.fromWeb( request, - new Response(decodeResponseBody(interaction.response), interaction.response), + new Response(decodeResponseBody(result.interaction.response), result.interaction.response), ) }) }) diff --git a/packages/http-recorder/src/index.ts b/packages/http-recorder/src/index.ts index d85e13bf4c..858fbc4aba 100644 --- a/packages/http-recorder/src/index.ts +++ b/packages/http-recorder/src/index.ts @@ -1,10 +1,18 @@ -export * from "./schema" -export * from "./redaction" -export * from "./matching" -export * from "./diff" -export * from "./storage" -export * from "./websocket" -export * from "./effect" +export type { CassetteMetadata, HttpInteraction, Interaction, RequestSnapshot, ResponseSnapshot, WebSocketFrame, WebSocketInteraction } from "./schema" +export { hasCassetteSync } from "./storage" +export { defaultMatcher, type RequestMatcher } from "./matching" +export { cassetteSecretFindings, redactHeaders, redactUrl, type SecretFinding } from "./redaction" +export { UnsafeCassetteError } from "./recorder" +export { cassetteLayer, recordingLayer, type RecordReplayMode, type RecordReplayOptions } from "./effect" +export { + makeWebSocketExecutor, + type WebSocketConnection, + type WebSocketExecutor, + type WebSocketRecordReplayOptions, + type WebSocketRequest, +} from "./websocket" + export * as Cassette from "./cassette" +export * as Redactor from "./redactor" export * as HttpRecorder from "." diff --git a/packages/http-recorder/src/matching.ts b/packages/http-recorder/src/matching.ts index b66c8fd146..9af85a2f3a 100644 --- a/packages/http-recorder/src/matching.ts +++ b/packages/http-recorder/src/matching.ts @@ -1,5 +1,6 @@ import { Option, Schema } from "effect" -import type { RequestSnapshot } from "./schema" +import { REDACTED, secretFindings } from "./redaction" +import type { HttpInteraction, RequestSnapshot } from "./schema" const JsonValue = Schema.fromJsonString(Schema.Unknown) export const decodeJson = Schema.decodeUnknownOption(JsonValue) @@ -34,3 +35,90 @@ export const canonicalSnapshot = (snapshot: RequestSnapshot): string => export const defaultMatcher: RequestMatcher = (incoming, recorded) => canonicalSnapshot(incoming) === canonicalSnapshot(recorded) + +const safeText = (value: unknown) => { + if (value === undefined) return "undefined" + if (secretFindings(value).length > 0) return JSON.stringify(REDACTED) + const text = JSON.stringify(value) + if (!text) return String(value) + return text.length > 300 ? `${text.slice(0, 300)}...` : text +} + +const jsonBody = (body: string) => Option.getOrUndefined(decodeJson(body)) + +const valueDiffs = (expected: unknown, received: unknown, base = "$", limit = 8): ReadonlyArray => { + if (Object.is(expected, received)) return [] + if (isRecord(expected) && isRecord(received)) { + return [...new Set([...Object.keys(expected), ...Object.keys(received)])] + .toSorted() + .flatMap((key) => valueDiffs(expected[key], received[key], `${base}.${key}`, limit)) + .slice(0, limit) + } + if (Array.isArray(expected) && Array.isArray(received)) { + return Array.from({ length: Math.max(expected.length, received.length) }, (_, index) => index) + .flatMap((index) => valueDiffs(expected[index], received[index], `${base}[${index}]`, limit)) + .slice(0, limit) + } + return [`${base} expected ${safeText(expected)}, received ${safeText(received)}`] +} + +const headerDiffs = (expected: Record, received: Record) => + [...new Set([...Object.keys(expected), ...Object.keys(received)])].toSorted().flatMap((key) => { + if (expected[key] === received[key]) return [] + if (expected[key] === undefined) return [` ${key} unexpected ${safeText(received[key])}`] + if (received[key] === undefined) return [` ${key} missing expected ${safeText(expected[key])}`] + return [` ${key} expected ${safeText(expected[key])}, received ${safeText(received[key])}`] + }) + +export const requestDiff = (expected: RequestSnapshot, received: RequestSnapshot): ReadonlyArray => { + const lines: string[] = [] + if (expected.method !== received.method) { + lines.push("method:", ` expected ${expected.method}, received ${received.method}`) + } + if (expected.url !== received.url) { + lines.push("url:", ` expected ${expected.url}`, ` received ${received.url}`) + } + const headers = headerDiffs(expected.headers, received.headers) + if (headers.length > 0) lines.push("headers:", ...headers.slice(0, 8)) + const expectedBody = jsonBody(expected.body) + const receivedBody = jsonBody(received.body) + const body = + expectedBody !== undefined && receivedBody !== undefined + ? valueDiffs(expectedBody, receivedBody).map((line) => ` ${line}`) + : expected.body === received.body + ? [] + : [` expected ${safeText(expected.body)}, received ${safeText(received.body)}`] + if (body.length > 0) lines.push("body:", ...body) + return lines +} + +export const mismatchDetail = (interactions: ReadonlyArray, incoming: RequestSnapshot): string => { + if (interactions.length === 0) return "cassette has no recorded HTTP interactions" + const ranked = interactions + .map((interaction, index) => ({ index, lines: requestDiff(interaction.request, incoming) })) + .toSorted((a, b) => a.lines.length - b.lines.length || a.index - b.index) + const best = ranked[0] + return ["no recorded interaction matched", `closest interaction: #${best.index + 1}`, ...best.lines].join("\n") +} + +export const selectMatch = ( + interactions: ReadonlyArray, + incoming: RequestSnapshot, + match: RequestMatcher, +): { readonly interaction: HttpInteraction | undefined; readonly detail: string } => { + const interaction = interactions.find((candidate) => match(incoming, candidate.request)) + return { interaction, detail: interaction ? "" : mismatchDetail(interactions, incoming) } +} + +export const selectSequential = ( + interactions: ReadonlyArray, + incoming: RequestSnapshot, + match: RequestMatcher, + index: number, +): { readonly interaction: HttpInteraction | undefined; readonly detail: string } => { + const interaction = interactions[index] + if (!interaction) return { interaction, detail: `interaction ${index + 1} of ${interactions.length} not recorded` } + if (!match(incoming, interaction.request)) + return { interaction: undefined, detail: requestDiff(interaction.request, incoming).join("\n") } + return { interaction, detail: "" } +} diff --git a/packages/http-recorder/src/recorder.ts b/packages/http-recorder/src/recorder.ts new file mode 100644 index 0000000000..8d69de5002 --- /dev/null +++ b/packages/http-recorder/src/recorder.ts @@ -0,0 +1,59 @@ +import { Effect, PlatformError, Ref, Scope } from "effect" +import type * as CassetteService from "./cassette" +import type { SecretFinding } from "./redaction" +import type { Cassette, CassetteMetadata, Interaction } from "./schema" + +export class UnsafeCassetteError extends Error { + readonly _tag = "UnsafeCassetteError" + constructor( + readonly cassetteName: string, + readonly findings: ReadonlyArray, + ) { + super( + `Refusing to write cassette "${cassetteName}" because it contains possible secrets: ${findings + .map((finding) => `${finding.path} (${finding.reason})`) + .join(", ")}`, + ) + } +} + +export const appendOrFail = ( + cassette: CassetteService.Interface, + name: string, + interaction: Interaction, + metadata: CassetteMetadata | undefined, +): Effect.Effect => + cassette.append(name, interaction, metadata).pipe( + Effect.orDie, + Effect.flatMap(({ cassette: result, findings }) => + findings.length === 0 ? Effect.succeed(result) : Effect.fail(new UnsafeCassetteError(name, findings)), + ), + ) + +export interface ReplayState { + readonly load: Effect.Effect, PlatformError.PlatformError> + readonly cursor: Effect.Effect + readonly advance: Effect.Effect +} + +export const makeReplayState = ( + cassette: CassetteService.Interface, + name: string, + project: (cassette: Cassette) => ReadonlyArray, +): Effect.Effect, never, Scope.Scope> => + Effect.gen(function* () { + const load = yield* Effect.cached(cassette.read(name).pipe(Effect.map(project))) + const position = yield* Ref.make(0) + + yield* Effect.addFinalizer(() => + Effect.gen(function* () { + const used = yield* Ref.get(position) + if (used === 0) return + const interactions = yield* load.pipe(Effect.orDie) + if (used < interactions.length) + yield* Effect.die(new Error(`Unused recorded interactions in ${name}: used ${used} of ${interactions.length}`)) + }), + ) + + return { load, cursor: Ref.get(position), advance: Ref.update(position, (n) => n + 1) } + }) diff --git a/packages/http-recorder/src/redactor.ts b/packages/http-recorder/src/redactor.ts new file mode 100644 index 0000000000..917ab05d09 --- /dev/null +++ b/packages/http-recorder/src/redactor.ts @@ -0,0 +1,76 @@ +import { Option } from "effect" +import { decodeJson } from "./matching" +import { redactHeaders, redactUrl } from "./redaction" +import type { RequestSnapshot, ResponseSnapshot } from "./schema" + +export const DEFAULT_REQUEST_HEADERS: ReadonlyArray = ["content-type", "accept", "openai-beta"] +export const DEFAULT_RESPONSE_HEADERS: ReadonlyArray = ["content-type"] + +const identity = (value: T) => value + +export interface Redactor { + readonly request: (snapshot: RequestSnapshot) => RequestSnapshot + readonly response: (snapshot: ResponseSnapshot) => ResponseSnapshot +} + +export const compose = (...redactors: ReadonlyArray>): Redactor => { + const requests = redactors.map((r) => r.request).filter((fn): fn is Redactor["request"] => fn !== undefined) + const responses = redactors.map((r) => r.response).filter((fn): fn is Redactor["response"] => fn !== undefined) + return { + request: requests.length === 0 ? identity : (snapshot) => requests.reduce((acc, fn) => fn(acc), snapshot), + response: responses.length === 0 ? identity : (snapshot) => responses.reduce((acc, fn) => fn(acc), snapshot), + } +} + +export interface HeaderOptions { + readonly allow?: ReadonlyArray + readonly redact?: ReadonlyArray +} + +export const requestHeaders = (options: HeaderOptions = {}): Partial => ({ + request: (snapshot) => ({ + ...snapshot, + headers: redactHeaders(snapshot.headers, options.allow ?? DEFAULT_REQUEST_HEADERS, options.redact), + }), +}) + +export const responseHeaders = (options: HeaderOptions = {}): Partial => ({ + response: (snapshot) => ({ + ...snapshot, + headers: redactHeaders(snapshot.headers, options.allow ?? DEFAULT_RESPONSE_HEADERS, options.redact), + }), +}) + +export interface UrlOptions { + readonly query?: ReadonlyArray + readonly transform?: (url: string) => string +} + +export const url = (options: UrlOptions = {}): Partial => ({ + request: (snapshot) => ({ ...snapshot, url: redactUrl(snapshot.url, options.query, options.transform) }), +}) + +export const body = (transform: (parsed: unknown) => unknown): Partial => ({ + request: (snapshot) => ({ + ...snapshot, + body: Option.match(decodeJson(snapshot.body), { + onNone: () => snapshot.body, + onSome: (parsed) => JSON.stringify(transform(parsed)), + }), + }), +}) + +export interface DefaultRedactorOverrides { + readonly requestHeaders?: HeaderOptions + readonly responseHeaders?: HeaderOptions + readonly url?: UrlOptions + readonly body?: (parsed: unknown) => unknown +} + +export const defaults = (overrides: DefaultRedactorOverrides = {}): Redactor => + compose( + requestHeaders(overrides.requestHeaders), + responseHeaders(overrides.responseHeaders), + url(overrides.url), + ...(overrides.body ? [body(overrides.body)] : []), + ) diff --git a/packages/http-recorder/src/storage.ts b/packages/http-recorder/src/storage.ts index 08dadb1bb9..e8f7869f6e 100644 --- a/packages/http-recorder/src/storage.ts +++ b/packages/http-recorder/src/storage.ts @@ -7,19 +7,13 @@ export const DEFAULT_RECORDINGS_DIR = path.resolve(process.cwd(), "test", "fixtu export const cassettePath = (name: string, directory = DEFAULT_RECORDINGS_DIR) => path.join(directory, `${name}.json`) -export const metadataFor = (name: string, metadata: CassetteMetadata | undefined): CassetteMetadata => ({ - name, - recordedAt: new Date().toISOString(), - ...(metadata ?? {}), -}) - export const cassetteFor = ( name: string, interactions: ReadonlyArray, metadata: CassetteMetadata | undefined, ): Cassette => ({ version: 1, - metadata: metadataFor(name, metadata), + metadata: { name, recordedAt: new Date().toISOString(), ...(metadata ?? {}) }, interactions, }) diff --git a/packages/http-recorder/src/websocket.ts b/packages/http-recorder/src/websocket.ts index 8a854cb62c..bf8a1fca06 100644 --- a/packages/http-recorder/src/websocket.ts +++ b/packages/http-recorder/src/websocket.ts @@ -2,10 +2,9 @@ import { Effect, Option, Ref, Scope, Stream } from "effect" import type { Headers } from "effect/unstable/http" import * as CassetteService from "./cassette" import { canonicalizeJson, decodeJson } from "./matching" -import { redactHeaders, redactUrl, type SecretFinding } from "./redaction" -import { webSocketInteractions, type CassetteMetadata, type WebSocketFrame, type WebSocketInteraction } from "./schema" - -export const DEFAULT_WEBSOCKET_REQUEST_HEADERS: ReadonlyArray = ["content-type", "accept", "openai-beta"] +import { appendOrFail, makeReplayState } from "./recorder" +import { defaults, type Redactor } from "./redactor" +import { webSocketInteractions, type CassetteMetadata, type WebSocketFrame } from "./schema" export interface WebSocketRequest { readonly url: string @@ -28,63 +27,32 @@ export interface WebSocketRecordReplayOptions { readonly metadata?: CassetteMetadata readonly cassette: CassetteService.Interface readonly live: WebSocketExecutor - readonly redact?: { - readonly headers?: ReadonlyArray - readonly query?: ReadonlyArray - readonly url?: (url: string) => string - } - readonly requestHeaders?: ReadonlyArray + readonly redactor?: Redactor readonly compareClientMessagesAsJson?: boolean } -const headersRecord = (headers: Headers.Headers) => +const headersRecord = (headers: Headers.Headers): Record => Object.fromEntries( - Object.entries(headers as Record) - .filter((entry): entry is [string, string] => typeof entry[1] === "string") - .toSorted(([a], [b]) => a.localeCompare(b)), + Object.entries(headers as Record).filter( + (entry): entry is [string, string] => typeof entry[1] === "string", + ), ) -const openSnapshot = ( - request: WebSocketRequest, - options: Pick, "redact" | "requestHeaders"> = {}, -) => ({ - url: redactUrl(request.url, options.redact?.query, options.redact?.url), - headers: redactHeaders( - headersRecord(request.headers), - options.requestHeaders ?? DEFAULT_WEBSOCKET_REQUEST_HEADERS, - options.redact?.headers, - ), -}) - -const textFrame = (body: string): WebSocketFrame => ({ kind: "text", body }) - -const frameText = (frame: WebSocketFrame) => { - if (frame.kind === "text") return frame.body - return new TextDecoder().decode(Buffer.from(frame.body, "base64")) -} - -const frameMessage = (frame: WebSocketFrame) => - frame.kind === "text" ? frame.body : new Uint8Array(Buffer.from(frame.body, "base64")) - -const receivedFrame = (message: string | Uint8Array): WebSocketFrame => +const encodeFrame = (message: string | Uint8Array): WebSocketFrame => typeof message === "string" - ? textFrame(message) + ? { kind: "text", body: message } : { kind: "binary", body: Buffer.from(message).toString("base64"), bodyEncoding: "base64" } -const unsafeCassette = (name: string, findings: ReadonlyArray) => - new Error( - `Refusing to write WebSocket cassette "${name}" because it contains possible secrets: ${findings - .map((item) => `${item.path} (${item.reason})`) - .join(", ")}`, - ) +const decodeFrameMessage = (frame: WebSocketFrame): string | Uint8Array => + frame.kind === "text" ? frame.body : new Uint8Array(Buffer.from(frame.body, "base64")) -const mismatch = (message: string, actual: unknown, expected: unknown) => - new Error(`${message}: expected ${JSON.stringify(expected)}, received ${JSON.stringify(actual)}`) +const decodeFrameText = (frame: WebSocketFrame) => + frame.kind === "text" ? frame.body : new TextDecoder().decode(Buffer.from(frame.body, "base64")) const assertEqual = (message: string, actual: unknown, expected: unknown) => Effect.sync(() => { if (JSON.stringify(actual) === JSON.stringify(expected)) return - throw mismatch(message, actual, expected) + throw new Error(`${message}: expected ${JSON.stringify(expected)}, received ${JSON.stringify(actual)}`) }) const jsonOrText = (value: string) => Option.match(decodeJson(value), { onNone: () => value, onSome: canonicalizeJson }) @@ -94,7 +62,7 @@ const compareClientMessage = (actual: string, expected: WebSocketFrame | undefin return Effect.sync(() => { throw new Error(`Unexpected WebSocket client frame ${index + 1}: ${actual}`) }) - const expectedText = frameText(expected) + const expectedText = decodeFrameText(expected) if (!asJson) return assertEqual(`WebSocket client frame ${index + 1}`, actual, expectedText) return assertEqual(`WebSocket client JSON frame ${index + 1}`, jsonOrText(actual), jsonOrText(expectedText)) } @@ -104,6 +72,16 @@ export const makeWebSocketExecutor = ( ): Effect.Effect, never, Scope.Scope> => Effect.gen(function* () { const mode = options.mode ?? "replay" + const redactor = options.redactor ?? defaults() + const openSnapshot = (request: WebSocketRequest) => { + const redacted = redactor.request({ + method: "GET", + url: request.url, + headers: headersRecord(request.headers), + body: "", + }) + return { url: redacted.url, headers: redacted.headers } + } if (mode === "passthrough") return options.live @@ -118,21 +96,21 @@ export const makeWebSocketExecutor = ( const closeOnce = Effect.gen(function* () { if (yield* Ref.getAndSet(closed, true)) return yield* connection.close - const result = yield* options.cassette - .append( - options.name, - { transport: "websocket", open: openSnapshot(request, options), client, server }, - options.metadata, - ) - .pipe(Effect.orDie) - if (result.findings.length > 0) yield* Effect.die(unsafeCassette(options.name, result.findings)) + yield* appendOrFail( + options.cassette, + options.name, + { transport: "websocket", open: openSnapshot(request), client, server }, + options.metadata, + ).pipe(Effect.orDie) }) return { - sendText: (message: string) => - connection.sendText(message).pipe(Effect.tap(() => Effect.sync(() => client.push(textFrame(message))))), + sendText: (message) => + connection + .sendText(message) + .pipe(Effect.tap(() => Effect.sync(() => client.push(encodeFrame(message))))), messages: connection.messages.pipe( Stream.map((message) => { - server.push(receivedFrame(message)) + server.push(encodeFrame(message)) return message }), ), @@ -142,44 +120,20 @@ export const makeWebSocketExecutor = ( } } - const replay = yield* Ref.make<{ readonly interactions: ReadonlyArray } | undefined>( - undefined, - ) - const cursor = yield* Ref.make(0) - - yield* Effect.addFinalizer(() => - Effect.gen(function* () { - const input = yield* Ref.get(replay) - if (!input) return - yield* assertEqual( - `Unused recorded WebSocket interactions in ${options.name}`, - yield* Ref.get(cursor), - input.interactions.length, - ) - }), - ) - - const loadReplay = Effect.fn("WebSocketRecorder.loadReplay")(function* () { - const cached = yield* Ref.get(replay) - if (cached) return cached - const input = { - interactions: webSocketInteractions(yield* options.cassette.read(options.name).pipe(Effect.orDie)), - } - yield* Ref.set(replay, input) - return input - }) + const replay = yield* makeReplayState(options.cassette, options.name, webSocketInteractions) return { - open: (request) => { - return Effect.gen(function* () { - const input = yield* loadReplay() - const index = yield* Ref.getAndUpdate(cursor, (value) => value + 1) - const interaction = input.interactions[index] + open: (request) => + Effect.gen(function* () { + const interactions = yield* replay.load.pipe(Effect.orDie) + const index = yield* replay.cursor + const interaction = interactions[index] if (!interaction) return yield* Effect.die(new Error(`No recorded WebSocket interaction for ${request.url}`)) - yield* assertEqual(`WebSocket open frame ${index + 1}`, openSnapshot(request, options), interaction.open) + yield* replay.advance + yield* assertEqual(`WebSocket open frame ${index + 1}`, openSnapshot(request), interaction.open) const messageIndex = yield* Ref.make(0) return { - sendText: (message: string) => + sendText: (message) => Effect.gen(function* () { const current = yield* Ref.getAndUpdate(messageIndex, (value) => value + 1) yield* compareClientMessage( @@ -189,7 +143,7 @@ export const makeWebSocketExecutor = ( options.compareClientMessagesAsJson === true, ) }), - messages: Stream.fromIterable(interaction.server).pipe(Stream.map(frameMessage)), + messages: Stream.fromIterable(interaction.server).pipe(Stream.map(decodeFrameMessage)), close: Effect.gen(function* () { yield* assertEqual( `WebSocket client frame count for interaction ${index + 1}`, @@ -198,7 +152,6 @@ export const makeWebSocketExecutor = ( ) }), } - }) - }, + }), } }) diff --git a/packages/http-recorder/test/record-replay.test.ts b/packages/http-recorder/test/record-replay.test.ts index 676422e6a4..df3bbf308e 100644 --- a/packages/http-recorder/test/record-replay.test.ts +++ b/packages/http-recorder/test/record-replay.test.ts @@ -6,7 +6,8 @@ import * as fs from "node:fs" import * as os from "node:os" import * as path from "node:path" import { HttpRecorder } from "../src" -import { redactedErrorRequest } from "../src/diff" +import { redactedErrorRequest } from "../src/effect" +import { cassetteFor, formatCassette, parseCassette } from "../src/storage" const post = (url: string, body: object) => Effect.gen(function* () { @@ -145,7 +146,7 @@ describe("http-recorder", () => { }) test("formats websocket cassettes with shared metadata", () => { - const cassette = HttpRecorder.cassetteFor( + const cassette = cassetteFor( "websocket/basic", [ { @@ -159,7 +160,7 @@ describe("http-recorder", () => { ) expect(cassette.metadata).toMatchObject({ name: "websocket/basic", provider: "openai" }) - expect(HttpRecorder.parseCassette(HttpRecorder.formatCassette(cassette))).toEqual(cassette) + expect(parseCassette(formatCassette(cassette))).toEqual(cassette) }) test("replays websocket interactions from the shared cassette service", async () => { @@ -168,7 +169,7 @@ describe("http-recorder", () => { const cassette = yield* HttpRecorder.Cassette.Service yield* cassette.write( "websocket/replay", - HttpRecorder.cassetteFor( + cassetteFor( "websocket/replay", [ { diff --git a/packages/llm/test/provider/anthropic-messages.recorded.test.ts b/packages/llm/test/provider/anthropic-messages.recorded.test.ts index a8d87c46ff..aa5b258d3d 100644 --- a/packages/llm/test/provider/anthropic-messages.recorded.test.ts +++ b/packages/llm/test/provider/anthropic-messages.recorded.test.ts @@ -1,3 +1,4 @@ +import { Redactor } from "@opencode-ai/http-recorder" import { describe, expect } from "bun:test" import { Effect } from "effect" import { LLM, LLMError } from "../../src" @@ -30,7 +31,7 @@ const recorded = recordedTests({ provider: "anthropic", protocol: "anthropic-messages", requires: ["ANTHROPIC_API_KEY"], - options: { requestHeaders: ["content-type", "anthropic-version"] }, + options: { redactor: Redactor.defaults({ requestHeaders: { allow: ["content-type", "anthropic-version"] } }) }, }) describe("Anthropic Messages sad-path recorded", () => { diff --git a/packages/llm/test/provider/golden.recorded.test.ts b/packages/llm/test/provider/golden.recorded.test.ts index 0e1151b7af..3fa27c706e 100644 --- a/packages/llm/test/provider/golden.recorded.test.ts +++ b/packages/llm/test/provider/golden.recorded.test.ts @@ -1,3 +1,4 @@ +import { Redactor } from "@opencode-ai/http-recorder" import * as AnthropicMessages from "../../src/protocols/anthropic-messages" import * as Gemini from "../../src/protocols/gemini" import * as OpenAIChat from "../../src/protocols/openai-chat" @@ -66,7 +67,7 @@ const redactCloudflareURL = (url: string) => .replace(/\/v1\/[^/]+\/[^/]+\/compat\//, "/v1/{account}/{gateway}/compat/") const cloudflareOptions = { - redact: { url: redactCloudflareURL }, + redactor: Redactor.defaults({ url: { transform: redactCloudflareURL } }), } describeRecordedGoldenScenarios([ @@ -102,7 +103,7 @@ describeRecordedGoldenScenarios([ prefix: "anthropic-messages", model: anthropicHaiku, requires: ["ANTHROPIC_API_KEY"], - options: { requestHeaders: ["content-type", "anthropic-version"] }, + options: { redactor: Redactor.defaults({ requestHeaders: { allow: ["content-type", "anthropic-version"] } }) }, scenarios: ["text", "tool-call"], }, { @@ -111,7 +112,7 @@ describeRecordedGoldenScenarios([ model: anthropicOpus, requires: ["ANTHROPIC_API_KEY"], tags: ["flagship"], - options: { requestHeaders: ["content-type", "anthropic-version"] }, + options: { redactor: Redactor.defaults({ requestHeaders: { allow: ["content-type", "anthropic-version"] } }) }, scenarios: [{ id: "tool-loop", temperature: false }], }, { From 4fc538378d3a291cba711b05f02889e0b03e418b Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sun, 10 May 2026 14:50:21 +0000 Subject: [PATCH 128/189] chore: generate --- packages/http-recorder/README.md | 16 +++++++--------- packages/http-recorder/src/cassette.ts | 1 - packages/http-recorder/src/effect.ts | 9 ++++++++- packages/http-recorder/src/index.ts | 10 +++++++++- packages/http-recorder/src/recorder.ts | 4 +++- 5 files changed, 27 insertions(+), 13 deletions(-) diff --git a/packages/http-recorder/README.md b/packages/http-recorder/README.md index 607b39af54..ed4cd2eb19 100644 --- a/packages/http-recorder/README.md +++ b/packages/http-recorder/README.md @@ -34,9 +34,7 @@ const program = Effect.gen(function* () { Effect.runPromise(program.pipe(Effect.provide(HttpRecorder.cassetteLayer("users/get-one")))) // Record. Hits the upstream and writes the cassette. -Effect.runPromise( - program.pipe(Effect.provide(HttpRecorder.cassetteLayer("users/get-one", { mode: "record" }))), -) +Effect.runPromise(program.pipe(Effect.provide(HttpRecorder.cassetteLayer("users/get-one", { mode: "record" })))) ``` Set the mode from the environment in your test setup: @@ -190,12 +188,12 @@ const audit = Effect.gen(function* () { ```ts type RecordReplayOptions = { - mode?: "record" | "replay" | "passthrough" // default: "replay" - directory?: string // default: /test/fixtures/recordings - metadata?: Record // merged into cassette.metadata - redactor?: Redactor // default: Redactor.defaults() - dispatch?: "match" | "sequential" // default: "match" - match?: (incoming, recorded) => boolean // custom matcher + mode?: "record" | "replay" | "passthrough" // default: "replay" + directory?: string // default: /test/fixtures/recordings + metadata?: Record // merged into cassette.metadata + redactor?: Redactor // default: Redactor.defaults() + dispatch?: "match" | "sequential" // default: "match" + match?: (incoming, recorded) => boolean // custom matcher } ``` diff --git a/packages/http-recorder/src/cassette.ts b/packages/http-recorder/src/cassette.ts index 4f8d2dd7f3..bf7c6ae998 100644 --- a/packages/http-recorder/src/cassette.ts +++ b/packages/http-recorder/src/cassette.ts @@ -111,4 +111,3 @@ export const layer = (options: { readonly directory?: string } = {}) => return Service.of({ path: pathFor, read, write, append, exists, list, scan: cassetteSecretFindings }) }), ) - diff --git a/packages/http-recorder/src/effect.ts b/packages/http-recorder/src/effect.ts index e8e46e6c68..977a15755d 100644 --- a/packages/http-recorder/src/effect.ts +++ b/packages/http-recorder/src/effect.ts @@ -44,7 +44,14 @@ const decodeResponseBody = (snapshot: ResponseSnapshot) => snapshot.bodyEncoding === "base64" ? Buffer.from(snapshot.body, "base64") : snapshot.body export const redactedErrorRequest = (request: HttpClientRequest.HttpClientRequest) => - HttpClientRequest.makeWith(request.method, redactUrl(request.url), UrlParams.empty, Option.none(), Headers.empty, HttpBody.empty) + HttpClientRequest.makeWith( + request.method, + redactUrl(request.url), + UrlParams.empty, + Option.none(), + Headers.empty, + HttpBody.empty, + ) const transportError = (request: HttpClientRequest.HttpClientRequest, description: string) => new HttpClientError.HttpClientError({ diff --git a/packages/http-recorder/src/index.ts b/packages/http-recorder/src/index.ts index 858fbc4aba..1ebac49e5a 100644 --- a/packages/http-recorder/src/index.ts +++ b/packages/http-recorder/src/index.ts @@ -1,4 +1,12 @@ -export type { CassetteMetadata, HttpInteraction, Interaction, RequestSnapshot, ResponseSnapshot, WebSocketFrame, WebSocketInteraction } from "./schema" +export type { + CassetteMetadata, + HttpInteraction, + Interaction, + RequestSnapshot, + ResponseSnapshot, + WebSocketFrame, + WebSocketInteraction, +} from "./schema" export { hasCassetteSync } from "./storage" export { defaultMatcher, type RequestMatcher } from "./matching" export { cassetteSecretFindings, redactHeaders, redactUrl, type SecretFinding } from "./redaction" diff --git a/packages/http-recorder/src/recorder.ts b/packages/http-recorder/src/recorder.ts index 8d69de5002..30648cd6a7 100644 --- a/packages/http-recorder/src/recorder.ts +++ b/packages/http-recorder/src/recorder.ts @@ -51,7 +51,9 @@ export const makeReplayState = ( if (used === 0) return const interactions = yield* load.pipe(Effect.orDie) if (used < interactions.length) - yield* Effect.die(new Error(`Unused recorded interactions in ${name}: used ${used} of ${interactions.length}`)) + yield* Effect.die( + new Error(`Unused recorded interactions in ${name}: used ${used} of ${interactions.length}`), + ) }), ) From 49ee3ba85a97211ce1fa47bfbd3cd295b7e2984d Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sun, 10 May 2026 11:54:54 -0400 Subject: [PATCH 129/189] Source diff message query pattern (#26638) --- .../server/routes/instance/httpapi/public.ts | 1 - .../server/httpapi-query-schema-drift.test.ts | 17 +++++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/server/routes/instance/httpapi/public.ts b/packages/opencode/src/server/routes/instance/httpapi/public.ts index 1a294f84e7..612ff83aeb 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/public.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/public.ts @@ -62,7 +62,6 @@ const QueryParameterSchemas: Record = { "GET /experimental/session limit": { type: "number" }, "GET /session start": { type: "number" }, "GET /session limit": { type: "number" }, - "GET /session/{sessionID}/diff messageID": { type: "string", pattern: "^msg.*" }, "GET /session/{sessionID}/message limit": { type: "integer", minimum: 0, maximum: Number.MAX_SAFE_INTEGER }, "GET /api/session limit": { type: "number" }, "GET /api/session start": { type: "number" }, diff --git a/packages/opencode/test/server/httpapi-query-schema-drift.test.ts b/packages/opencode/test/server/httpapi-query-schema-drift.test.ts index bf24f90353..d8eb780646 100644 --- a/packages/opencode/test/server/httpapi-query-schema-drift.test.ts +++ b/packages/opencode/test/server/httpapi-query-schema-drift.test.ts @@ -75,6 +75,10 @@ const numericSdkQueryParams = [ { method: "get", path: "/api/session/:sessionID/message", name: "limit", schema: { type: "number" } }, ] satisfies Array<{ method: Method; path: string; name: string; schema: OpenApiSchema }> +const queryParamPatterns = [ + { method: "get", path: SessionPaths.diff, name: "messageID", pattern: "^msg" }, +] satisfies Array<{ method: Method; path: string; name: string; pattern: string }> + const pathParamPatterns = [ { method: "get", path: SessionPaths.get, name: "sessionID", pattern: "^ses" }, { method: "get", path: SessionPaths.message, name: "messageID", pattern: "^msg" }, @@ -169,6 +173,19 @@ describe("httpapi query schema drift", () => { }), ) + it.effect( + "OpenAPI query parameter patterns come from runtime schemas", + Effect.sync(() => { + const spec = OpenApi.fromApi(PublicApi) + for (const expected of queryParamPatterns) { + expect( + queryParameter(spec.paths[openApiPath(expected.path)]?.[expected.method], expected.name)?.schema, + `${expected.method.toUpperCase()} ${expected.path} ${expected.name}`, + ).toEqual({ type: "string", pattern: expected.pattern }) + } + }), + ) + it.effect( "OpenAPI workspace query params are declared by runtime query schemas", Effect.sync(() => { From c104098a66fd7c9dbcdc6c1e4b93dc64d69da405 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sun, 10 May 2026 15:55:49 +0000 Subject: [PATCH 130/189] chore: generate --- packages/sdk/openapi.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index db3705688d..8ed22e3101 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -5025,7 +5025,7 @@ "in": "query", "schema": { "type": "string", - "pattern": "^msg.*" + "pattern": "^msg" }, "required": false } From 11030c627b4fd7f3a95fe36df840868851b1b194 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sun, 10 May 2026 11:57:52 -0400 Subject: [PATCH 131/189] Scope boolean query overrides --- .../routes/instance/httpapi/groups/query.ts | 4 ++ .../server/routes/instance/httpapi/public.ts | 12 ++--- .../server/httpapi-query-schema-drift.test.ts | 47 ++++++++++--------- 3 files changed, 33 insertions(+), 30 deletions(-) diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/query.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/query.ts index d5b10d1800..c780f5222c 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/query.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/query.ts @@ -6,3 +6,7 @@ export const QueryBoolean = Schema.Literals(["true", "false"]).pipe( encode: SchemaGetter.transform((value) => (value ? "true" : "false")), }), ) + +export const QueryBooleanOpenApi = { + anyOf: [{ type: "boolean" }, { type: "string", enum: ["true", "false"] }], +} diff --git a/packages/opencode/src/server/routes/instance/httpapi/public.ts b/packages/opencode/src/server/routes/instance/httpapi/public.ts index 612ff83aeb..4286e6f6cc 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/public.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/public.ts @@ -1,5 +1,6 @@ import { OpenApi } from "effect/unstable/httpapi" import { OpenCodeHttpApi } from "./api" +import { QueryBooleanOpenApi } from "./groups/query" type OpenApiParameter = { name: string @@ -54,17 +55,20 @@ type OpenApiResponse = { // Query schemas describe decoded Effect values, but the generated SDK needs the // public call shape. These keep SDK callers passing numbers/booleans while the // server still decodes string query params at runtime. -const QueryBooleanParameters = new Set(["roots", "archived"]) const QueryParameterSchemas: Record = { "GET /experimental/session start": { type: "number" }, + "GET /experimental/session roots": QueryBooleanOpenApi, + "GET /experimental/session archived": QueryBooleanOpenApi, "GET /find/file limit": { type: "integer", minimum: 1, maximum: 200 }, "GET /experimental/session cursor": { type: "number" }, "GET /experimental/session limit": { type: "number" }, "GET /session start": { type: "number" }, + "GET /session roots": QueryBooleanOpenApi, "GET /session limit": { type: "number" }, "GET /session/{sessionID}/message limit": { type: "integer", minimum: 0, maximum: Number.MAX_SAFE_INTEGER }, "GET /api/session limit": { type: "number" }, "GET /api/session start": { type: "number" }, + "GET /api/session roots": QueryBooleanOpenApi, "GET /api/session/{sessionID}/message limit": { type: "number" }, } @@ -486,12 +490,6 @@ function normalizeParameter(param: OpenApiParameter, route: string) { param.schema = override return } - if (QueryBooleanParameters.has(param.name)) { - param.schema = { - anyOf: [{ type: "boolean" }, { type: "string", enum: ["true", "false"] }], - } - return - } } param.schema = stripOptionalNull(param.schema) } diff --git a/packages/opencode/test/server/httpapi-query-schema-drift.test.ts b/packages/opencode/test/server/httpapi-query-schema-drift.test.ts index d8eb780646..d9f2b56cb0 100644 --- a/packages/opencode/test/server/httpapi-query-schema-drift.test.ts +++ b/packages/opencode/test/server/httpapi-query-schema-drift.test.ts @@ -26,7 +26,7 @@ import { import { PtyPaths } from "../../src/server/routes/instance/httpapi/groups/pty" import { MessagesQuery as V2MessagesQuery } from "../../src/server/routes/instance/httpapi/groups/v2/message" import { SessionsQuery as V2SessionsQuery } from "../../src/server/routes/instance/httpapi/groups/v2/session" -import { QueryBoolean } from "../../src/server/routes/instance/httpapi/groups/query" +import { QueryBoolean, QueryBooleanOpenApi } from "../../src/server/routes/instance/httpapi/groups/query" import { resetDatabase } from "../fixture/db" import { disposeAllInstances, tmpdir } from "../fixture/fixture" import { it } from "../lib/effect" @@ -36,6 +36,8 @@ const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES type Method = "get" | "post" | "put" | "delete" | "patch" type QuerySchema = { readonly fields: Record } type OpenApiSchema = { + readonly anyOf?: readonly OpenApiSchema[] + readonly enum?: readonly string[] readonly maximum?: number readonly minimum?: number readonly pattern?: string @@ -75,6 +77,13 @@ const numericSdkQueryParams = [ { method: "get", path: "/api/session/:sessionID/message", name: "limit", schema: { type: "number" } }, ] satisfies Array<{ method: Method; path: string; name: string; schema: OpenApiSchema }> +const booleanSdkQueryParams = [ + { method: "get", path: ExperimentalPaths.session, name: "roots" }, + { method: "get", path: ExperimentalPaths.session, name: "archived" }, + { method: "get", path: SessionPaths.list, name: "roots" }, + { method: "get", path: "/api/session", name: "roots" }, +] satisfies Array<{ method: Method; path: string; name: string }> + const queryParamPatterns = [ { method: "get", path: SessionPaths.diff, name: "messageID", pattern: "^msg" }, ] satisfies Array<{ method: Method; path: string; name: string; pattern: string }> @@ -174,20 +183,7 @@ describe("httpapi query schema drift", () => { ) it.effect( - "OpenAPI query parameter patterns come from runtime schemas", - Effect.sync(() => { - const spec = OpenApi.fromApi(PublicApi) - for (const expected of queryParamPatterns) { - expect( - queryParameter(spec.paths[openApiPath(expected.path)]?.[expected.method], expected.name)?.schema, - `${expected.method.toUpperCase()} ${expected.path} ${expected.name}`, - ).toEqual({ type: "string", pattern: expected.pattern }) - } - }), - ) - - it.effect( - "OpenAPI workspace query params are declared by runtime query schemas", + "OpenAPI query params are declared by runtime query schemas", Effect.sync(() => { const spec = OpenApi.fromApi(PublicApi) for (const route of openApiDriftRoutes) { @@ -200,7 +196,7 @@ describe("httpapi query schema drift", () => { ) it.effect( - "OpenAPI numeric query params preserve generated SDK call shapes", + "OpenAPI query and path schemas preserve compatibility metadata", Effect.sync(() => { const spec = OpenApi.fromApi(PublicApi) for (const expected of numericSdkQueryParams) { @@ -209,13 +205,18 @@ describe("httpapi query schema drift", () => { `${expected.method.toUpperCase()} ${expected.path} ${expected.name}`, ).toEqual(expected.schema) } - }), - ) - - it.effect( - "OpenAPI path parameter patterns come from runtime schemas", - Effect.sync(() => { - const spec = OpenApi.fromApi(PublicApi) + for (const expected of booleanSdkQueryParams) { + expect( + queryParameter(spec.paths[openApiPath(expected.path)]?.[expected.method], expected.name)?.schema, + `${expected.method.toUpperCase()} ${expected.path} ${expected.name}`, + ).toEqual(QueryBooleanOpenApi) + } + for (const expected of queryParamPatterns) { + expect( + queryParameter(spec.paths[openApiPath(expected.path)]?.[expected.method], expected.name)?.schema, + `${expected.method.toUpperCase()} ${expected.path} ${expected.name}`, + ).toEqual({ type: "string", pattern: expected.pattern }) + } for (const expected of pathParamPatterns) { expect( pathParameter(spec.paths[openApiPath(expected.path)]?.[expected.method], expected.name)?.schema, From 4fb417d3b544a0d3cad864e3c2cf6e9e5d012cb2 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sun, 10 May 2026 12:05:11 -0400 Subject: [PATCH 132/189] feat(http-recorder): default mode to "auto" (#26719) --- packages/http-recorder/README.md | 33 +++++------- packages/http-recorder/src/effect.ts | 14 +++-- packages/http-recorder/src/recorder.ts | 16 ++++++ packages/http-recorder/src/websocket.ts | 8 +-- .../http-recorder/test/record-replay.test.ts | 53 +++++++++++++++++++ packages/llm/test/recorded-websocket.ts | 5 +- 6 files changed, 99 insertions(+), 30 deletions(-) diff --git a/packages/http-recorder/README.md b/packages/http-recorder/README.md index ed4cd2eb19..134c7b316b 100644 --- a/packages/http-recorder/README.md +++ b/packages/http-recorder/README.md @@ -16,8 +16,9 @@ import { HttpRecorder } from "@opencode-ai/http-recorder" ## Quickstart Provide `cassetteLayer(name)` in place of (or layered over) your `HttpClient`. -The first run records to `test/fixtures/recordings/.json`; subsequent -runs replay from it. +By default the layer records on first run and replays on subsequent runs — +no env-var ternary at the call site, and `CI=true` forces strict replay so +missing cassettes fail loudly in CI rather than silently re-recording. ```ts import { Effect } from "effect" @@ -30,28 +31,22 @@ const program = Effect.gen(function* () { return yield* response.json }) -// Replay (default). Fails if the cassette is missing. +// Records if the cassette is missing, replays if it exists. +// In CI (CI=true) always replays — fails loudly on missing fixtures. Effect.runPromise(program.pipe(Effect.provide(HttpRecorder.cassetteLayer("users/get-one")))) -// Record. Hits the upstream and writes the cassette. +// Force a refresh — always hits upstream and overwrites. Effect.runPromise(program.pipe(Effect.provide(HttpRecorder.cassetteLayer("users/get-one", { mode: "record" })))) ``` -Set the mode from the environment in your test setup: - -```ts -HttpRecorder.cassetteLayer("users/get-one", { - mode: process.env.RECORD === "true" ? "record" : "replay", -}) -``` - ## Modes -| Mode | Behavior | -| ------------- | -------------------------------------------------------------------- | -| `replay` | Default. Match the request to a recorded interaction; error if none. | -| `record` | Execute upstream, append the interaction, write the cassette. | -| `passthrough` | Bypass the recorder entirely — just call upstream. | +| Mode | Behavior | +| ------------- | ----------------------------------------------------------------------------------- | +| `auto` | Default. Replay if the cassette exists; record if missing. `CI=true` forces replay. | +| `replay` | Strict — match the request to a recorded interaction; error if none. | +| `record` | Execute upstream, append the interaction, write the cassette. | +| `passthrough` | Bypass the recorder entirely — just call upstream. | ## Cassette format @@ -101,7 +96,6 @@ secrets escape. Redaction is configured by composing a `Redactor`: import { HttpRecorder, Redactor } from "@opencode-ai/http-recorder" HttpRecorder.cassetteLayer("anthropic/messages", { - mode: process.env.RECORD === "true" ? "record" : "replay", redactor: Redactor.defaults({ requestHeaders: { allow: ["content-type", "anthropic-version"] }, url: { transform: (url) => url.replace(/\/accounts\/[^/]+/, "/accounts/{account}") }, @@ -157,7 +151,6 @@ const program = Effect.gen(function* () { const cassette = yield* HttpRecorder.Cassette.Service const executor = yield* HttpRecorder.makeWebSocketExecutor({ name: "ws/subscribe", - mode: process.env.RECORD === "true" ? "record" : "replay", cassette, live: liveExecutor, }) @@ -188,7 +181,7 @@ const audit = Effect.gen(function* () { ```ts type RecordReplayOptions = { - mode?: "record" | "replay" | "passthrough" // default: "replay" + mode?: "auto" | "replay" | "record" | "passthrough" // default: "auto" (CI=true forces "replay") directory?: string // default: /test/fixtures/recordings metadata?: Record // merged into cassette.metadata redactor?: Redactor // default: Redactor.defaults() diff --git a/packages/http-recorder/src/effect.ts b/packages/http-recorder/src/effect.ts index 977a15755d..1e59b62beb 100644 --- a/packages/http-recorder/src/effect.ts +++ b/packages/http-recorder/src/effect.ts @@ -12,12 +12,12 @@ import { } from "effect/unstable/http" import * as CassetteService from "./cassette" import { defaultMatcher, selectMatch, selectSequential, type RequestMatcher } from "./matching" -import { appendOrFail, makeReplayState } from "./recorder" +import { appendOrFail, makeReplayState, resolveAutoMode } from "./recorder" import { defaults, type Redactor } from "./redactor" import { redactUrl } from "./redaction" import { httpInteractions, type CassetteMetadata, type HttpInteraction, type ResponseSnapshot } from "./schema" -export type RecordReplayMode = "record" | "replay" | "passthrough" +export type RecordReplayMode = "auto" | "record" | "replay" | "passthrough" export interface RecordReplayOptions { readonly mode?: RecordReplayMode @@ -69,7 +69,8 @@ export const recordingLayer = ( const cassetteService = yield* CassetteService.Service const redactor = options.redactor ?? defaults() const match = options.match ?? defaultMatcher - const mode = options.mode ?? "replay" + const requested = options.mode ?? "auto" + const mode = requested === "auto" ? yield* resolveAutoMode(cassetteService, name) : requested const sequential = options.dispatch === "sequential" const replay = yield* makeReplayState(cassetteService, name, httpInteractions) @@ -114,7 +115,12 @@ export const recordingLayer = ( return Effect.gen(function* () { const incoming = yield* snapshotRequest(request) const interactions = yield* replay.load.pipe( - Effect.mapError(() => transportError(request, `Fixture "${name}" not found.`)), + Effect.mapError(() => + transportError( + request, + `Fixture "${name}" not found. Run locally to record it (CI=true forces replay).`, + ), + ), ) const result = sequential ? selectSequential(interactions, incoming, match, yield* replay.cursor) diff --git a/packages/http-recorder/src/recorder.ts b/packages/http-recorder/src/recorder.ts index 30648cd6a7..64f20c519e 100644 --- a/packages/http-recorder/src/recorder.ts +++ b/packages/http-recorder/src/recorder.ts @@ -17,6 +17,22 @@ export class UnsafeCassetteError extends Error { } } +export type ResolvedMode = "record" | "replay" | "passthrough" + +const isCI = () => { + const value = process.env.CI + return value !== undefined && value !== "" && value !== "false" && value !== "0" +} + +export const resolveAutoMode = ( + cassette: CassetteService.Interface, + name: string, +): Effect.Effect => + Effect.gen(function* () { + if (isCI()) return "replay" + return (yield* cassette.exists(name)) ? "replay" : "record" + }) + export const appendOrFail = ( cassette: CassetteService.Interface, name: string, diff --git a/packages/http-recorder/src/websocket.ts b/packages/http-recorder/src/websocket.ts index bf8a1fca06..f7529b4888 100644 --- a/packages/http-recorder/src/websocket.ts +++ b/packages/http-recorder/src/websocket.ts @@ -2,7 +2,8 @@ import { Effect, Option, Ref, Scope, Stream } from "effect" import type { Headers } from "effect/unstable/http" import * as CassetteService from "./cassette" import { canonicalizeJson, decodeJson } from "./matching" -import { appendOrFail, makeReplayState } from "./recorder" +import { appendOrFail, makeReplayState, resolveAutoMode } from "./recorder" +import type { RecordReplayMode } from "./effect" import { defaults, type Redactor } from "./redactor" import { webSocketInteractions, type CassetteMetadata, type WebSocketFrame } from "./schema" @@ -23,7 +24,7 @@ export interface WebSocketExecutor { export interface WebSocketRecordReplayOptions { readonly name: string - readonly mode?: "record" | "replay" | "passthrough" + readonly mode?: RecordReplayMode readonly metadata?: CassetteMetadata readonly cassette: CassetteService.Interface readonly live: WebSocketExecutor @@ -71,7 +72,8 @@ export const makeWebSocketExecutor = ( options: WebSocketRecordReplayOptions, ): Effect.Effect, never, Scope.Scope> => Effect.gen(function* () { - const mode = options.mode ?? "replay" + const requested = options.mode ?? "auto" + const mode = requested === "auto" ? yield* resolveAutoMode(options.cassette, options.name) : requested const redactor = options.redactor ?? defaults() const openSnapshot = (request: WebSocketRequest) => { const redacted = redactor.request({ diff --git a/packages/http-recorder/test/record-replay.test.ts b/packages/http-recorder/test/record-replay.test.ts index df3bbf308e..117620cc64 100644 --- a/packages/http-recorder/test/record-replay.test.ts +++ b/packages/http-recorder/test/record-replay.test.ts @@ -301,6 +301,59 @@ describe("http-recorder", () => { ) }) + test("auto mode replays when the cassette exists", async () => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-auto-")) + const cassettePath = path.join(directory, "auto-replay.json") + fs.writeFileSync( + cassettePath, + formatCassette( + cassetteFor( + "auto-replay", + [ + { + transport: "http", + request: { + method: "POST", + url: "https://example.test/echo", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ step: 1 }), + }, + response: { status: 200, headers: { "content-type": "application/json" }, body: '{"reply":"hi"}' }, + }, + ], + undefined, + ), + ), + ) + + const result = await runWith( + "auto-replay", + { directory, mode: "auto" }, + post("https://example.test/echo", { step: 1 }), + ) + expect(result).toBe('{"reply":"hi"}') + }) + + test("auto mode forces replay when CI=true even if cassette is missing", async () => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-auto-ci-")) + const previous = process.env.CI + process.env.CI = "true" + try { + const exit = await Effect.runPromise( + Effect.exit( + post("https://example.test/echo", { step: 1 }).pipe( + Effect.provide(HttpRecorder.cassetteLayer("missing-cassette", { directory, mode: "auto" })), + ), + ), + ) + expect(Exit.isFailure(exit)).toBe(true) + expect(failureText(exit)).toContain('Fixture "missing-cassette" not found') + } finally { + if (previous === undefined) delete process.env.CI + else process.env.CI = previous + } + }) + test("mismatch diagnostics show closest redacted request differences", async () => { await run( Effect.gen(function* () { diff --git a/packages/llm/test/recorded-websocket.ts b/packages/llm/test/recorded-websocket.ts index eeea9f1b78..b7ad380dad 100644 --- a/packages/llm/test/recorded-websocket.ts +++ b/packages/llm/test/recorded-websocket.ts @@ -1,14 +1,13 @@ -import { Cassette, makeWebSocketExecutor } from "@opencode-ai/http-recorder" +import { Cassette, makeWebSocketExecutor, type RecordReplayMode } from "@opencode-ai/http-recorder" import { Effect, Layer } from "effect" import { WebSocketExecutor } from "../src/route" import type { Service as WebSocketExecutorService } from "../src/route/transport/websocket" const liveWebSocket = WebSocketExecutor.open -type Mode = "record" | "replay" | "passthrough" export const webSocketCassetteLayer = ( cassette: string, - input: { readonly metadata?: Record; readonly mode: Mode }, + input: { readonly metadata?: Record; readonly mode: RecordReplayMode }, ): Layer.Layer => Layer.effect( WebSocketExecutor.Service, From 1c9a2eb2397855aed701f4ace8e0fae2fa69290b Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sun, 10 May 2026 16:06:18 +0000 Subject: [PATCH 133/189] chore: generate --- packages/http-recorder/src/effect.ts | 5 +---- packages/http-recorder/src/recorder.ts | 5 +---- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/packages/http-recorder/src/effect.ts b/packages/http-recorder/src/effect.ts index 1e59b62beb..1583f327d3 100644 --- a/packages/http-recorder/src/effect.ts +++ b/packages/http-recorder/src/effect.ts @@ -116,10 +116,7 @@ export const recordingLayer = ( const incoming = yield* snapshotRequest(request) const interactions = yield* replay.load.pipe( Effect.mapError(() => - transportError( - request, - `Fixture "${name}" not found. Run locally to record it (CI=true forces replay).`, - ), + transportError(request, `Fixture "${name}" not found. Run locally to record it (CI=true forces replay).`), ), ) const result = sequential diff --git a/packages/http-recorder/src/recorder.ts b/packages/http-recorder/src/recorder.ts index 64f20c519e..c63e3b3105 100644 --- a/packages/http-recorder/src/recorder.ts +++ b/packages/http-recorder/src/recorder.ts @@ -24,10 +24,7 @@ const isCI = () => { return value !== undefined && value !== "" && value !== "false" && value !== "0" } -export const resolveAutoMode = ( - cassette: CassetteService.Interface, - name: string, -): Effect.Effect => +export const resolveAutoMode = (cassette: CassetteService.Interface, name: string): Effect.Effect => Effect.gen(function* () { if (isCI()) return "replay" return (yield* cassette.exists(name)) ? "replay" : "record" From a4f3cedcdfb87396ec4bfdbc86f7f1a73835c22d Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sun, 10 May 2026 12:12:00 -0400 Subject: [PATCH 134/189] Start effect-style compaction tests --- packages/opencode/src/session/compaction.ts | 3 + .../opencode/test/session/compaction.test.ts | 105 +++++++++++++----- 2 files changed, 79 insertions(+), 29 deletions(-) diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index 067d43da2e..b4ff0309e6 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -18,6 +18,7 @@ import * as DateTime from "effect/DateTime" import { InstanceState } from "@/effect/instance-state" import { isOverflow as overflow, usable } from "./overflow" import { makeRuntime } from "@/effect/run-service" +import { serviceUse } from "@/effect/service-use" import { fn } from "@/util/fn" import { EventV2 } from "@/v2/event" import { SessionEvent } from "@/v2/session-event" @@ -208,6 +209,8 @@ export interface Interface { export class Service extends Context.Service()("@opencode/SessionCompaction") {} +export const use = serviceUse(Service) + export const layer: Layer.Layer< Service, never, diff --git a/packages/opencode/test/session/compaction.test.ts b/packages/opencode/test/session/compaction.test.ts index cb0bd2b9cd..2b03e35944 100644 --- a/packages/opencode/test/session/compaction.test.ts +++ b/packages/opencode/test/session/compaction.test.ts @@ -15,7 +15,7 @@ import { WithInstance } from "../../src/project/with-instance" import * as Log from "@opencode-ai/core/util/log" import { Permission } from "../../src/permission" import { Plugin } from "../../src/plugin" -import { provideTmpdirInstance, tmpdir } from "../fixture/fixture" +import { provideTmpdirInstance, TestInstance, tmpdir } from "../fixture/fixture" import { Session as SessionNs } from "@/session/session" import { MessageV2 } from "../../src/session/message-v2" import { MessageID, PartID, SessionID } from "../../src/session/schema" @@ -147,6 +147,53 @@ async function assistant(sessionID: SessionID, parentID: MessageID, root: string return msg } +function createUserMessage(sessionID: SessionID, text: string) { + return Effect.gen(function* () { + const ssn = yield* SessionNs.Service + const msg = yield* ssn.updateMessage({ + id: MessageID.ascending(), + role: "user", + sessionID, + agent: "build", + model: ref, + time: { created: Date.now() }, + }) + yield* ssn.updatePart({ + id: PartID.ascending(), + messageID: msg.id, + sessionID, + type: "text", + text, + }) + return msg + }) +} + +function createAssistantMessage(sessionID: SessionID, parentID: MessageID, root: string) { + return SessionNs.Service.use((ssn) => + ssn.updateMessage({ + id: MessageID.ascending(), + role: "assistant", + sessionID, + mode: "build", + agent: "build", + path: { cwd: root, root }, + cost: 0, + tokens: { + output: 0, + input: 0, + reasoning: 0, + cache: { read: 0, write: 0 }, + }, + modelID: ref.modelID, + providerID: ref.providerID, + parentID, + time: { created: Date.now() }, + finish: "end_turn", + }), + ) +} + async function summaryAssistant(sessionID: SessionID, parentID: MessageID, root: string, text: string) { const msg: MessageV2.Assistant = { id: MessageID.ascending(), @@ -805,35 +852,35 @@ describe("session.compaction.prune", () => { }) describe("session.compaction.process", () => { - test("throws when parent is not a user message", async () => { - await using tmp = await tmpdir() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const session = await svc.create({}) - const msg = await user(session.id, "hello") - const reply = await assistant(session.id, msg.id, tmp.path) - const rt = runtime("continue") - try { - const msgs = await svc.messages({ sessionID: session.id }) - await expect( - rt.runPromise( - SessionCompaction.Service.use((svc) => - svc.process({ - parentID: reply.id, - messages: msgs, - sessionID: session.id, - auto: false, - }), - ), - ), - ).rejects.toThrow(`Compaction parent must be a user message: ${reply.id}`) - } finally { - await rt.dispose() + it.instance( + "throws when parent is not a user message", + Effect.gen(function* () { + const test = yield* TestInstance + const ssn = yield* SessionNs.Service + const session = yield* ssn.create({}) + const msg = yield* createUserMessage(session.id, "hello") + const reply = yield* createAssistantMessage(session.id, msg.id, test.directory) + const msgs = yield* ssn.messages({ sessionID: session.id }) + + const exit = yield* Effect.exit( + SessionCompaction.use.process({ + parentID: reply.id, + messages: msgs, + sessionID: session.id, + auto: false, + }), + ) + + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) { + const error = Cause.squash(exit.cause) + expect(error).toBeInstanceOf(Error) + if (error instanceof Error) { + expect(error.message).toContain(`Compaction parent must be a user message: ${reply.id}`) } - }, - }) - }) + } + }), + ) test("publishes compacted event on continue", async () => { await using tmp = await tmpdir() From 3b8790e03438a9ecfcacd8a19e659d56830d784a Mon Sep 17 00:00:00 2001 From: Frank Date: Sun, 10 May 2026 12:14:09 -0400 Subject: [PATCH 135/189] zen: fix usage css on mobile --- .../src/routes/workspace/[id]/usage/usage-section.module.css | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/console/app/src/routes/workspace/[id]/usage/usage-section.module.css b/packages/console/app/src/routes/workspace/[id]/usage/usage-section.module.css index 00232de88f..866ed9ab5c 100644 --- a/packages/console/app/src/routes/workspace/[id]/usage/usage-section.module.css +++ b/packages/console/app/src/routes/workspace/[id]/usage/usage-section.module.css @@ -67,6 +67,7 @@ display: inline-flex; align-items: center; justify-content: center; + flex-shrink: 0; padding: 0; background: transparent; border: none; @@ -79,6 +80,7 @@ } svg { + flex-shrink: 0; width: 16px; height: 16px; } From ce061bf6615ab7384fe0f2403a884980b8eea91c Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sun, 10 May 2026 12:19:13 -0400 Subject: [PATCH 136/189] Add explicit LLM stream lifecycle events (#26722) --- packages/llm/example/tutorial.ts | 2 +- .../llm/src/protocols/anthropic-messages.ts | 35 +++--- .../llm/src/protocols/bedrock-converse.ts | 25 +++- packages/llm/src/protocols/gemini.ts | 12 +- packages/llm/src/protocols/openai-chat.ts | 6 +- .../llm/src/protocols/openai-responses.ts | 39 +++--- .../llm/src/protocols/utils/tool-stream.ts | 38 +++--- packages/llm/src/schema/events.ts | 117 ++++++++++++++---- packages/llm/src/schema/ids.ts | 9 ++ packages/llm/src/tool-runtime.ts | 20 ++- packages/llm/test/adapter.test.ts | 4 +- packages/llm/test/llm.test.ts | 2 +- .../test/provider/anthropic-messages.test.ts | 2 +- packages/llm/test/provider/gemini.test.ts | 6 +- .../llm/test/provider/openai-chat.test.ts | 4 +- .../test/provider/openai-responses.test.ts | 6 +- 16 files changed, 201 insertions(+), 126 deletions(-) diff --git a/packages/llm/example/tutorial.ts b/packages/llm/example/tutorial.ts index 6b0b894b13..a9adecf369 100644 --- a/packages/llm/example/tutorial.ts +++ b/packages/llm/example/tutorial.ts @@ -184,7 +184,7 @@ const FakeProtocol = Protocol.make({ stream: { event: Schema.String, initial: () => undefined, - step: (_, frame) => Effect.succeed([undefined, [{ type: "text-delta", text: frame }]] as const), + step: (_, frame) => Effect.succeed([undefined, [{ type: "text-delta", id: "text-0", text: frame }]] as const), onHalt: () => [{ type: "request-finish", reason: "stop" }], }, }) diff --git a/packages/llm/src/protocols/anthropic-messages.ts b/packages/llm/src/protocols/anthropic-messages.ts index ff2239c0d7..7f87fee89d 100644 --- a/packages/llm/src/protocols/anthropic-messages.ts +++ b/packages/llm/src/protocols/anthropic-messages.ts @@ -5,10 +5,10 @@ import { Endpoint } from "../route/endpoint" import { Framing } from "../route/framing" import { Protocol } from "../route/protocol" import { + LLMEvent, Usage, type CacheHint, type FinishReason, - type LLMEvent, type LLMRequest, type ProviderMetadata, type ToolCallPart, @@ -415,14 +415,13 @@ const serverToolResultEvent = (block: NonNullable).type) : "" const isError = errorPayload.endsWith("_tool_result_error") - return { - type: "tool-result", + return LLMEvent.toolResult({ id: block.tool_use_id ?? "", name: SERVER_TOOL_RESULT_NAMES[block.type], result: isError ? { type: "error", value: block.content } : { type: "json", value: block.content }, providerExecuted: true, providerMetadata: anthropicMetadata({ blockType: block.type }), - } + }) } type StepResult = readonly [ParserState, ReadonlyArray] @@ -453,18 +452,17 @@ const onContentBlockStart = (state: ParserState, event: AnthropicEvent): StepRes } if (block.type === "text" && block.text) { - return [state, [{ type: "text-delta", text: block.text }]] + return [state, [LLMEvent.textDelta({ id: `text-${event.index ?? 0}`, text: block.text })]] } if (block.type === "thinking" && block.thinking) { return [ state, [ - { - type: "reasoning-delta", + LLMEvent.reasoningDelta({ + id: `reasoning-${event.index ?? 0}`, text: block.thinking, - ...(block.signature ? { providerMetadata: anthropicMetadata({ signature: block.signature }) } : {}), - }, + }), ], ] } @@ -480,17 +478,17 @@ const onContentBlockDelta = Effect.fn("AnthropicMessages.onContentBlockDelta")(f const delta = event.delta if (delta?.type === "text_delta" && delta.text) { - return [state, [{ type: "text-delta", text: delta.text }]] satisfies StepResult + return [state, [LLMEvent.textDelta({ id: `text-${event.index ?? 0}`, text: delta.text })]] satisfies StepResult } if (delta?.type === "thinking_delta" && delta.thinking) { - return [state, [{ type: "reasoning-delta", text: delta.thinking }]] satisfies StepResult + return [state, [LLMEvent.reasoningDelta({ id: `reasoning-${event.index ?? 0}`, text: delta.thinking })]] satisfies StepResult } if (delta?.type === "signature_delta" && delta.signature) { return [ state, - [{ type: "reasoning-delta", text: "", providerMetadata: anthropicMetadata({ signature: delta.signature }) }], + [LLMEvent.reasoningEnd({ id: `reasoning-${event.index ?? 0}`, providerMetadata: anthropicMetadata({ signature: delta.signature }) })], ] satisfies StepResult } @@ -524,21 +522,20 @@ const onMessageDelta = (state: ParserState, event: AnthropicEvent): StepResult = return [ { ...state, usage }, [ - { - type: "request-finish", + LLMEvent.requestFinish({ reason: mapFinishReason(event.delta?.stop_reason), usage, - ...(event.delta?.stop_sequence - ? { providerMetadata: anthropicMetadata({ stopSequence: event.delta.stop_sequence }) } - : {}), - }, + providerMetadata: event.delta?.stop_sequence + ? anthropicMetadata({ stopSequence: event.delta.stop_sequence }) + : undefined, + }), ], ] } const onError = (state: ParserState, event: AnthropicEvent): StepResult => [ state, - [{ type: "provider-error", message: event.error?.message ?? "Anthropic Messages stream error" }], + [LLMEvent.providerError({ message: event.error?.message ?? "Anthropic Messages stream error" })], ] const step = (state: ParserState, event: AnthropicEvent) => { diff --git a/packages/llm/src/protocols/bedrock-converse.ts b/packages/llm/src/protocols/bedrock-converse.ts index 09176104df..5632396fe0 100644 --- a/packages/llm/src/protocols/bedrock-converse.ts +++ b/packages/llm/src/protocols/bedrock-converse.ts @@ -3,10 +3,10 @@ import { Route, type RouteModelInput } from "../route/client" import { Endpoint } from "../route/endpoint" import { Protocol } from "../route/protocol" import { + LLMEvent, Usage, type CacheHint, type FinishReason, - type LLMEvent, type LLMRequest, type ToolCallPart, type ToolDefinition, @@ -400,13 +400,26 @@ const step = (state: ParserState, event: BedrockEvent) => } if (event.contentBlockDelta?.delta?.text) { - return [state, [{ type: "text-delta" as const, text: event.contentBlockDelta.delta.text }]] as const + return [ + state, + [ + LLMEvent.textDelta({ + id: `text-${event.contentBlockDelta.contentBlockIndex}`, + text: event.contentBlockDelta.delta.text, + }), + ], + ] as const } if (event.contentBlockDelta?.delta?.reasoningContent?.text) { return [ state, - [{ type: "reasoning-delta" as const, text: event.contentBlockDelta.delta.reasoningContent.text }], + [ + LLMEvent.reasoningDelta({ + id: `reasoning-${event.contentBlockDelta.contentBlockIndex}`, + text: event.contentBlockDelta.delta.reasoningContent.text, + }), + ], ] as const } @@ -449,7 +462,7 @@ const step = (state: ParserState, event: BedrockEvent) => event.modelStreamErrorException?.message ?? event.serviceUnavailableException?.message ?? "Bedrock Converse stream error" - return [state, [{ type: "provider-error" as const, message, retryable: true }]] as const + return [state, [LLMEvent.providerError({ message, retryable: true })]] as const } if (event.validationException || event.throttlingException) { @@ -457,7 +470,7 @@ const step = (state: ParserState, event: BedrockEvent) => event.validationException?.message ?? event.throttlingException?.message ?? "Bedrock Converse error" return [ state, - [{ type: "provider-error" as const, message, retryable: event.throttlingException !== undefined }], + [LLMEvent.providerError({ message, retryable: event.throttlingException !== undefined })], ] as const } @@ -468,7 +481,7 @@ const framing = BedrockEventStream.framing(ADAPTER) const onHalt = (state: ParserState): ReadonlyArray => state.pendingFinish - ? [{ type: "request-finish", reason: state.pendingFinish.reason, usage: state.pendingFinish.usage }] + ? [LLMEvent.requestFinish({ reason: state.pendingFinish.reason, usage: state.pendingFinish.usage })] : [] // ============================================================================= diff --git a/packages/llm/src/protocols/gemini.ts b/packages/llm/src/protocols/gemini.ts index 0d2bdc8e14..140da521a5 100644 --- a/packages/llm/src/protocols/gemini.ts +++ b/packages/llm/src/protocols/gemini.ts @@ -5,9 +5,9 @@ import { Endpoint } from "../route/endpoint" import { Framing } from "../route/framing" import { Protocol } from "../route/protocol" import { + LLMEvent, Usage, type FinishReason, - type LLMEvent, type LLMRequest, type MediaPart, type TextPart, @@ -311,7 +311,7 @@ const mapFinishReason = (finishReason: string | undefined, hasToolCalls: boolean const finish = (state: ParserState): ReadonlyArray => state.finishReason || state.usage - ? [{ type: "request-finish", reason: mapFinishReason(state.finishReason, state.hasToolCalls), usage: state.usage }] + ? [LLMEvent.requestFinish({ reason: mapFinishReason(state.finishReason, state.hasToolCalls), usage: state.usage })] : [] const step = (state: ParserState, event: GeminiEvent) => { @@ -332,14 +332,18 @@ const step = (state: ParserState, event: GeminiEvent) => { for (const part of candidate.content.parts) { if ("text" in part && part.text.length > 0) { - events.push({ type: part.thought ? "reasoning-delta" : "text-delta", text: part.text }) + events.push( + part.thought + ? LLMEvent.reasoningDelta({ id: "reasoning-0", text: part.text }) + : LLMEvent.textDelta({ id: "text-0", text: part.text }), + ) continue } if ("functionCall" in part) { const input = part.functionCall.args const id = `tool_${nextToolCallId++}` - events.push({ type: "tool-call", id, name: part.functionCall.name, input }) + events.push(LLMEvent.toolCall({ id, name: part.functionCall.name, input })) hasToolCalls = true } } diff --git a/packages/llm/src/protocols/openai-chat.ts b/packages/llm/src/protocols/openai-chat.ts index 974e22950d..8948c31c9d 100644 --- a/packages/llm/src/protocols/openai-chat.ts +++ b/packages/llm/src/protocols/openai-chat.ts @@ -6,9 +6,9 @@ import { Framing } from "../route/framing" import { HttpTransport } from "../route/transport" import { Protocol } from "../route/protocol" import { + LLMEvent, Usage, type FinishReason, - type LLMEvent, type LLMRequest, type TextPart, type ToolCallPart, @@ -312,7 +312,7 @@ const step = (state: ParserState, event: OpenAIChatEvent) => const toolDeltas = delta?.tool_calls ?? [] let tools = state.tools - if (delta?.content) events.push({ type: "text-delta", text: delta.content }) + if (delta?.content) events.push(LLMEvent.textDelta({ id: "text-0", text: delta.content })) for (const tool of toolDeltas) { const result = ToolStream.appendOrStart( @@ -350,7 +350,7 @@ const finishEvents = (state: ParserState): ReadonlyArray => { const reason = state.finishReason === "stop" && hasToolCalls ? "tool-calls" : state.finishReason return [ ...state.toolCallEvents, - ...(reason ? ([{ type: "request-finish", reason, usage: state.usage }] satisfies ReadonlyArray) : []), + ...(reason ? [LLMEvent.requestFinish({ reason, usage: state.usage })] : []), ] } diff --git a/packages/llm/src/protocols/openai-responses.ts b/packages/llm/src/protocols/openai-responses.ts index 780ed31bfc..863c4aa7bc 100644 --- a/packages/llm/src/protocols/openai-responses.ts +++ b/packages/llm/src/protocols/openai-responses.ts @@ -6,9 +6,9 @@ import { Framing } from "../route/framing" import { HttpTransport, WebSocketTransport } from "../route/transport" import { Protocol } from "../route/protocol" import { + LLMEvent, Usage, type FinishReason, - type LLMEvent, type LLMRequest, type ProviderMetadata, type TextPart, @@ -348,22 +348,20 @@ const hostedToolEvents = ( const tool = HOSTED_TOOLS[item.type] const providerMetadata = openaiMetadata({ itemId: item.id }) return [ - { - type: "tool-call", + LLMEvent.toolCall({ id: item.id, name: tool.name, input: tool.input(item), providerExecuted: true, providerMetadata, - }, - { - type: "tool-result", + }), + LLMEvent.toolResult({ id: item.id, name: tool.name, result: hostedToolResult(item), providerExecuted: true, providerMetadata, - }, + }), ] } @@ -382,12 +380,7 @@ const onOutputTextDelta = (state: ParserState, event: OpenAIResponsesEvent): Ste return [ state, [ - { - type: "text-delta", - id: event.item_id, - text: event.delta, - ...(event.item_id ? { providerMetadata: openaiMetadata({ itemId: event.item_id }) } : {}), - }, + LLMEvent.textDelta({ id: event.item_id ?? "text-0", text: event.delta }), ], ] } @@ -458,30 +451,28 @@ const onOutputItemDone = Effect.fn("OpenAIResponses.onOutputItemDone")(function* const onResponseFinish = (state: ParserState, event: OpenAIResponsesEvent): StepResult => [ state, [ - { - type: "request-finish", + LLMEvent.requestFinish({ reason: mapFinishReason(event, state.hasFunctionCall), usage: mapUsage(event.response?.usage), - ...(event.response?.id || event.response?.service_tier - ? { - providerMetadata: openaiMetadata({ + providerMetadata: + event.response?.id || event.response?.service_tier + ? openaiMetadata({ responseId: event.response.id, serviceTier: event.response.service_tier, - }), - } - : {}), - }, + }) + : undefined, + }), ], ] const onResponseFailed = (state: ParserState, event: OpenAIResponsesEvent): StepResult => [ state, - [{ type: "provider-error", message: event.message ?? event.code ?? "OpenAI Responses response failed" }], + [LLMEvent.providerError({ message: event.message ?? event.code ?? "OpenAI Responses response failed" })], ] const onError = (state: ParserState, event: OpenAIResponsesEvent): StepResult => [ state, - [{ type: "provider-error", message: event.message ?? event.code ?? "OpenAI Responses stream error" }], + [LLMEvent.providerError({ message: event.message ?? event.code ?? "OpenAI Responses stream error" })], ] const step = (state: ParserState, event: OpenAIResponsesEvent) => { diff --git a/packages/llm/src/protocols/utils/tool-stream.ts b/packages/llm/src/protocols/utils/tool-stream.ts index e6ac5fefd0..aa9c70f017 100644 --- a/packages/llm/src/protocols/utils/tool-stream.ts +++ b/packages/llm/src/protocols/utils/tool-stream.ts @@ -1,5 +1,5 @@ import { Effect } from "effect" -import { LLMError, type ProviderMetadata, type ToolCall, type ToolInputDelta } from "../../schema" +import { LLMError, LLMEvent, type ProviderMetadata, type ToolCall, type ToolInputDelta } from "../../schema" import { eventError, parseToolInput, type ToolAccumulator } from "../shared" type StreamKey = string | number @@ -49,34 +49,24 @@ const withoutTool = (tools: State, key: K): State => return next } -const inputDelta = (tool: PendingTool, text: string): ToolInputDelta => ({ - type: "tool-input-delta", - id: tool.id, - name: tool.name, - text, - ...(tool.providerMetadata ? { providerMetadata: tool.providerMetadata } : {}), -}) +const inputDelta = (tool: PendingTool, text: string): ToolInputDelta => + LLMEvent.toolInputDelta({ + id: tool.id, + name: tool.name, + text, + }) const toolCall = (route: string, tool: PendingTool, inputOverride?: string) => parseToolInput(route, tool.name, inputOverride ?? tool.input).pipe( Effect.map( (input): ToolCall => - tool.providerExecuted - ? { - type: "tool-call", - id: tool.id, - name: tool.name, - input, - providerExecuted: true, - ...(tool.providerMetadata ? { providerMetadata: tool.providerMetadata } : {}), - } - : { - type: "tool-call", - id: tool.id, - name: tool.name, - input, - ...(tool.providerMetadata ? { providerMetadata: tool.providerMetadata } : {}), - }, + LLMEvent.toolCall({ + id: tool.id, + name: tool.name, + input, + providerExecuted: tool.providerExecuted ? true : undefined, + providerMetadata: tool.providerMetadata, + }), ), ) diff --git a/packages/llm/src/schema/events.ts b/packages/llm/src/schema/events.ts index 2fa69370f4..d0befe246e 100644 --- a/packages/llm/src/schema/events.ts +++ b/packages/llm/src/schema/events.ts @@ -1,5 +1,5 @@ import { Schema } from "effect" -import { FinishReason, ProtocolID, ProviderMetadata, RouteID } from "./ids" +import { ContentBlockID, FinishReason, ProtocolID, ProviderMetadata, ResponseID, RouteID, ToolCallID } from "./ids" import { ModelRef } from "./options" import { ToolResultValue } from "./messages" @@ -14,60 +14,87 @@ export class Usage extends Schema.Class("LLM.Usage")({ }) {} export const RequestStart = Schema.Struct({ - type: Schema.Literal("request-start"), - id: Schema.String, + type: Schema.tag("request-start"), + id: ResponseID, model: ModelRef, }).annotate({ identifier: "LLM.Event.RequestStart" }) export type RequestStart = Schema.Schema.Type export const StepStart = Schema.Struct({ - type: Schema.Literal("step-start"), + type: Schema.tag("step-start"), index: Schema.Number, }).annotate({ identifier: "LLM.Event.StepStart" }) export type StepStart = Schema.Schema.Type export const TextStart = Schema.Struct({ - type: Schema.Literal("text-start"), - id: Schema.String, + type: Schema.tag("text-start"), + id: ContentBlockID, providerMetadata: Schema.optional(ProviderMetadata), }).annotate({ identifier: "LLM.Event.TextStart" }) export type TextStart = Schema.Schema.Type export const TextDelta = Schema.Struct({ - type: Schema.Literal("text-delta"), - id: Schema.optional(Schema.String), + type: Schema.tag("text-delta"), + id: ContentBlockID, text: Schema.String, - providerMetadata: Schema.optional(ProviderMetadata), }).annotate({ identifier: "LLM.Event.TextDelta" }) export type TextDelta = Schema.Schema.Type export const TextEnd = Schema.Struct({ - type: Schema.Literal("text-end"), - id: Schema.String, + type: Schema.tag("text-end"), + id: ContentBlockID, providerMetadata: Schema.optional(ProviderMetadata), }).annotate({ identifier: "LLM.Event.TextEnd" }) export type TextEnd = Schema.Schema.Type -export const ReasoningDelta = Schema.Struct({ - type: Schema.Literal("reasoning-delta"), - id: Schema.optional(Schema.String), - text: Schema.String, +export const ReasoningStart = Schema.Struct({ + type: Schema.tag("reasoning-start"), + id: ContentBlockID, providerMetadata: Schema.optional(ProviderMetadata), +}).annotate({ identifier: "LLM.Event.ReasoningStart" }) +export type ReasoningStart = Schema.Schema.Type + +export const ReasoningDelta = Schema.Struct({ + type: Schema.tag("reasoning-delta"), + id: ContentBlockID, + text: Schema.String, }).annotate({ identifier: "LLM.Event.ReasoningDelta" }) export type ReasoningDelta = Schema.Schema.Type +export const ReasoningEnd = Schema.Struct({ + type: Schema.tag("reasoning-end"), + id: ContentBlockID, + providerMetadata: Schema.optional(ProviderMetadata), +}).annotate({ identifier: "LLM.Event.ReasoningEnd" }) +export type ReasoningEnd = Schema.Schema.Type + +export const ToolInputStart = Schema.Struct({ + type: Schema.tag("tool-input-start"), + id: ToolCallID, + name: Schema.String, + providerMetadata: Schema.optional(ProviderMetadata), +}).annotate({ identifier: "LLM.Event.ToolInputStart" }) +export type ToolInputStart = Schema.Schema.Type + export const ToolInputDelta = Schema.Struct({ - type: Schema.Literal("tool-input-delta"), - id: Schema.String, + type: Schema.tag("tool-input-delta"), + id: ToolCallID, name: Schema.String, text: Schema.String, - providerMetadata: Schema.optional(ProviderMetadata), }).annotate({ identifier: "LLM.Event.ToolInputDelta" }) export type ToolInputDelta = Schema.Schema.Type +export const ToolInputEnd = Schema.Struct({ + type: Schema.tag("tool-input-end"), + id: ToolCallID, + name: Schema.String, + providerMetadata: Schema.optional(ProviderMetadata), +}).annotate({ identifier: "LLM.Event.ToolInputEnd" }) +export type ToolInputEnd = Schema.Schema.Type + export const ToolCall = Schema.Struct({ - type: Schema.Literal("tool-call"), - id: Schema.String, + type: Schema.tag("tool-call"), + id: ToolCallID, name: Schema.String, input: Schema.Unknown, providerExecuted: Schema.optional(Schema.Boolean), @@ -76,8 +103,8 @@ export const ToolCall = Schema.Struct({ export type ToolCall = Schema.Schema.Type export const ToolResult = Schema.Struct({ - type: Schema.Literal("tool-result"), - id: Schema.String, + type: Schema.tag("tool-result"), + id: ToolCallID, name: Schema.String, result: ToolResultValue, providerExecuted: Schema.optional(Schema.Boolean), @@ -86,8 +113,8 @@ export const ToolResult = Schema.Struct({ export type ToolResult = Schema.Schema.Type export const ToolError = Schema.Struct({ - type: Schema.Literal("tool-error"), - id: Schema.String, + type: Schema.tag("tool-error"), + id: ToolCallID, name: Schema.String, message: Schema.String, providerMetadata: Schema.optional(ProviderMetadata), @@ -95,7 +122,7 @@ export const ToolError = Schema.Struct({ export type ToolError = Schema.Schema.Type export const StepFinish = Schema.Struct({ - type: Schema.Literal("step-finish"), + type: Schema.tag("step-finish"), index: Schema.Number, reason: FinishReason, usage: Schema.optional(Usage), @@ -104,7 +131,7 @@ export const StepFinish = Schema.Struct({ export type StepFinish = Schema.Schema.Type export const RequestFinish = Schema.Struct({ - type: Schema.Literal("request-finish"), + type: Schema.tag("request-finish"), reason: FinishReason, usage: Schema.optional(Usage), providerMetadata: Schema.optional(ProviderMetadata), @@ -112,7 +139,7 @@ export const RequestFinish = Schema.Struct({ export type RequestFinish = Schema.Schema.Type export const ProviderErrorEvent = Schema.Struct({ - type: Schema.Literal("provider-error"), + type: Schema.tag("provider-error"), message: Schema.String, retryable: Schema.optional(Schema.Boolean), providerMetadata: Schema.optional(ProviderMetadata), @@ -125,8 +152,12 @@ const llmEventTagged = Schema.Union([ TextStart, TextDelta, TextEnd, + ReasoningStart, ReasoningDelta, + ReasoningEnd, + ToolInputStart, ToolInputDelta, + ToolInputEnd, ToolCall, ToolResult, ToolError, @@ -135,20 +166,52 @@ const llmEventTagged = Schema.Union([ ProviderErrorEvent, ]).pipe(Schema.toTaggedUnion("type")) +type WithID = Omit & { readonly id: ID | string } + +const responseID = (value: ResponseID | string) => ResponseID.make(value) +const contentBlockID = (value: ContentBlockID | string) => ContentBlockID.make(value) +const toolCallID = (value: ToolCallID | string) => ToolCallID.make(value) + /** * camelCase aliases for `LLMEvent.guards` (provided by `Schema.toTaggedUnion`). * Lets consumers write `events.filter(LLMEvent.is.toolCall)` instead of * `events.filter(LLMEvent.guards["tool-call"])`. */ export const LLMEvent = Object.assign(llmEventTagged, { + requestStart: (input: WithID) => RequestStart.make({ ...input, id: responseID(input.id) }), + stepStart: StepStart.make, + textStart: (input: WithID) => TextStart.make({ ...input, id: contentBlockID(input.id) }), + textDelta: (input: WithID) => TextDelta.make({ ...input, id: contentBlockID(input.id) }), + textEnd: (input: WithID) => TextEnd.make({ ...input, id: contentBlockID(input.id) }), + reasoningStart: (input: WithID) => + ReasoningStart.make({ ...input, id: contentBlockID(input.id) }), + reasoningDelta: (input: WithID) => + ReasoningDelta.make({ ...input, id: contentBlockID(input.id) }), + reasoningEnd: (input: WithID) => + ReasoningEnd.make({ ...input, id: contentBlockID(input.id) }), + toolInputStart: (input: WithID) => + ToolInputStart.make({ ...input, id: toolCallID(input.id) }), + toolInputDelta: (input: WithID) => + ToolInputDelta.make({ ...input, id: toolCallID(input.id) }), + toolInputEnd: (input: WithID) => ToolInputEnd.make({ ...input, id: toolCallID(input.id) }), + toolCall: (input: WithID) => ToolCall.make({ ...input, id: toolCallID(input.id) }), + toolResult: (input: WithID) => ToolResult.make({ ...input, id: toolCallID(input.id) }), + toolError: (input: WithID) => ToolError.make({ ...input, id: toolCallID(input.id) }), + stepFinish: StepFinish.make, + requestFinish: RequestFinish.make, + providerError: ProviderErrorEvent.make, is: { requestStart: llmEventTagged.guards["request-start"], stepStart: llmEventTagged.guards["step-start"], textStart: llmEventTagged.guards["text-start"], textDelta: llmEventTagged.guards["text-delta"], textEnd: llmEventTagged.guards["text-end"], + reasoningStart: llmEventTagged.guards["reasoning-start"], reasoningDelta: llmEventTagged.guards["reasoning-delta"], + reasoningEnd: llmEventTagged.guards["reasoning-end"], + toolInputStart: llmEventTagged.guards["tool-input-start"], toolInputDelta: llmEventTagged.guards["tool-input-delta"], + toolInputEnd: llmEventTagged.guards["tool-input-end"], toolCall: llmEventTagged.guards["tool-call"], toolResult: llmEventTagged.guards["tool-result"], toolError: llmEventTagged.guards["tool-error"], diff --git a/packages/llm/src/schema/ids.ts b/packages/llm/src/schema/ids.ts index 9261842770..ada133f0db 100644 --- a/packages/llm/src/schema/ids.ts +++ b/packages/llm/src/schema/ids.ts @@ -14,6 +14,15 @@ export type ModelID = typeof ModelID.Type export const ProviderID = Schema.String.pipe(Schema.brand("LLM.ProviderID")) export type ProviderID = typeof ProviderID.Type +export const ResponseID = Schema.String +export type ResponseID = Schema.Schema.Type + +export const ContentBlockID = Schema.String +export type ContentBlockID = Schema.Schema.Type + +export const ToolCallID = Schema.String +export type ToolCallID = Schema.Schema.Type + export const ReasoningEfforts = ["none", "minimal", "low", "medium", "high", "xhigh", "max"] as const export const ReasoningEffort = Schema.Literals(ReasoningEfforts) export type ReasoningEffort = Schema.Schema.Type diff --git a/packages/llm/src/tool-runtime.ts b/packages/llm/src/tool-runtime.ts index 20e27379bd..c6e716d45e 100644 --- a/packages/llm/src/tool-runtime.ts +++ b/packages/llm/src/tool-runtime.ts @@ -4,7 +4,7 @@ import { type ContentPart, type FinishReason, type LLMError, - type LLMEvent, + LLMEvent, LLMRequest, Message, type ProviderMetadata, @@ -115,11 +115,19 @@ interface StepState { const accumulate = (state: StepState, event: LLMEvent) => { if (event.type === "text-delta") { - appendStreamingText(state, "text", event.text, event.providerMetadata) + appendStreamingText(state, "text", event.text, undefined) return } if (event.type === "reasoning-delta") { - appendStreamingText(state, "reasoning", event.text, event.providerMetadata) + appendStreamingText(state, "reasoning", event.text, undefined) + return + } + if (event.type === "reasoning-end") { + appendStreamingText(state, "reasoning", "", event.providerMetadata) + return + } + if (event.type === "text-end") { + appendStreamingText(state, "text", "", event.providerMetadata) return } if (event.type === "tool-call") { @@ -219,10 +227,10 @@ const decodeAndExecute = (tool: AnyTool, input: unknown): Effect.Effect => result.type === "error" ? [ - { type: "tool-error", id: call.id, name: call.name, message: String(result.value) }, - { type: "tool-result", id: call.id, name: call.name, result }, + LLMEvent.toolError({ id: call.id, name: call.name, message: String(result.value) }), + LLMEvent.toolResult({ id: call.id, name: call.name, result }), ] - : [{ type: "tool-result", id: call.id, name: call.name, result }] + : [LLMEvent.toolResult({ id: call.id, name: call.name, result })] const followUpRequest = ( request: LLMRequest, diff --git a/packages/llm/test/adapter.test.ts b/packages/llm/test/adapter.test.ts index 191b8529c0..5ac8b9d818 100644 --- a/packages/llm/test/adapter.test.ts +++ b/packages/llm/test/adapter.test.ts @@ -50,7 +50,9 @@ const request = LLM.request({ }) const raiseEvent = (event: FakeEvent): import("../src/schema").LLMEvent => - event.type === "finish" ? { type: "request-finish", reason: event.reason } : { type: "text-delta", text: event.text } + event.type === "finish" + ? { type: "request-finish", reason: event.reason } + : { type: "text-delta", id: "text-0", text: event.text } const fakeProtocol = Protocol.make({ id: "fake", diff --git a/packages/llm/test/llm.test.ts b/packages/llm/test/llm.test.ts index 9380e554bf..e9ef58afa8 100644 --- a/packages/llm/test/llm.test.ts +++ b/packages/llm/test/llm.test.ts @@ -126,7 +126,7 @@ describe("llm constructors", () => { expect( LLMResponse.text({ events: [ - { type: "text-delta", text: "hi" }, + { type: "text-delta", id: "text-0", text: "hi" }, { type: "request-finish", reason: "stop" }, ], }), diff --git a/packages/llm/test/provider/anthropic-messages.test.ts b/packages/llm/test/provider/anthropic-messages.test.ts index 263828a0ad..85900a1143 100644 --- a/packages/llm/test/provider/anthropic-messages.test.ts +++ b/packages/llm/test/provider/anthropic-messages.test.ts @@ -115,7 +115,7 @@ describe("Anthropic Messages route", () => { cacheReadInputTokens: 1, totalTokens: 7, }) - expect(response.events.find((event) => event.type === "reasoning-delta" && event.text === "")).toMatchObject({ + expect(response.events.find((event) => event.type === "reasoning-end")).toMatchObject({ providerMetadata: { anthropic: { signature: "sig_1" } }, }) expect(response.events.at(-1)).toMatchObject({ diff --git a/packages/llm/test/provider/gemini.test.ts b/packages/llm/test/provider/gemini.test.ts index a80ab740c3..9de4e0dc25 100644 --- a/packages/llm/test/provider/gemini.test.ts +++ b/packages/llm/test/provider/gemini.test.ts @@ -204,9 +204,9 @@ describe("Gemini route", () => { totalTokens: 7, }) expect(response.events).toEqual([ - { type: "reasoning-delta", text: "thinking" }, - { type: "text-delta", text: "Hello" }, - { type: "text-delta", text: "!" }, + { type: "reasoning-delta", id: "reasoning-0", text: "thinking" }, + { type: "text-delta", id: "text-0", text: "Hello" }, + { type: "text-delta", id: "text-0", text: "!" }, { type: "request-finish", reason: "stop", diff --git a/packages/llm/test/provider/openai-chat.test.ts b/packages/llm/test/provider/openai-chat.test.ts index 0998401094..8b0dfc2894 100644 --- a/packages/llm/test/provider/openai-chat.test.ts +++ b/packages/llm/test/provider/openai-chat.test.ts @@ -225,8 +225,8 @@ describe("OpenAI Chat route", () => { expect(response.text).toBe("Hello!") expect(response.events).toEqual([ - { type: "text-delta", text: "Hello" }, - { type: "text-delta", text: "!" }, + { type: "text-delta", id: "text-0", text: "Hello" }, + { type: "text-delta", id: "text-0", text: "!" }, { type: "request-finish", reason: "stop", diff --git a/packages/llm/test/provider/openai-responses.test.ts b/packages/llm/test/provider/openai-responses.test.ts index 30add06d83..5141b44cc2 100644 --- a/packages/llm/test/provider/openai-responses.test.ts +++ b/packages/llm/test/provider/openai-responses.test.ts @@ -336,8 +336,8 @@ describe("OpenAI Responses route", () => { expect(response.text).toBe("Hello!") expect(response.events).toEqual([ - { type: "text-delta", id: "msg_1", text: "Hello", providerMetadata: { openai: { itemId: "msg_1" } } }, - { type: "text-delta", id: "msg_1", text: "!", providerMetadata: { openai: { itemId: "msg_1" } } }, + { type: "text-delta", id: "msg_1", text: "Hello" }, + { type: "text-delta", id: "msg_1", text: "!" }, { type: "request-finish", reason: "stop", @@ -394,14 +394,12 @@ describe("OpenAI Responses route", () => { id: "call_1", name: "lookup", text: '{"query"', - providerMetadata: { openai: { itemId: "item_1" } }, }, { type: "tool-input-delta", id: "call_1", name: "lookup", text: ':"weather"}', - providerMetadata: { openai: { itemId: "item_1" } }, }, { type: "tool-call", From cc2915be164880a9a68a21d7960d6ba778109121 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sun, 10 May 2026 16:20:16 +0000 Subject: [PATCH 137/189] chore: generate --- packages/llm/src/protocols/anthropic-messages.ts | 12 ++++++++++-- packages/llm/src/protocols/bedrock-converse.ts | 5 +---- packages/llm/src/protocols/openai-chat.ts | 5 +---- packages/llm/src/protocols/openai-responses.ts | 7 +------ 4 files changed, 13 insertions(+), 16 deletions(-) diff --git a/packages/llm/src/protocols/anthropic-messages.ts b/packages/llm/src/protocols/anthropic-messages.ts index 7f87fee89d..fba785373d 100644 --- a/packages/llm/src/protocols/anthropic-messages.ts +++ b/packages/llm/src/protocols/anthropic-messages.ts @@ -482,13 +482,21 @@ const onContentBlockDelta = Effect.fn("AnthropicMessages.onContentBlockDelta")(f } if (delta?.type === "thinking_delta" && delta.thinking) { - return [state, [LLMEvent.reasoningDelta({ id: `reasoning-${event.index ?? 0}`, text: delta.thinking })]] satisfies StepResult + return [ + state, + [LLMEvent.reasoningDelta({ id: `reasoning-${event.index ?? 0}`, text: delta.thinking })], + ] satisfies StepResult } if (delta?.type === "signature_delta" && delta.signature) { return [ state, - [LLMEvent.reasoningEnd({ id: `reasoning-${event.index ?? 0}`, providerMetadata: anthropicMetadata({ signature: delta.signature }) })], + [ + LLMEvent.reasoningEnd({ + id: `reasoning-${event.index ?? 0}`, + providerMetadata: anthropicMetadata({ signature: delta.signature }), + }), + ], ] satisfies StepResult } diff --git a/packages/llm/src/protocols/bedrock-converse.ts b/packages/llm/src/protocols/bedrock-converse.ts index 5632396fe0..260ee612cd 100644 --- a/packages/llm/src/protocols/bedrock-converse.ts +++ b/packages/llm/src/protocols/bedrock-converse.ts @@ -468,10 +468,7 @@ const step = (state: ParserState, event: BedrockEvent) => if (event.validationException || event.throttlingException) { const message = event.validationException?.message ?? event.throttlingException?.message ?? "Bedrock Converse error" - return [ - state, - [LLMEvent.providerError({ message, retryable: event.throttlingException !== undefined })], - ] as const + return [state, [LLMEvent.providerError({ message, retryable: event.throttlingException !== undefined })]] as const } return [state, []] as const diff --git a/packages/llm/src/protocols/openai-chat.ts b/packages/llm/src/protocols/openai-chat.ts index 8948c31c9d..5d42c0a4e9 100644 --- a/packages/llm/src/protocols/openai-chat.ts +++ b/packages/llm/src/protocols/openai-chat.ts @@ -348,10 +348,7 @@ const step = (state: ParserState, event: OpenAIChatEvent) => const finishEvents = (state: ParserState): ReadonlyArray => { const hasToolCalls = state.toolCallEvents.length > 0 const reason = state.finishReason === "stop" && hasToolCalls ? "tool-calls" : state.finishReason - return [ - ...state.toolCallEvents, - ...(reason ? [LLMEvent.requestFinish({ reason, usage: state.usage })] : []), - ] + return [...state.toolCallEvents, ...(reason ? [LLMEvent.requestFinish({ reason, usage: state.usage })] : [])] } // ============================================================================= diff --git a/packages/llm/src/protocols/openai-responses.ts b/packages/llm/src/protocols/openai-responses.ts index 863c4aa7bc..14dc32130c 100644 --- a/packages/llm/src/protocols/openai-responses.ts +++ b/packages/llm/src/protocols/openai-responses.ts @@ -377,12 +377,7 @@ const TERMINAL_TYPES = new Set(["response.completed", "response.incomplete", "re const onOutputTextDelta = (state: ParserState, event: OpenAIResponsesEvent): StepResult => { if (!event.delta) return [state, NO_EVENTS] - return [ - state, - [ - LLMEvent.textDelta({ id: event.item_id ?? "text-0", text: event.delta }), - ], - ] + return [state, [LLMEvent.textDelta({ id: event.item_id ?? "text-0", text: event.delta })]] } const onOutputItemAdded = (state: ParserState, event: OpenAIResponsesEvent): StepResult => { From 049502fac6136f4c44eb73f929130c8fb4deeb62 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sun, 10 May 2026 12:21:32 -0400 Subject: [PATCH 138/189] fix(server): return diagnosable body for schema rejections (#26631) --- .../src/server/routes/instance/httpapi/api.ts | 3 + .../httpapi/middleware/schema-error.ts | 36 ++ .../server/routes/instance/httpapi/public.ts | 19 +- .../server/routes/instance/httpapi/server.ts | 3 + .../server/httpapi-schema-error-body.test.ts | 162 +++++++ .../test/server/sdk-error-shape.test.ts | 22 +- .../opencode/test/server/sdk-v1-smoke.test.ts | 60 +++ packages/sdk/js/src/gen/types.gen.ts | 10 +- packages/sdk/js/src/v2/gen/types.gen.ts | 176 +++---- packages/sdk/openapi.json | 439 +++++++++--------- 10 files changed, 606 insertions(+), 324 deletions(-) create mode 100644 packages/opencode/src/server/routes/instance/httpapi/middleware/schema-error.ts create mode 100644 packages/opencode/test/server/httpapi-schema-error-body.test.ts create mode 100644 packages/opencode/test/server/sdk-v1-smoke.test.ts diff --git a/packages/opencode/src/server/routes/instance/httpapi/api.ts b/packages/opencode/src/server/routes/instance/httpapi/api.ts index bdd917e4e8..4c6e46a455 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/api.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/api.ts @@ -21,6 +21,7 @@ import { TuiApi } from "./groups/tui" import { WorkspaceApi } from "./groups/workspace" import { V2Api } from "./groups/v2" import { Authorization } from "./middleware/authorization" +import { SchemaErrorMiddleware } from "./middleware/schema-error" // SSE event schemas built from the BusEvent/SyncEvent registries. const EventSchema = Schema.Union(BusEvent.effectPayloads()).annotate({ identifier: "Event" }) @@ -29,6 +30,7 @@ const SyncEventSchemas = SyncEvent.effectPayloads() export const RootHttpApi = HttpApi.make("opencode-root") .addHttpApi(ControlApi) .addHttpApi(GlobalApi) + .middleware(SchemaErrorMiddleware) .middleware(Authorization) export const InstanceHttpApi = HttpApi.make("opencode-instance") @@ -47,6 +49,7 @@ export const InstanceHttpApi = HttpApi.make("opencode-instance") .addHttpApi(V2Api) .addHttpApi(TuiApi) .addHttpApi(WorkspaceApi) + .middleware(SchemaErrorMiddleware) export const OpenCodeHttpApi = HttpApi.make("opencode") .addHttpApi(RootHttpApi) diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/schema-error.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/schema-error.ts new file mode 100644 index 0000000000..2a8b54f322 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/schema-error.ts @@ -0,0 +1,36 @@ +import { Effect } from "effect" +import { HttpServerResponse } from "effect/unstable/http" +import { HttpApiMiddleware } from "effect/unstable/httpapi" +import * as Log from "@opencode-ai/core/util/log" + +const log = Log.create({ service: "server" }) + +// Effect's Issue formatter recursively dumps the rejected `actual` value with +// no truncation, so a 5KB invalid array produces a ~360KB string. Cap to keep +// 4xx responses small and avoid mirroring entire request payloads (which may +// contain secrets) into the response body and log file. +const REASON_LIMIT = 1024 +function truncateReason(reason: string) { + if (reason.length <= REASON_LIMIT) return reason + return reason.slice(0, REASON_LIMIT) + `… (${reason.length - REASON_LIMIT} more chars)` +} + +// Default Respondable returns an empty 400 body. Match the NamedError shape +// used by other 4xx/5xx so the SDK's `wrapClientError` extracts `.data.message`. +export class SchemaErrorMiddleware extends HttpApiMiddleware.Service()( + "@opencode/HttpApiSchemaError", +) {} + +export const schemaErrorLayer = HttpApiMiddleware.layerSchemaErrorTransform( + SchemaErrorMiddleware, + (error) => { + const reason = truncateReason(error.cause.message) + log.warn("schema rejection", { kind: error.kind, reason }) + return Effect.succeed( + HttpServerResponse.jsonUnsafe( + { name: "BadRequest", data: { message: reason, kind: error.kind } }, + { status: 400 }, + ), + ) + }, +) diff --git a/packages/opencode/src/server/routes/instance/httpapi/public.ts b/packages/opencode/src/server/routes/instance/httpapi/public.ts index 4286e6f6cc..460a2be7a5 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/public.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/public.ts @@ -178,17 +178,20 @@ function addLegacyErrorSchemas(spec: OpenApiSpec) { if (!spec.components?.schemas) return spec.components.schemas.BadRequestError = { type: "object", - required: ["data", "errors", "success"], + required: ["name", "data"], properties: { - data: {}, - errors: { - type: "array", - items: { - type: "object", - additionalProperties: {}, + name: { type: "string", enum: ["BadRequest"] }, + data: { + type: "object", + required: ["message"], + properties: { + message: { type: "string" }, + kind: { + type: "string", + enum: ["Params", "Headers", "Query", "Body", "Payload"], + }, }, }, - success: { type: "boolean", enum: [false] }, }, } spec.components.schemas.NotFoundError = { diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index 495497ecb4..7ce21dfadb 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -84,6 +84,7 @@ import { compressionLayer } from "./middleware/compression" import { corsVaryFix } from "./middleware/cors-vary" import { errorLayer } from "./middleware/error" import { fenceLayer } from "./middleware/fence" +import { schemaErrorLayer } from "./middleware/schema-error" export const context = Context.makeUnsafe(new Map()) @@ -114,6 +115,7 @@ const authOnlyRouterLayer = authorizationRouterMiddleware.layer.pipe(Layer.provi const httpApiAuthLayer = authorizationLayer.pipe(Layer.provide(ServerAuth.Config.defaultLayer)) const rootApiRoutes = HttpApiBuilder.layer(RootHttpApi).pipe( Layer.provide([controlHandlers, globalHandlers]), + Layer.provide(schemaErrorLayer), Layer.provide(httpApiAuthLayer), ) const instanceRouterLayer = authorizationRouterMiddleware @@ -150,6 +152,7 @@ const instanceRoutes = Layer.mergeAll(rawInstanceRoutes, instanceApiRoutes).pipe httpApiAuthLayer, workspaceRoutingLayer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal)), instanceContextLayer, + schemaErrorLayer, ]), ) diff --git a/packages/opencode/test/server/httpapi-schema-error-body.test.ts b/packages/opencode/test/server/httpapi-schema-error-body.test.ts new file mode 100644 index 0000000000..fe6a1caad0 --- /dev/null +++ b/packages/opencode/test/server/httpapi-schema-error-body.test.ts @@ -0,0 +1,162 @@ +import { afterEach, describe, expect } from "bun:test" +import { Effect } from "effect" +import { eq } from "drizzle-orm" +import * as Database from "@/storage/db" +import { ModelID, ProviderID } from "../../src/provider/schema" +import { WithInstance } from "../../src/project/with-instance" +import { Server } from "../../src/server/server" +import { Session } from "@/session/session" +import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session" +import { SyncPaths } from "../../src/server/routes/instance/httpapi/groups/sync" +import { MessageID, PartID } from "../../src/session/schema" +import { PartTable } from "@/session/session.sql" +import { resetDatabase } from "../fixture/db" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" +import { it } from "../lib/effect" + +afterEach(async () => { + await disposeAllInstances() + await resetDatabase() +}) + +const withTmp = ( + options: Parameters[0], + fn: (tmp: Awaited>) => Effect.Effect, +) => + Effect.acquireRelease( + Effect.promise(() => tmpdir(options)), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ).pipe(Effect.flatMap(fn)) + +async function seedCorruptStepFinishPart(directory: string) { + return WithInstance.provide({ + directory, + fn: () => + Effect.runPromise( + Effect.gen(function* () { + const session = yield* Session.Service + const info = yield* session.create({}) + const message = yield* session.updateMessage({ + id: MessageID.ascending(), + role: "user", + sessionID: info.id, + agent: "build", + model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") }, + time: { created: Date.now() }, + }) + const partID = PartID.ascending() + yield* session.updatePart({ + id: partID, + sessionID: info.id, + messageID: message.id, + type: "step-finish", + reason: "stop", + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + }) + // Schema.Finite still rejects NaN at encode — exact mirror of the + // corrupt row that broke the user's session in the OMO/Windows bug. + Database.use((db) => + db + .update(PartTable) + .set({ + data: { + type: "step-finish", + reason: "stop", + cost: 0, + tokens: { input: 0, output: NaN, reasoning: 0, cache: { read: 0, write: 0 } }, + } as never, // drizzle's .set() can't narrow the discriminated union + }) + .where(eq(PartTable.id, partID)) + .run(), + ) + return info.id + }).pipe(Effect.provide(Session.defaultLayer)), + ), + }) +} + +describe("schema-rejection wire shape", () => { + it.live( + "Payload schema rejection returns NamedError-shaped JSON, not empty", + withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) => + Effect.gen(function* () { + const res = yield* Effect.promise(async () => + Server.Default().app.request(SyncPaths.history, { + method: "POST", + headers: { "x-opencode-directory": tmp.path, "content-type": "application/json" }, + body: JSON.stringify({ aggregate: -1 }), + }), + ) + const body = yield* Effect.promise(async () => res.text()) + expect(res.status).toBe(400) + expect(res.headers.get("content-type") ?? "").toContain("application/json") + const parsed = JSON.parse(body) + expect(parsed).toMatchObject({ + name: "BadRequest", + data: { kind: expect.stringMatching(/^(Body|Payload)$/) }, + }) + expect(parsed.data.message).toEqual(expect.any(String)) + expect(parsed.data.message.length).toBeGreaterThan(0) + }), + ), + ) + + it.live( + "Query schema rejection returns NamedError-shaped JSON", + withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) => + Effect.gen(function* () { + // /find/file?limit=999999 violates the limit constraint check. + const url = `/find/file?query=foo&limit=999999&directory=${encodeURIComponent(tmp.path)}` + const res = yield* Effect.promise(async () => Server.Default().app.request(url)) + const body = yield* Effect.promise(async () => res.text()) + expect(res.status).toBe(400) + const parsed = JSON.parse(body) + expect(parsed).toMatchObject({ name: "BadRequest", data: { kind: "Query" } }) + }), + ), + ) + + it.live( + "rejected request body never echoes back unbounded — message is capped", + // Defense against DoS-amplification + secret-echo: Effect's Issue formatter + // dumps the rejected `actual` verbatim. A multi-MB invalid array would + // become a multi-MB 400 response and log line. Cap kicks in around 1KB. + withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) => + Effect.gen(function* () { + const huge = "X".repeat(50_000) + const res = yield* Effect.promise(async () => + Server.Default().app.request(SyncPaths.history, { + method: "POST", + headers: { "x-opencode-directory": tmp.path, "content-type": "application/json" }, + body: JSON.stringify({ aggregate: huge }), + }), + ) + const body = yield* Effect.promise(async () => res.text()) + expect(res.status).toBe(400) + // 1 KB cap + small JSON envelope ≈ <2 KB — never tens of KB. + expect(body.length).toBeLessThan(2 * 1024) + const parsed = JSON.parse(body) + expect(parsed.data.message).not.toContain(huge) + }), + ), + ) + + it.live( + "response-encode failure: corrupted stored row returns NamedError-shaped JSON with field path", + withTmp({ config: { formatter: false, lsp: false } }, (tmp) => + Effect.gen(function* () { + const sessionID = yield* Effect.promise(() => seedCorruptStepFinishPart(tmp.path)) + const url = `${SessionPaths.messages.replace(":sessionID", sessionID)}?limit=80&directory=${encodeURIComponent(tmp.path)}` + const res = yield* Effect.promise(async () => Server.Default().app.request(url)) + const body = yield* Effect.promise(async () => res.text()) + expect(res.status).toBe(400) + expect(res.headers.get("content-type") ?? "").toContain("application/json") + const parsed = JSON.parse(body) + expect(parsed).toMatchObject({ name: "BadRequest", data: { kind: "Body" } }) + // Field path in data.message — what made this PR worth shipping. + expect(parsed.data.message).toMatch(/output/) + }), + ), + ) +}) diff --git a/packages/opencode/test/server/sdk-error-shape.test.ts b/packages/opencode/test/server/sdk-error-shape.test.ts index 30eedc9adb..f41fe2cb30 100644 --- a/packages/opencode/test/server/sdk-error-shape.test.ts +++ b/packages/opencode/test/server/sdk-error-shape.test.ts @@ -52,23 +52,33 @@ describe("v2 SDK error shape", () => { }) }) - test("400 with empty body throws a real Error naming the status", async () => { + test("400 schema rejection: SDK extracts the field-level reason from the NamedError body", async () => { + // Canary for the #26631 wire shape. Asserts the contract end-to-end: + // server emits {name:"BadRequest", data:{message, kind}}, SDK's + // wrapClientError extracts .data.message into Error.message. If either + // side regresses (#26457 reverted because both layers were missing), + // this test fails before users see (empty response body). await using tmp = await tmpdir({ config: { formatter: false, lsp: false } }) const sdk = client(tmp.path) let caught: unknown try { - // POST /sync/history with `aggregate: -1` triggers schema validation - // that returns an empty 400 body (verified via plan-mode probe). - await sdk.sync.history.list({ aggregate: -1 } as any, { throwOnError: true }) + await sdk.sync.history.list({ body: { aggregate: -1 } as any }, { throwOnError: true }) } catch (e) { caught = e } expect(caught).toBeInstanceOf(Error) const err = caught as Error - const cause = err.cause as { status?: number } - expect(err.message.length).toBeGreaterThan(0) + const cause = err.cause as { body?: any; status?: number } expect(cause.status).toBe(400) + expect(cause.body).toMatchObject({ + name: "BadRequest", + data: { kind: expect.stringMatching(/^(Body|Payload)$/) }, + }) + expect(typeof cause.body.data.message).toBe("string") + expect(cause.body.data.message.length).toBeGreaterThan(0) + // Whatever the server put in data.message must be what the user sees. + expect(err.message).toBe(cause.body.data.message) }) }) diff --git a/packages/opencode/test/server/sdk-v1-smoke.test.ts b/packages/opencode/test/server/sdk-v1-smoke.test.ts new file mode 100644 index 0000000000..2b09e0c872 --- /dev/null +++ b/packages/opencode/test/server/sdk-v1-smoke.test.ts @@ -0,0 +1,60 @@ +// Smoke test: v1 SDK (the plugin contract) can actually reach core endpoints +// against the current server. v1 generation has been frozen since #5216 +// (2025-12-07) so types may be stale, but runtime calls should still work +// for endpoints the v1 SDK was generated against. +import { afterEach, describe, expect, test } from "bun:test" +import { createOpencodeClient } from "@opencode-ai/sdk" +import { Server } from "../../src/server/server" +import { tmpdir, disposeAllInstances } from "../fixture/fixture" +import { resetDatabase } from "../fixture/db" +import * as Log from "@opencode-ai/core/util/log" + +void Log.init({ print: false }) + +afterEach(async () => { + await disposeAllInstances() + await resetDatabase() +}) + +function client(directory: string) { + return createOpencodeClient({ + baseUrl: "http://test", + directory, + fetch: ((req: Request) => Server.Default().app.fetch(req)) as unknown as typeof fetch, + }) +} + +describe("v1 SDK runtime smoke", () => { + test("session.list reaches the server and returns 200", async () => { + await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) + const sdk = client(tmp.path) + const result = await sdk.session.list() + expect(result.error).toBeUndefined() + expect(Array.isArray(result.data)).toBe(true) + }) + + test("path.get reaches the server and returns 200", async () => { + await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) + const sdk = client(tmp.path) + const result = await sdk.path.get() + expect(result.error).toBeUndefined() + expect(result.data).toBeDefined() + }) + + test("config.get reaches the server and returns 200", async () => { + await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) + const sdk = client(tmp.path) + const result = await sdk.config.get() + expect(result.error).toBeUndefined() + expect(result.data).toBeDefined() + }) + + test("session 404: result-tuple path returns the error body", async () => { + await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) + const sdk = client(tmp.path) + const result = await sdk.session.get({ path: { id: "ses_no_such" } as never }) + expect(result.error).toBeDefined() + // wire body for 404 is NamedError-shaped + expect(result.error).toMatchObject({ name: "NotFoundError" }) + }) +}) diff --git a/packages/sdk/js/src/gen/types.gen.ts b/packages/sdk/js/src/gen/types.gen.ts index 62e1b8fe8d..8fd2a02b92 100644 --- a/packages/sdk/js/src/gen/types.gen.ts +++ b/packages/sdk/js/src/gen/types.gen.ts @@ -752,11 +752,11 @@ export type Project = { } export type BadRequestError = { - data: unknown - errors: Array<{ - [key: string]: unknown - }> - success: false + name: "BadRequest" + data: { + message: string + kind?: "Params" | "Headers" | "Query" | "Body" | "Payload" + } } export type NotFoundError = { diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index ae7e9767ce..1c8bb5ac7c 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -5,6 +5,12 @@ export type ClientOptions = { } export type Event = + | EventTuiPromptAppend + | EventTuiCommandExecute + | EventTuiToastShow1 + | EventTuiSessionSelect + | EventServerConnected + | EventGlobalDisposed | EventServerInstanceDisposed | EventFileEdited | EventFileWatcherUpdated @@ -24,10 +30,6 @@ export type Event = | EventSessionStatus | EventSessionIdle | EventSessionCompacted - | EventTuiPromptAppend - | EventTuiCommandExecute - | EventTuiToastShow1 - | EventTuiSessionSelect | EventMcpToolsChanged | EventMcpBrowserOpenFailed | EventCommandExecuted @@ -75,8 +77,6 @@ export type Event = | EventSessionNextCompactionStarted | EventSessionNextCompactionDelta | EventSessionNextCompactionEnded - | EventServerConnected - | EventGlobalDisposed export type OAuth = { type: "oauth" @@ -103,6 +103,61 @@ export type WellKnownAuth = { export type Auth = OAuth | ApiAuth | WellKnownAuth +export type EventTuiPromptAppend = { + id: string + type: "tui.prompt.append" + properties: { + text: string + } +} + +export type EventTuiCommandExecute = { + id: string + type: "tui.command.execute" + properties: { + command: + | "session.list" + | "session.new" + | "session.share" + | "session.interrupt" + | "session.compact" + | "session.page.up" + | "session.page.down" + | "session.line.up" + | "session.line.down" + | "session.half.page.up" + | "session.half.page.down" + | "session.first" + | "session.last" + | "prompt.clear" + | "prompt.submit" + | "agent.cycle" + | string + } +} + +export type EventTuiToastShow = { + id: string + type: "tui.toast.show" + properties: { + title?: string + message: string + variant: "info" | "success" | "warning" | "error" + duration?: number + } +} + +export type EventTuiSessionSelect = { + id: string + type: "tui.session.select" + properties: { + /** + * Session ID to navigate to + */ + sessionID: string + } +} + export type PermissionRequest = { id: string sessionID: string @@ -280,61 +335,6 @@ export type SessionStatus = type: "busy" } -export type EventTuiPromptAppend = { - id: string - type: "tui.prompt.append" - properties: { - text: string - } -} - -export type EventTuiCommandExecute = { - id: string - type: "tui.command.execute" - properties: { - command: - | "session.list" - | "session.new" - | "session.share" - | "session.interrupt" - | "session.compact" - | "session.page.up" - | "session.page.down" - | "session.line.up" - | "session.line.down" - | "session.half.page.up" - | "session.half.page.down" - | "session.first" - | "session.last" - | "prompt.clear" - | "prompt.submit" - | "agent.cycle" - | string - } -} - -export type EventTuiToastShow = { - id: string - type: "tui.toast.show" - properties: { - title?: string - message: string - variant: "info" | "success" | "warning" | "error" - duration?: number - } -} - -export type EventTuiSessionSelect = { - id: string - type: "tui.session.select" - properties: { - /** - * Session ID to navigate to - */ - sessionID: string - } -} - export type Project = { id: string worktree: string @@ -778,6 +778,12 @@ export type GlobalEvent = { project?: string workspace?: string payload: + | EventTuiPromptAppend + | EventTuiCommandExecute + | EventTuiToastShow + | EventTuiSessionSelect + | EventServerConnected + | EventGlobalDisposed | EventServerInstanceDisposed | EventFileEdited | EventFileWatcherUpdated @@ -797,10 +803,6 @@ export type GlobalEvent = { | EventSessionStatus | EventSessionIdle | EventSessionCompacted - | EventTuiPromptAppend - | EventTuiCommandExecute - | EventTuiToastShow - | EventTuiSessionSelect | EventMcpToolsChanged | EventMcpBrowserOpenFailed | EventCommandExecuted @@ -848,8 +850,6 @@ export type GlobalEvent = { | EventSessionNextCompactionStarted | EventSessionNextCompactionDelta | EventSessionNextCompactionEnded - | EventServerConnected - | EventGlobalDisposed | SyncEventMessageUpdated | SyncEventMessageRemoved | SyncEventMessagePartUpdated @@ -2330,6 +2330,22 @@ export type SyncEventSessionNextCompactionEnded = { } } +export type EventServerConnected = { + id: string + type: "server.connected" + properties: { + [key: string]: unknown + } +} + +export type EventGlobalDisposed = { + id: string + type: "global.disposed" + properties: { + [key: string]: unknown + } +} + export type EventServerInstanceDisposed = { id: string type: "server.instance.disposed" @@ -3044,22 +3060,6 @@ export type EventSessionNextCompactionEnded = { } } -export type EventServerConnected = { - id: string - type: "server.connected" - properties: { - [key: string]: unknown - } -} - -export type EventGlobalDisposed = { - id: string - type: "global.disposed" - properties: { - [key: string]: unknown - } -} - export type SessionInfo = { id: string parentID?: string @@ -3296,11 +3296,11 @@ export type EventTuiToastShow1 = { } export type BadRequestError = { - data: unknown - errors: Array<{ - [key: string]: unknown - }> - success: false + name: "BadRequest" + data: { + message: string + kind?: "Params" | "Headers" | "Query" | "Body" | "Payload" + } } export type AuthRemoveData = { diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 8ed22e3101..73cb5c3318 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -8868,6 +8868,24 @@ "schemas": { "Event": { "anyOf": [ + { + "$ref": "#/components/schemas/Event.tui.prompt.append" + }, + { + "$ref": "#/components/schemas/Event.tui.command.execute" + }, + { + "$ref": "#/components/schemas/EventTuiToastShow1" + }, + { + "$ref": "#/components/schemas/Event.tui.session.select" + }, + { + "$ref": "#/components/schemas/EventServerConnected" + }, + { + "$ref": "#/components/schemas/EventGlobalDisposed" + }, { "$ref": "#/components/schemas/EventServerInstanceDisposed" }, @@ -8925,18 +8943,6 @@ { "$ref": "#/components/schemas/EventSessionCompacted" }, - { - "$ref": "#/components/schemas/Event.tui.prompt.append" - }, - { - "$ref": "#/components/schemas/Event.tui.command.execute" - }, - { - "$ref": "#/components/schemas/EventTuiToastShow1" - }, - { - "$ref": "#/components/schemas/Event.tui.session.select" - }, { "$ref": "#/components/schemas/EventMcpToolsChanged" }, @@ -9077,12 +9083,6 @@ }, { "$ref": "#/components/schemas/EventSessionNextCompactionEnded" - }, - { - "$ref": "#/components/schemas/EventServerConnected" - }, - { - "$ref": "#/components/schemas/EventGlobalDisposed" } ] }, @@ -9163,6 +9163,140 @@ } ] }, + "Event.tui.prompt.append": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["tui.prompt.append"] + }, + "properties": { + "type": "object", + "properties": { + "text": { + "type": "string" + } + }, + "required": ["text"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "Event.tui.command.execute": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["tui.command.execute"] + }, + "properties": { + "type": "object", + "properties": { + "command": { + "anyOf": [ + { + "type": "string", + "enum": [ + "session.list", + "session.new", + "session.share", + "session.interrupt", + "session.compact", + "session.page.up", + "session.page.down", + "session.line.up", + "session.line.down", + "session.half.page.up", + "session.half.page.down", + "session.first", + "session.last", + "prompt.clear", + "prompt.submit", + "agent.cycle" + ] + }, + { + "type": "string" + } + ] + } + }, + "required": ["command"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "Event.tui.toast.show": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["tui.toast.show"] + }, + "properties": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "message": { + "type": "string" + }, + "variant": { + "type": "string", + "enum": ["info", "success", "warning", "error"] + }, + "duration": { + "type": "integer", + "exclusiveMinimum": 0 + } + }, + "required": ["message", "variant"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "Event.tui.session.select": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["tui.session.select"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses", + "description": "Session ID to navigate to" + } + }, + "required": ["sessionID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, "PermissionRequest": { "type": "object", "properties": { @@ -9622,140 +9756,6 @@ } ] }, - "Event.tui.prompt.append": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["tui.prompt.append"] - }, - "properties": { - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, - "Event.tui.command.execute": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["tui.command.execute"] - }, - "properties": { - "type": "object", - "properties": { - "command": { - "anyOf": [ - { - "type": "string", - "enum": [ - "session.list", - "session.new", - "session.share", - "session.interrupt", - "session.compact", - "session.page.up", - "session.page.down", - "session.line.up", - "session.line.down", - "session.half.page.up", - "session.half.page.down", - "session.first", - "session.last", - "prompt.clear", - "prompt.submit", - "agent.cycle" - ] - }, - { - "type": "string" - } - ] - } - }, - "required": ["command"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, - "Event.tui.toast.show": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["tui.toast.show"] - }, - "properties": { - "type": "object", - "properties": { - "title": { - "type": "string" - }, - "message": { - "type": "string" - }, - "variant": { - "type": "string", - "enum": ["info", "success", "warning", "error"] - }, - "duration": { - "type": "integer", - "exclusiveMinimum": 0 - } - }, - "required": ["message", "variant"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, - "Event.tui.session.select": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["tui.session.select"] - }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses", - "description": "Session ID to navigate to" - } - }, - "required": ["sessionID"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, "Project": { "type": "object", "properties": { @@ -11129,6 +11129,24 @@ }, "payload": { "anyOf": [ + { + "$ref": "#/components/schemas/Event.tui.prompt.append" + }, + { + "$ref": "#/components/schemas/Event.tui.command.execute" + }, + { + "$ref": "#/components/schemas/Event.tui.toast.show" + }, + { + "$ref": "#/components/schemas/Event.tui.session.select" + }, + { + "$ref": "#/components/schemas/EventServerConnected" + }, + { + "$ref": "#/components/schemas/EventGlobalDisposed" + }, { "$ref": "#/components/schemas/EventServerInstanceDisposed" }, @@ -11186,18 +11204,6 @@ { "$ref": "#/components/schemas/EventSessionCompacted" }, - { - "$ref": "#/components/schemas/Event.tui.prompt.append" - }, - { - "$ref": "#/components/schemas/Event.tui.command.execute" - }, - { - "$ref": "#/components/schemas/Event.tui.toast.show" - }, - { - "$ref": "#/components/schemas/Event.tui.session.select" - }, { "$ref": "#/components/schemas/EventMcpToolsChanged" }, @@ -11339,12 +11345,6 @@ { "$ref": "#/components/schemas/EventSessionNextCompactionEnded" }, - { - "$ref": "#/components/schemas/EventServerConnected" - }, - { - "$ref": "#/components/schemas/EventGlobalDisposed" - }, { "$ref": "#/components/schemas/SyncEventMessageUpdated" }, @@ -15843,6 +15843,42 @@ "required": ["type", "name", "id", "seq", "aggregateID", "data"], "additionalProperties": false }, + "EventServerConnected": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["server.connected"] + }, + "properties": { + "type": "object", + "properties": {} + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventGlobalDisposed": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["global.disposed"] + }, + "properties": { + "type": "object", + "properties": {} + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, "EventServerInstanceDisposed": { "type": "object", "properties": { @@ -18011,42 +18047,6 @@ "required": ["id", "type", "properties"], "additionalProperties": false }, - "EventServerConnected": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["server.connected"] - }, - "properties": { - "type": "object", - "properties": {} - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, - "EventGlobalDisposed": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["global.disposed"] - }, - "properties": { - "type": "object", - "properties": {} - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, "SessionInfo": { "type": "object", "properties": { @@ -18725,19 +18725,24 @@ }, "BadRequestError": { "type": "object", - "required": ["data", "errors", "success"], + "required": ["name", "data"], "properties": { - "data": {}, - "errors": { - "type": "array", - "items": { - "type": "object", - "additionalProperties": {} - } + "name": { + "type": "string", + "enum": ["BadRequest"] }, - "success": { - "type": "boolean", - "enum": [false] + "data": { + "type": "object", + "required": ["message"], + "properties": { + "message": { + "type": "string" + }, + "kind": { + "type": "string", + "enum": ["Params", "Headers", "Query", "Body", "Payload"] + } + } } } } From 312e5c7a7cf3f1d73e981bc7db67536f47e39c0f Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sun, 10 May 2026 16:22:29 +0000 Subject: [PATCH 139/189] chore: generate --- .../httpapi/middleware/schema-error.ts | 20 +- packages/sdk/js/src/v2/gen/types.gen.ts | 166 +++---- packages/sdk/openapi.json | 412 +++++++++--------- 3 files changed, 296 insertions(+), 302 deletions(-) diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/schema-error.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/schema-error.ts index 2a8b54f322..e7d661c5a8 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/middleware/schema-error.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/schema-error.ts @@ -21,16 +21,10 @@ export class SchemaErrorMiddleware extends HttpApiMiddleware.Service { - const reason = truncateReason(error.cause.message) - log.warn("schema rejection", { kind: error.kind, reason }) - return Effect.succeed( - HttpServerResponse.jsonUnsafe( - { name: "BadRequest", data: { message: reason, kind: error.kind } }, - { status: 400 }, - ), - ) - }, -) +export const schemaErrorLayer = HttpApiMiddleware.layerSchemaErrorTransform(SchemaErrorMiddleware, (error) => { + const reason = truncateReason(error.cause.message) + log.warn("schema rejection", { kind: error.kind, reason }) + return Effect.succeed( + HttpServerResponse.jsonUnsafe({ name: "BadRequest", data: { message: reason, kind: error.kind } }, { status: 400 }), + ) +}) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 1c8bb5ac7c..6b0f4c6f88 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -5,12 +5,6 @@ export type ClientOptions = { } export type Event = - | EventTuiPromptAppend - | EventTuiCommandExecute - | EventTuiToastShow1 - | EventTuiSessionSelect - | EventServerConnected - | EventGlobalDisposed | EventServerInstanceDisposed | EventFileEdited | EventFileWatcherUpdated @@ -30,6 +24,10 @@ export type Event = | EventSessionStatus | EventSessionIdle | EventSessionCompacted + | EventTuiPromptAppend + | EventTuiCommandExecute + | EventTuiToastShow1 + | EventTuiSessionSelect | EventMcpToolsChanged | EventMcpBrowserOpenFailed | EventCommandExecuted @@ -77,6 +75,8 @@ export type Event = | EventSessionNextCompactionStarted | EventSessionNextCompactionDelta | EventSessionNextCompactionEnded + | EventServerConnected + | EventGlobalDisposed export type OAuth = { type: "oauth" @@ -103,61 +103,6 @@ export type WellKnownAuth = { export type Auth = OAuth | ApiAuth | WellKnownAuth -export type EventTuiPromptAppend = { - id: string - type: "tui.prompt.append" - properties: { - text: string - } -} - -export type EventTuiCommandExecute = { - id: string - type: "tui.command.execute" - properties: { - command: - | "session.list" - | "session.new" - | "session.share" - | "session.interrupt" - | "session.compact" - | "session.page.up" - | "session.page.down" - | "session.line.up" - | "session.line.down" - | "session.half.page.up" - | "session.half.page.down" - | "session.first" - | "session.last" - | "prompt.clear" - | "prompt.submit" - | "agent.cycle" - | string - } -} - -export type EventTuiToastShow = { - id: string - type: "tui.toast.show" - properties: { - title?: string - message: string - variant: "info" | "success" | "warning" | "error" - duration?: number - } -} - -export type EventTuiSessionSelect = { - id: string - type: "tui.session.select" - properties: { - /** - * Session ID to navigate to - */ - sessionID: string - } -} - export type PermissionRequest = { id: string sessionID: string @@ -335,6 +280,61 @@ export type SessionStatus = type: "busy" } +export type EventTuiPromptAppend = { + id: string + type: "tui.prompt.append" + properties: { + text: string + } +} + +export type EventTuiCommandExecute = { + id: string + type: "tui.command.execute" + properties: { + command: + | "session.list" + | "session.new" + | "session.share" + | "session.interrupt" + | "session.compact" + | "session.page.up" + | "session.page.down" + | "session.line.up" + | "session.line.down" + | "session.half.page.up" + | "session.half.page.down" + | "session.first" + | "session.last" + | "prompt.clear" + | "prompt.submit" + | "agent.cycle" + | string + } +} + +export type EventTuiToastShow = { + id: string + type: "tui.toast.show" + properties: { + title?: string + message: string + variant: "info" | "success" | "warning" | "error" + duration?: number + } +} + +export type EventTuiSessionSelect = { + id: string + type: "tui.session.select" + properties: { + /** + * Session ID to navigate to + */ + sessionID: string + } +} + export type Project = { id: string worktree: string @@ -778,12 +778,6 @@ export type GlobalEvent = { project?: string workspace?: string payload: - | EventTuiPromptAppend - | EventTuiCommandExecute - | EventTuiToastShow - | EventTuiSessionSelect - | EventServerConnected - | EventGlobalDisposed | EventServerInstanceDisposed | EventFileEdited | EventFileWatcherUpdated @@ -803,6 +797,10 @@ export type GlobalEvent = { | EventSessionStatus | EventSessionIdle | EventSessionCompacted + | EventTuiPromptAppend + | EventTuiCommandExecute + | EventTuiToastShow + | EventTuiSessionSelect | EventMcpToolsChanged | EventMcpBrowserOpenFailed | EventCommandExecuted @@ -850,6 +848,8 @@ export type GlobalEvent = { | EventSessionNextCompactionStarted | EventSessionNextCompactionDelta | EventSessionNextCompactionEnded + | EventServerConnected + | EventGlobalDisposed | SyncEventMessageUpdated | SyncEventMessageRemoved | SyncEventMessagePartUpdated @@ -2330,22 +2330,6 @@ export type SyncEventSessionNextCompactionEnded = { } } -export type EventServerConnected = { - id: string - type: "server.connected" - properties: { - [key: string]: unknown - } -} - -export type EventGlobalDisposed = { - id: string - type: "global.disposed" - properties: { - [key: string]: unknown - } -} - export type EventServerInstanceDisposed = { id: string type: "server.instance.disposed" @@ -3060,6 +3044,22 @@ export type EventSessionNextCompactionEnded = { } } +export type EventServerConnected = { + id: string + type: "server.connected" + properties: { + [key: string]: unknown + } +} + +export type EventGlobalDisposed = { + id: string + type: "global.disposed" + properties: { + [key: string]: unknown + } +} + export type SessionInfo = { id: string parentID?: string diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 73cb5c3318..40b33147bf 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -8868,24 +8868,6 @@ "schemas": { "Event": { "anyOf": [ - { - "$ref": "#/components/schemas/Event.tui.prompt.append" - }, - { - "$ref": "#/components/schemas/Event.tui.command.execute" - }, - { - "$ref": "#/components/schemas/EventTuiToastShow1" - }, - { - "$ref": "#/components/schemas/Event.tui.session.select" - }, - { - "$ref": "#/components/schemas/EventServerConnected" - }, - { - "$ref": "#/components/schemas/EventGlobalDisposed" - }, { "$ref": "#/components/schemas/EventServerInstanceDisposed" }, @@ -8943,6 +8925,18 @@ { "$ref": "#/components/schemas/EventSessionCompacted" }, + { + "$ref": "#/components/schemas/Event.tui.prompt.append" + }, + { + "$ref": "#/components/schemas/Event.tui.command.execute" + }, + { + "$ref": "#/components/schemas/EventTuiToastShow1" + }, + { + "$ref": "#/components/schemas/Event.tui.session.select" + }, { "$ref": "#/components/schemas/EventMcpToolsChanged" }, @@ -9083,6 +9077,12 @@ }, { "$ref": "#/components/schemas/EventSessionNextCompactionEnded" + }, + { + "$ref": "#/components/schemas/EventServerConnected" + }, + { + "$ref": "#/components/schemas/EventGlobalDisposed" } ] }, @@ -9163,140 +9163,6 @@ } ] }, - "Event.tui.prompt.append": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["tui.prompt.append"] - }, - "properties": { - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, - "Event.tui.command.execute": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["tui.command.execute"] - }, - "properties": { - "type": "object", - "properties": { - "command": { - "anyOf": [ - { - "type": "string", - "enum": [ - "session.list", - "session.new", - "session.share", - "session.interrupt", - "session.compact", - "session.page.up", - "session.page.down", - "session.line.up", - "session.line.down", - "session.half.page.up", - "session.half.page.down", - "session.first", - "session.last", - "prompt.clear", - "prompt.submit", - "agent.cycle" - ] - }, - { - "type": "string" - } - ] - } - }, - "required": ["command"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, - "Event.tui.toast.show": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["tui.toast.show"] - }, - "properties": { - "type": "object", - "properties": { - "title": { - "type": "string" - }, - "message": { - "type": "string" - }, - "variant": { - "type": "string", - "enum": ["info", "success", "warning", "error"] - }, - "duration": { - "type": "integer", - "exclusiveMinimum": 0 - } - }, - "required": ["message", "variant"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, - "Event.tui.session.select": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["tui.session.select"] - }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses", - "description": "Session ID to navigate to" - } - }, - "required": ["sessionID"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, "PermissionRequest": { "type": "object", "properties": { @@ -9756,6 +9622,140 @@ } ] }, + "Event.tui.prompt.append": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["tui.prompt.append"] + }, + "properties": { + "type": "object", + "properties": { + "text": { + "type": "string" + } + }, + "required": ["text"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "Event.tui.command.execute": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["tui.command.execute"] + }, + "properties": { + "type": "object", + "properties": { + "command": { + "anyOf": [ + { + "type": "string", + "enum": [ + "session.list", + "session.new", + "session.share", + "session.interrupt", + "session.compact", + "session.page.up", + "session.page.down", + "session.line.up", + "session.line.down", + "session.half.page.up", + "session.half.page.down", + "session.first", + "session.last", + "prompt.clear", + "prompt.submit", + "agent.cycle" + ] + }, + { + "type": "string" + } + ] + } + }, + "required": ["command"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "Event.tui.toast.show": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["tui.toast.show"] + }, + "properties": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "message": { + "type": "string" + }, + "variant": { + "type": "string", + "enum": ["info", "success", "warning", "error"] + }, + "duration": { + "type": "integer", + "exclusiveMinimum": 0 + } + }, + "required": ["message", "variant"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "Event.tui.session.select": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["tui.session.select"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses", + "description": "Session ID to navigate to" + } + }, + "required": ["sessionID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, "Project": { "type": "object", "properties": { @@ -11129,24 +11129,6 @@ }, "payload": { "anyOf": [ - { - "$ref": "#/components/schemas/Event.tui.prompt.append" - }, - { - "$ref": "#/components/schemas/Event.tui.command.execute" - }, - { - "$ref": "#/components/schemas/Event.tui.toast.show" - }, - { - "$ref": "#/components/schemas/Event.tui.session.select" - }, - { - "$ref": "#/components/schemas/EventServerConnected" - }, - { - "$ref": "#/components/schemas/EventGlobalDisposed" - }, { "$ref": "#/components/schemas/EventServerInstanceDisposed" }, @@ -11204,6 +11186,18 @@ { "$ref": "#/components/schemas/EventSessionCompacted" }, + { + "$ref": "#/components/schemas/Event.tui.prompt.append" + }, + { + "$ref": "#/components/schemas/Event.tui.command.execute" + }, + { + "$ref": "#/components/schemas/Event.tui.toast.show" + }, + { + "$ref": "#/components/schemas/Event.tui.session.select" + }, { "$ref": "#/components/schemas/EventMcpToolsChanged" }, @@ -11345,6 +11339,12 @@ { "$ref": "#/components/schemas/EventSessionNextCompactionEnded" }, + { + "$ref": "#/components/schemas/EventServerConnected" + }, + { + "$ref": "#/components/schemas/EventGlobalDisposed" + }, { "$ref": "#/components/schemas/SyncEventMessageUpdated" }, @@ -15843,42 +15843,6 @@ "required": ["type", "name", "id", "seq", "aggregateID", "data"], "additionalProperties": false }, - "EventServerConnected": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["server.connected"] - }, - "properties": { - "type": "object", - "properties": {} - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, - "EventGlobalDisposed": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["global.disposed"] - }, - "properties": { - "type": "object", - "properties": {} - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, "EventServerInstanceDisposed": { "type": "object", "properties": { @@ -18047,6 +18011,42 @@ "required": ["id", "type", "properties"], "additionalProperties": false }, + "EventServerConnected": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["server.connected"] + }, + "properties": { + "type": "object", + "properties": {} + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventGlobalDisposed": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["global.disposed"] + }, + "properties": { + "type": "object", + "properties": {} + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, "SessionInfo": { "type": "object", "properties": { From fa15dbc5ec7f5b7698ff4cdd418400b373bc3964 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sun, 10 May 2026 12:25:44 -0400 Subject: [PATCH 140/189] Migrate compaction process tests (#26723) --- .../opencode/test/session/compaction.test.ts | 149 ++++++++---------- 1 file changed, 63 insertions(+), 86 deletions(-) diff --git a/packages/opencode/test/session/compaction.test.ts b/packages/opencode/test/session/compaction.test.ts index 2b03e35944..990733dbd9 100644 --- a/packages/opencode/test/session/compaction.test.ts +++ b/packages/opencode/test/session/compaction.test.ts @@ -285,7 +285,7 @@ function runtime( } const deps = Layer.mergeAll( - ProviderTest.fake().layer, + wide().layer, layer("continue"), Agent.defaultLayer, Plugin.defaultLayer, @@ -882,55 +882,42 @@ describe("session.compaction.process", () => { }), ) - test("publishes compacted event on continue", async () => { - await using tmp = await tmpdir() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const session = await svc.create({}) - const msg = await user(session.id, "hello") - const msgs = await svc.messages({ sessionID: session.id }) - const done = defer() - let seen = false - const rt = runtime("continue", Plugin.defaultLayer, wide()) - let unsub: (() => void) | undefined - try { - unsub = await rt.runPromise( - Bus.Service.use((svc) => - svc.subscribeCallback(SessionCompaction.Event.Compacted, (evt) => { - if (evt.properties.sessionID !== session.id) return - seen = true - done.resolve() - }), - ), - ) + it.instance( + "publishes compacted event on continue", + Effect.gen(function* () { + const bus = yield* Bus.Service + const ssn = yield* SessionNs.Service + const session = yield* ssn.create({}) + const msg = yield* createUserMessage(session.id, "hello") + const msgs = yield* ssn.messages({ sessionID: session.id }) + const done = defer() + let seen = false + const unsub = yield* bus.subscribeCallback(SessionCompaction.Event.Compacted, (evt) => { + if (evt.properties.sessionID !== session.id) return + seen = true + done.resolve() + }) + yield* Effect.addFinalizer(() => Effect.sync(unsub)) - const result = await rt.runPromise( - SessionCompaction.Service.use((svc) => - svc.process({ - parentID: msg.id, - messages: msgs, - sessionID: session.id, - auto: false, - }), - ), - ) + const result = yield* SessionCompaction.use.process({ + parentID: msg.id, + messages: msgs, + sessionID: session.id, + auto: false, + }) - await Promise.race([ - done.promise, - wait(500).then(() => { - throw new Error("timed out waiting for compacted event") - }), - ]) - expect(result).toBe("continue") - expect(seen).toBe(true) - } finally { - unsub?.() - await rt.dispose() - } - }, - }) - }) + yield* Effect.promise(() => + Promise.race([ + done.promise, + wait(500).then(() => { + throw new Error("timed out waiting for compacted event") + }), + ]), + ) + expect(result).toBe("continue") + expect(seen).toBe(true) + }), + ) test("marks summary message as errored on compact result", async () => { await using tmp = await tmpdir() @@ -970,46 +957,36 @@ describe("session.compaction.process", () => { }) }) - test("adds synthetic continue prompt when auto is enabled", async () => { - await using tmp = await tmpdir() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const session = await svc.create({}) - const msg = await user(session.id, "hello") - const rt = runtime("continue", Plugin.defaultLayer, wide()) - try { - const msgs = await svc.messages({ sessionID: session.id }) - const result = await rt.runPromise( - SessionCompaction.Service.use((svc) => - svc.process({ - parentID: msg.id, - messages: msgs, - sessionID: session.id, - auto: true, - }), - ), - ) + it.instance( + "adds synthetic continue prompt when auto is enabled", + Effect.gen(function* () { + const ssn = yield* SessionNs.Service + const session = yield* ssn.create({}) + const msg = yield* createUserMessage(session.id, "hello") + const msgs = yield* ssn.messages({ sessionID: session.id }) - const all = await svc.messages({ sessionID: session.id }) - const last = all.at(-1) + const result = yield* SessionCompaction.use.process({ + parentID: msg.id, + messages: msgs, + sessionID: session.id, + auto: true, + }) - expect(result).toBe("continue") - expect(last?.info.role).toBe("user") - expect(last?.parts[0]).toMatchObject({ - type: "text", - synthetic: true, - metadata: { compaction_continue: true }, - }) - if (last?.parts[0]?.type === "text") { - expect(last.parts[0].text).toContain("Continue if you have next steps") - } - } finally { - await rt.dispose() - } - }, - }) - }) + const all = yield* ssn.messages({ sessionID: session.id }) + const last = all.at(-1) + + expect(result).toBe("continue") + expect(last?.info.role).toBe("user") + expect(last?.parts[0]).toMatchObject({ + type: "text", + synthetic: true, + metadata: { compaction_continue: true }, + }) + if (last?.parts[0]?.type === "text") { + expect(last.parts[0].text).toContain("Continue if you have next steps") + } + }), + ) test("persists tail_start_id for retained recent turns", async () => { await using tmp = await tmpdir() From 2bd3d9a6962914bce6dec692fa34d474317230db Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sun, 10 May 2026 12:29:55 -0400 Subject: [PATCH 141/189] refactor(http-recorder): hide cassette format behind Cassette seam (#26725) --- packages/http-recorder/README.md | 21 +- packages/http-recorder/src/cassette.ts | 196 +++++++++++------- packages/http-recorder/src/effect.ts | 2 +- packages/http-recorder/src/index.ts | 4 +- packages/http-recorder/src/recorder.ts | 37 ++-- packages/http-recorder/src/redaction.ts | 3 - packages/http-recorder/src/schema.ts | 5 +- packages/http-recorder/src/storage.ts | 28 --- .../http-recorder/test/record-replay.test.ts | 158 ++++++-------- packages/llm/test/recorded-test.ts | 2 +- 10 files changed, 225 insertions(+), 231 deletions(-) delete mode 100644 packages/http-recorder/src/storage.ts diff --git a/packages/http-recorder/README.md b/packages/http-recorder/README.md index 134c7b316b..09bb3af5a5 100644 --- a/packages/http-recorder/README.md +++ b/packages/http-recorder/README.md @@ -160,9 +160,9 @@ const program = Effect.gen(function* () { ## Inspecting cassettes programmatically -`Cassette.Service` exposes `read`, `write`, `append`, `exists`, `list`, and -`scan` (re-running the secret detector over an existing cassette). Useful -for CI checks: +`Cassette.Service` exposes `read`, `append`, `exists`, and `list`. `read` +returns the recorded interactions for a name; the file format is hidden +behind the seam. Useful for CI checks: ```ts import { HttpRecorder } from "@opencode-ai/http-recorder" @@ -170,13 +170,22 @@ import { Effect } from "effect" const audit = Effect.gen(function* () { const cassettes = yield* HttpRecorder.Cassette.Service - const findings = yield* Effect.forEach(yield* cassettes.list(), (entry) => - cassettes.read(entry.name).pipe(Effect.map((c) => ({ entry, findings: cassettes.scan(c) }))), + const entries = yield* cassettes.list() + const issues = yield* Effect.forEach(entries, (entry) => + cassettes.read(entry.name).pipe( + Effect.map((interactions) => ({ name: entry.name, findings: HttpRecorder.secretFindings(interactions) })), + ), ) - return findings.filter((r) => r.findings.length > 0) + return issues.filter((i) => i.findings.length > 0) }) ``` +`cassetteLayer` is the batteries-included entry point — it provides +`Cassette.fileSystem({ directory })` automatically. If you want to provide +your own `Cassette.Service` (e.g. an in-memory adapter for the recorder's +own unit tests), use `recordingLayer` and supply `Cassette.fileSystem` / +`Cassette.memory` yourself. + ## Options reference ```ts diff --git a/packages/http-recorder/src/cassette.ts b/packages/http-recorder/src/cassette.ts index bf7c6ae998..4e574da628 100644 --- a/packages/http-recorder/src/cassette.ts +++ b/packages/http-recorder/src/cassette.ts @@ -1,62 +1,80 @@ -import { Context, Effect, FileSystem, Layer, PlatformError } from "effect" +import { Context, Data, Effect, FileSystem, Layer } from "effect" +import * as fs from "node:fs" import * as path from "node:path" -import { cassetteSecretFindings, secretFindings, type SecretFinding } from "./redaction" -import type { Cassette, CassetteMetadata, Interaction } from "./schema" -import { cassetteFor, cassettePath, DEFAULT_RECORDINGS_DIR, formatCassette, parseCassette } from "./storage" +import { secretFindings, type SecretFinding } from "./redaction" +import { decodeCassette, encodeCassette, type Cassette, type CassetteMetadata, type Interaction } from "./schema" -export interface Entry { - readonly name: string - readonly path: string +const DEFAULT_RECORDINGS_DIR = path.resolve(process.cwd(), "test", "fixtures", "recordings") + +export class CassetteNotFoundError extends Data.TaggedError("CassetteNotFoundError")<{ + readonly cassetteName: string +}> { + override get message() { + return `Cassette "${this.cassetteName}" not found` + } +} + +export interface AppendResult { + readonly findings: ReadonlyArray } export interface Interface { - readonly path: (name: string) => string - readonly read: (name: string) => Effect.Effect - readonly write: (name: string, cassette: Cassette) => Effect.Effect + readonly read: (name: string) => Effect.Effect, CassetteNotFoundError> readonly append: ( name: string, interaction: Interaction, - metadata: CassetteMetadata | undefined, - ) => Effect.Effect< - { - readonly cassette: Cassette - readonly findings: ReadonlyArray - }, - PlatformError.PlatformError - > + metadata?: CassetteMetadata, + ) => Effect.Effect readonly exists: (name: string) => Effect.Effect - readonly list: () => Effect.Effect, PlatformError.PlatformError> - readonly scan: (cassette: Cassette) => ReadonlyArray + readonly list: () => Effect.Effect> } export class Service extends Context.Service()("@opencode-ai/http-recorder/Cassette") {} -export const layer = (options: { readonly directory?: string } = {}) => +export const hasCassetteSync = (name: string, options: { readonly directory?: string } = {}) => + fs.existsSync(path.join(options.directory ?? DEFAULT_RECORDINGS_DIR, `${name}.json`)) + +const buildCassette = ( + name: string, + interactions: ReadonlyArray, + metadata: CassetteMetadata | undefined, +): Cassette => ({ + version: 1, + metadata: { name, recordedAt: new Date().toISOString(), ...(metadata ?? {}) }, + interactions, +}) + +const formatCassette = (cassette: Cassette) => `${JSON.stringify(encodeCassette(cassette), null, 2)}\n` + +const parseCassette = (raw: string) => decodeCassette(JSON.parse(raw)) + +export const fileSystem = ( + options: { readonly directory?: string } = {}, +): Layer.Layer => Layer.effect( Service, Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem + const fs = yield* FileSystem.FileSystem const directory = options.directory ?? DEFAULT_RECORDINGS_DIR const recorded = new Map() const directoriesEnsured = new Set() - const pathFor = (name: string) => cassettePath(name, directory) + const cassettePath = (name: string) => path.join(directory, `${name}.json`) - const ensureDirectory = Effect.fn("Cassette.ensureDirectory")(function* (name: string) { - const dir = path.dirname(pathFor(name)) - if (directoriesEnsured.has(dir)) return - yield* fileSystem.makeDirectory(dir, { recursive: true }) - directoriesEnsured.add(dir) - }) - - const walk = (directory: string): Effect.Effect, PlatformError.PlatformError> => + const ensureDirectory = (name: string) => Effect.gen(function* () { - const entries = yield* fileSystem - .readDirectory(directory) - .pipe(Effect.catch(() => Effect.succeed([] as string[]))) + const dir = path.dirname(cassettePath(name)) + if (directoriesEnsured.has(dir)) return + yield* fs.makeDirectory(dir, { recursive: true }).pipe(Effect.orDie) + directoriesEnsured.add(dir) + }) + + const walk = (current: string): Effect.Effect> => + Effect.gen(function* () { + const entries = yield* fs.readDirectory(current).pipe(Effect.catch(() => Effect.succeed([] as string[]))) const nested = yield* Effect.forEach(entries, (entry) => { - const full = path.join(directory, entry) - return fileSystem.stat(full).pipe( + const full = path.join(current, entry) + return fs.stat(full).pipe( Effect.flatMap((stat) => (stat.type === "Directory" ? walk(full) : Effect.succeed([full]))), Effect.catch(() => Effect.succeed([] as string[])), ) @@ -64,50 +82,68 @@ export const layer = (options: { readonly directory?: string } = {}) => return nested.flat() }) - const read = Effect.fn("Cassette.read")(function* (name: string) { - return parseCassette(yield* fileSystem.readFileString(pathFor(name))) + return Service.of({ + read: (name) => + fs.readFileString(cassettePath(name)).pipe( + Effect.map((raw) => parseCassette(raw).interactions), + Effect.catch(() => Effect.fail(new CassetteNotFoundError({ cassetteName: name }))), + ), + append: (name, interaction, metadata) => + Effect.gen(function* () { + const entry = recorded.get(name) ?? { interactions: [], findings: [] } + if (!recorded.has(name)) recorded.set(name, entry) + entry.interactions.push(interaction) + entry.findings.push(...secretFindings(interaction)) + const cassette = buildCassette(name, entry.interactions, metadata) + const findings = [...entry.findings, ...secretFindings(cassette.metadata ?? {})] + if (findings.length === 0) { + yield* ensureDirectory(name) + yield* fs.writeFileString(cassettePath(name), formatCassette(cassette)).pipe(Effect.orDie) + } + return { findings } + }), + exists: (name) => + fs.access(cassettePath(name)).pipe( + Effect.as(true), + Effect.catch(() => Effect.succeed(false)), + ), + list: () => + walk(directory).pipe( + Effect.map((files) => + files + .filter((file) => file.endsWith(".json")) + .map((file) => path.relative(directory, file).replace(/\\/g, "/").replace(/\.json$/, "")) + .toSorted((a, b) => a.localeCompare(b)), + ), + ), }) - - const write = Effect.fn("Cassette.write")(function* (name: string, cassette: Cassette) { - yield* ensureDirectory(name) - yield* fileSystem.writeFileString(pathFor(name), formatCassette(cassette)) - }) - - const append = Effect.fn("Cassette.append")(function* ( - name: string, - interaction: Interaction, - metadata: CassetteMetadata | undefined, - ) { - const entry = recorded.get(name) ?? { interactions: [], findings: [] } - entry.interactions.push(interaction) - entry.findings.push(...secretFindings(interaction)) - recorded.set(name, entry) - const cassette = cassetteFor(name, entry.interactions, metadata) - const findings = [...entry.findings, ...secretFindings(cassette.metadata ?? {})] - if (findings.length === 0) yield* write(name, cassette) - return { cassette, findings } - }) - - const exists = Effect.fn("Cassette.exists")(function* (name: string) { - return yield* fileSystem.access(pathFor(name)).pipe( - Effect.as(true), - Effect.catch(() => Effect.succeed(false)), - ) - }) - - const list = Effect.fn("Cassette.list")(function* () { - return (yield* walk(directory)) - .filter((file) => file.endsWith(".json")) - .map((file) => ({ - name: path - .relative(directory, file) - .replace(/\\/g, "/") - .replace(/\.json$/, ""), - path: file, - })) - .toSorted((a, b) => a.name.localeCompare(b.name)) - }) - - return Service.of({ path: pathFor, read, write, append, exists, list, scan: cassetteSecretFindings }) }), ) + +export const memory = (initial: Record> = {}): Layer.Layer => + Layer.sync(Service, () => { + const stored = new Map( + Object.entries(initial).map(([name, interactions]) => [name, [...interactions]]), + ) + const accumulatedFindings = new Map() + + return Service.of({ + read: (name) => + stored.has(name) + ? Effect.succeed(stored.get(name) ?? []) + : Effect.fail(new CassetteNotFoundError({ cassetteName: name })), + append: (name, interaction, metadata) => + Effect.sync(() => { + const existing = stored.get(name) + if (existing) existing.push(interaction) + else stored.set(name, [interaction]) + const findings = accumulatedFindings.get(name) + if (findings) findings.push(...secretFindings(interaction)) + else accumulatedFindings.set(name, [...secretFindings(interaction)]) + if (metadata) accumulatedFindings.get(name)!.push(...secretFindings({ name, ...metadata })) + return { findings: accumulatedFindings.get(name) ?? [] } + }), + exists: (name) => Effect.sync(() => stored.has(name)), + list: () => Effect.sync(() => Array.from(stored.keys()).toSorted()), + }) + }) diff --git a/packages/http-recorder/src/effect.ts b/packages/http-recorder/src/effect.ts index 1583f327d3..e6c3ccbc15 100644 --- a/packages/http-recorder/src/effect.ts +++ b/packages/http-recorder/src/effect.ts @@ -138,7 +138,7 @@ export const recordingLayer = ( export const cassetteLayer = (name: string, options: RecordReplayOptions = {}): Layer.Layer => recordingLayer(name, options).pipe( - Layer.provide(CassetteService.layer({ directory: options.directory })), + Layer.provide(CassetteService.fileSystem({ directory: options.directory })), Layer.provide(FetchHttpClient.layer), Layer.provide(NodeFileSystem.layer), ) diff --git a/packages/http-recorder/src/index.ts b/packages/http-recorder/src/index.ts index 1ebac49e5a..4b47e4513d 100644 --- a/packages/http-recorder/src/index.ts +++ b/packages/http-recorder/src/index.ts @@ -7,9 +7,9 @@ export type { WebSocketFrame, WebSocketInteraction, } from "./schema" -export { hasCassetteSync } from "./storage" +export { CassetteNotFoundError, hasCassetteSync } from "./cassette" export { defaultMatcher, type RequestMatcher } from "./matching" -export { cassetteSecretFindings, redactHeaders, redactUrl, type SecretFinding } from "./redaction" +export { redactHeaders, redactUrl, secretFindings, type SecretFinding } from "./redaction" export { UnsafeCassetteError } from "./recorder" export { cassetteLayer, recordingLayer, type RecordReplayMode, type RecordReplayOptions } from "./effect" export { diff --git a/packages/http-recorder/src/recorder.ts b/packages/http-recorder/src/recorder.ts index c63e3b3105..0fd28541eb 100644 --- a/packages/http-recorder/src/recorder.ts +++ b/packages/http-recorder/src/recorder.ts @@ -1,19 +1,17 @@ -import { Effect, PlatformError, Ref, Scope } from "effect" +import { Data, Effect, Ref, Scope } from "effect" import type * as CassetteService from "./cassette" +import type { CassetteNotFoundError } from "./cassette" import type { SecretFinding } from "./redaction" -import type { Cassette, CassetteMetadata, Interaction } from "./schema" +import type { CassetteMetadata, Interaction } from "./schema" -export class UnsafeCassetteError extends Error { - readonly _tag = "UnsafeCassetteError" - constructor( - readonly cassetteName: string, - readonly findings: ReadonlyArray, - ) { - super( - `Refusing to write cassette "${cassetteName}" because it contains possible secrets: ${findings - .map((finding) => `${finding.path} (${finding.reason})`) - .join(", ")}`, - ) +export class UnsafeCassetteError extends Data.TaggedError("UnsafeCassetteError")<{ + readonly cassetteName: string + readonly findings: ReadonlyArray +}> { + override get message() { + return `Refusing to write cassette "${this.cassetteName}" because it contains possible secrets: ${this.findings + .map((finding) => `${finding.path} (${finding.reason})`) + .join(", ")}` } } @@ -35,16 +33,17 @@ export const appendOrFail = ( name: string, interaction: Interaction, metadata: CassetteMetadata | undefined, -): Effect.Effect => +): Effect.Effect => cassette.append(name, interaction, metadata).pipe( - Effect.orDie, - Effect.flatMap(({ cassette: result, findings }) => - findings.length === 0 ? Effect.succeed(result) : Effect.fail(new UnsafeCassetteError(name, findings)), + Effect.flatMap(({ findings }) => + findings.length === 0 + ? Effect.void + : Effect.fail(new UnsafeCassetteError({ cassetteName: name, findings })), ), ) export interface ReplayState { - readonly load: Effect.Effect, PlatformError.PlatformError> + readonly load: Effect.Effect, CassetteNotFoundError> readonly cursor: Effect.Effect readonly advance: Effect.Effect } @@ -52,7 +51,7 @@ export interface ReplayState { export const makeReplayState = ( cassette: CassetteService.Interface, name: string, - project: (cassette: Cassette) => ReadonlyArray, + project: (interactions: ReadonlyArray) => ReadonlyArray, ): Effect.Effect, never, Scope.Scope> => Effect.gen(function* () { const load = yield* Effect.cached(cassette.read(name).pipe(Effect.map(project))) diff --git a/packages/http-recorder/src/redaction.ts b/packages/http-recorder/src/redaction.ts index 3a8b097839..59adedb4c2 100644 --- a/packages/http-recorder/src/redaction.ts +++ b/packages/http-recorder/src/redaction.ts @@ -1,5 +1,3 @@ -import type { Cassette } from "./schema" - export const REDACTED = "[REDACTED]" const DEFAULT_REDACT_HEADERS = [ @@ -113,4 +111,3 @@ export const secretFindings = (value: unknown): ReadonlyArray => .map((item) => ({ path: entry.path, reason: `environment secret ${item.name}` })), ]) -export const cassetteSecretFindings = (cassette: Cassette) => secretFindings(cassette) diff --git a/packages/http-recorder/src/schema.ts b/packages/http-recorder/src/schema.ts index 2692b525b4..113769c7b7 100644 --- a/packages/http-recorder/src/schema.ts +++ b/packages/http-recorder/src/schema.ts @@ -52,9 +52,10 @@ export const isHttpInteraction = InteractionSchema.guards.http export const isWebSocketInteraction = InteractionSchema.guards.websocket -export const httpInteractions = (cassette: Cassette) => cassette.interactions.filter(isHttpInteraction) +export const httpInteractions = (interactions: ReadonlyArray) => interactions.filter(isHttpInteraction) -export const webSocketInteractions = (cassette: Cassette) => cassette.interactions.filter(isWebSocketInteraction) +export const webSocketInteractions = (interactions: ReadonlyArray) => + interactions.filter(isWebSocketInteraction) export const CassetteSchema = Schema.Struct({ version: Schema.Literal(1), diff --git a/packages/http-recorder/src/storage.ts b/packages/http-recorder/src/storage.ts deleted file mode 100644 index e8f7869f6e..0000000000 --- a/packages/http-recorder/src/storage.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { Option } from "effect" -import * as fs from "node:fs" -import * as path from "node:path" -import { encodeCassette, decodeCassette, type Cassette, type CassetteMetadata, type Interaction } from "./schema" - -export const DEFAULT_RECORDINGS_DIR = path.resolve(process.cwd(), "test", "fixtures", "recordings") - -export const cassettePath = (name: string, directory = DEFAULT_RECORDINGS_DIR) => path.join(directory, `${name}.json`) - -export const cassetteFor = ( - name: string, - interactions: ReadonlyArray, - metadata: CassetteMetadata | undefined, -): Cassette => ({ - version: 1, - metadata: { name, recordedAt: new Date().toISOString(), ...(metadata ?? {}) }, - interactions, -}) - -export const formatCassette = (cassette: Cassette) => `${JSON.stringify(encodeCassette(cassette), null, 2)}\n` - -export const parseCassette = (raw: string) => decodeCassette(JSON.parse(raw)) - -export const hasCassetteSync = (name: string, options: { readonly directory?: string } = {}) => { - const file = cassettePath(name, options.directory) - if (!fs.existsSync(file)) return false - return Option.isSome(Option.liftThrowable(parseCassette)(fs.readFileSync(file, "utf8"))) -} diff --git a/packages/http-recorder/test/record-replay.test.ts b/packages/http-recorder/test/record-replay.test.ts index 117620cc64..f75140e8e8 100644 --- a/packages/http-recorder/test/record-replay.test.ts +++ b/packages/http-recorder/test/record-replay.test.ts @@ -7,7 +7,18 @@ import * as os from "node:os" import * as path from "node:path" import { HttpRecorder } from "../src" import { redactedErrorRequest } from "../src/effect" -import { cassetteFor, formatCassette, parseCassette } from "../src/storage" +import type { Interaction } from "../src/schema" + +const seedCassetteDirectory = (directory: string, name: string, interactions: ReadonlyArray) => + Effect.runPromise( + Effect.gen(function* () { + const cassette = yield* HttpRecorder.Cassette.Service + yield* Effect.forEach(interactions, (interaction) => cassette.append(name, interaction)) + }).pipe( + Effect.provide(HttpRecorder.Cassette.fileSystem({ directory })), + Effect.provide(NodeFileSystem.layer), + ), + ) const post = (url: string, body: object) => Effect.gen(function* () { @@ -34,7 +45,7 @@ const runRecorder = (effect: Effect.Effect { test("detects secret-looking values without returning the secret", () => { expect( - HttpRecorder.cassetteSecretFindings({ + HttpRecorder.secretFindings({ version: 1, interactions: [ { @@ -137,7 +148,7 @@ describe("http-recorder", () => { test("detects secret-looking values inside metadata", () => { expect( - HttpRecorder.cassetteSecretFindings({ + HttpRecorder.secretFindings({ version: 1, metadata: { token: "sk-123456789012345678901234" }, interactions: [], @@ -145,60 +156,42 @@ describe("http-recorder", () => { ).toEqual([{ path: "metadata.token", reason: "API key" }]) }) - test("formats websocket cassettes with shared metadata", () => { - const cassette = cassetteFor( - "websocket/basic", - [ - { - transport: "websocket", - open: { url: "wss://example.test/realtime", headers: { "content-type": "application/json" } }, - client: [{ kind: "text", body: JSON.stringify({ type: "response.create" }) }], - server: [{ kind: "text", body: JSON.stringify({ type: "response.completed" }) }], - }, - ], - { provider: "openai" }, - ) + test("replays websocket interactions seeded into the in-memory cassette adapter", async () => { + await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const cassette = yield* HttpRecorder.Cassette.Service + const executor = yield* HttpRecorder.makeWebSocketExecutor({ + name: "websocket/replay", + cassette, + compareClientMessagesAsJson: true, + live: { open: () => Effect.die(new Error("unexpected live WebSocket open")) }, + }) + const connection = yield* executor.open({ + url: "wss://example.test/realtime", + headers: Headers.fromInput({ "content-type": "application/json" }), + }) + yield* connection.sendText(JSON.stringify({ type: "response.create" })) + const messages: Array = [] + yield* connection.messages.pipe(Stream.runForEach((message) => Effect.sync(() => messages.push(message)))) + yield* connection.close - expect(cassette.metadata).toMatchObject({ name: "websocket/basic", provider: "openai" }) - expect(parseCassette(formatCassette(cassette))).toEqual(cassette) - }) - - test("replays websocket interactions from the shared cassette service", async () => { - await runRecorder( - Effect.gen(function* () { - const cassette = yield* HttpRecorder.Cassette.Service - yield* cassette.write( - "websocket/replay", - cassetteFor( - "websocket/replay", - [ - { - transport: "websocket", - open: { url: "wss://example.test/realtime", headers: { "content-type": "application/json" } }, - client: [{ kind: "text", body: JSON.stringify({ type: "response.create" }) }], - server: [{ kind: "text", body: JSON.stringify({ type: "response.completed" }) }], - }, - ], - undefined, + expect(messages).toEqual([JSON.stringify({ type: "response.completed" })]) + }).pipe( + Effect.provide( + HttpRecorder.Cassette.memory({ + "websocket/replay": [ + { + transport: "websocket", + open: { url: "wss://example.test/realtime", headers: { "content-type": "application/json" } }, + client: [{ kind: "text", body: JSON.stringify({ type: "response.create" }) }], + server: [{ kind: "text", body: JSON.stringify({ type: "response.completed" }) }], + }, + ], + }), ), - ) - const executor = yield* HttpRecorder.makeWebSocketExecutor({ - name: "websocket/replay", - cassette, - compareClientMessagesAsJson: true, - live: { open: () => Effect.die(new Error("unexpected live WebSocket open")) }, - }) - const connection = yield* executor.open({ - url: "wss://example.test/realtime", - headers: Headers.fromInput({ "content-type": "application/json" }), - }) - yield* connection.sendText(JSON.stringify({ type: "response.create" })) - const messages: Array = [] - yield* connection.messages.pipe(Stream.runForEach((message) => Effect.sync(() => messages.push(message)))) - yield* connection.close - - expect(messages).toEqual([JSON.stringify({ type: "response.completed" })]) - }), + ), + ), ) }) @@ -228,17 +221,14 @@ describe("http-recorder", () => { yield* connection.messages.pipe(Stream.runDrain) yield* connection.close - expect(yield* cassette.read("websocket/record")).toMatchObject({ - metadata: { name: "websocket/record", provider: "test" }, - interactions: [ - { - transport: "websocket", - open: { url: "wss://example.test/realtime", headers: { "content-type": "application/json" } }, - client: [{ kind: "text", body: JSON.stringify({ type: "response.create" }) }], - server: [{ kind: "text", body: JSON.stringify({ type: "response.completed" }) }], - }, - ], - }) + expect(yield* cassette.read("websocket/record")).toMatchObject([ + { + transport: "websocket", + open: { url: "wss://example.test/realtime", headers: { "content-type": "application/json" } }, + client: [{ kind: "text", body: JSON.stringify({ type: "response.create" }) }], + server: [{ kind: "text", body: JSON.stringify({ type: "response.completed" }) }], + }, + ]) }), ) }) @@ -303,28 +293,18 @@ describe("http-recorder", () => { test("auto mode replays when the cassette exists", async () => { const directory = fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-auto-")) - const cassettePath = path.join(directory, "auto-replay.json") - fs.writeFileSync( - cassettePath, - formatCassette( - cassetteFor( - "auto-replay", - [ - { - transport: "http", - request: { - method: "POST", - url: "https://example.test/echo", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ step: 1 }), - }, - response: { status: 200, headers: { "content-type": "application/json" }, body: '{"reply":"hi"}' }, - }, - ], - undefined, - ), - ), - ) + await seedCassetteDirectory(directory, "auto-replay", [ + { + transport: "http", + request: { + method: "POST", + url: "https://example.test/echo", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ step: 1 }), + }, + response: { status: 200, headers: { "content-type": "application/json" }, body: '{"reply":"hi"}' }, + }, + ]) const result = await runWith( "auto-replay", diff --git a/packages/llm/test/recorded-test.ts b/packages/llm/test/recorded-test.ts index 6514f13dad..62e51337d9 100644 --- a/packages/llm/test/recorded-test.ts +++ b/packages/llm/test/recorded-test.ts @@ -53,7 +53,7 @@ export const recordedTests = (options: RecordedTestsOptions) => ...metadata, } const mode = recorderOptions?.mode ?? (recording ? "record" : "replay") - const cassetteService = HttpRecorder.Cassette.layer({ directory: FIXTURES_DIR }).pipe( + const cassetteService = HttpRecorder.Cassette.fileSystem({ directory: FIXTURES_DIR }).pipe( Layer.provide(NodeFileSystem.layer), ) const requestExecutor = RequestExecutor.layer.pipe( From b616543ac2f5effb18dc463882ba878c89f4aa67 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sun, 10 May 2026 16:30:55 +0000 Subject: [PATCH 142/189] chore: generate --- packages/http-recorder/README.md | 6 +++--- packages/http-recorder/src/cassette.ts | 13 +++++++------ packages/http-recorder/src/recorder.ts | 14 +++++++------- packages/http-recorder/src/redaction.ts | 1 - packages/http-recorder/test/record-replay.test.ts | 5 +---- 5 files changed, 18 insertions(+), 21 deletions(-) diff --git a/packages/http-recorder/README.md b/packages/http-recorder/README.md index 09bb3af5a5..f6aaed4358 100644 --- a/packages/http-recorder/README.md +++ b/packages/http-recorder/README.md @@ -172,9 +172,9 @@ const audit = Effect.gen(function* () { const cassettes = yield* HttpRecorder.Cassette.Service const entries = yield* cassettes.list() const issues = yield* Effect.forEach(entries, (entry) => - cassettes.read(entry.name).pipe( - Effect.map((interactions) => ({ name: entry.name, findings: HttpRecorder.secretFindings(interactions) })), - ), + cassettes + .read(entry.name) + .pipe(Effect.map((interactions) => ({ name: entry.name, findings: HttpRecorder.secretFindings(interactions) }))), ) return issues.filter((i) => i.findings.length > 0) }) diff --git a/packages/http-recorder/src/cassette.ts b/packages/http-recorder/src/cassette.ts index 4e574da628..d6f7e6d3af 100644 --- a/packages/http-recorder/src/cassette.ts +++ b/packages/http-recorder/src/cassette.ts @@ -20,11 +20,7 @@ export interface AppendResult { export interface Interface { readonly read: (name: string) => Effect.Effect, CassetteNotFoundError> - readonly append: ( - name: string, - interaction: Interaction, - metadata?: CassetteMetadata, - ) => Effect.Effect + readonly append: (name: string, interaction: Interaction, metadata?: CassetteMetadata) => Effect.Effect readonly exists: (name: string) => Effect.Effect readonly list: () => Effect.Effect> } @@ -112,7 +108,12 @@ export const fileSystem = ( Effect.map((files) => files .filter((file) => file.endsWith(".json")) - .map((file) => path.relative(directory, file).replace(/\\/g, "/").replace(/\.json$/, "")) + .map((file) => + path + .relative(directory, file) + .replace(/\\/g, "/") + .replace(/\.json$/, ""), + ) .toSorted((a, b) => a.localeCompare(b)), ), ), diff --git a/packages/http-recorder/src/recorder.ts b/packages/http-recorder/src/recorder.ts index 0fd28541eb..381122d31c 100644 --- a/packages/http-recorder/src/recorder.ts +++ b/packages/http-recorder/src/recorder.ts @@ -34,13 +34,13 @@ export const appendOrFail = ( interaction: Interaction, metadata: CassetteMetadata | undefined, ): Effect.Effect => - cassette.append(name, interaction, metadata).pipe( - Effect.flatMap(({ findings }) => - findings.length === 0 - ? Effect.void - : Effect.fail(new UnsafeCassetteError({ cassetteName: name, findings })), - ), - ) + cassette + .append(name, interaction, metadata) + .pipe( + Effect.flatMap(({ findings }) => + findings.length === 0 ? Effect.void : Effect.fail(new UnsafeCassetteError({ cassetteName: name, findings })), + ), + ) export interface ReplayState { readonly load: Effect.Effect, CassetteNotFoundError> diff --git a/packages/http-recorder/src/redaction.ts b/packages/http-recorder/src/redaction.ts index 59adedb4c2..07534d9520 100644 --- a/packages/http-recorder/src/redaction.ts +++ b/packages/http-recorder/src/redaction.ts @@ -110,4 +110,3 @@ export const secretFindings = (value: unknown): ReadonlyArray => .filter((item) => entry.value.includes(item.value)) .map((item) => ({ path: entry.path, reason: `environment secret ${item.name}` })), ]) - diff --git a/packages/http-recorder/test/record-replay.test.ts b/packages/http-recorder/test/record-replay.test.ts index f75140e8e8..7613563fd0 100644 --- a/packages/http-recorder/test/record-replay.test.ts +++ b/packages/http-recorder/test/record-replay.test.ts @@ -14,10 +14,7 @@ const seedCassetteDirectory = (directory: string, name: string, interactions: Re Effect.gen(function* () { const cassette = yield* HttpRecorder.Cassette.Service yield* Effect.forEach(interactions, (interaction) => cassette.append(name, interaction)) - }).pipe( - Effect.provide(HttpRecorder.Cassette.fileSystem({ directory })), - Effect.provide(NodeFileSystem.layer), - ), + }).pipe(Effect.provide(HttpRecorder.Cassette.fileSystem({ directory })), Effect.provide(NodeFileSystem.layer)), ) const post = (url: string, body: object) => From e45b6ef1dec3795d0daa3a4e8d8744a52e0d8841 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sun, 10 May 2026 12:43:33 -0400 Subject: [PATCH 143/189] refactor(http-recorder): use Schema.TaggedErrorClass for cassette errors (#26729) --- packages/http-recorder/src/cassette.ts | 9 +++++---- packages/http-recorder/src/recorder.ts | 12 ++++++------ packages/http-recorder/src/redaction.ts | 11 +++++++---- 3 files changed, 18 insertions(+), 14 deletions(-) diff --git a/packages/http-recorder/src/cassette.ts b/packages/http-recorder/src/cassette.ts index d6f7e6d3af..527afe8251 100644 --- a/packages/http-recorder/src/cassette.ts +++ b/packages/http-recorder/src/cassette.ts @@ -1,4 +1,4 @@ -import { Context, Data, Effect, FileSystem, Layer } from "effect" +import { Context, Effect, FileSystem, Layer, Schema } from "effect" import * as fs from "node:fs" import * as path from "node:path" import { secretFindings, type SecretFinding } from "./redaction" @@ -6,9 +6,10 @@ import { decodeCassette, encodeCassette, type Cassette, type CassetteMetadata, t const DEFAULT_RECORDINGS_DIR = path.resolve(process.cwd(), "test", "fixtures", "recordings") -export class CassetteNotFoundError extends Data.TaggedError("CassetteNotFoundError")<{ - readonly cassetteName: string -}> { +export class CassetteNotFoundError extends Schema.TaggedErrorClass()( + "CassetteNotFoundError", + { cassetteName: Schema.String }, +) { override get message() { return `Cassette "${this.cassetteName}" not found` } diff --git a/packages/http-recorder/src/recorder.ts b/packages/http-recorder/src/recorder.ts index 381122d31c..460b427c2a 100644 --- a/packages/http-recorder/src/recorder.ts +++ b/packages/http-recorder/src/recorder.ts @@ -1,13 +1,13 @@ -import { Data, Effect, Ref, Scope } from "effect" +import { Effect, Ref, Schema, Scope } from "effect" import type * as CassetteService from "./cassette" import type { CassetteNotFoundError } from "./cassette" -import type { SecretFinding } from "./redaction" +import { SecretFindingSchema } from "./redaction" import type { CassetteMetadata, Interaction } from "./schema" -export class UnsafeCassetteError extends Data.TaggedError("UnsafeCassetteError")<{ - readonly cassetteName: string - readonly findings: ReadonlyArray -}> { +export class UnsafeCassetteError extends Schema.TaggedErrorClass()("UnsafeCassetteError", { + cassetteName: Schema.String, + findings: Schema.Array(SecretFindingSchema), +}) { override get message() { return `Refusing to write cassette "${this.cassetteName}" because it contains possible secrets: ${this.findings .map((finding) => `${finding.path} (${finding.reason})`) diff --git a/packages/http-recorder/src/redaction.ts b/packages/http-recorder/src/redaction.ts index 07534d9520..b6aa8b3b87 100644 --- a/packages/http-recorder/src/redaction.ts +++ b/packages/http-recorder/src/redaction.ts @@ -95,10 +95,13 @@ export const redactHeaders = ( ) } -export type SecretFinding = { - readonly path: string - readonly reason: string -} +import { Schema } from "effect" + +export const SecretFindingSchema = Schema.Struct({ + path: Schema.String, + reason: Schema.String, +}) +export type SecretFinding = Schema.Schema.Type export const secretFindings = (value: unknown): ReadonlyArray => stringEntries(value).flatMap((entry) => [ From a78018697cf45539a40da2c23a4de91487e0ac8c Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sun, 10 May 2026 16:44:40 +0000 Subject: [PATCH 144/189] chore: generate --- packages/http-recorder/src/cassette.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/http-recorder/src/cassette.ts b/packages/http-recorder/src/cassette.ts index 527afe8251..3897f0222c 100644 --- a/packages/http-recorder/src/cassette.ts +++ b/packages/http-recorder/src/cassette.ts @@ -6,10 +6,9 @@ import { decodeCassette, encodeCassette, type Cassette, type CassetteMetadata, t const DEFAULT_RECORDINGS_DIR = path.resolve(process.cwd(), "test", "fixtures", "recordings") -export class CassetteNotFoundError extends Schema.TaggedErrorClass()( - "CassetteNotFoundError", - { cassetteName: Schema.String }, -) { +export class CassetteNotFoundError extends Schema.TaggedErrorClass()("CassetteNotFoundError", { + cassetteName: Schema.String, +}) { override get message() { return `Cassette "${this.cassetteName}" not found` } From 9c8da691968551ed04e3620f0338a545214111bf Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sun, 10 May 2026 12:45:54 -0400 Subject: [PATCH 145/189] Use Effect timeout in compaction test (#26728) --- packages/opencode/test/session/compaction.test.ts | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/packages/opencode/test/session/compaction.test.ts b/packages/opencode/test/session/compaction.test.ts index 990733dbd9..032a6b4ad7 100644 --- a/packages/opencode/test/session/compaction.test.ts +++ b/packages/opencode/test/session/compaction.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, mock, test } from "bun:test" import { APICallError } from "ai" -import { Cause, Effect, Exit, Layer, ManagedRuntime } from "effect" +import { Cause, Deferred, Effect, Exit, Layer, ManagedRuntime } from "effect" import * as Stream from "effect/Stream" import z from "zod" import { Bus } from "../../src/bus" @@ -890,12 +890,12 @@ describe("session.compaction.process", () => { const session = yield* ssn.create({}) const msg = yield* createUserMessage(session.id, "hello") const msgs = yield* ssn.messages({ sessionID: session.id }) - const done = defer() + const done = yield* Deferred.make() let seen = false const unsub = yield* bus.subscribeCallback(SessionCompaction.Event.Compacted, (evt) => { if (evt.properties.sessionID !== session.id) return seen = true - done.resolve() + Deferred.doneUnsafe(done, Effect.void) }) yield* Effect.addFinalizer(() => Effect.sync(unsub)) @@ -906,14 +906,7 @@ describe("session.compaction.process", () => { auto: false, }) - yield* Effect.promise(() => - Promise.race([ - done.promise, - wait(500).then(() => { - throw new Error("timed out waiting for compacted event") - }), - ]), - ) + yield* Deferred.await(done).pipe(Effect.timeout("500 millis")) expect(result).toBe("continue") expect(seen).toBe(true) }), From 56d818fc348f677c1f371f22a4354e815a4de866 Mon Sep 17 00:00:00 2001 From: Frank Date: Sun, 10 May 2026 13:35:59 -0400 Subject: [PATCH 146/189] zen: fix reasoning token for openai compatible endpoint --- .../workspace/[id]/usage/usage-section.tsx | 2 +- .../console/app/src/routes/zen/util/handler.ts | 17 ++--------------- .../app/src/routes/zen/util/provider/openai.ts | 2 +- 3 files changed, 4 insertions(+), 17 deletions(-) diff --git a/packages/console/app/src/routes/workspace/[id]/usage/usage-section.tsx b/packages/console/app/src/routes/workspace/[id]/usage/usage-section.tsx index 2cf8ef850a..2075052c7d 100644 --- a/packages/console/app/src/routes/workspace/[id]/usage/usage-section.tsx +++ b/packages/console/app/src/routes/workspace/[id]/usage/usage-section.tsx @@ -53,7 +53,7 @@ export function UsageSection() { } const calculateTotalOutputTokens = (u: Awaited>[0]) => { - return u.outputTokens + (u.reasoningTokens ?? 0) + return u.outputTokens } const goPrev = async () => { diff --git a/packages/console/app/src/routes/zen/util/handler.ts b/packages/console/app/src/routes/zen/util/handler.ts index 278a541610..dad65807d3 100644 --- a/packages/console/app/src/routes/zen/util/handler.ts +++ b/packages/console/app/src/routes/zen/util/handler.ts @@ -889,10 +889,6 @@ export async function handler( const inputCost = modelCost.input * inputTokens * 100 const outputCost = modelCost.output * outputTokens * 100 - const reasoningCost = (() => { - if (!reasoningTokens) return undefined - return modelCost.output * reasoningTokens * 100 - })() const cacheReadCost = (() => { if (!cacheReadTokens) return undefined if (!modelCost.cacheRead) return undefined @@ -909,17 +905,11 @@ export async function handler( return modelCost.cacheWrite1h * cacheWrite1hTokens * 100 })() const totalCostInCent = - inputCost + - outputCost + - (reasoningCost ?? 0) + - (cacheReadCost ?? 0) + - (cacheWrite5mCost ?? 0) + - (cacheWrite1hCost ?? 0) + inputCost + outputCost + (cacheReadCost ?? 0) + (cacheWrite5mCost ?? 0) + (cacheWrite1hCost ?? 0) return { totalCostInCent, inputCost, outputCost, - reasoningCost, cacheReadCost, cacheWrite5mCost, cacheWrite1hCost, @@ -941,8 +931,7 @@ export async function handler( ) { const { inputTokens, outputTokens, reasoningTokens, cacheReadTokens, cacheWrite5mTokens, cacheWrite1hTokens } = usageInfo - const { totalCostInCent, inputCost, outputCost, reasoningCost, cacheReadCost, cacheWrite5mCost, cacheWrite1hCost } = - costInfo + const { totalCostInCent, inputCost, outputCost, cacheReadCost, cacheWrite5mCost, cacheWrite1hCost } = costInfo logger.metric({ "tokens.input": inputTokens, @@ -953,14 +942,12 @@ export async function handler( "tokens.cache_write_1h": cacheWrite1hTokens, "cost.input.microcents": centsToMicroCents(inputCost), "cost.output.microcents": centsToMicroCents(outputCost), - "cost.reasoning.microcents": reasoningCost ? centsToMicroCents(reasoningCost) : undefined, "cost.cache_read.microcents": cacheReadCost ? centsToMicroCents(cacheReadCost) : undefined, "cost.cache_write.microcents": cacheWrite5mCost ? centsToMicroCents(cacheWrite5mCost) : undefined, "cost.total.microcents": centsToMicroCents(totalCostInCent), // deprecated - remove after May 20, 2026 "cost.input": Math.round(inputCost), "cost.output": Math.round(outputCost), - "cost.reasoning": reasoningCost ? Math.round(reasoningCost) : undefined, "cost.cache_read": cacheReadCost ? Math.round(cacheReadCost) : undefined, "cost.cache_write_5m": cacheWrite5mCost ? Math.round(cacheWrite5mCost) : undefined, "cost.cache_write_1h": cacheWrite1hCost ? Math.round(cacheWrite1hCost) : undefined, diff --git a/packages/console/app/src/routes/zen/util/provider/openai.ts b/packages/console/app/src/routes/zen/util/provider/openai.ts index 5d61a903ef..1c5cbdb3c9 100644 --- a/packages/console/app/src/routes/zen/util/provider/openai.ts +++ b/packages/console/app/src/routes/zen/util/provider/openai.ts @@ -50,7 +50,7 @@ export const openaiHelper: ProviderHelper = ({ workspaceID }) => ({ const cacheReadTokens = usage.input_tokens_details?.cached_tokens ?? undefined return { inputTokens: inputTokens - (cacheReadTokens ?? 0), - outputTokens: outputTokens - (reasoningTokens ?? 0), + outputTokens, reasoningTokens, cacheReadTokens, cacheWrite5mTokens: undefined, From 2ba2ee52e872c7b386ad6d092a0d50fb6ba99465 Mon Sep 17 00:00:00 2001 From: Dax Date: Sun, 10 May 2026 17:41:20 -0400 Subject: [PATCH 147/189] docs: document bun dev tmux capture (#26764) --- packages/opencode/AGENTS.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/opencode/AGENTS.md b/packages/opencode/AGENTS.md index 2a39b6c144..ec4131a46c 100644 --- a/packages/opencode/AGENTS.md +++ b/packages/opencode/AGENTS.md @@ -9,6 +9,13 @@ - **Output**: creates `migration/_/migration.sql` and `snapshot.json`. - **Tests**: migration tests should read the per-folder layout (no `_journal.json`). +## Development server + +- Running `bun dev` from `packages/opencode` starts the live interactive TUI. Do not run it as a blocking foreground command when you need to inspect the result. +- Start it in `tmux` instead: `tmux new-session -d -s opencode-dev 'bun dev'`. +- Capture the current TUI output with: `tmux capture-pane -pt opencode-dev`. +- Stop the session explicitly when done: `tmux kill-session -t opencode-dev`. + # Module shape Do not use `export namespace Foo { ... }` for module organization. It is not From 0fffcdfe4660ba826be9705313a0c6441bf62e12 Mon Sep 17 00:00:00 2001 From: Dax Date: Sun, 10 May 2026 18:53:14 -0400 Subject: [PATCH 148/189] Persist session model switches outside event flag (#26765) --- packages/opencode/src/session/prompt.ts | 70 ++++++++++++++++--------- packages/opencode/src/v2/event.ts | 5 +- packages/opencode/src/v2/session.ts | 28 ++++++---- 3 files changed, 65 insertions(+), 38 deletions(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 5cf04719e5..521bacbc2f 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -758,7 +758,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the yield* bus.publish(Session.Event.Error, { sessionID: input.sessionID, error: error.toObject() }) throw error } - const model = input.model ?? agent.model ?? (yield* lastModel(input.sessionID)) + const model = input.model ?? agent.model ?? (yield* currentModel(input.sessionID)) const userMsg: MessageV2.User = { id: input.messageID ?? MessageID.ascending(), sessionID: input.sessionID, @@ -916,7 +916,17 @@ NOTE: At any point in time through this workflow you should feel free to ask the return yield* Effect.failCause(exit.cause) }) - const lastModel = Effect.fnUntraced(function* (sessionID: SessionID) { + const currentModel = Effect.fnUntraced(function* (sessionID: SessionID) { + const current = Database.use((db) => + db.select({ model: SessionTable.model }).from(SessionTable).where(eq(SessionTable.id, sessionID)).get(), + ) + if (current?.model) { + return { + providerID: ProviderID.make(current.model.providerID), + modelID: ModelID.make(current.model.id), + ...(current.model.variant && current.model.variant !== "default" ? { variant: current.model.variant } : {}), + } + } const match = yield* sessions.findMessage(sessionID, (m) => m.info.role === "user" && !!m.info.model) if (Option.isSome(match) && match.value.info.role === "user") return match.value.info.model return yield* provider.defaultModel() @@ -933,7 +943,14 @@ NOTE: At any point in time through this workflow you should feel free to ask the throw error } - const model = input.model ?? ag.model ?? (yield* lastModel(input.sessionID)) + const current = Database.use((db) => + db + .select({ agent: SessionTable.agent, model: SessionTable.model }) + .from(SessionTable) + .where(eq(SessionTable.id, input.sessionID)) + .get(), + ) + const model = input.model ?? ag.model ?? (yield* currentModel(input.sessionID)) const same = ag.model && model.providerID === ag.model.providerID && model.modelID === ag.model.modelID const full = !input.variant && ag.variant && same @@ -957,34 +974,35 @@ NOTE: At any point in time through this workflow you should feel free to ask the format: input.format, } - const current = Database.use((db) => - db - .select({ agent: SessionTable.agent, model: SessionTable.model }) - .from(SessionTable) - .where(eq(SessionTable.id, input.sessionID)) - .get(), - ) if (current?.agent !== info.agent) { - EventV2.run(SessionEvent.AgentSwitched.Sync, { - sessionID: input.sessionID, - timestamp: DateTime.makeUnsafe(info.time.created), - agent: info.agent, - }) + EventV2.run( + SessionEvent.AgentSwitched.Sync, + { + sessionID: input.sessionID, + timestamp: DateTime.makeUnsafe(info.time.created), + agent: info.agent, + }, + { bypassExperimentalEventSystem: true }, + ) } if ( current?.model?.providerID !== info.model.providerID || current.model.id !== info.model.modelID || - current.model.variant !== info.model.variant + (current.model.variant === "default" ? undefined : current.model.variant) !== info.model.variant ) { - EventV2.run(SessionEvent.ModelSwitched.Sync, { - sessionID: input.sessionID, - timestamp: DateTime.makeUnsafe(info.time.created), - model: { - id: Modelv2.ID.make(info.model.modelID), - providerID: Modelv2.ProviderID.make(info.model.providerID), - variant: Modelv2.VariantID.make(info.model.variant ?? "default"), + EventV2.run( + SessionEvent.ModelSwitched.Sync, + { + sessionID: input.sessionID, + timestamp: DateTime.makeUnsafe(info.time.created), + model: { + id: Modelv2.ID.make(info.model.modelID), + providerID: Modelv2.ProviderID.make(info.model.providerID), + variant: Modelv2.VariantID.make(info.model.variant ?? "default"), + }, }, - }) + { bypassExperimentalEventSystem: true }, + ) } yield* Effect.addFinalizer(() => instruction.clear(info.id)) @@ -1704,7 +1722,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the if (cmdAgent?.model) return cmdAgent.model } if (input.model) return Provider.parseModel(input.model) - return yield* lastModel(input.sessionID) + return yield* currentModel(input.sessionID) }) yield* getModel(taskModel.providerID, taskModel.modelID, input.sessionID) @@ -1737,7 +1755,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the const userModel = isSubtask ? input.model ? Provider.parseModel(input.model) - : yield* lastModel(input.sessionID) + : yield* currentModel(input.sessionID) : taskModel yield* plugin.trigger( diff --git a/packages/opencode/src/v2/event.ts b/packages/opencode/src/v2/event.ts index 8d0562eba3..0eb6af966c 100644 --- a/packages/opencode/src/v2/event.ts +++ b/packages/opencode/src/v2/event.ts @@ -44,9 +44,10 @@ export function define( def: Def, data: SyncEvent.Event["data"], - options?: { publish?: boolean }, + // Temporary escape hatch while the full v2 event system remains experimental. + options?: { publish?: boolean; bypassExperimentalEventSystem?: boolean }, ) { - if (!Flag.OPENCODE_EXPERIMENTAL_EVENT_SYSTEM) return + if (!options?.bypassExperimentalEventSystem && !Flag.OPENCODE_EXPERIMENTAL_EVENT_SYSTEM) return SyncEvent.run(def, data, options) } diff --git a/packages/opencode/src/v2/session.ts b/packages/opencode/src/v2/session.ts index b3da6009f6..39deabfc7c 100644 --- a/packages/opencode/src/v2/session.ts +++ b/packages/opencode/src/v2/session.ts @@ -269,18 +269,26 @@ export const layer = Layer.effect( shell: Effect.fn("V2Session.shell")(function* (_input) {}), skill: Effect.fn("V2Session.skill")(function* (_input) {}), switchAgent: Effect.fn("V2Session.switchAgent")(function* (input) { - EventV2.run(SessionEvent.AgentSwitched.Sync, { - sessionID: input.sessionID, - timestamp: DateTime.makeUnsafe(Date.now()), - agent: input.agent, - }) + EventV2.run( + SessionEvent.AgentSwitched.Sync, + { + sessionID: input.sessionID, + timestamp: DateTime.makeUnsafe(Date.now()), + agent: input.agent, + }, + { bypassExperimentalEventSystem: true }, + ) }), switchModel: Effect.fn("V2Session.switchModel")(function* (input) { - EventV2.run(SessionEvent.ModelSwitched.Sync, { - sessionID: input.sessionID, - timestamp: DateTime.makeUnsafe(Date.now()), - model: input.model, - }) + EventV2.run( + SessionEvent.ModelSwitched.Sync, + { + sessionID: input.sessionID, + timestamp: DateTime.makeUnsafe(Date.now()), + model: input.model, + }, + { bypassExperimentalEventSystem: true }, + ) }), subagent: Effect.fn("V2Session.subagent")(function* (input) { const parent = yield* result.get(input.parentID) From 2a571b3cee3c3f3ed64586d841816a57a68aca77 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sun, 10 May 2026 19:12:49 -0400 Subject: [PATCH 149/189] Migrate compaction overflow tests (#26731) --- .../opencode/test/session/compaction.test.ts | 138 ++++++++---------- 1 file changed, 58 insertions(+), 80 deletions(-) diff --git a/packages/opencode/test/session/compaction.test.ts b/packages/opencode/test/session/compaction.test.ts index 032a6b4ad7..5aeb0804e2 100644 --- a/packages/opencode/test/session/compaction.test.ts +++ b/packages/opencode/test/session/compaction.test.ts @@ -10,7 +10,6 @@ import { Agent } from "../../src/agent/agent" import { LLM } from "../../src/session/llm" import { SessionCompaction } from "../../src/session/compaction" import { Token } from "@/util/token" -import { Instance } from "../../src/project/instance" import { WithInstance } from "../../src/project/with-instance" import * as Log from "@opencode-ai/core/util/log" import { Permission } from "../../src/permission" @@ -1293,91 +1292,70 @@ describe("session.compaction.process", () => { }) }) - test("replays the prior user turn on overflow when earlier context exists", async () => { - await using tmp = await tmpdir() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const session = await svc.create({}) - await user(session.id, "root") - const replay = await user(session.id, "image") - await svc.updatePart({ - id: PartID.ascending(), - messageID: replay.id, - sessionID: session.id, - type: "file", - mime: "image/png", - filename: "cat.png", - url: "https://example.com/cat.png", - }) - const msg = await user(session.id, "current") - const rt = runtime("continue", Plugin.defaultLayer, wide()) - try { - const msgs = await svc.messages({ sessionID: session.id }) - const result = await rt.runPromise( - SessionCompaction.Service.use((svc) => - svc.process({ - parentID: msg.id, - messages: msgs, - sessionID: session.id, - auto: true, - overflow: true, - }), - ), - ) + it.instance( + "replays the prior user turn on overflow when earlier context exists", + Effect.gen(function* () { + const ssn = yield* SessionNs.Service + const session = yield* ssn.create({}) + yield* createUserMessage(session.id, "root") + const replay = yield* createUserMessage(session.id, "image") + yield* ssn.updatePart({ + id: PartID.ascending(), + messageID: replay.id, + sessionID: session.id, + type: "file", + mime: "image/png", + filename: "cat.png", + url: "https://example.com/cat.png", + }) + const msg = yield* createUserMessage(session.id, "current") + const msgs = yield* ssn.messages({ sessionID: session.id }) - const last = (await svc.messages({ sessionID: session.id })).at(-1) + const result = yield* SessionCompaction.use.process({ + parentID: msg.id, + messages: msgs, + sessionID: session.id, + auto: true, + overflow: true, + }) - expect(result).toBe("continue") - expect(last?.info.role).toBe("user") - expect(last?.parts.some((part) => part.type === "file")).toBe(false) - expect( - last?.parts.some((part) => part.type === "text" && part.text.includes("Attached image/png: cat.png")), - ).toBe(true) - } finally { - await rt.dispose() - } - }, - }) - }) + const last = (yield* ssn.messages({ sessionID: session.id })).at(-1) - test("falls back to overflow guidance when no replayable turn exists", async () => { - await using tmp = await tmpdir() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const session = await svc.create({}) - await user(session.id, "earlier") - const msg = await user(session.id, "current") + expect(result).toBe("continue") + expect(last?.info.role).toBe("user") + expect(last?.parts.some((part) => part.type === "file")).toBe(false) + expect( + last?.parts.some((part) => part.type === "text" && part.text.includes("Attached image/png: cat.png")), + ).toBe(true) + }), + ) - const rt = runtime("continue", Plugin.defaultLayer, wide()) - try { - const msgs = await svc.messages({ sessionID: session.id }) - const result = await rt.runPromise( - SessionCompaction.Service.use((svc) => - svc.process({ - parentID: msg.id, - messages: msgs, - sessionID: session.id, - auto: true, - overflow: true, - }), - ), - ) + it.instance( + "falls back to overflow guidance when no replayable turn exists", + Effect.gen(function* () { + const ssn = yield* SessionNs.Service + const session = yield* ssn.create({}) + yield* createUserMessage(session.id, "earlier") + const msg = yield* createUserMessage(session.id, "current") + const msgs = yield* ssn.messages({ sessionID: session.id }) - const last = (await svc.messages({ sessionID: session.id })).at(-1) + const result = yield* SessionCompaction.use.process({ + parentID: msg.id, + messages: msgs, + sessionID: session.id, + auto: true, + overflow: true, + }) - expect(result).toBe("continue") - expect(last?.info.role).toBe("user") - if (last?.parts[0]?.type === "text") { - expect(last.parts[0].text).toContain("previous request exceeded the provider's size limit") - } - } finally { - await rt.dispose() - } - }, - }) - }) + const last = (yield* ssn.messages({ sessionID: session.id })).at(-1) + + expect(result).toBe("continue") + expect(last?.info.role).toBe("user") + if (last?.parts[0]?.type === "text") { + expect(last.parts[0].text).toContain("previous request exceeded the provider's size limit") + } + }), + ) test("stops quickly when aborted during retry backoff", async () => { const stub = llm() From 4ff0d07b1d4180800ac5ab39a2c8d4c98bd9dbe9 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sun, 10 May 2026 19:18:02 -0400 Subject: [PATCH 150/189] Migrate configurable compaction tests (#26732) --- .../opencode/test/session/compaction.test.ts | 221 ++++++++---------- 1 file changed, 103 insertions(+), 118 deletions(-) diff --git a/packages/opencode/test/session/compaction.test.ts b/packages/opencode/test/session/compaction.test.ts index 5aeb0804e2..028cefe7de 100644 --- a/packages/opencode/test/session/compaction.test.ts +++ b/packages/opencode/test/session/compaction.test.ts @@ -232,6 +232,14 @@ async function lastCompactionPart(sessionID: SessionID) { ?.parts.find((item): item is MessageV2.CompactionPart => item.type === "compaction") } +function readLastCompactionPart(sessionID: SessionID) { + return SessionNs.Service.use((ssn) => ssn.messages({ sessionID })).pipe( + Effect.map((messages) => + messages.at(-2)?.parts.find((item): item is MessageV2.CompactionPart => item.type === "compaction"), + ), + ) +} + function fake( input: Parameters[0], result: "continue" | "compact", @@ -300,6 +308,30 @@ const env = Layer.mergeAll( const it = testEffect(env) +const processEnv = Layer.mergeAll(SessionNs.defaultLayer, CrossSpawnSpawner.defaultLayer) +const itProcess = testEffect(processEnv) + +function compactionProcessLayer(options?: { + result?: "continue" | "compact" + plugin?: Layer.Layer + provider?: ReturnType + config?: Layer.Layer +}) { + const bus = Bus.layer + return SessionCompaction.layer.pipe( + Layer.provideMerge( + Layer.mergeAll( + (options?.provider ?? wide()).layer, + layer(options?.result ?? "continue"), + Agent.defaultLayer, + options?.plugin ?? Plugin.defaultLayer, + bus, + options?.config ?? Config.defaultLayer, + ), + ), + ) +} + function llm() { const queue: Array< Stream.Stream | ((input: LLM.StreamInput) => Stream.Stream) @@ -911,43 +943,33 @@ describe("session.compaction.process", () => { }), ) - test("marks summary message as errored on compact result", async () => { - await using tmp = await tmpdir() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const session = await svc.create({}) - const msg = await user(session.id, "hello") - const rt = runtime("compact", Plugin.defaultLayer, wide()) - try { - const msgs = await svc.messages({ sessionID: session.id }) - const result = await rt.runPromise( - SessionCompaction.Service.use((svc) => - svc.process({ - parentID: msg.id, - messages: msgs, - sessionID: session.id, - auto: false, - }), - ), - ) + itProcess.instance( + "marks summary message as errored on compact result", + Effect.gen(function* () { + const ssn = yield* SessionNs.Service + const session = yield* ssn.create({}) + const msg = yield* createUserMessage(session.id, "hello") + const msgs = yield* ssn.messages({ sessionID: session.id }) - const summary = (await svc.messages({ sessionID: session.id })).find( - (msg) => msg.info.role === "assistant" && msg.info.summary, - ) + const result = yield* SessionCompaction.use.process({ + parentID: msg.id, + messages: msgs, + sessionID: session.id, + auto: false, + }) - expect(result).toBe("stop") - expect(summary?.info.role).toBe("assistant") - if (summary?.info.role === "assistant") { - expect(summary.info.finish).toBe("error") - expect(JSON.stringify(summary.info.error)).toContain("Session too large to compact") - } - } finally { - await rt.dispose() - } - }, - }) - }) + const summary = (yield* ssn.messages({ sessionID: session.id })).find( + (msg) => msg.info.role === "assistant" && msg.info.summary, + ) + + expect(result).toBe("stop") + expect(summary?.info.role).toBe("assistant") + if (summary?.info.role === "assistant") { + expect(summary.info.finish).toBe("error") + expect(JSON.stringify(summary.info.error)).toContain("Session too large to compact") + } + }).pipe(Effect.provide(compactionProcessLayer({ result: "compact" }))), + ) it.instance( "adds synthetic continue prompt when auto is enabled", @@ -980,94 +1002,57 @@ describe("session.compaction.process", () => { }), ) - test("persists tail_start_id for retained recent turns", async () => { - await using tmp = await tmpdir() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const session = await svc.create({}) - await user(session.id, "first") - const keep = await user(session.id, "second") - await user(session.id, "third") - await SessionCompaction.create({ - sessionID: session.id, - agent: "build", - model: ref, - auto: false, - }) + itProcess.instance( + "persists tail_start_id for retained recent turns", + Effect.gen(function* () { + const ssn = yield* SessionNs.Service + const session = yield* ssn.create({}) + yield* createUserMessage(session.id, "first") + const keep = yield* createUserMessage(session.id, "second") + yield* createUserMessage(session.id, "third") + yield* SessionCompaction.use.create({ sessionID: session.id, agent: "build", model: ref, auto: false }) - const rt = runtime( - "continue", - Plugin.defaultLayer, - wide(), - cfg({ tail_turns: 2, preserve_recent_tokens: 10_000 }), - ) - try { - const msgs = await svc.messages({ sessionID: session.id }) - const parent = msgs.at(-1)?.info.id - expect(parent).toBeTruthy() - await rt.runPromise( - SessionCompaction.Service.use((svc) => - svc.process({ - parentID: parent!, - messages: msgs, - sessionID: session.id, - auto: false, - }), - ), - ) + const msgs = yield* ssn.messages({ sessionID: session.id }) + const parent = msgs.at(-1)?.info.id + expect(parent).toBeTruthy() + yield* SessionCompaction.use.process({ + parentID: parent!, + messages: msgs, + sessionID: session.id, + auto: false, + }) - const part = await lastCompactionPart(session.id) - expect(part?.type).toBe("compaction") - expect(part?.tail_start_id).toBe(keep.id) - } finally { - await rt.dispose() - } - }, - }) - }) + const part = yield* readLastCompactionPart(session.id) + expect(part?.type).toBe("compaction") + expect(part?.tail_start_id).toBe(keep.id) + }).pipe(Effect.provide(compactionProcessLayer({ config: cfg({ tail_turns: 2, preserve_recent_tokens: 10_000 }) }))), + ) - test("shrinks retained tail to fit preserve token budget", async () => { - await using tmp = await tmpdir() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const session = await svc.create({}) - await user(session.id, "first") - await user(session.id, "x".repeat(2_000)) - const keep = await user(session.id, "tiny") - await SessionCompaction.create({ - sessionID: session.id, - agent: "build", - model: ref, - auto: false, - }) + itProcess.instance( + "shrinks retained tail to fit preserve token budget", + Effect.gen(function* () { + const ssn = yield* SessionNs.Service + const session = yield* ssn.create({}) + yield* createUserMessage(session.id, "first") + yield* createUserMessage(session.id, "x".repeat(2_000)) + const keep = yield* createUserMessage(session.id, "tiny") + yield* SessionCompaction.use.create({ sessionID: session.id, agent: "build", model: ref, auto: false }) - const rt = runtime("continue", Plugin.defaultLayer, wide(), cfg({ tail_turns: 2, preserve_recent_tokens: 100 })) - try { - const msgs = await svc.messages({ sessionID: session.id }) - const parent = msgs.at(-1)?.info.id - expect(parent).toBeTruthy() - await rt.runPromise( - SessionCompaction.Service.use((svc) => - svc.process({ - parentID: parent!, - messages: msgs, - sessionID: session.id, - auto: false, - }), - ), - ) + const msgs = yield* ssn.messages({ sessionID: session.id }) + const parent = msgs.at(-1)?.info.id + expect(parent).toBeTruthy() + yield* SessionCompaction.use.process({ + parentID: parent!, + messages: msgs, + sessionID: session.id, + auto: false, + }) - const part = await lastCompactionPart(session.id) - expect(part?.type).toBe("compaction") - expect(part?.tail_start_id).toBe(keep.id) - } finally { - await rt.dispose() - } - }, - }) - }) + const part = yield* readLastCompactionPart(session.id) + expect(part?.type).toBe("compaction") + expect(part?.tail_start_id).toBe(keep.id) + }).pipe(Effect.provide(compactionProcessLayer({ config: cfg({ tail_turns: 2, preserve_recent_tokens: 100 }) }))), + ) test("falls back to full summary when even one recent turn exceeds preserve token budget", async () => { await using tmp = await tmpdir({ git: true }) From 8f5f75db120936feb872fd5fd1beacb406c121d6 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sun, 10 May 2026 19:20:44 -0400 Subject: [PATCH 151/189] Migrate compaction LLM test (#26733) --- .../opencode/test/session/compaction.test.ts | 115 +++++++++--------- 1 file changed, 59 insertions(+), 56 deletions(-) diff --git a/packages/opencode/test/session/compaction.test.ts b/packages/opencode/test/session/compaction.test.ts index 028cefe7de..e681b7484e 100644 --- a/packages/opencode/test/session/compaction.test.ts +++ b/packages/opencode/test/session/compaction.test.ts @@ -313,21 +313,42 @@ const itProcess = testEffect(processEnv) function compactionProcessLayer(options?: { result?: "continue" | "compact" + llm?: Layer.Layer plugin?: Layer.Layer provider?: ReturnType config?: Layer.Layer }) { const bus = Bus.layer - return SessionCompaction.layer.pipe( - Layer.provideMerge( - Layer.mergeAll( - (options?.provider ?? wide()).layer, - layer(options?.result ?? "continue"), - Agent.defaultLayer, - options?.plugin ?? Plugin.defaultLayer, - bus, - options?.config ?? Config.defaultLayer, - ), + const status = SessionStatus.layer.pipe(Layer.provide(bus)) + const processor = options?.llm + ? SessionProcessorModule.SessionProcessor.layer.pipe( + Layer.provide(summary), + Layer.provide(Image.defaultLayer), + Layer.provide(status), + ) + : layer(options?.result ?? "continue") + return Layer.mergeAll(SessionCompaction.layer.pipe(Layer.provide(processor)), processor, bus, status).pipe( + Layer.provide(SessionNs.defaultLayer), + Layer.provide((options?.provider ?? wide()).layer), + Layer.provide(Snapshot.defaultLayer), + Layer.provide(options?.llm ?? LLM.defaultLayer), + Layer.provide(Permission.defaultLayer), + Layer.provide(Agent.defaultLayer), + Layer.provide(options?.plugin ?? Plugin.defaultLayer), + Layer.provide(status), + Layer.provide(bus), + Layer.provide(options?.config ?? Config.defaultLayer), + ) +} + +function createSummaryCompaction(sessionID: SessionID) { + return SessionCompaction.use.create({ sessionID, agent: "build", model: ref, auto: false }) +} + +function readCompactionPart(sessionID: SessionID) { + return SessionNs.Service.use((ssn) => ssn.messages({ sessionID })).pipe( + Effect.map((messages) => + messages.at(-2)?.parts.find((item): item is MessageV2.CompactionPart => item.type === "compaction"), ), ) } @@ -1054,54 +1075,36 @@ describe("session.compaction.process", () => { }).pipe(Effect.provide(compactionProcessLayer({ config: cfg({ tail_turns: 2, preserve_recent_tokens: 100 }) }))), ) - test("falls back to full summary when even one recent turn exceeds preserve token budget", async () => { - await using tmp = await tmpdir({ git: true }) - const stub = llm() - let captured = "" - stub.push( - reply("summary", (input) => { - captured = JSON.stringify(input.messages) - }), - ) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const session = await svc.create({}) - await user(session.id, "first") - await user(session.id, "y".repeat(2_000)) - await SessionCompaction.create({ - sessionID: session.id, - agent: "build", - model: ref, - auto: false, - }) + itProcess.instance( + "falls back to full summary when even one recent turn exceeds preserve token budget", + () => { + const stub = llm() + let captured = "" + stub.push(reply("summary", (input) => (captured = JSON.stringify(input.messages)))) + return Effect.gen(function* () { + const ssn = yield* SessionNs.Service + const session = yield* ssn.create({}) + yield* createUserMessage(session.id, "first") + yield* createUserMessage(session.id, "y".repeat(2_000)) + yield* createSummaryCompaction(session.id) - const rt = liveRuntime(stub.layer, wide(), cfg({ tail_turns: 1, preserve_recent_tokens: 20 })) - try { - const msgs = await svc.messages({ sessionID: session.id }) - const parent = msgs.at(-1)?.info.id - expect(parent).toBeTruthy() - await rt.runPromise( - SessionCompaction.Service.use((svc) => - svc.process({ - parentID: parent!, - messages: msgs, - sessionID: session.id, - auto: false, - }), - ), - ) + const msgs = yield* ssn.messages({ sessionID: session.id }) + const parent = msgs.at(-1)?.info.id + expect(parent).toBeTruthy() + yield* SessionCompaction.use.process({ parentID: parent!, messages: msgs, sessionID: session.id, auto: false }) - const part = await lastCompactionPart(session.id) - expect(part?.type).toBe("compaction") - expect(part?.tail_start_id).toBeUndefined() - expect(captured).toContain("yyyy") - } finally { - await rt.dispose() - } - }, - }) - }) + const part = yield* readCompactionPart(session.id) + expect(part?.type).toBe("compaction") + expect(part?.tail_start_id).toBeUndefined() + expect(captured).toContain("yyyy") + }).pipe( + Effect.provide( + compactionProcessLayer({ llm: stub.layer, config: cfg({ tail_turns: 1, preserve_recent_tokens: 20 }) }), + ), + ) + }, + { git: true }, + ) test("falls back to full summary when retained tail media exceeds preserve token budget", async () => { await using tmp = await tmpdir({ git: true }) From ca8a42c9737d265a36a3228914031d5381ab2227 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sun, 10 May 2026 19:41:28 -0400 Subject: [PATCH 152/189] Migrate LLM compaction tail tests (#26734) --- .../opencode/test/session/compaction.test.ts | 173 +++++++----------- 1 file changed, 69 insertions(+), 104 deletions(-) diff --git a/packages/opencode/test/session/compaction.test.ts b/packages/opencode/test/session/compaction.test.ts index e681b7484e..6aca646df1 100644 --- a/packages/opencode/test/session/compaction.test.ts +++ b/packages/opencode/test/session/compaction.test.ts @@ -1106,22 +1106,18 @@ describe("session.compaction.process", () => { { git: true }, ) - test("falls back to full summary when retained tail media exceeds preserve token budget", async () => { - await using tmp = await tmpdir({ git: true }) - const stub = llm() - let captured = "" - stub.push( - reply("summary", (input) => { - captured = JSON.stringify(input.messages) - }), - ) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const session = await svc.create({}) - await user(session.id, "older") - const recent = await user(session.id, "recent image turn") - await svc.updatePart({ + itProcess.instance( + "falls back to full summary when retained tail media exceeds preserve token budget", + () => { + const stub = llm() + let captured = "" + stub.push(reply("summary", (input) => (captured = JSON.stringify(input.messages)))) + return Effect.gen(function* () { + const ssn = yield* SessionNs.Service + const session = yield* ssn.create({}) + yield* createUserMessage(session.id, "older") + const recent = yield* createUserMessage(session.id, "recent image turn") + yield* ssn.updatePart({ id: PartID.ascending(), messageID: recent.id, sessionID: session.id, @@ -1130,112 +1126,81 @@ describe("session.compaction.process", () => { filename: "big.png", url: `data:image/png;base64,${"a".repeat(4_000)}`, }) - await SessionCompaction.create({ - sessionID: session.id, - agent: "build", - model: ref, - auto: false, - }) + yield* createSummaryCompaction(session.id) - const rt = liveRuntime(stub.layer, wide(), cfg({ tail_turns: 1, preserve_recent_tokens: 100 })) - try { - const msgs = await svc.messages({ sessionID: session.id }) - const parent = msgs.at(-1)?.info.id - expect(parent).toBeTruthy() - await rt.runPromise( - SessionCompaction.Service.use((svc) => - svc.process({ - parentID: parent!, - messages: msgs, - sessionID: session.id, - auto: false, - }), - ), - ) + const msgs = yield* ssn.messages({ sessionID: session.id }) + const parent = msgs.at(-1)?.info.id + expect(parent).toBeTruthy() + yield* SessionCompaction.use.process({ parentID: parent!, messages: msgs, sessionID: session.id, auto: false }) - const part = await lastCompactionPart(session.id) - expect(part?.type).toBe("compaction") - expect(part?.tail_start_id).toBeUndefined() - expect(captured).toContain("recent image turn") - expect(captured).toContain("Attached image/png: big.png") - } finally { - await rt.dispose() - } - }, - }) - }) + const part = yield* readCompactionPart(session.id) + expect(part?.type).toBe("compaction") + expect(part?.tail_start_id).toBeUndefined() + expect(captured).toContain("recent image turn") + expect(captured).toContain("Attached image/png: big.png") + }).pipe( + Effect.provide( + compactionProcessLayer({ llm: stub.layer, config: cfg({ tail_turns: 1, preserve_recent_tokens: 100 }) }), + ), + ) + }, + { git: true }, + ) - test("retains a split turn suffix when a later message fits the preserve token budget", async () => { - await using tmp = await tmpdir({ git: true }) - const stub = llm() - let captured = "" - stub.push( - reply("summary", (input) => { - captured = JSON.stringify(input.messages) - }), - ) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const session = await svc.create({}) - await user(session.id, "older") - const recent = await user(session.id, "recent turn") - const large = await assistant(session.id, recent.id, tmp.path) - await svc.updatePart({ + itProcess.instance( + "retains a split turn suffix when a later message fits the preserve token budget", + () => { + const stub = llm() + let captured = "" + stub.push(reply("summary", (input) => (captured = JSON.stringify(input.messages)))) + return Effect.gen(function* () { + const test = yield* TestInstance + const ssn = yield* SessionNs.Service + const session = yield* ssn.create({}) + yield* createUserMessage(session.id, "older") + const recent = yield* createUserMessage(session.id, "recent turn") + const large = yield* createAssistantMessage(session.id, recent.id, test.directory) + yield* ssn.updatePart({ id: PartID.ascending(), messageID: large.id, sessionID: session.id, type: "text", text: "z".repeat(2_000), }) - const keep = await assistant(session.id, recent.id, tmp.path) - await svc.updatePart({ + const keep = yield* createAssistantMessage(session.id, recent.id, test.directory) + yield* ssn.updatePart({ id: PartID.ascending(), messageID: keep.id, sessionID: session.id, type: "text", text: "keep tail", }) - await SessionCompaction.create({ - sessionID: session.id, - agent: "build", - model: ref, - auto: false, - }) + yield* createSummaryCompaction(session.id) - const rt = liveRuntime(stub.layer, wide(), cfg({ tail_turns: 1, preserve_recent_tokens: 100 })) - try { - const msgs = await svc.messages({ sessionID: session.id }) - const parent = msgs.at(-1)?.info.id - expect(parent).toBeTruthy() - await rt.runPromise( - SessionCompaction.Service.use((svc) => - svc.process({ - parentID: parent!, - messages: msgs, - sessionID: session.id, - auto: false, - }), - ), - ) + const msgs = yield* ssn.messages({ sessionID: session.id }) + const parent = msgs.at(-1)?.info.id + expect(parent).toBeTruthy() + yield* SessionCompaction.use.process({ parentID: parent!, messages: msgs, sessionID: session.id, auto: false }) - const part = await lastCompactionPart(session.id) - expect(part?.type).toBe("compaction") - expect(part?.tail_start_id).toBe(keep.id) - expect(captured).toContain("zzzz") - expect(captured).not.toContain("keep tail") + const part = yield* readCompactionPart(session.id) + expect(part?.type).toBe("compaction") + expect(part?.tail_start_id).toBe(keep.id) + expect(captured).toContain("zzzz") + expect(captured).not.toContain("keep tail") - const filtered = MessageV2.filterCompacted(MessageV2.stream(session.id)) - expect(filtered.map((msg) => msg.info.id).slice(0, 3)).toEqual([parent!, expect.any(String), keep.id]) - expect(filtered[1]?.info.role).toBe("assistant") - expect(filtered[1]?.info.role === "assistant" ? filtered[1].info.summary : false).toBe(true) - expect(filtered.map((msg) => msg.info.id)).not.toContain(large.id) - } finally { - await rt.dispose() - } - }, - }) - }) + const filtered = MessageV2.filterCompacted(MessageV2.stream(session.id)) + expect(filtered.map((msg) => msg.info.id).slice(0, 3)).toEqual([parent!, expect.any(String), keep.id]) + expect(filtered[1]?.info.role).toBe("assistant") + expect(filtered[1]?.info.role === "assistant" ? filtered[1].info.summary : false).toBe(true) + expect(filtered.map((msg) => msg.info.id)).not.toContain(large.id) + }).pipe( + Effect.provide( + compactionProcessLayer({ llm: stub.layer, config: cfg({ tail_turns: 1, preserve_recent_tokens: 100 }) }), + ), + ) + }, + { git: true }, + ) test("allows plugins to disable synthetic continue prompt", async () => { await using tmp = await tmpdir() From a658e43eebddff9124c4b8e084f7de9997b02453 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sun, 10 May 2026 19:42:44 -0400 Subject: [PATCH 153/189] Migrate compaction plugin test (#26736) --- .../opencode/test/session/compaction.test.ts | 67 ++++++++----------- 1 file changed, 28 insertions(+), 39 deletions(-) diff --git a/packages/opencode/test/session/compaction.test.ts b/packages/opencode/test/session/compaction.test.ts index 6aca646df1..8570ee3991 100644 --- a/packages/opencode/test/session/compaction.test.ts +++ b/packages/opencode/test/session/compaction.test.ts @@ -1202,48 +1202,37 @@ describe("session.compaction.process", () => { { git: true }, ) - test("allows plugins to disable synthetic continue prompt", async () => { - await using tmp = await tmpdir() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const session = await svc.create({}) - const msg = await user(session.id, "hello") - const rt = runtime("continue", autocontinue(false), wide()) - try { - const msgs = await svc.messages({ sessionID: session.id }) - const result = await rt.runPromise( - SessionCompaction.Service.use((svc) => - svc.process({ - parentID: msg.id, - messages: msgs, - sessionID: session.id, - auto: true, - }), - ), - ) + itProcess.instance( + "allows plugins to disable synthetic continue prompt", + Effect.gen(function* () { + const ssn = yield* SessionNs.Service + const session = yield* ssn.create({}) + const msg = yield* createUserMessage(session.id, "hello") + const msgs = yield* ssn.messages({ sessionID: session.id }) - const all = await svc.messages({ sessionID: session.id }) - const last = all.at(-1) + const result = yield* SessionCompaction.use.process({ + parentID: msg.id, + messages: msgs, + sessionID: session.id, + auto: true, + }) - expect(result).toBe("continue") - expect(last?.info.role).toBe("assistant") - expect( - all.some( - (msg) => - msg.info.role === "user" && - msg.parts.some( - (part) => - part.type === "text" && part.synthetic && part.text.includes("Continue if you have next steps"), - ), + const all = yield* ssn.messages({ sessionID: session.id }) + const last = all.at(-1) + + expect(result).toBe("continue") + expect(last?.info.role).toBe("assistant") + expect( + all.some( + (msg) => + msg.info.role === "user" && + msg.parts.some( + (part) => part.type === "text" && part.synthetic && part.text.includes("Continue if you have next steps"), ), - ).toBe(false) - } finally { - await rt.dispose() - } - }, - }) - }) + ), + ).toBe(false) + }).pipe(Effect.provide(compactionProcessLayer({ plugin: autocontinue(false) }))), + ) it.instance( "replays the prior user turn on overflow when earlier context exists", From 64dde0cb15c362a5c2d345fd11368a82db7aad90 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sun, 10 May 2026 19:44:42 -0400 Subject: [PATCH 154/189] Migrate compaction tool-call test (#26737) --- .../opencode/test/session/compaction.test.ts | 142 ++++++++---------- 1 file changed, 64 insertions(+), 78 deletions(-) diff --git a/packages/opencode/test/session/compaction.test.ts b/packages/opencode/test/session/compaction.test.ts index 8570ee3991..6f96ab3d07 100644 --- a/packages/opencode/test/session/compaction.test.ts +++ b/packages/opencode/test/session/compaction.test.ts @@ -1448,88 +1448,74 @@ describe("session.compaction.process", () => { }) }) - test("does not allow tool calls while generating the summary", async () => { - const stub = llm() - stub.push( - Stream.make( - { type: "start" } satisfies LLM.Event, - { type: "tool-input-start", id: "call-1", toolName: "_noop" } satisfies LLM.Event, - { type: "tool-call", toolCallId: "call-1", toolName: "_noop", input: {} } satisfies LLM.Event, - { - type: "finish-step", - finishReason: "tool-calls", - rawFinishReason: "tool_calls", - response: { id: "res", modelId: "test-model", timestamp: new Date() }, - providerMetadata: undefined, - usage: { - inputTokens: 1, - outputTokens: 1, - totalTokens: 2, - inputTokenDetails: { - noCacheTokens: undefined, - cacheReadTokens: undefined, - cacheWriteTokens: undefined, + itProcess.instance( + "does not allow tool calls while generating the summary", + () => { + const stub = llm() + stub.push( + Stream.make( + { type: "start" } satisfies LLM.Event, + { type: "tool-input-start", id: "call-1", toolName: "_noop" } satisfies LLM.Event, + { type: "tool-call", toolCallId: "call-1", toolName: "_noop", input: {} } satisfies LLM.Event, + { + type: "finish-step", + finishReason: "tool-calls", + rawFinishReason: "tool_calls", + response: { id: "res", modelId: "test-model", timestamp: new Date() }, + providerMetadata: undefined, + usage: { + inputTokens: 1, + outputTokens: 1, + totalTokens: 2, + inputTokenDetails: { + noCacheTokens: undefined, + cacheReadTokens: undefined, + cacheWriteTokens: undefined, + }, + outputTokenDetails: { + textTokens: undefined, + reasoningTokens: undefined, + }, }, - outputTokenDetails: { - textTokens: undefined, - reasoningTokens: undefined, + } satisfies LLM.Event, + { + type: "finish", + finishReason: "tool-calls", + rawFinishReason: "tool_calls", + totalUsage: { + inputTokens: 1, + outputTokens: 1, + totalTokens: 2, + inputTokenDetails: { + noCacheTokens: undefined, + cacheReadTokens: undefined, + cacheWriteTokens: undefined, + }, + outputTokenDetails: { + textTokens: undefined, + reasoningTokens: undefined, + }, }, - }, - } satisfies LLM.Event, - { - type: "finish", - finishReason: "tool-calls", - rawFinishReason: "tool_calls", - totalUsage: { - inputTokens: 1, - outputTokens: 1, - totalTokens: 2, - inputTokenDetails: { - noCacheTokens: undefined, - cacheReadTokens: undefined, - cacheWriteTokens: undefined, - }, - outputTokenDetails: { - textTokens: undefined, - reasoningTokens: undefined, - }, - }, - } satisfies LLM.Event, - ), - ) + } satisfies LLM.Event, + ), + ) + return Effect.gen(function* () { + const ssn = yield* SessionNs.Service + const session = yield* ssn.create({}) + const msg = yield* createUserMessage(session.id, "hello") + const msgs = yield* ssn.messages({ sessionID: session.id }) + yield* SessionCompaction.use.process({ parentID: msg.id, messages: msgs, sessionID: session.id, auto: false }) - await using tmp = await tmpdir({ git: true }) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const session = await svc.create({}) - const msg = await user(session.id, "hello") - const rt = liveRuntime(stub.layer, wide()) - try { - const msgs = await svc.messages({ sessionID: session.id }) - await rt.runPromise( - SessionCompaction.Service.use((svc) => - svc.process({ - parentID: msg.id, - messages: msgs, - sessionID: session.id, - auto: false, - }), - ), - ) + const summary = (yield* ssn.messages({ sessionID: session.id })).find( + (item) => item.info.role === "assistant" && item.info.summary, + ) - const summary = (await svc.messages({ sessionID: session.id })).find( - (item) => item.info.role === "assistant" && item.info.summary, - ) - - expect(summary?.info.role).toBe("assistant") - expect(summary?.parts.some((part) => part.type === "tool")).toBe(false) - } finally { - await rt.dispose() - } - }, - }) - }) + expect(summary?.info.role).toBe("assistant") + expect(summary?.parts.some((part) => part.type === "tool")).toBe(false) + }).pipe(Effect.provide(compactionProcessLayer({ llm: stub.layer }))) + }, + { git: true }, + ) test("summarizes only the head while keeping recent tail out of summary input", async () => { const stub = llm() From 5654dd2aad13db2f2e27d1ee35bf02076b9811c4 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Mon, 11 May 2026 01:45:59 +0200 Subject: [PATCH 155/189] restore managed textarea keymap handling (#26771) --- packages/opencode/src/cli/cmd/tui/app.tsx | 17 ++++++--- .../cli/cmd/tui/component/prompt/index.tsx | 1 - .../cli/cmd/tui/component/prompt/traits.ts | 9 ++--- packages/opencode/src/cli/cmd/tui/keymap.tsx | 38 +++++++++++++++---- .../test/cli/cmd/tui/prompt-traits.test.ts | 25 ++++-------- 5 files changed, 53 insertions(+), 37 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index c7a2cd560f..cc2afd1cdf 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -93,7 +93,6 @@ const appBindingCommands = [ "theme.mode.lock", "help.show", "docs.open", - "app.exit", "app.debug", "app.console", "app.heap_snapshot", @@ -648,11 +647,6 @@ function App(props: { onSnapshot?: () => Promise }) { title: "Exit the app", slashName: "exit", slashAliases: ["quit", "q"], - enabled: () => { - const current = promptRef.current - if (!current?.focused) return true - return current.current.input === "" - }, run: () => exit(), category: "System", }, @@ -785,6 +779,17 @@ function App(props: { onSnapshot?: () => Promise }) { bindings: tuiConfig.keybinds.gather("app", appBindingCommands), })) + useBindings(() => ({ + enabled: () => { + const ok = command.matcher.get() + if (!ok) return false + const current = promptRef.current + if (!current?.focused) return true + return current.current.input === "" + }, + bindings: tuiConfig.keybinds.gather("app_exit", ["app.exit"]), + })) + event.on(TuiEvent.CommandExecute.type, (evt) => { command.run(evt.properties.command) }) diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index d3bfdfbac3..f1ce2b6765 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -712,7 +712,6 @@ export function Prompt(props: PromptProps) { ...input.traits, ...computePromptTraits({ mode: store.mode, - disabled: !!props.disabled, autocompleteVisible: !!auto()?.visible, }), } diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/traits.ts b/packages/opencode/src/cli/cmd/tui/component/prompt/traits.ts index a701396562..03b0580529 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/traits.ts +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/traits.ts @@ -4,7 +4,6 @@ export type PromptMode = "normal" | "shell" export interface PromptTraitsInput { mode: PromptMode - disabled: boolean autocompleteVisible: boolean } @@ -16,10 +15,9 @@ export type PromptTraits = EditorTraits & { /** * Compute the textarea editor traits for the prompt. * - * `traits.suspend` gates the textarea's keybinding actions (backspace, - * delete-word, arrow movement, undo/redo, etc.). Shell mode is an active - * editing mode — only `disabled` should suspend the textarea, otherwise - * users can type in shell mode but cannot delete or move the cursor. + * The OpenTUI managed textarea keymap owns `traits.suspend`. Prompt traits + * only expose capture/status metadata so focus changes cannot unsuspend the + * keymap-managed editor mappings. */ export function computePromptTraits(input: PromptTraitsInput): PromptTraits { const capture = @@ -30,7 +28,6 @@ export function computePromptTraits(input: PromptTraitsInput): PromptTraits { : undefined return { capture, - suspend: input.disabled, status: input.mode === "shell" ? "SHELL" : undefined, owner: "opencode", role: "prompt", diff --git a/packages/opencode/src/cli/cmd/tui/keymap.tsx b/packages/opencode/src/cli/cmd/tui/keymap.tsx index 379fa5afdf..289bb901d6 100644 --- a/packages/opencode/src/cli/cmd/tui/keymap.tsx +++ b/packages/opencode/src/cli/cmd/tui/keymap.tsx @@ -8,9 +8,9 @@ import { import { KeymapProvider, reactiveMatcherFromSignal, - useBindings, useKeymap, useKeymapSelector, + useBindings, } from "@opentui/keymap/solid" import type { Accessor } from "solid-js" import type { TuiConfig } from "./config/tui" @@ -26,6 +26,28 @@ export { reactiveMatcherFromSignal, useBindings, useKeymapSelector } export type OpenTuiKeymap = ReturnType +const KEY_ALIASES = { + enter: "return", + esc: "escape", +} as const + +function expandKeyAliases(input: string) { + const result = Object.entries(KEY_ALIASES).reduce( + (acc, [alias, key]) => acc.replace(new RegExp(`(^|[+,\\s>])${alias}(?=$|[+,\\s<])`, "gi"), `$1${key}`), + input, + ) + if (result === input) return + return result +} + +function registerKeyAliases(keymap: OpenTuiKeymap) { + return keymap.appendBindingExpander((ctx) => { + const key = expandKeyAliases(ctx.input) + if (!key) return + return [{ key, displays: ctx.displays }] + }) +} + const inputCommands = [ "input.move.left", "input.move.right", @@ -98,8 +120,13 @@ export function formatKeyBindings( return formatCommandBindingsExtra(bindings, formatOptions(config)) } -export function registerOpencodeKeymap(keymap: OpenTuiKeymap, renderer: CliRenderer, config: TuiConfig.Resolved) { +export function registerOpencodeKeymap( + keymap: OpenTuiKeymap, + renderer: CliRenderer, + config: Pick, +) { const offCommaBindings = addons.registerCommaBindings(keymap) + const offAliasExpander = registerKeyAliases(keymap) const offBaseLayout = addons.registerBaseLayoutFallback(keymap) const offLeader = addons.registerTimedLeader(keymap, { trigger: config.keybinds.get(LEADER_TOKEN), @@ -108,20 +135,17 @@ export function registerOpencodeKeymap(keymap: OpenTuiKeymap, renderer: CliRende }) const offEscape = addons.registerEscapeClearsPendingSequence(keymap) const offBackspace = addons.registerBackspacePopsPendingSequence(keymap) - const offInputCommands = addons.registerEditBufferCommands(keymap, renderer) - const offInputSuspension = addons.registerTextareaMappingSuspension(keymap, renderer) - const offInputBindings = keymap.registerLayer({ + const offInputBindings = addons.registerManagedTextareaLayer(keymap, renderer, { enabled: () => renderer.currentFocusedEditor !== null, bindings: config.keybinds.gather("input", inputCommands), }) return () => { offInputBindings() - offInputSuspension() - offInputCommands() offBackspace() offEscape() offLeader() + offAliasExpander() offBaseLayout() offCommaBindings() } diff --git a/packages/opencode/test/cli/cmd/tui/prompt-traits.test.ts b/packages/opencode/test/cli/cmd/tui/prompt-traits.test.ts index 34a16aedd6..a7b1643357 100644 --- a/packages/opencode/test/cli/cmd/tui/prompt-traits.test.ts +++ b/packages/opencode/test/cli/cmd/tui/prompt-traits.test.ts @@ -3,36 +3,27 @@ import { computePromptTraits } from "../../../../src/cli/cmd/tui/component/promp describe("computePromptTraits", () => { test("normal mode without autocomplete only captures tab", () => { - const traits = computePromptTraits({ mode: "normal", disabled: false, autocompleteVisible: false }) + const traits = computePromptTraits({ mode: "normal", autocompleteVisible: false }) expect(traits.capture).toEqual(["tab"]) - expect(traits.suspend).toBe(false) + expect(traits.suspend).toBeUndefined() expect(traits.status).toBeUndefined() }) test("normal mode with autocomplete captures navigation keys", () => { - const traits = computePromptTraits({ mode: "normal", disabled: false, autocompleteVisible: true }) + const traits = computePromptTraits({ mode: "normal", autocompleteVisible: true }) expect(traits.capture).toEqual(["escape", "navigate", "submit", "tab"]) - expect(traits.suspend).toBe(false) + expect(traits.suspend).toBeUndefined() expect(traits.status).toBeUndefined() }) - test("shell mode does not suspend the textarea", () => { - // Suspending the textarea would gate every keybinding action - // (backspace, delete-word-backward, arrow movement, etc.) — see - // @opentui/core 0.2.x TextareaRenderable.handleKeyPress. Shell mode is - // an active editing mode, so suspend must stay off. - const traits = computePromptTraits({ mode: "shell", disabled: false, autocompleteVisible: false }) - expect(traits.suspend).toBe(false) + test("shell mode does not write the keymap-owned suspend trait", () => { + const traits = computePromptTraits({ mode: "shell", autocompleteVisible: false }) + expect(traits.suspend).toBeUndefined() }) test("shell mode disables capture and labels the prompt", () => { - const traits = computePromptTraits({ mode: "shell", disabled: false, autocompleteVisible: false }) + const traits = computePromptTraits({ mode: "shell", autocompleteVisible: false }) expect(traits.capture).toBeUndefined() expect(traits.status).toBe("SHELL") }) - - test("disabled suspends regardless of mode", () => { - expect(computePromptTraits({ mode: "normal", disabled: true, autocompleteVisible: false }).suspend).toBe(true) - expect(computePromptTraits({ mode: "shell", disabled: true, autocompleteVisible: false }).suspend).toBe(true) - }) }) From f71fb18d3db63d163ce131fdd50c903cb6e04990 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sun, 10 May 2026 19:53:22 -0400 Subject: [PATCH 156/189] Replace compaction create test fixtures (#26738) --- .../opencode/test/session/compaction.test.ts | 75 ++++++++----------- 1 file changed, 33 insertions(+), 42 deletions(-) diff --git a/packages/opencode/test/session/compaction.test.ts b/packages/opencode/test/session/compaction.test.ts index 6f96ab3d07..b890d1149e 100644 --- a/packages/opencode/test/session/compaction.test.ts +++ b/packages/opencode/test/session/compaction.test.ts @@ -232,6 +232,32 @@ async function lastCompactionPart(sessionID: SessionID) { ?.parts.find((item): item is MessageV2.CompactionPart => item.type === "compaction") } +function createCompactionMarker(sessionID: SessionID) { + return SessionNs.Service.use((ssn) => + Effect.gen(function* () { + const msg = yield* ssn.updateMessage({ + id: MessageID.ascending(), + role: "user", + model: ref, + sessionID, + agent: "build", + time: { created: Date.now() }, + }) + yield* ssn.updatePart({ + id: PartID.ascending(), + messageID: msg.id, + sessionID: msg.sessionID, + type: "compaction", + auto: false, + }) + }), + ) +} + +async function createCompactionMarkerAsync(sessionID: SessionID) { + return run(createCompactionMarker(sessionID)) +} + function readLastCompactionPart(sessionID: SessionID) { return SessionNs.Service.use((ssn) => ssn.messages({ sessionID })).pipe( Effect.map((messages) => @@ -1534,12 +1560,7 @@ describe("session.compaction.process", () => { await user(session.id, "older context") await user(session.id, "keep this turn") await user(session.id, "and this one too") - await SessionCompaction.create({ - sessionID: session.id, - agent: "build", - model: ref, - auto: false, - }) + await createCompactionMarkerAsync(session.id) const rt = liveRuntime(stub.layer, wide()) try { @@ -1585,12 +1606,7 @@ describe("session.compaction.process", () => { const session = await svc.create({}) await user(session.id, "older context") await user(session.id, "keep this turn") - await SessionCompaction.create({ - sessionID: session.id, - agent: "build", - model: ref, - auto: false, - }) + await createCompactionMarkerAsync(session.id) const rt = liveRuntime(stub.layer, wide()) try { @@ -1609,12 +1625,7 @@ describe("session.compaction.process", () => { ) await user(session.id, "latest turn") - await SessionCompaction.create({ - sessionID: session.id, - agent: "build", - model: ref, - auto: false, - }) + await createCompactionMarkerAsync(session.id) msgs = MessageV2.filterCompacted(MessageV2.stream(session.id)) parent = msgs.at(-1)?.info.id @@ -1654,12 +1665,7 @@ describe("session.compaction.process", () => { const u1 = await user(session.id, "one") const u2 = await user(session.id, "two") const u3 = await user(session.id, "three") - await SessionCompaction.create({ - sessionID: session.id, - agent: "build", - model: ref, - auto: false, - }) + await createCompactionMarkerAsync(session.id) const rt = liveRuntime(stub.layer, wide(), cfg({ tail_turns: 2, preserve_recent_tokens: 10_000 })) try { @@ -1678,12 +1684,7 @@ describe("session.compaction.process", () => { ) const u4 = await user(session.id, "four") - await SessionCompaction.create({ - sessionID: session.id, - agent: "build", - model: ref, - auto: false, - }) + await createCompactionMarkerAsync(session.id) msgs = MessageV2.filterCompacted(MessageV2.stream(session.id)) parent = msgs.at(-1)?.info.id @@ -1734,12 +1735,7 @@ describe("session.compaction.process", () => { text: "keep reply", }) - await SessionCompaction.create({ - sessionID: session.id, - agent: "build", - model: ref, - auto: false, - }) + await createCompactionMarkerAsync(session.id) const firstCompaction = (await svc.messages({ sessionID: session.id })).at(-1)?.info.id expect(firstCompaction).toBeTruthy() await summaryAssistant(session.id, firstCompaction!, tmp.path, "summary ".repeat(800)) @@ -1754,12 +1750,7 @@ describe("session.compaction.process", () => { text: "recent reply", }) - await SessionCompaction.create({ - sessionID: session.id, - agent: "build", - model: ref, - auto: false, - }) + await createCompactionMarkerAsync(session.id) const rt = runtime("continue", Plugin.defaultLayer, wide(), cfg({ tail_turns: 2, preserve_recent_tokens: 500 })) try { From d8060dc9ad644c74e94f24ddef6d4bd5f01196e1 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sun, 10 May 2026 19:54:16 -0400 Subject: [PATCH 157/189] Drop compaction create facade (#26739) --- packages/opencode/src/session/compaction.ts | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index b4ff0309e6..3b16a9a5d7 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -4,7 +4,6 @@ import * as Session from "./session" import { SessionID, MessageID, PartID } from "./schema" import { Provider } from "@/provider/provider" import { MessageV2 } from "./message-v2" -import z from "zod" import { Token } from "@/util/token" import * as Log from "@opencode-ai/core/util/log" import { SessionProcessor } from "./processor" @@ -19,7 +18,6 @@ import { InstanceState } from "@/effect/instance-state" import { isOverflow as overflow, usable } from "./overflow" import { makeRuntime } from "@/effect/run-service" import { serviceUse } from "@/effect/service-use" -import { fn } from "@/util/fn" import { EventV2 } from "@/v2/event" import { SessionEvent } from "@/v2/session-event" @@ -641,15 +639,4 @@ export async function prune(input: { sessionID: SessionID }) { return runPromise((svc) => svc.prune(input)) } -export const create = fn( - z.object({ - sessionID: SessionID.zod, - agent: z.string(), - model: z.object({ providerID: ProviderID.zod, modelID: ModelID.zod }), - auto: z.boolean(), - overflow: z.boolean().optional(), - }), - (input) => runPromise((svc) => svc.create(input)), -) - export * as SessionCompaction from "./compaction" From 5ef72e110140380b06bb6d6cabc987777207f210 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sun, 10 May 2026 19:58:21 -0400 Subject: [PATCH 158/189] Drop unused ID Zod statics (#26740) --- .../opencode/src/cli/cmd/tui/validate-session.ts | 13 +++++++++---- packages/opencode/src/control-plane/schema.ts | 2 -- packages/opencode/src/session/schema.ts | 4 ---- .../opencode/test/session/schema-decoding.test.ts | 14 +++++++------- 4 files changed, 16 insertions(+), 17 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/validate-session.ts b/packages/opencode/src/cli/cmd/tui/validate-session.ts index e2a21d51e1..31329a6533 100644 --- a/packages/opencode/src/cli/cmd/tui/validate-session.ts +++ b/packages/opencode/src/cli/cmd/tui/validate-session.ts @@ -1,5 +1,8 @@ import { createOpencodeClient } from "@opencode-ai/sdk/v2" import { SessionID } from "@/session/schema" +import { Schema } from "effect" + +const decodeSessionID = Schema.decodeUnknownSync(SessionID) export async function validateSession(input: { url: string @@ -10,9 +13,11 @@ export async function validateSession(input: { }) { if (!input.sessionID) return - const result = SessionID.zod.safeParse(input.sessionID) - if (!result.success) { - throw new Error(`Invalid session ID: ${result.error.issues.at(0)?.message ?? "unknown error"}`) + let sessionID: SessionID + try { + sessionID = decodeSessionID(input.sessionID) + } catch (error) { + throw new Error(`Invalid session ID: ${error instanceof Error ? error.message : "unknown error"}`, { cause: error }) } await createOpencodeClient({ @@ -20,5 +25,5 @@ export async function validateSession(input: { directory: input.directory, fetch: input.fetch, headers: input.headers, - }).session.get({ sessionID: result.data }, { throwOnError: true }) + }).session.get({ sessionID }, { throwOnError: true }) } diff --git a/packages/opencode/src/control-plane/schema.ts b/packages/opencode/src/control-plane/schema.ts index 53ce0cff97..1954543f4a 100644 --- a/packages/opencode/src/control-plane/schema.ts +++ b/packages/opencode/src/control-plane/schema.ts @@ -1,7 +1,6 @@ import { Schema } from "effect" import { Identifier } from "@/id/id" -import { zod } from "@opencode-ai/core/effect-zod" import { withStatics } from "@opencode-ai/core/schema" const workspaceIdSchema = Schema.String.check(Schema.isStartsWith("wrk")).pipe(Schema.brand("WorkspaceID")) @@ -11,6 +10,5 @@ export type WorkspaceID = typeof workspaceIdSchema.Type export const WorkspaceID = workspaceIdSchema.pipe( withStatics((schema: typeof workspaceIdSchema) => ({ ascending: (id?: string) => schema.make(Identifier.ascending("workspace", id)), - zod: zod(schema), })), ) diff --git a/packages/opencode/src/session/schema.ts b/packages/opencode/src/session/schema.ts index 991c9ccc6b..caf8f9d783 100644 --- a/packages/opencode/src/session/schema.ts +++ b/packages/opencode/src/session/schema.ts @@ -1,14 +1,12 @@ import { Schema } from "effect" import { Identifier } from "@/id/id" -import { zod } from "@opencode-ai/core/effect-zod" import { withStatics } from "@opencode-ai/core/schema" export const SessionID = Schema.String.check(Schema.isStartsWith("ses")).pipe( Schema.brand("SessionID"), withStatics((s) => ({ descending: (id?: string) => s.make(Identifier.descending("session", id)), - zod: zod(s), })), ) @@ -18,7 +16,6 @@ export const MessageID = Schema.String.check(Schema.isStartsWith("msg")).pipe( Schema.brand("MessageID"), withStatics((s) => ({ ascending: (id?: string) => s.make(Identifier.ascending("message", id)), - zod: zod(s), })), ) @@ -28,7 +25,6 @@ export const PartID = Schema.String.check(Schema.isStartsWith("prt")).pipe( Schema.brand("PartID"), withStatics((s) => ({ ascending: (id?: string) => s.make(Identifier.ascending("part", id)), - zod: zod(s), })), ) diff --git a/packages/opencode/test/session/schema-decoding.test.ts b/packages/opencode/test/session/schema-decoding.test.ts index e9628ce49f..67c438a386 100644 --- a/packages/opencode/test/session/schema-decoding.test.ts +++ b/packages/opencode/test/session/schema-decoding.test.ts @@ -15,20 +15,20 @@ import { WorkspaceID } from "../../src/control-plane/schema" // schema we assert: // 1. The Effect decoder (`Schema.decodeUnknownSync`) accepts valid input. // 2. The derived Zod (`X.zod.parse`) accepts the same input and returns the -// same shape. -// 3. Clearly-invalid input is rejected by both paths. +// same shape for schemas that still expose Zod statics. +// 3. Clearly-invalid input is rejected by both paths where both exist. // // The point is to lock down the Schema <-> Zod bridge so a future edit to // any input schema can't silently drop or widen a field on one side. // Representative valid IDs — the branded schemas require the right prefix // (see src/id/id.ts). -const sessionID = SessionID.zod.parse("ses_01J5Y5H0AH4Q4NXJ6P4C3P5V2K") -const sessionIDChild = SessionID.zod.parse("ses_01J5Y5H0AH4Q4NXJ6P4C3P5V2L") -const messageID = MessageID.zod.parse("msg_01J5Y5H0AH4Q4NXJ6P4C3P5V2M") -const partID = PartID.zod.parse("prt_01J5Y5H0AH4Q4NXJ6P4C3P5V2N") +const sessionID = Schema.decodeUnknownSync(SessionID)("ses_01J5Y5H0AH4Q4NXJ6P4C3P5V2K") +const sessionIDChild = Schema.decodeUnknownSync(SessionID)("ses_01J5Y5H0AH4Q4NXJ6P4C3P5V2L") +const messageID = Schema.decodeUnknownSync(MessageID)("msg_01J5Y5H0AH4Q4NXJ6P4C3P5V2M") +const partID = Schema.decodeUnknownSync(PartID)("prt_01J5Y5H0AH4Q4NXJ6P4C3P5V2N") const projectID = ProjectID.zod.parse("proj-alpha") -const workspaceID = WorkspaceID.zod.parse("wrk-primary") +const workspaceID = Schema.decodeUnknownSync(WorkspaceID)("wrk-primary") function decodeUnknown(schema: S) { const decode = Schema.decodeUnknownSync(schema as any) From 128d10d9e97e60c1bf326357f071e59e90ac6c6c Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sun, 10 May 2026 20:03:11 -0400 Subject: [PATCH 159/189] Simplify compaction test helpers (#26742) --- .../opencode/test/session/compaction.test.ts | 26 ++++++------------- 1 file changed, 8 insertions(+), 18 deletions(-) diff --git a/packages/opencode/test/session/compaction.test.ts b/packages/opencode/test/session/compaction.test.ts index b890d1149e..2b36610e58 100644 --- a/packages/opencode/test/session/compaction.test.ts +++ b/packages/opencode/test/session/compaction.test.ts @@ -226,12 +226,6 @@ async function summaryAssistant(sessionID: SessionID, parentID: MessageID, root: return msg } -async function lastCompactionPart(sessionID: SessionID) { - return (await svc.messages({ sessionID })) - .at(-2) - ?.parts.find((item): item is MessageV2.CompactionPart => item.type === "compaction") -} - function createCompactionMarker(sessionID: SessionID) { return SessionNs.Service.use((ssn) => Effect.gen(function* () { @@ -258,14 +252,6 @@ async function createCompactionMarkerAsync(sessionID: SessionID) { return run(createCompactionMarker(sessionID)) } -function readLastCompactionPart(sessionID: SessionID) { - return SessionNs.Service.use((ssn) => ssn.messages({ sessionID })).pipe( - Effect.map((messages) => - messages.at(-2)?.parts.find((item): item is MessageV2.CompactionPart => item.type === "compaction"), - ), - ) -} - function fake( input: Parameters[0], result: "continue" | "compact", @@ -379,6 +365,10 @@ function readCompactionPart(sessionID: SessionID) { ) } +async function lastCompactionPart(sessionID: SessionID) { + return run(readCompactionPart(sessionID)) +} + function llm() { const queue: Array< Stream.Stream | ((input: LLM.StreamInput) => Stream.Stream) @@ -1057,7 +1047,7 @@ describe("session.compaction.process", () => { yield* createUserMessage(session.id, "first") const keep = yield* createUserMessage(session.id, "second") yield* createUserMessage(session.id, "third") - yield* SessionCompaction.use.create({ sessionID: session.id, agent: "build", model: ref, auto: false }) + yield* createSummaryCompaction(session.id) const msgs = yield* ssn.messages({ sessionID: session.id }) const parent = msgs.at(-1)?.info.id @@ -1069,7 +1059,7 @@ describe("session.compaction.process", () => { auto: false, }) - const part = yield* readLastCompactionPart(session.id) + const part = yield* readCompactionPart(session.id) expect(part?.type).toBe("compaction") expect(part?.tail_start_id).toBe(keep.id) }).pipe(Effect.provide(compactionProcessLayer({ config: cfg({ tail_turns: 2, preserve_recent_tokens: 10_000 }) }))), @@ -1083,7 +1073,7 @@ describe("session.compaction.process", () => { yield* createUserMessage(session.id, "first") yield* createUserMessage(session.id, "x".repeat(2_000)) const keep = yield* createUserMessage(session.id, "tiny") - yield* SessionCompaction.use.create({ sessionID: session.id, agent: "build", model: ref, auto: false }) + yield* createSummaryCompaction(session.id) const msgs = yield* ssn.messages({ sessionID: session.id }) const parent = msgs.at(-1)?.info.id @@ -1095,7 +1085,7 @@ describe("session.compaction.process", () => { auto: false, }) - const part = yield* readLastCompactionPart(session.id) + const part = yield* readCompactionPart(session.id) expect(part?.type).toBe("compaction") expect(part?.tail_start_id).toBe(keep.id) }).pipe(Effect.provide(compactionProcessLayer({ config: cfg({ tail_turns: 2, preserve_recent_tokens: 100 }) }))), From 7cea32ee085bb54f486a053f3ad0798ca72d961d Mon Sep 17 00:00:00 2001 From: Dax Date: Sun, 10 May 2026 20:16:42 -0400 Subject: [PATCH 160/189] Add background code migration service (#26652) --- .../migration.sql | 4 + .../snapshot.json | 1564 +++++++++++++++++ packages/opencode/src/data-migration.sql.ts | 6 + packages/opencode/src/data-migration.ts | 59 + packages/opencode/src/effect/app-runtime.ts | 2 + 5 files changed, 1635 insertions(+) create mode 100644 packages/opencode/migration/20260511000411_data_migration_state/migration.sql create mode 100644 packages/opencode/migration/20260511000411_data_migration_state/snapshot.json create mode 100644 packages/opencode/src/data-migration.sql.ts create mode 100644 packages/opencode/src/data-migration.ts diff --git a/packages/opencode/migration/20260511000411_data_migration_state/migration.sql b/packages/opencode/migration/20260511000411_data_migration_state/migration.sql new file mode 100644 index 0000000000..ba36a7f078 --- /dev/null +++ b/packages/opencode/migration/20260511000411_data_migration_state/migration.sql @@ -0,0 +1,4 @@ +CREATE TABLE `data_migration` ( + `name` text PRIMARY KEY, + `time_completed` integer NOT NULL +); diff --git a/packages/opencode/migration/20260511000411_data_migration_state/snapshot.json b/packages/opencode/migration/20260511000411_data_migration_state/snapshot.json new file mode 100644 index 0000000000..09b1bc9bf7 --- /dev/null +++ b/packages/opencode/migration/20260511000411_data_migration_state/snapshot.json @@ -0,0 +1,1564 @@ +{ + "version": "7", + "dialect": "sqlite", + "id": "fdfcccee-fb3a-481f-b801-b9835fa30d5d", + "prevIds": [ + "630a93f2-c6c6-4191-a351-868d8f3a05d4" + ], + "ddl": [ + { + "name": "account_state", + "entityType": "tables" + }, + { + "name": "account", + "entityType": "tables" + }, + { + "name": "control_account", + "entityType": "tables" + }, + { + "name": "workspace", + "entityType": "tables" + }, + { + "name": "data_migration", + "entityType": "tables" + }, + { + "name": "project", + "entityType": "tables" + }, + { + "name": "message", + "entityType": "tables" + }, + { + "name": "part", + "entityType": "tables" + }, + { + "name": "permission", + "entityType": "tables" + }, + { + "name": "session_message", + "entityType": "tables" + }, + { + "name": "session", + "entityType": "tables" + }, + { + "name": "todo", + "entityType": "tables" + }, + { + "name": "session_share", + "entityType": "tables" + }, + { + "name": "event_sequence", + "entityType": "tables" + }, + { + "name": "event", + "entityType": "tables" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active_account_id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active_org_id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "access_token", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "refresh_token", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "token_expiry", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "access_token", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "refresh_token", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "token_expiry", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "''", + "generated": null, + "name": "name", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "branch", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "directory", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "extra", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_used", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "data_migration" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_completed", + "entityType": "columns", + "table": "data_migration" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "worktree", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "vcs", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_url", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_url_override", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_color", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_initialized", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "sandboxes", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "commands", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "message_id", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "part" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "part" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "permission" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "permission" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "permission" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "permission" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "parent_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "slug", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "directory", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "path", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "title", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "version", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "share_url", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_additions", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_deletions", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_files", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_diffs", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "revert", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "permission", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "agent", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "model", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_compacting", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_archived", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "content", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "status", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "priority", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "position", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "secret", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "aggregate_id", + "entityType": "columns", + "table": "event_sequence" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "seq", + "entityType": "columns", + "table": "event_sequence" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "owner_id", + "entityType": "columns", + "table": "event_sequence" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "aggregate_id", + "entityType": "columns", + "table": "event" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "seq", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "event" + }, + { + "columns": [ + "active_account_id" + ], + "tableTo": "account", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "nameExplicit": false, + "name": "fk_account_state_active_account_id_account_id_fk", + "entityType": "fks", + "table": "account_state" + }, + { + "columns": [ + "project_id" + ], + "tableTo": "project", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_workspace_project_id_project_id_fk", + "entityType": "fks", + "table": "workspace" + }, + { + "columns": [ + "session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_message_session_id_session_id_fk", + "entityType": "fks", + "table": "message" + }, + { + "columns": [ + "message_id" + ], + "tableTo": "message", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_part_message_id_message_id_fk", + "entityType": "fks", + "table": "part" + }, + { + "columns": [ + "project_id" + ], + "tableTo": "project", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_permission_project_id_project_id_fk", + "entityType": "fks", + "table": "permission" + }, + { + "columns": [ + "session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_message_session_id_session_id_fk", + "entityType": "fks", + "table": "session_message" + }, + { + "columns": [ + "project_id" + ], + "tableTo": "project", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_project_id_project_id_fk", + "entityType": "fks", + "table": "session" + }, + { + "columns": [ + "session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_todo_session_id_session_id_fk", + "entityType": "fks", + "table": "todo" + }, + { + "columns": [ + "session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_share_session_id_session_id_fk", + "entityType": "fks", + "table": "session_share" + }, + { + "columns": [ + "aggregate_id" + ], + "tableTo": "event_sequence", + "columnsTo": [ + "aggregate_id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_event_aggregate_id_event_sequence_aggregate_id_fk", + "entityType": "fks", + "table": "event" + }, + { + "columns": [ + "email", + "url" + ], + "nameExplicit": false, + "name": "control_account_pk", + "entityType": "pks", + "table": "control_account" + }, + { + "columns": [ + "session_id", + "position" + ], + "nameExplicit": false, + "name": "todo_pk", + "entityType": "pks", + "table": "todo" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "account_state_pk", + "table": "account_state", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "account_pk", + "table": "account", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "workspace_pk", + "table": "workspace", + "entityType": "pks" + }, + { + "columns": [ + "name" + ], + "nameExplicit": false, + "name": "data_migration_pk", + "table": "data_migration", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "project_pk", + "table": "project", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "message_pk", + "table": "message", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "part_pk", + "table": "part", + "entityType": "pks" + }, + { + "columns": [ + "project_id" + ], + "nameExplicit": false, + "name": "permission_pk", + "table": "permission", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "session_message_pk", + "table": "session_message", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "session_pk", + "table": "session", + "entityType": "pks" + }, + { + "columns": [ + "session_id" + ], + "nameExplicit": false, + "name": "session_share_pk", + "table": "session_share", + "entityType": "pks" + }, + { + "columns": [ + "aggregate_id" + ], + "nameExplicit": false, + "name": "event_sequence_pk", + "table": "event_sequence", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "event_pk", + "table": "event", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "time_created", + "isExpression": false + }, + { + "value": "id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "message_session_time_created_id_idx", + "entityType": "indexes", + "table": "message" + }, + { + "columns": [ + { + "value": "message_id", + "isExpression": false + }, + { + "value": "id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "part_message_id_id_idx", + "entityType": "indexes", + "table": "part" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "part_session_idx", + "entityType": "indexes", + "table": "part" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_message_session_idx", + "entityType": "indexes", + "table": "session_message" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "type", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_message_session_type_idx", + "entityType": "indexes", + "table": "session_message" + }, + { + "columns": [ + { + "value": "time_created", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_message_time_created_idx", + "entityType": "indexes", + "table": "session_message" + }, + { + "columns": [ + { + "value": "project_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_project_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_workspace_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "parent_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_parent_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "todo_session_idx", + "entityType": "indexes", + "table": "todo" + } + ], + "renames": [] +} \ No newline at end of file diff --git a/packages/opencode/src/data-migration.sql.ts b/packages/opencode/src/data-migration.sql.ts new file mode 100644 index 0000000000..ba446b501c --- /dev/null +++ b/packages/opencode/src/data-migration.sql.ts @@ -0,0 +1,6 @@ +import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core" + +export const DataMigrationTable = sqliteTable("data_migration", { + name: text().primaryKey(), + time_completed: integer().notNull(), +}) diff --git a/packages/opencode/src/data-migration.ts b/packages/opencode/src/data-migration.ts new file mode 100644 index 0000000000..c3e5a9d2b0 --- /dev/null +++ b/packages/opencode/src/data-migration.ts @@ -0,0 +1,59 @@ +import { Context, Effect, Layer } from "effect" +import { Database } from "./storage/db" +import { DataMigrationTable } from "./data-migration.sql" +import * as Log from "@opencode-ai/core/util/log" +import { eq } from "drizzle-orm" + +export type Migration = { + name: string + run: Effect.Effect +} + +const log = Log.create({ service: "data-migration" }) + +export interface Interface {} + +export class Service extends Context.Service()("@opencode/DataMigration") {} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const migrations: Migration[] = [] + + yield* Effect.gen(function* () { + if (migrations.length === 0) return + + // Migrations run in a background fiber, so they must be resumable until + // their completion row is written. + for (const migration of migrations) { + const completed = Database.use((db) => + db + .select({ name: DataMigrationTable.name }) + .from(DataMigrationTable) + .where(eq(DataMigrationTable.name, migration.name)) + .get(), + ) + if (completed) continue + + log.info("running data migration", { name: migration.name }) + yield* migration.run + Database.use((db) => + db + .insert(DataMigrationTable) + .values({ name: migration.name, time_completed: Date.now() }) + .onConflictDoNothing() + .run(), + ) + } + }).pipe( + Effect.tapCause((cause) => Effect.logError("failed to run data migrations", { cause })), + Effect.ignore, + Effect.forkScoped, + ) + return Service.of({}) + }), +) + +export const defaultLayer = layer + +export * as DataMigration from "./data-migration" diff --git a/packages/opencode/src/effect/app-runtime.ts b/packages/opencode/src/effect/app-runtime.ts index a955cb86dc..4c1637006c 100644 --- a/packages/opencode/src/effect/app-runtime.ts +++ b/packages/opencode/src/effect/app-runtime.ts @@ -54,6 +54,7 @@ import { SessionShare } from "@/share/session" import { SyncEvent } from "@/sync" import { Npm } from "@opencode-ai/core/npm" import { memoMap } from "@opencode-ai/core/effect/memo-map" +import { DataMigration } from "@/data-migration" export const AppLayer = Layer.mergeAll( Npm.defaultLayer, @@ -106,6 +107,7 @@ export const AppLayer = Layer.mergeAll( ShareNext.defaultLayer, SessionShare.defaultLayer, SyncEvent.defaultLayer, + DataMigration.defaultLayer, ).pipe(Layer.provideMerge(InstanceLayer.layer), Layer.provideMerge(Observability.layer)) const rt = ManagedRuntime.make(AppLayer, { memoMap }) From 16aa67086b00ba6c64e4ed714c2ee33ffe06f73a Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sun, 10 May 2026 20:17:01 -0400 Subject: [PATCH 161/189] Effectify remaining compaction process tests (#26776) --- .../opencode/test/session/compaction.test.ts | 778 ++++++------------ 1 file changed, 266 insertions(+), 512 deletions(-) diff --git a/packages/opencode/test/session/compaction.test.ts b/packages/opencode/test/session/compaction.test.ts index 2b36610e58..982e2d8040 100644 --- a/packages/opencode/test/session/compaction.test.ts +++ b/packages/opencode/test/session/compaction.test.ts @@ -1,8 +1,7 @@ import { afterEach, describe, expect, mock, test } from "bun:test" import { APICallError } from "ai" -import { Cause, Deferred, Effect, Exit, Layer, ManagedRuntime } from "effect" +import { Cause, Deferred, Effect, Exit, Fiber, Layer } from "effect" import * as Stream from "effect/Stream" -import z from "zod" import { Bus } from "../../src/bus" import { Config } from "@/config/config" import { Image } from "@/image/image" @@ -10,11 +9,10 @@ import { Agent } from "../../src/agent/agent" import { LLM } from "../../src/session/llm" import { SessionCompaction } from "../../src/session/compaction" import { Token } from "@/util/token" -import { WithInstance } from "../../src/project/with-instance" import * as Log from "@opencode-ai/core/util/log" import { Permission } from "../../src/permission" import { Plugin } from "../../src/plugin" -import { provideTmpdirInstance, TestInstance, tmpdir } from "../fixture/fixture" +import { provideTmpdirInstance, TestInstance } from "../fixture/fixture" import { Session as SessionNs } from "@/session/session" import { MessageV2 } from "../../src/session/message-v2" import { MessageID, PartID, SessionID } from "../../src/session/schema" @@ -32,26 +30,6 @@ import { TestConfig } from "../fixture/config" void Log.init({ print: false }) -function run(fx: Effect.Effect) { - return Effect.runPromise(fx.pipe(Effect.provide(SessionNs.defaultLayer))) -} - -const svc = { - ...SessionNs, - create(input?: SessionNs.CreateInput) { - return run(SessionNs.Service.use((svc) => svc.create(input))) - }, - messages(input: z.output) { - return run(SessionNs.Service.use((svc) => svc.messages(input))) - }, - updateMessage(msg: T) { - return run(SessionNs.Service.use((svc) => svc.updateMessage(msg))) - }, - updatePart(part: T) { - return run(SessionNs.Service.use((svc) => svc.updatePart(part))) - }, -} - const summary = Layer.succeed( SessionSummary.Service, SessionSummary.Service.of({ @@ -102,50 +80,6 @@ function createModel(opts: { const wide = () => ProviderTest.fake({ model: createModel({ context: 100_000, output: 32_000 }) }) -async function user(sessionID: SessionID, text: string) { - const msg = await svc.updateMessage({ - id: MessageID.ascending(), - role: "user", - sessionID, - agent: "build", - model: ref, - time: { created: Date.now() }, - }) - await svc.updatePart({ - id: PartID.ascending(), - messageID: msg.id, - sessionID, - type: "text", - text, - }) - return msg -} - -async function assistant(sessionID: SessionID, parentID: MessageID, root: string) { - const msg: MessageV2.Assistant = { - id: MessageID.ascending(), - role: "assistant", - sessionID, - mode: "build", - agent: "build", - path: { cwd: root, root }, - cost: 0, - tokens: { - output: 0, - input: 0, - reasoning: 0, - cache: { read: 0, write: 0 }, - }, - modelID: ref.modelID, - providerID: ref.providerID, - parentID, - time: { created: Date.now() }, - finish: "end_turn", - } - await svc.updateMessage(msg) - return msg -} - function createUserMessage(sessionID: SessionID, text: string) { return Effect.gen(function* () { const ssn = yield* SessionNs.Service @@ -193,37 +127,40 @@ function createAssistantMessage(sessionID: SessionID, parentID: MessageID, root: ) } -async function summaryAssistant(sessionID: SessionID, parentID: MessageID, root: string, text: string) { - const msg: MessageV2.Assistant = { - id: MessageID.ascending(), - role: "assistant", - sessionID, - mode: "compaction", - agent: "compaction", - path: { cwd: root, root }, - cost: 0, - tokens: { - output: 0, - input: 0, - reasoning: 0, - cache: { read: 0, write: 0 }, - }, - modelID: ref.modelID, - providerID: ref.providerID, - parentID, - summary: true, - time: { created: Date.now() }, - finish: "end_turn", - } - await svc.updateMessage(msg) - await svc.updatePart({ - id: PartID.ascending(), - messageID: msg.id, - sessionID, - type: "text", - text, - }) - return msg +function createSummaryAssistantMessage(sessionID: SessionID, parentID: MessageID, root: string, text: string) { + return SessionNs.Service.use((ssn) => + Effect.gen(function* () { + const msg = yield* ssn.updateMessage({ + id: MessageID.ascending(), + role: "assistant", + sessionID, + mode: "compaction", + agent: "compaction", + path: { cwd: root, root }, + cost: 0, + tokens: { + output: 0, + input: 0, + reasoning: 0, + cache: { read: 0, write: 0 }, + }, + modelID: ref.modelID, + providerID: ref.providerID, + parentID, + summary: true, + time: { created: Date.now() }, + finish: "end_turn", + }) + yield* ssn.updatePart({ + id: PartID.ascending(), + messageID: msg.id, + sessionID, + type: "text", + text, + }) + return msg + }), + ) } function createCompactionMarker(sessionID: SessionID) { @@ -248,10 +185,6 @@ function createCompactionMarker(sessionID: SessionID) { ) } -async function createCompactionMarkerAsync(sessionID: SessionID) { - return run(createCompactionMarker(sessionID)) -} - function fake( input: Parameters[0], result: "continue" | "compact", @@ -283,26 +216,6 @@ function cfg(compaction?: Config.Info["compaction"]) { }) } -function runtime( - result: "continue" | "compact", - plugin = Plugin.defaultLayer, - provider = ProviderTest.fake(), - config = Config.defaultLayer, -) { - const bus = Bus.layer - return ManagedRuntime.make( - Layer.mergeAll(SessionCompaction.layer, bus).pipe( - Layer.provide(provider.layer), - Layer.provide(SessionNs.defaultLayer), - Layer.provide(layer(result)), - Layer.provide(Agent.defaultLayer), - Layer.provide(plugin), - Layer.provide(bus), - Layer.provide(config), - ), - ) -} - const deps = Layer.mergeAll( wide().layer, layer("continue"), @@ -365,10 +278,6 @@ function readCompactionPart(sessionID: SessionID) { ) } -async function lastCompactionPart(sessionID: SessionID) { - return run(readCompactionPart(sessionID)) -} - function llm() { const queue: Array< Stream.Stream | ((input: LLM.StreamInput) => Stream.Stream) @@ -391,29 +300,6 @@ function llm() { } } -function liveRuntime(layer: Layer.Layer, provider = ProviderTest.fake(), config = Config.defaultLayer) { - const bus = Bus.layer - const status = SessionStatus.layer.pipe(Layer.provide(bus)) - const processor = SessionProcessorModule.SessionProcessor.layer.pipe( - Layer.provide(summary), - Layer.provide(Image.defaultLayer), - ) - return ManagedRuntime.make( - Layer.mergeAll(SessionCompaction.layer.pipe(Layer.provide(processor)), processor, bus, status).pipe( - Layer.provide(provider.layer), - Layer.provide(SessionNs.defaultLayer), - Layer.provide(Snapshot.defaultLayer), - Layer.provide(layer), - Layer.provide(Permission.defaultLayer), - Layer.provide(Agent.defaultLayer), - Layer.provide(Plugin.defaultLayer), - Layer.provide(status), - Layer.provide(bus), - Layer.provide(config), - ), - ) -} - function reply( text: string, capture?: (input: LLM.StreamInput) => void, @@ -469,23 +355,14 @@ function reply( } } -function wait(ms = 50) { - return new Promise((resolve) => setTimeout(resolve, ms)) -} - -function defer() { - let resolve!: () => void - const promise = new Promise((done) => { - resolve = done - }) - return { promise, resolve } -} - -function plugin(ready: ReturnType) { +function plugin(ready: Deferred.Deferred) { return Layer.mock(Plugin.Service)({ trigger: (name: Name, _input: Input, output: Output) => { if (name !== "experimental.session.compacting") return Effect.succeed(output) - return Effect.sync(() => ready.resolve()).pipe(Effect.andThen(Effect.never), Effect.as(output)) + return Effect.sync(() => Deferred.doneUnsafe(ready, Effect.void)).pipe( + Effect.andThen(Effect.never), + Effect.as(output), + ) }, list: () => Effect.succeed([]), init: () => Effect.void, @@ -1315,154 +1192,99 @@ describe("session.compaction.process", () => { }), ) - test("stops quickly when aborted during retry backoff", async () => { - const stub = llm() - const ready = defer() - stub.push( - Stream.fromAsyncIterable( - { - async *[Symbol.asyncIterator]() { - yield { type: "start" } as LLM.Event - throw new APICallError({ - message: "boom", - url: "https://example.com/v1/chat/completions", - requestBodyValues: {}, - statusCode: 503, - responseHeaders: { "retry-after-ms": "10000" }, - responseBody: '{"error":"boom"}', - isRetryable: true, - }) + itProcess.instance( + "stops quickly when aborted during retry backoff", + () => { + const stub = llm() + stub.push( + Stream.fromAsyncIterable( + { + async *[Symbol.asyncIterator]() { + yield { type: "start" } as LLM.Event + throw new APICallError({ + message: "boom", + url: "https://example.com/v1/chat/completions", + requestBodyValues: {}, + statusCode: 503, + responseHeaders: { "retry-after-ms": "10000" }, + responseBody: '{"error":"boom"}', + isRetryable: true, + }) + }, }, - }, - (err) => err, - ), - ) + (err) => err, + ), + ) - await using tmp = await tmpdir({ git: true }) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const session = await svc.create({}) - const msg = await user(session.id, "hello") - const msgs = await svc.messages({ sessionID: session.id }) - const abort = new AbortController() - const rt = liveRuntime(stub.layer, wide()) - let off: (() => void) | undefined - let run: Promise<"continue" | "stop"> | undefined - try { - off = await rt.runPromise( - Bus.Service.use((svc) => - svc.subscribeCallback(SessionStatus.Event.Status, (evt) => { - if (evt.properties.sessionID !== session.id) return - if (evt.properties.status.type !== "retry") return - ready.resolve() - }), - ), - ) + return Effect.gen(function* () { + const ssn = yield* SessionNs.Service + const bus = yield* Bus.Service + const ready = yield* Deferred.make() + const session = yield* ssn.create({}) + const msg = yield* createUserMessage(session.id, "hello") + const msgs = yield* ssn.messages({ sessionID: session.id }) + const off = yield* bus.subscribeCallback(SessionStatus.Event.Status, (evt) => { + if (evt.properties.sessionID !== session.id) return + if (evt.properties.status.type !== "retry") return + Deferred.doneUnsafe(ready, Effect.void) + }) + yield* Effect.addFinalizer(() => Effect.sync(off)) - run = rt - .runPromiseExit( - SessionCompaction.Service.use((svc) => - svc.process({ - parentID: msg.id, - messages: msgs, - sessionID: session.id, - auto: false, - }), - ), - { signal: abort.signal }, - ) - .then((exit) => { - if (Exit.isFailure(exit)) { - if (Cause.hasInterrupts(exit.cause) && abort.signal.aborted) return "stop" - throw Cause.squash(exit.cause) - } - return exit.value - }) + const fiber = yield* SessionCompaction.use + .process({ + parentID: msg.id, + messages: msgs, + sessionID: session.id, + auto: false, + }) + .pipe(Effect.forkChild) - await Promise.race([ - ready.promise, - wait(1000).then(() => { - throw new Error("timed out waiting for retry status") - }), - ]) + yield* Deferred.await(ready).pipe(Effect.timeout("1 second")) + const start = Date.now() + yield* Fiber.interrupt(fiber) + const exit = yield* Fiber.await(fiber).pipe(Effect.timeout("250 millis")) - const start = Date.now() - abort.abort() - const result = await Promise.race([ - run.then((value) => ({ kind: "done" as const, value, ms: Date.now() - start })), - wait(250).then(() => ({ kind: "timeout" as const })), - ]) - - expect(result.kind).toBe("done") - if (result.kind === "done") { - expect(result.value).toBe("stop") - expect(result.ms).toBeLessThan(250) - } - } finally { - off?.() - abort.abort() - await rt.dispose() - await run?.catch(() => undefined) + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) { + expect(Cause.hasInterrupts(exit.cause)).toBe(true) + expect(Date.now() - start).toBeLessThan(250) } - }, - }) - }) + }).pipe(Effect.provide(compactionProcessLayer({ llm: stub.layer }))) + }, + { git: true }, + ) - test("does not leave a summary assistant when aborted before processor setup", async () => { - const ready = defer() - - await using tmp = await tmpdir({ git: true }) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const session = await svc.create({}) - const msg = await user(session.id, "hello") - const msgs = await svc.messages({ sessionID: session.id }) - const abort = new AbortController() - const rt = runtime("continue", plugin(ready), wide()) - let run: Promise<"continue" | "stop"> | undefined - try { - run = rt - .runPromiseExit( - SessionCompaction.Service.use((svc) => - svc.process({ - parentID: msg.id, - messages: msgs, - sessionID: session.id, - auto: false, - }), - ), - { signal: abort.signal }, - ) - .then((exit) => { - if (Exit.isFailure(exit)) { - if (Cause.hasInterrupts(exit.cause) && abort.signal.aborted) return "stop" - throw Cause.squash(exit.cause) - } - return exit.value + itProcess.instance( + "does not leave a summary assistant when aborted before processor setup", + () => + Effect.gen(function* () { + const ready = yield* Deferred.make() + return yield* Effect.gen(function* () { + const ssn = yield* SessionNs.Service + const session = yield* ssn.create({}) + const msg = yield* createUserMessage(session.id, "hello") + const msgs = yield* ssn.messages({ sessionID: session.id }) + const fiber = yield* SessionCompaction.use + .process({ + parentID: msg.id, + messages: msgs, + sessionID: session.id, + auto: false, }) + .pipe(Effect.forkChild) - await Promise.race([ - ready.promise, - wait(1000).then(() => { - throw new Error("timed out waiting for compaction hook") - }), - ]) + yield* Deferred.await(ready).pipe(Effect.timeout("1 second")) + yield* Fiber.interrupt(fiber) + const exit = yield* Fiber.await(fiber).pipe(Effect.timeout("250 millis")) + const all = yield* ssn.messages({ sessionID: session.id }) - abort.abort() - expect(await run).toBe("stop") - - const all = await svc.messages({ sessionID: session.id }) + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) expect(Cause.hasInterrupts(exit.cause)).toBe(true) expect(all.some((msg) => msg.info.role === "assistant" && msg.info.summary)).toBe(false) - } finally { - abort.abort() - await rt.dispose() - await run?.catch(() => undefined) - } - }, - }) - }) + }).pipe(Effect.provide(compactionProcessLayer({ plugin: plugin(ready) }))) + }), + { git: true }, + ) itProcess.instance( "does not allow tool calls while generating the summary", @@ -1533,240 +1355,172 @@ describe("session.compaction.process", () => { { git: true }, ) - test("summarizes only the head while keeping recent tail out of summary input", async () => { - const stub = llm() - let captured = "" - stub.push( - reply("summary", (input) => { - captured = JSON.stringify(input.messages) - }), - ) + itProcess.instance( + "summarizes only the head while keeping recent tail out of summary input", + () => { + const stub = llm() + let captured = "" + stub.push( + reply("summary", (input) => { + captured = JSON.stringify(input.messages) + }), + ) + return Effect.gen(function* () { + const ssn = yield* SessionNs.Service + const session = yield* ssn.create({}) + yield* createUserMessage(session.id, "older context") + yield* createUserMessage(session.id, "keep this turn") + yield* createUserMessage(session.id, "and this one too") + yield* createCompactionMarker(session.id) - await using tmp = await tmpdir({ git: true }) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const session = await svc.create({}) - await user(session.id, "older context") - await user(session.id, "keep this turn") - await user(session.id, "and this one too") - await createCompactionMarkerAsync(session.id) + const msgs = yield* ssn.messages({ sessionID: session.id }) + const parent = msgs.at(-1)?.info.id + expect(parent).toBeTruthy() + yield* SessionCompaction.use.process({ + parentID: parent!, + messages: msgs, + sessionID: session.id, + auto: false, + }) - const rt = liveRuntime(stub.layer, wide()) - try { - const msgs = await svc.messages({ sessionID: session.id }) - const parent = msgs.at(-1)?.info.id - expect(parent).toBeTruthy() - await rt.runPromise( - SessionCompaction.Service.use((svc) => - svc.process({ - parentID: parent!, - messages: msgs, - sessionID: session.id, - auto: false, - }), - ), - ) + expect(captured).toContain("older context") + expect(captured).not.toContain("keep this turn") + expect(captured).not.toContain("and this one too") + expect(captured).not.toContain("What did we do so far?") + }).pipe(Effect.provide(compactionProcessLayer({ llm: stub.layer }))) + }, + { git: true }, + ) - expect(captured).toContain("older context") - expect(captured).not.toContain("keep this turn") - expect(captured).not.toContain("and this one too") - expect(captured).not.toContain("What did we do so far?") - } finally { - await rt.dispose() - } - }, - }) - }) + itProcess.instance( + "anchors repeated compactions with the previous summary", + () => { + const stub = llm() + let captured = "" + stub.push(reply("summary one")) + stub.push( + reply("summary two", (input) => { + captured = JSON.stringify(input.messages) + }), + ) - test("anchors repeated compactions with the previous summary", async () => { - const stub = llm() - let captured = "" - stub.push(reply("summary one")) - stub.push( - reply("summary two", (input) => { - captured = JSON.stringify(input.messages) - }), - ) + return Effect.gen(function* () { + const ssn = yield* SessionNs.Service + const session = yield* ssn.create({}) + yield* createUserMessage(session.id, "older context") + yield* createUserMessage(session.id, "keep this turn") + yield* createCompactionMarker(session.id) - await using tmp = await tmpdir({ git: true }) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const session = await svc.create({}) - await user(session.id, "older context") - await user(session.id, "keep this turn") - await createCompactionMarkerAsync(session.id) + let msgs = yield* ssn.messages({ sessionID: session.id }) + let parent = msgs.at(-1)?.info.id + expect(parent).toBeTruthy() + yield* SessionCompaction.use.process({ parentID: parent!, messages: msgs, sessionID: session.id, auto: false }) - const rt = liveRuntime(stub.layer, wide()) - try { - let msgs = await svc.messages({ sessionID: session.id }) - let parent = msgs.at(-1)?.info.id - expect(parent).toBeTruthy() - await rt.runPromise( - SessionCompaction.Service.use((svc) => - svc.process({ - parentID: parent!, - messages: msgs, - sessionID: session.id, - auto: false, - }), - ), - ) + yield* createUserMessage(session.id, "latest turn") + yield* createCompactionMarker(session.id) - await user(session.id, "latest turn") - await createCompactionMarkerAsync(session.id) + msgs = MessageV2.filterCompacted(MessageV2.stream(session.id)) + parent = msgs.at(-1)?.info.id + expect(parent).toBeTruthy() + yield* SessionCompaction.use.process({ parentID: parent!, messages: msgs, sessionID: session.id, auto: false }) - msgs = MessageV2.filterCompacted(MessageV2.stream(session.id)) - parent = msgs.at(-1)?.info.id - expect(parent).toBeTruthy() - await rt.runPromise( - SessionCompaction.Service.use((svc) => - svc.process({ - parentID: parent!, - messages: msgs, - sessionID: session.id, - auto: false, - }), - ), - ) + expect(captured).toContain("") + expect(captured).toContain("summary one") + expect(captured.match(/summary one/g)?.length).toBe(1) + expect(captured).toContain("## Constraints & Preferences") + expect(captured).toContain("## Progress") + }).pipe(Effect.provide(compactionProcessLayer({ llm: stub.layer }))) + }, + { git: true }, + ) - expect(captured).toContain("") - expect(captured).toContain("summary one") - expect(captured.match(/summary one/g)?.length).toBe(1) - expect(captured).toContain("## Constraints & Preferences") - expect(captured).toContain("## Progress") - } finally { - await rt.dispose() - } - }, - }) - }) - - test("keeps recent pre-compaction turns across repeated compactions", async () => { + itProcess.instance("keeps recent pre-compaction turns across repeated compactions", () => { const stub = llm() stub.push(reply("summary one")) stub.push(reply("summary two")) - await using tmp = await tmpdir() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const session = await svc.create({}) - const u1 = await user(session.id, "one") - const u2 = await user(session.id, "two") - const u3 = await user(session.id, "three") - await createCompactionMarkerAsync(session.id) - const rt = liveRuntime(stub.layer, wide(), cfg({ tail_turns: 2, preserve_recent_tokens: 10_000 })) - try { - let msgs = await svc.messages({ sessionID: session.id }) - let parent = msgs.at(-1)?.info.id - expect(parent).toBeTruthy() - await rt.runPromise( - SessionCompaction.Service.use((svc) => - svc.process({ - parentID: parent!, - messages: msgs, - sessionID: session.id, - auto: false, - }), - ), - ) + return Effect.gen(function* () { + const ssn = yield* SessionNs.Service + const session = yield* ssn.create({}) + const u1 = yield* createUserMessage(session.id, "one") + const u2 = yield* createUserMessage(session.id, "two") + const u3 = yield* createUserMessage(session.id, "three") + yield* createCompactionMarker(session.id) - const u4 = await user(session.id, "four") - await createCompactionMarkerAsync(session.id) + let msgs = yield* ssn.messages({ sessionID: session.id }) + let parent = msgs.at(-1)?.info.id + expect(parent).toBeTruthy() + yield* SessionCompaction.use.process({ parentID: parent!, messages: msgs, sessionID: session.id, auto: false }) - msgs = MessageV2.filterCompacted(MessageV2.stream(session.id)) - parent = msgs.at(-1)?.info.id - expect(parent).toBeTruthy() - await rt.runPromise( - SessionCompaction.Service.use((svc) => - svc.process({ - parentID: parent!, - messages: msgs, - sessionID: session.id, - auto: false, - }), - ), - ) + const u4 = yield* createUserMessage(session.id, "four") + yield* createCompactionMarker(session.id) - const filtered = MessageV2.filterCompacted(MessageV2.stream(session.id)) - const ids = filtered.map((msg) => msg.info.id) + msgs = MessageV2.filterCompacted(MessageV2.stream(session.id)) + parent = msgs.at(-1)?.info.id + expect(parent).toBeTruthy() + yield* SessionCompaction.use.process({ parentID: parent!, messages: msgs, sessionID: session.id, auto: false }) - expect(ids).not.toContain(u1.id) - expect(ids).not.toContain(u2.id) - expect(ids).toContain(u3.id) - expect(ids).toContain(u4.id) - expect(filtered.some((msg) => msg.info.role === "assistant" && msg.info.summary)).toBe(true) - expect( - filtered.some((msg) => msg.info.role === "user" && msg.parts.some((part) => part.type === "compaction")), - ).toBe(true) - } finally { - await rt.dispose() - } - }, - }) + const filtered = MessageV2.filterCompacted(MessageV2.stream(session.id)) + const ids = filtered.map((msg) => msg.info.id) + + expect(ids).not.toContain(u1.id) + expect(ids).not.toContain(u2.id) + expect(ids).toContain(u3.id) + expect(ids).toContain(u4.id) + expect(filtered.some((msg) => msg.info.role === "assistant" && msg.info.summary)).toBe(true) + expect( + filtered.some((msg) => msg.info.role === "user" && msg.parts.some((part) => part.type === "compaction")), + ).toBe(true) + }).pipe( + Effect.provide( + compactionProcessLayer({ llm: stub.layer, config: cfg({ tail_turns: 2, preserve_recent_tokens: 10_000 }) }), + ), + ) }) - test("ignores previous summaries when sizing the retained tail", async () => { - await using tmp = await tmpdir() - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const session = await svc.create({}) - await user(session.id, "older") - const keep = await user(session.id, "keep this turn") - const keepReply = await assistant(session.id, keep.id, tmp.path) - await svc.updatePart({ - id: PartID.ascending(), - messageID: keepReply.id, - sessionID: session.id, - type: "text", - text: "keep reply", - }) + itProcess.instance( + "ignores previous summaries when sizing the retained tail", + Effect.gen(function* () { + const ssn = yield* SessionNs.Service + const test = yield* TestInstance + const session = yield* ssn.create({}) + yield* createUserMessage(session.id, "older") + const keep = yield* createUserMessage(session.id, "keep this turn") + const keepReply = yield* createAssistantMessage(session.id, keep.id, test.directory) + yield* ssn.updatePart({ + id: PartID.ascending(), + messageID: keepReply.id, + sessionID: session.id, + type: "text", + text: "keep reply", + }) - await createCompactionMarkerAsync(session.id) - const firstCompaction = (await svc.messages({ sessionID: session.id })).at(-1)?.info.id - expect(firstCompaction).toBeTruthy() - await summaryAssistant(session.id, firstCompaction!, tmp.path, "summary ".repeat(800)) + yield* createCompactionMarker(session.id) + const firstCompaction = (yield* ssn.messages({ sessionID: session.id })).at(-1)?.info.id + expect(firstCompaction).toBeTruthy() + yield* createSummaryAssistantMessage(session.id, firstCompaction!, test.directory, "summary ".repeat(800)) - const recent = await user(session.id, "recent turn") - const recentReply = await assistant(session.id, recent.id, tmp.path) - await svc.updatePart({ - id: PartID.ascending(), - messageID: recentReply.id, - sessionID: session.id, - type: "text", - text: "recent reply", - }) + const recent = yield* createUserMessage(session.id, "recent turn") + const recentReply = yield* createAssistantMessage(session.id, recent.id, test.directory) + yield* ssn.updatePart({ + id: PartID.ascending(), + messageID: recentReply.id, + sessionID: session.id, + type: "text", + text: "recent reply", + }) - await createCompactionMarkerAsync(session.id) + yield* createCompactionMarker(session.id) + const msgs = yield* ssn.messages({ sessionID: session.id }) + const parent = msgs.at(-1)?.info.id + expect(parent).toBeTruthy() + yield* SessionCompaction.use.process({ parentID: parent!, messages: msgs, sessionID: session.id, auto: false }) - const rt = runtime("continue", Plugin.defaultLayer, wide(), cfg({ tail_turns: 2, preserve_recent_tokens: 500 })) - try { - const msgs = await svc.messages({ sessionID: session.id }) - const parent = msgs.at(-1)?.info.id - expect(parent).toBeTruthy() - await rt.runPromise( - SessionCompaction.Service.use((svc) => - svc.process({ - parentID: parent!, - messages: msgs, - sessionID: session.id, - auto: false, - }), - ), - ) - - const part = await lastCompactionPart(session.id) - expect(part?.type).toBe("compaction") - expect(part?.tail_start_id).toBe(keep.id) - } finally { - await rt.dispose() - } - }, - }) - }) + const part = yield* readCompactionPart(session.id) + expect(part?.type).toBe("compaction") + expect(part?.tail_start_id).toBe(keep.id) + }).pipe(Effect.provide(compactionProcessLayer({ config: cfg({ tail_turns: 2, preserve_recent_tokens: 500 }) }))), + ) }) describe("util.token.estimate", () => { From d0412a213be5e9d5882f216d17f0a122e16d9a6d Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Mon, 11 May 2026 00:18:03 +0000 Subject: [PATCH 162/189] chore: generate --- .../snapshot.json | 148 +++++------------- 1 file changed, 37 insertions(+), 111 deletions(-) diff --git a/packages/opencode/migration/20260511000411_data_migration_state/snapshot.json b/packages/opencode/migration/20260511000411_data_migration_state/snapshot.json index 09b1bc9bf7..e84aa1a6a1 100644 --- a/packages/opencode/migration/20260511000411_data_migration_state/snapshot.json +++ b/packages/opencode/migration/20260511000411_data_migration_state/snapshot.json @@ -2,9 +2,7 @@ "version": "7", "dialect": "sqlite", "id": "fdfcccee-fb3a-481f-b801-b9835fa30d5d", - "prevIds": [ - "630a93f2-c6c6-4191-a351-868d8f3a05d4" - ], + "prevIds": ["630a93f2-c6c6-4191-a351-868d8f3a05d4"], "ddl": [ { "name": "account_state", @@ -1117,13 +1115,9 @@ "table": "event" }, { - "columns": [ - "active_account_id" - ], + "columns": ["active_account_id"], "tableTo": "account", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "SET NULL", "nameExplicit": false, @@ -1132,13 +1126,9 @@ "table": "account_state" }, { - "columns": [ - "project_id" - ], + "columns": ["project_id"], "tableTo": "project", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1147,13 +1137,9 @@ "table": "workspace" }, { - "columns": [ - "session_id" - ], + "columns": ["session_id"], "tableTo": "session", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1162,13 +1148,9 @@ "table": "message" }, { - "columns": [ - "message_id" - ], + "columns": ["message_id"], "tableTo": "message", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1177,13 +1159,9 @@ "table": "part" }, { - "columns": [ - "project_id" - ], + "columns": ["project_id"], "tableTo": "project", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1192,13 +1170,9 @@ "table": "permission" }, { - "columns": [ - "session_id" - ], + "columns": ["session_id"], "tableTo": "session", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1207,13 +1181,9 @@ "table": "session_message" }, { - "columns": [ - "project_id" - ], + "columns": ["project_id"], "tableTo": "project", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1222,13 +1192,9 @@ "table": "session" }, { - "columns": [ - "session_id" - ], + "columns": ["session_id"], "tableTo": "session", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1237,13 +1203,9 @@ "table": "todo" }, { - "columns": [ - "session_id" - ], + "columns": ["session_id"], "tableTo": "session", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1252,13 +1214,9 @@ "table": "session_share" }, { - "columns": [ - "aggregate_id" - ], + "columns": ["aggregate_id"], "tableTo": "event_sequence", - "columnsTo": [ - "aggregate_id" - ], + "columnsTo": ["aggregate_id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1267,137 +1225,105 @@ "table": "event" }, { - "columns": [ - "email", - "url" - ], + "columns": ["email", "url"], "nameExplicit": false, "name": "control_account_pk", "entityType": "pks", "table": "control_account" }, { - "columns": [ - "session_id", - "position" - ], + "columns": ["session_id", "position"], "nameExplicit": false, "name": "todo_pk", "entityType": "pks", "table": "todo" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "account_state_pk", "table": "account_state", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "account_pk", "table": "account", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "workspace_pk", "table": "workspace", "entityType": "pks" }, { - "columns": [ - "name" - ], + "columns": ["name"], "nameExplicit": false, "name": "data_migration_pk", "table": "data_migration", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "project_pk", "table": "project", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "message_pk", "table": "message", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "part_pk", "table": "part", "entityType": "pks" }, { - "columns": [ - "project_id" - ], + "columns": ["project_id"], "nameExplicit": false, "name": "permission_pk", "table": "permission", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "session_message_pk", "table": "session_message", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "session_pk", "table": "session", "entityType": "pks" }, { - "columns": [ - "session_id" - ], + "columns": ["session_id"], "nameExplicit": false, "name": "session_share_pk", "table": "session_share", "entityType": "pks" }, { - "columns": [ - "aggregate_id" - ], + "columns": ["aggregate_id"], "nameExplicit": false, "name": "event_sequence_pk", "table": "event_sequence", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "event_pk", "table": "event", @@ -1561,4 +1487,4 @@ } ], "renames": [] -} \ No newline at end of file +} From 426d92e3524dd7aaad305fbcf9c5fddec94569c8 Mon Sep 17 00:00:00 2001 From: opencode Date: Mon, 11 May 2026 00:23:17 +0000 Subject: [PATCH 163/189] sync release versions for v1.14.47 --- bun.lock | 34 +++++++++++++------------- packages/app/package.json | 2 +- packages/console/app/package.json | 2 +- packages/console/core/package.json | 2 +- packages/console/function/package.json | 2 +- packages/console/mail/package.json | 2 +- packages/core/package.json | 2 +- packages/desktop/package.json | 2 +- packages/enterprise/package.json | 2 +- packages/extensions/zed/extension.toml | 12 ++++----- packages/function/package.json | 2 +- packages/http-recorder/package.json | 2 +- packages/llm/package.json | 2 +- packages/opencode/package.json | 2 +- packages/plugin/package.json | 2 +- packages/sdk/js/package.json | 2 +- packages/slack/package.json | 2 +- packages/ui/package.json | 2 +- packages/web/package.json | 2 +- sdks/vscode/package.json | 2 +- 20 files changed, 41 insertions(+), 41 deletions(-) diff --git a/bun.lock b/bun.lock index d98f1b2456..8cd3ce0ff2 100644 --- a/bun.lock +++ b/bun.lock @@ -29,7 +29,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "1.14.46", + "version": "1.14.47", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/core": "workspace:*", @@ -85,7 +85,7 @@ }, "packages/console/app": { "name": "@opencode-ai/console-app", - "version": "1.14.46", + "version": "1.14.47", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@ibm/plex": "6.4.1", @@ -120,7 +120,7 @@ }, "packages/console/core": { "name": "@opencode-ai/console-core", - "version": "1.14.46", + "version": "1.14.47", "dependencies": { "@aws-sdk/client-sts": "3.782.0", "@jsx-email/render": "1.1.1", @@ -147,7 +147,7 @@ }, "packages/console/function": { "name": "@opencode-ai/console-function", - "version": "1.14.46", + "version": "1.14.47", "dependencies": { "@ai-sdk/anthropic": "3.0.64", "@ai-sdk/openai": "3.0.48", @@ -171,7 +171,7 @@ }, "packages/console/mail": { "name": "@opencode-ai/console-mail", - "version": "1.14.46", + "version": "1.14.47", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", @@ -195,7 +195,7 @@ }, "packages/core": { "name": "@opencode-ai/core", - "version": "1.14.46", + "version": "1.14.47", "bin": { "opencode": "./bin/opencode", }, @@ -229,7 +229,7 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "1.14.46", + "version": "1.14.47", "dependencies": { "drizzle-orm": "catalog:", "effect": "catalog:", @@ -283,7 +283,7 @@ }, "packages/enterprise": { "name": "@opencode-ai/enterprise", - "version": "1.14.46", + "version": "1.14.47", "dependencies": { "@opencode-ai/core": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -313,7 +313,7 @@ }, "packages/function": { "name": "@opencode-ai/function", - "version": "1.14.46", + "version": "1.14.47", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "catalog:", @@ -329,7 +329,7 @@ }, "packages/http-recorder": { "name": "@opencode-ai/http-recorder", - "version": "1.14.46", + "version": "1.14.47", "dependencies": { "@effect/platform-node": "catalog:", "effect": "catalog:", @@ -342,7 +342,7 @@ }, "packages/llm": { "name": "@opencode-ai/llm", - "version": "1.14.46", + "version": "1.14.47", "dependencies": { "@smithy/eventstream-codec": "4.2.14", "@smithy/util-utf8": "4.2.2", @@ -360,7 +360,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.14.46", + "version": "1.14.47", "bin": { "opencode": "./bin/opencode", }, @@ -496,7 +496,7 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.14.46", + "version": "1.14.47", "dependencies": { "@opencode-ai/sdk": "workspace:*", "effect": "catalog:", @@ -534,7 +534,7 @@ }, "packages/sdk/js": { "name": "@opencode-ai/sdk", - "version": "1.14.46", + "version": "1.14.47", "dependencies": { "cross-spawn": "catalog:", }, @@ -549,7 +549,7 @@ }, "packages/slack": { "name": "@opencode-ai/slack", - "version": "1.14.46", + "version": "1.14.47", "dependencies": { "@opencode-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -584,7 +584,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.14.46", + "version": "1.14.47", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/core": "workspace:*", @@ -633,7 +633,7 @@ }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.14.46", + "version": "1.14.47", "dependencies": { "@astrojs/cloudflare": "12.6.3", "@astrojs/markdown-remark": "6.3.1", diff --git a/packages/app/package.json b/packages/app/package.json index 635614bf13..dc6c2b2cd4 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "1.14.46", + "version": "1.14.47", "description": "", "type": "module", "exports": { diff --git a/packages/console/app/package.json b/packages/console/app/package.json index 9c7cbbc3bf..5338115728 100644 --- a/packages/console/app/package.json +++ b/packages/console/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-app", - "version": "1.14.46", + "version": "1.14.47", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/console/core/package.json b/packages/console/core/package.json index fa9d8fe15c..9621edae80 100644 --- a/packages/console/core/package.json +++ b/packages/console/core/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/console-core", - "version": "1.14.46", + "version": "1.14.47", "private": true, "type": "module", "license": "MIT", diff --git a/packages/console/function/package.json b/packages/console/function/package.json index a24da8fa1d..76b7f74ba2 100644 --- a/packages/console/function/package.json +++ b/packages/console/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-function", - "version": "1.14.46", + "version": "1.14.47", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/console/mail/package.json b/packages/console/mail/package.json index b5015ec22a..9844662d55 100644 --- a/packages/console/mail/package.json +++ b/packages/console/mail/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-mail", - "version": "1.14.46", + "version": "1.14.47", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", diff --git a/packages/core/package.json b/packages/core/package.json index 1567df4b85..6cd7ecefeb 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.14.46", + "version": "1.14.47", "name": "@opencode-ai/core", "type": "module", "license": "MIT", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 9a1d15ea12..99fe7422b0 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop", "private": true, - "version": "1.14.46", + "version": "1.14.47", "type": "module", "license": "MIT", "homepage": "https://opencode.ai", diff --git a/packages/enterprise/package.json b/packages/enterprise/package.json index b02d14c3b1..e738498984 100644 --- a/packages/enterprise/package.json +++ b/packages/enterprise/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/enterprise", - "version": "1.14.46", + "version": "1.14.47", "private": true, "type": "module", "license": "MIT", diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml index 061cd6ab38..03e179d170 100644 --- a/packages/extensions/zed/extension.toml +++ b/packages/extensions/zed/extension.toml @@ -1,7 +1,7 @@ id = "opencode" name = "OpenCode" description = "The open source coding agent." -version = "1.14.46" +version = "1.14.47" schema_version = 1 authors = ["Anomaly"] repository = "https://github.com/anomalyco/opencode" @@ -11,26 +11,26 @@ name = "OpenCode" icon = "./icons/opencode.svg" [agent_servers.opencode.targets.darwin-aarch64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.46/opencode-darwin-arm64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.47/opencode-darwin-arm64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.darwin-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.46/opencode-darwin-x64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.47/opencode-darwin-x64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-aarch64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.46/opencode-linux-arm64.tar.gz" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.47/opencode-linux-arm64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.46/opencode-linux-x64.tar.gz" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.47/opencode-linux-x64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.windows-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.46/opencode-windows-x64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.47/opencode-windows-x64.zip" cmd = "./opencode.exe" args = ["acp"] diff --git a/packages/function/package.json b/packages/function/package.json index 90ad91f89d..991a694372 100644 --- a/packages/function/package.json +++ b/packages/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/function", - "version": "1.14.46", + "version": "1.14.47", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/http-recorder/package.json b/packages/http-recorder/package.json index 9cf71a7436..ef97dcc86f 100644 --- a/packages/http-recorder/package.json +++ b/packages/http-recorder/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.14.46", + "version": "1.14.47", "name": "@opencode-ai/http-recorder", "type": "module", "license": "MIT", diff --git a/packages/llm/package.json b/packages/llm/package.json index 8fa5e833e6..af206a18b7 100644 --- a/packages/llm/package.json +++ b/packages/llm/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.14.46", + "version": "1.14.47", "name": "@opencode-ai/llm", "type": "module", "license": "MIT", diff --git a/packages/opencode/package.json b/packages/opencode/package.json index f6977296f9..1e26e2be9b 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.14.46", + "version": "1.14.47", "name": "opencode", "type": "module", "license": "MIT", diff --git a/packages/plugin/package.json b/packages/plugin/package.json index e25f89338a..28aae738d7 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/plugin", - "version": "1.14.46", + "version": "1.14.47", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index 3faace81cd..fa3064c1d9 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/sdk", - "version": "1.14.46", + "version": "1.14.47", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/slack/package.json b/packages/slack/package.json index 5f4aa7eeba..e68b8aa7eb 100644 --- a/packages/slack/package.json +++ b/packages/slack/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/slack", - "version": "1.14.46", + "version": "1.14.47", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/ui/package.json b/packages/ui/package.json index 5301e38032..a32f683d6b 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/ui", - "version": "1.14.46", + "version": "1.14.47", "type": "module", "license": "MIT", "exports": { diff --git a/packages/web/package.json b/packages/web/package.json index 490fea2aff..6207038e70 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -2,7 +2,7 @@ "name": "@opencode-ai/web", "type": "module", "license": "MIT", - "version": "1.14.46", + "version": "1.14.47", "scripts": { "dev": "astro dev", "dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev", diff --git a/sdks/vscode/package.json b/sdks/vscode/package.json index 1430192895..8e8728fe6a 100644 --- a/sdks/vscode/package.json +++ b/sdks/vscode/package.json @@ -2,7 +2,7 @@ "name": "opencode", "displayName": "opencode", "description": "opencode for VS Code", - "version": "1.14.46", + "version": "1.14.47", "publisher": "sst-dev", "repository": { "type": "git", From fed716ada5659409fa97567ccc33de3d88978002 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sun, 10 May 2026 20:41:21 -0400 Subject: [PATCH 164/189] Clarify compaction test harness (#26777) --- .../opencode/test/session/compaction.test.ts | 86 ++++++++----------- 1 file changed, 38 insertions(+), 48 deletions(-) diff --git a/packages/opencode/test/session/compaction.test.ts b/packages/opencode/test/session/compaction.test.ts index 982e2d8040..b48e68b10f 100644 --- a/packages/opencode/test/session/compaction.test.ts +++ b/packages/opencode/test/session/compaction.test.ts @@ -233,16 +233,22 @@ const env = Layer.mergeAll( const it = testEffect(env) -const processEnv = Layer.mergeAll(SessionNs.defaultLayer, CrossSpawnSpawner.defaultLayer) -const itProcess = testEffect(processEnv) +const compactionEnv = Layer.mergeAll(SessionNs.defaultLayer, CrossSpawnSpawner.defaultLayer) +const itCompaction = testEffect(compactionEnv) -function compactionProcessLayer(options?: { +type CompactionProcessOptions = { result?: "continue" | "compact" llm?: Layer.Layer plugin?: Layer.Layer provider?: ReturnType config?: Layer.Layer -}) { +} + +function withCompaction(options?: CompactionProcessOptions) { + return Effect.provide(compactionProcessLayer(options)) +} + +function compactionProcessLayer(options?: CompactionProcessOptions) { const bus = Bus.layer const status = SessionStatus.layer.pipe(Layer.provide(bus)) const processor = options?.llm @@ -857,7 +863,7 @@ describe("session.compaction.process", () => { }), ) - itProcess.instance( + itCompaction.instance( "marks summary message as errored on compact result", Effect.gen(function* () { const ssn = yield* SessionNs.Service @@ -882,7 +888,7 @@ describe("session.compaction.process", () => { expect(summary.info.finish).toBe("error") expect(JSON.stringify(summary.info.error)).toContain("Session too large to compact") } - }).pipe(Effect.provide(compactionProcessLayer({ result: "compact" }))), + }).pipe(withCompaction({ result: "compact" })), ) it.instance( @@ -916,7 +922,7 @@ describe("session.compaction.process", () => { }), ) - itProcess.instance( + itCompaction.instance( "persists tail_start_id for retained recent turns", Effect.gen(function* () { const ssn = yield* SessionNs.Service @@ -939,10 +945,10 @@ describe("session.compaction.process", () => { const part = yield* readCompactionPart(session.id) expect(part?.type).toBe("compaction") expect(part?.tail_start_id).toBe(keep.id) - }).pipe(Effect.provide(compactionProcessLayer({ config: cfg({ tail_turns: 2, preserve_recent_tokens: 10_000 }) }))), + }).pipe(withCompaction({ config: cfg({ tail_turns: 2, preserve_recent_tokens: 10_000 }) })), ) - itProcess.instance( + itCompaction.instance( "shrinks retained tail to fit preserve token budget", Effect.gen(function* () { const ssn = yield* SessionNs.Service @@ -965,10 +971,10 @@ describe("session.compaction.process", () => { const part = yield* readCompactionPart(session.id) expect(part?.type).toBe("compaction") expect(part?.tail_start_id).toBe(keep.id) - }).pipe(Effect.provide(compactionProcessLayer({ config: cfg({ tail_turns: 2, preserve_recent_tokens: 100 }) }))), + }).pipe(withCompaction({ config: cfg({ tail_turns: 2, preserve_recent_tokens: 100 }) })), ) - itProcess.instance( + itCompaction.instance( "falls back to full summary when even one recent turn exceeds preserve token budget", () => { const stub = llm() @@ -990,16 +996,12 @@ describe("session.compaction.process", () => { expect(part?.type).toBe("compaction") expect(part?.tail_start_id).toBeUndefined() expect(captured).toContain("yyyy") - }).pipe( - Effect.provide( - compactionProcessLayer({ llm: stub.layer, config: cfg({ tail_turns: 1, preserve_recent_tokens: 20 }) }), - ), - ) + }).pipe(withCompaction({ llm: stub.layer, config: cfg({ tail_turns: 1, preserve_recent_tokens: 20 }) })) }, { git: true }, ) - itProcess.instance( + itCompaction.instance( "falls back to full summary when retained tail media exceeds preserve token budget", () => { const stub = llm() @@ -1031,16 +1033,12 @@ describe("session.compaction.process", () => { expect(part?.tail_start_id).toBeUndefined() expect(captured).toContain("recent image turn") expect(captured).toContain("Attached image/png: big.png") - }).pipe( - Effect.provide( - compactionProcessLayer({ llm: stub.layer, config: cfg({ tail_turns: 1, preserve_recent_tokens: 100 }) }), - ), - ) + }).pipe(withCompaction({ llm: stub.layer, config: cfg({ tail_turns: 1, preserve_recent_tokens: 100 }) })) }, { git: true }, ) - itProcess.instance( + itCompaction.instance( "retains a split turn suffix when a later message fits the preserve token budget", () => { const stub = llm() @@ -1086,16 +1084,12 @@ describe("session.compaction.process", () => { expect(filtered[1]?.info.role).toBe("assistant") expect(filtered[1]?.info.role === "assistant" ? filtered[1].info.summary : false).toBe(true) expect(filtered.map((msg) => msg.info.id)).not.toContain(large.id) - }).pipe( - Effect.provide( - compactionProcessLayer({ llm: stub.layer, config: cfg({ tail_turns: 1, preserve_recent_tokens: 100 }) }), - ), - ) + }).pipe(withCompaction({ llm: stub.layer, config: cfg({ tail_turns: 1, preserve_recent_tokens: 100 }) })) }, { git: true }, ) - itProcess.instance( + itCompaction.instance( "allows plugins to disable synthetic continue prompt", Effect.gen(function* () { const ssn = yield* SessionNs.Service @@ -1124,7 +1118,7 @@ describe("session.compaction.process", () => { ), ), ).toBe(false) - }).pipe(Effect.provide(compactionProcessLayer({ plugin: autocontinue(false) }))), + }).pipe(withCompaction({ plugin: autocontinue(false) })), ) it.instance( @@ -1192,7 +1186,7 @@ describe("session.compaction.process", () => { }), ) - itProcess.instance( + itCompaction.instance( "stops quickly when aborted during retry backoff", () => { const stub = llm() @@ -1249,12 +1243,12 @@ describe("session.compaction.process", () => { expect(Cause.hasInterrupts(exit.cause)).toBe(true) expect(Date.now() - start).toBeLessThan(250) } - }).pipe(Effect.provide(compactionProcessLayer({ llm: stub.layer }))) + }).pipe(withCompaction({ llm: stub.layer })) }, { git: true }, ) - itProcess.instance( + itCompaction.instance( "does not leave a summary assistant when aborted before processor setup", () => Effect.gen(function* () { @@ -1281,12 +1275,12 @@ describe("session.compaction.process", () => { expect(Exit.isFailure(exit)).toBe(true) if (Exit.isFailure(exit)) expect(Cause.hasInterrupts(exit.cause)).toBe(true) expect(all.some((msg) => msg.info.role === "assistant" && msg.info.summary)).toBe(false) - }).pipe(Effect.provide(compactionProcessLayer({ plugin: plugin(ready) }))) + }).pipe(withCompaction({ plugin: plugin(ready) })) }), { git: true }, ) - itProcess.instance( + itCompaction.instance( "does not allow tool calls while generating the summary", () => { const stub = llm() @@ -1350,12 +1344,12 @@ describe("session.compaction.process", () => { expect(summary?.info.role).toBe("assistant") expect(summary?.parts.some((part) => part.type === "tool")).toBe(false) - }).pipe(Effect.provide(compactionProcessLayer({ llm: stub.layer }))) + }).pipe(withCompaction({ llm: stub.layer })) }, { git: true }, ) - itProcess.instance( + itCompaction.instance( "summarizes only the head while keeping recent tail out of summary input", () => { const stub = llm() @@ -1387,12 +1381,12 @@ describe("session.compaction.process", () => { expect(captured).not.toContain("keep this turn") expect(captured).not.toContain("and this one too") expect(captured).not.toContain("What did we do so far?") - }).pipe(Effect.provide(compactionProcessLayer({ llm: stub.layer }))) + }).pipe(withCompaction({ llm: stub.layer })) }, { git: true }, ) - itProcess.instance( + itCompaction.instance( "anchors repeated compactions with the previous summary", () => { const stub = llm() @@ -1429,12 +1423,12 @@ describe("session.compaction.process", () => { expect(captured.match(/summary one/g)?.length).toBe(1) expect(captured).toContain("## Constraints & Preferences") expect(captured).toContain("## Progress") - }).pipe(Effect.provide(compactionProcessLayer({ llm: stub.layer }))) + }).pipe(withCompaction({ llm: stub.layer })) }, { git: true }, ) - itProcess.instance("keeps recent pre-compaction turns across repeated compactions", () => { + itCompaction.instance("keeps recent pre-compaction turns across repeated compactions", () => { const stub = llm() stub.push(reply("summary one")) stub.push(reply("summary two")) @@ -1471,14 +1465,10 @@ describe("session.compaction.process", () => { expect( filtered.some((msg) => msg.info.role === "user" && msg.parts.some((part) => part.type === "compaction")), ).toBe(true) - }).pipe( - Effect.provide( - compactionProcessLayer({ llm: stub.layer, config: cfg({ tail_turns: 2, preserve_recent_tokens: 10_000 }) }), - ), - ) + }).pipe(withCompaction({ llm: stub.layer, config: cfg({ tail_turns: 2, preserve_recent_tokens: 10_000 }) })) }) - itProcess.instance( + itCompaction.instance( "ignores previous summaries when sizing the retained tail", Effect.gen(function* () { const ssn = yield* SessionNs.Service @@ -1519,7 +1509,7 @@ describe("session.compaction.process", () => { const part = yield* readCompactionPart(session.id) expect(part?.type).toBe("compaction") expect(part?.tail_start_id).toBe(keep.id) - }).pipe(Effect.provide(compactionProcessLayer({ config: cfg({ tail_turns: 2, preserve_recent_tokens: 500 }) }))), + }).pipe(withCompaction({ config: cfg({ tail_turns: 2, preserve_recent_tokens: 500 }) })), ) }) From 77e6c0d329ee568818bcf495f2b8b858b286e453 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sun, 10 May 2026 21:17:38 -0400 Subject: [PATCH 165/189] feat(llm): cache hint TTL, breakpoint cap, and tool placement (#26779) --- .../llm/src/protocols/anthropic-messages.ts | 74 +++++++--- .../llm/src/protocols/bedrock-converse.ts | 65 +++++++-- .../llm/src/protocols/utils/bedrock-cache.ts | 31 ++++- packages/llm/src/protocols/utils/cache.ts | 16 +++ packages/llm/src/schema/messages.ts | 3 + .../anthropic-messages-cache.recorded.test.ts | 48 +++++++ .../test/provider/anthropic-messages.test.ts | 130 ++++++++++++++++++ .../bedrock-converse-cache.recorded.test.ts | 50 +++++++ .../test/provider/bedrock-converse.test.ts | 73 ++++++++++ .../provider/gemini-cache.recorded.test.ts | 47 +++++++ .../openai-responses-cache.recorded.test.ts | 44 ++++++ packages/llm/test/recorded-scenarios.ts | 13 ++ 12 files changed, 555 insertions(+), 39 deletions(-) create mode 100644 packages/llm/src/protocols/utils/cache.ts create mode 100644 packages/llm/test/provider/anthropic-messages-cache.recorded.test.ts create mode 100644 packages/llm/test/provider/bedrock-converse-cache.recorded.test.ts create mode 100644 packages/llm/test/provider/gemini-cache.recorded.test.ts create mode 100644 packages/llm/test/provider/openai-responses-cache.recorded.test.ts diff --git a/packages/llm/src/protocols/anthropic-messages.ts b/packages/llm/src/protocols/anthropic-messages.ts index fba785373d..a426807c02 100644 --- a/packages/llm/src/protocols/anthropic-messages.ts +++ b/packages/llm/src/protocols/anthropic-messages.ts @@ -16,6 +16,7 @@ import { type ToolResultPart, } from "../schema" import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared" +import * as Cache from "./utils/cache" import { ToolStream } from "./utils/tool-stream" const ADAPTER = "anthropic-messages" @@ -25,7 +26,10 @@ export const PATH = "/messages" // ============================================================================= // Request Body Schema // ============================================================================= -const AnthropicCacheControl = Schema.Struct({ type: Schema.tag("ephemeral") }) +const AnthropicCacheControl = Schema.Struct({ + type: Schema.tag("ephemeral"), + ttl: Schema.optional(Schema.Literals(["5m", "1h"])), +}) const AnthropicTextBlock = Schema.Struct({ type: Schema.tag("text"), @@ -193,8 +197,24 @@ const invalid = ProviderShared.invalidRequest // ============================================================================= // Request Lowering // ============================================================================= -const cacheControl = (cache: CacheHint | undefined) => - cache?.type === "ephemeral" ? { type: "ephemeral" as const } : undefined +// Anthropic accepts at most 4 explicit cache_control breakpoints per request, +// across `tools`, `system`, and `messages`. Beyond the cap the API returns a +// 400 — so the lowering layer counts emitted markers and silently drops any +// that exceed it. +const ANTHROPIC_BREAKPOINT_CAP = 4 + +const EPHEMERAL_5M = { type: "ephemeral" as const } +const EPHEMERAL_1H = { type: "ephemeral" as const, ttl: "1h" as const } + +const cacheControl = (breakpoints: Cache.Breakpoints, cache: CacheHint | undefined) => { + if (cache?.type !== "ephemeral" && cache?.type !== "persistent") return undefined + if (breakpoints.remaining <= 0) { + breakpoints.dropped += 1 + return undefined + } + breakpoints.remaining -= 1 + return Cache.ttlBucket(cache.ttlSeconds) === "1h" ? EPHEMERAL_1H : EPHEMERAL_5M +} const anthropicMetadata = (metadata: Record): ProviderMetadata => ({ anthropic: metadata }) @@ -204,10 +224,11 @@ const signatureFromMetadata = (metadata: ProviderMetadata | undefined): string | return typeof anthropic.signature === "string" ? anthropic.signature : undefined } -const lowerTool = (tool: ToolDefinition): AnthropicTool => ({ +const lowerTool = (breakpoints: Cache.Breakpoints, tool: ToolDefinition): AnthropicTool => ({ name: tool.name, description: tool.description, input_schema: tool.inputSchema, + cache_control: cacheControl(breakpoints, tool.cache), }) const lowerToolChoice = (toolChoice: NonNullable) => @@ -249,7 +270,10 @@ const lowerServerToolResult = Effect.fn("AnthropicMessages.lowerServerToolResult return { type: wireType, tool_use_id: part.id, content: part.result.value } satisfies AnthropicServerToolResultBlock }) -const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (request: LLMRequest) { +const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* ( + request: LLMRequest, + breakpoints: Cache.Breakpoints, +) { const messages: AnthropicMessage[] = [] for (const message of request.messages) { @@ -258,7 +282,7 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (re for (const part of message.content) { if (!ProviderShared.supportsContent(part, ["text"])) return yield* ProviderShared.unsupportedContent("Anthropic Messages", "user", ["text"]) - content.push({ type: "text", text: part.text, cache_control: cacheControl(part.cache) }) + content.push({ type: "text", text: part.text, cache_control: cacheControl(breakpoints, part.cache) }) } messages.push({ role: "user", content }) continue @@ -268,7 +292,7 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (re const content: AnthropicAssistantBlock[] = [] for (const part of message.content) { if (part.type === "text") { - content.push({ type: "text", text: part.text, cache_control: cacheControl(part.cache) }) + content.push({ type: "text", text: part.text, cache_control: cacheControl(breakpoints, part.cache) }) continue } if (part.type === "reasoning") { @@ -304,6 +328,7 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (re tool_use_id: part.id, content: ProviderShared.toolResultText(part), is_error: part.result.type === "error" ? true : undefined, + cache_control: cacheControl(breakpoints, part.cache), }) } messages.push({ role: "user", content }) @@ -330,18 +355,33 @@ const lowerThinking = Effect.fn("AnthropicMessages.lowerThinking")(function* (re const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (request: LLMRequest) { const toolChoice = request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined const generation = request.generation + // Allocate the 4-breakpoint budget in invalidation order: tools → system → + // messages. Tools live highest in the cache hierarchy, so when callers + // over-mark we keep their tool hints and shed the message-tail ones first. + const breakpoints = Cache.newBreakpoints(ANTHROPIC_BREAKPOINT_CAP) + const tools = + request.tools.length === 0 || request.toolChoice?.type === "none" + ? undefined + : request.tools.map((tool) => lowerTool(breakpoints, tool)) + const system = + request.system.length === 0 + ? undefined + : request.system.map((part) => ({ + type: "text" as const, + text: part.text, + cache_control: cacheControl(breakpoints, part.cache), + })) + const messages = yield* lowerMessages(request, breakpoints) + if (breakpoints.dropped > 0) { + yield* Effect.logWarning( + `Anthropic Messages: dropped ${breakpoints.dropped} cache breakpoint(s); the API allows at most ${ANTHROPIC_BREAKPOINT_CAP} per request.`, + ) + } return { model: request.model.id, - system: - request.system.length === 0 - ? undefined - : request.system.map((part) => ({ - type: "text" as const, - text: part.text, - cache_control: cacheControl(part.cache), - })), - messages: yield* lowerMessages(request), - tools: request.tools.length === 0 || request.toolChoice?.type === "none" ? undefined : request.tools.map(lowerTool), + system, + messages, + tools, tool_choice: toolChoice, stream: true as const, max_tokens: generation?.maxTokens ?? request.model.limits.output ?? 4096, diff --git a/packages/llm/src/protocols/bedrock-converse.ts b/packages/llm/src/protocols/bedrock-converse.ts index 260ee612cd..34bb13e61a 100644 --- a/packages/llm/src/protocols/bedrock-converse.ts +++ b/packages/llm/src/protocols/bedrock-converse.ts @@ -108,7 +108,7 @@ type BedrockMessage = Schema.Schema.Type const BedrockSystemBlock = Schema.Union([BedrockTextBlock, BedrockCache.CachePointBlock]) type BedrockSystemBlock = Schema.Schema.Type -const BedrockTool = Schema.Struct({ +const BedrockToolSpec = Schema.Struct({ toolSpec: Schema.Struct({ name: Schema.String, description: Schema.String, @@ -117,6 +117,9 @@ const BedrockTool = Schema.Struct({ }), }), }) +type BedrockToolSpec = Schema.Schema.Type + +const BedrockTool = Schema.Union([BedrockToolSpec, BedrockCache.CachePointBlock]) type BedrockTool = Schema.Schema.Type const BedrockToolChoice = Schema.Union([ @@ -214,7 +217,7 @@ type BedrockEvent = Schema.Schema.Type // ============================================================================= // Request Lowering // ============================================================================= -const lowerTool = (tool: ToolDefinition): BedrockTool => ({ +const lowerToolSpec = (tool: ToolDefinition): BedrockToolSpec => ({ toolSpec: { name: tool.name, description: tool.description, @@ -222,11 +225,25 @@ const lowerTool = (tool: ToolDefinition): BedrockTool => ({ }, }) +const lowerTools = ( + breakpoints: BedrockCache.Breakpoints, + tools: ReadonlyArray, +): BedrockTool[] => { + const result: BedrockTool[] = [] + for (const tool of tools) { + result.push(lowerToolSpec(tool)) + const cachePoint = BedrockCache.block(breakpoints, tool.cache) + if (cachePoint) result.push(cachePoint) + } + return result +} + const textWithCache = ( + breakpoints: BedrockCache.Breakpoints, text: string, cache: CacheHint | undefined, ): Array => { - const cachePoint = BedrockCache.block(cache) + const cachePoint = BedrockCache.block(breakpoints, cache) return cachePoint ? [{ text }, cachePoint] : [{ text }] } @@ -257,7 +274,10 @@ const lowerToolResult = (part: ToolResultPart): BedrockToolResultBlock => ({ }, }) -const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (request: LLMRequest) { +const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* ( + request: LLMRequest, + breakpoints: BedrockCache.Breakpoints, +) { const messages: BedrockMessage[] = [] for (const message of request.messages) { @@ -267,7 +287,7 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (requ if (!ProviderShared.supportsContent(part, ["text", "media"])) return yield* ProviderShared.unsupportedContent("Bedrock Converse", "user", ["text", "media"]) if (part.type === "text") { - content.push(...textWithCache(part.text, part.cache)) + content.push(...textWithCache(breakpoints, part.text, part.cache)) continue } if (part.type === "media") { @@ -289,7 +309,7 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (requ "tool-call", ]) if (part.type === "text") { - content.push(...textWithCache(part.text, part.cache)) + content.push(...textWithCache(breakpoints, part.text, part.cache)) continue } if (part.type === "reasoning") { @@ -309,11 +329,13 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (requ continue } - const content: BedrockToolResultBlock[] = [] + const content: BedrockUserBlock[] = [] for (const part of message.content) { if (!ProviderShared.supportsContent(part, ["tool-result"])) return yield* ProviderShared.unsupportedContent("Bedrock Converse", "tool", ["tool-result"]) content.push(lowerToolResult(part)) + const cachePoint = BedrockCache.block(breakpoints, part.cache) + if (cachePoint) content.push(cachePoint) } messages.push({ role: "user", content }) } @@ -323,16 +345,32 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (requ // System prompts share the cache-point convention: emit the text block, then // optionally a positional `cachePoint` marker. -const lowerSystem = (system: ReadonlyArray): BedrockSystemBlock[] => - system.flatMap((part) => textWithCache(part.text, part.cache)) +const lowerSystem = ( + breakpoints: BedrockCache.Breakpoints, + system: ReadonlyArray, +): BedrockSystemBlock[] => system.flatMap((part) => textWithCache(breakpoints, part.text, part.cache)) const fromRequest = Effect.fn("BedrockConverse.fromRequest")(function* (request: LLMRequest) { const toolChoice = request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined const generation = request.generation + // Bedrock-Claude shares Anthropic's 4-breakpoint cap. Spend the budget in + // tools → system → messages order to favour the highest-impact prefixes. + const breakpoints = BedrockCache.breakpoints() + const toolConfig = + request.tools.length > 0 && request.toolChoice?.type !== "none" + ? { tools: lowerTools(breakpoints, request.tools), toolChoice } + : undefined + const system = request.system.length === 0 ? undefined : lowerSystem(breakpoints, request.system) + const messages = yield* lowerMessages(request, breakpoints) + if (breakpoints.dropped > 0) { + yield* Effect.logWarning( + `Bedrock Converse: dropped ${breakpoints.dropped} cache breakpoint(s); the API allows at most ${BedrockCache.BEDROCK_BREAKPOINT_CAP} per request.`, + ) + } return { modelId: request.model.id, - messages: yield* lowerMessages(request), - system: request.system.length === 0 ? undefined : lowerSystem(request.system), + messages, + system, inferenceConfig: generation?.maxTokens === undefined && generation?.temperature === undefined && @@ -345,10 +383,7 @@ const fromRequest = Effect.fn("BedrockConverse.fromRequest")(function* (request: topP: generation?.topP, stopSequences: generation?.stop, }, - toolConfig: - request.tools.length > 0 && request.toolChoice?.type !== "none" - ? { tools: request.tools.map(lowerTool), toolChoice } - : undefined, + toolConfig, } }) diff --git a/packages/llm/src/protocols/utils/bedrock-cache.ts b/packages/llm/src/protocols/utils/bedrock-cache.ts index ca6e52cd11..fab4d07b5c 100644 --- a/packages/llm/src/protocols/utils/bedrock-cache.ts +++ b/packages/llm/src/protocols/utils/bedrock-cache.ts @@ -1,20 +1,37 @@ import { Schema } from "effect" import type { CacheHint } from "../../schema" +import { newBreakpoints, ttlBucket, type Breakpoints } from "./cache" // Bedrock cache markers are positional: emit a `cachePoint` block immediately -// after the content the caller wants treated as a cacheable prefix. +// after the content the caller wants treated as a cacheable prefix. Bedrock +// accepts optional `ttl: "5m" | "1h"` on cachePoint, mirroring Anthropic. export const CachePointBlock = Schema.Struct({ - cachePoint: Schema.Struct({ type: Schema.tag("default") }), + cachePoint: Schema.Struct({ + type: Schema.tag("default"), + ttl: Schema.optional(Schema.Literals(["5m", "1h"])), + }), }) export type CachePointBlock = Schema.Schema.Type -// Bedrock recently added optional `ttl: "5m" | "1h"` on cachePoint. Map -// `CacheHint.ttlSeconds` here once a recorded cassette validates the wire shape. -const DEFAULT: CachePointBlock = { cachePoint: { type: "default" } } +// Bedrock-Claude enforces the same 4-breakpoint cap as the Anthropic Messages +// API. Callers pass a shared counter through every `block()` call site so the +// budget is respected across `system`, `messages`, and `tools`. +export const BEDROCK_BREAKPOINT_CAP = 4 -export const block = (cache: CacheHint | undefined): CachePointBlock | undefined => { +export type { Breakpoints } from "./cache" +export const breakpoints = () => newBreakpoints(BEDROCK_BREAKPOINT_CAP) + +const DEFAULT_5M: CachePointBlock = { cachePoint: { type: "default" } } +const DEFAULT_1H: CachePointBlock = { cachePoint: { type: "default", ttl: "1h" } } + +export const block = (breakpoints: Breakpoints, cache: CacheHint | undefined): CachePointBlock | undefined => { if (cache?.type !== "ephemeral" && cache?.type !== "persistent") return undefined - return DEFAULT + if (breakpoints.remaining <= 0) { + breakpoints.dropped += 1 + return undefined + } + breakpoints.remaining -= 1 + return ttlBucket(cache.ttlSeconds) === "1h" ? DEFAULT_1H : DEFAULT_5M } export * as BedrockCache from "./bedrock-cache" diff --git a/packages/llm/src/protocols/utils/cache.ts b/packages/llm/src/protocols/utils/cache.ts new file mode 100644 index 0000000000..dd3e213e0e --- /dev/null +++ b/packages/llm/src/protocols/utils/cache.ts @@ -0,0 +1,16 @@ +// Shared helpers for provider cache-marker lowering. Anthropic and Bedrock +// both enforce a 4-breakpoint cap per request and accept the same `5m`/`1h` +// TTL buckets, so the counter and TTL mapping live here. + +export interface Breakpoints { + remaining: number + dropped: number +} + +export const newBreakpoints = (cap: number): Breakpoints => ({ remaining: cap, dropped: 0 }) + +// Returns `"1h"` for any `ttlSeconds >= 3600`, otherwise `undefined` (the +// provider default 5m). Anthropic & Bedrock both treat anything shorter than +// an hour as 5m. +export const ttlBucket = (ttlSeconds: number | undefined): "1h" | undefined => + ttlSeconds !== undefined && ttlSeconds >= 3600 ? "1h" : undefined diff --git a/packages/llm/src/schema/messages.ts b/packages/llm/src/schema/messages.ts index 3daf00bbc0..cc6b89a2c7 100644 --- a/packages/llm/src/schema/messages.ts +++ b/packages/llm/src/schema/messages.ts @@ -79,6 +79,7 @@ export const ToolResultPart = Object.assign( name: Schema.String, result: ToolResultValue, providerExecuted: Schema.optional(Schema.Boolean), + cache: Schema.optional(CacheHint), metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), providerMetadata: Schema.optional(ProviderMetadata), }).annotate({ identifier: "LLM.Content.ToolResult" }), @@ -94,6 +95,7 @@ export const ToolResultPart = Object.assign( name: input.name, result: ToolResultValue.make(input.result, input.resultType), providerExecuted: input.providerExecuted, + cache: input.cache, metadata: input.metadata, providerMetadata: input.providerMetadata, }), @@ -151,6 +153,7 @@ export class ToolDefinition extends Schema.Class("LLM.ToolDefini name: Schema.String, description: Schema.String, inputSchema: JsonSchema, + cache: Schema.optional(CacheHint), metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), native: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), }) {} diff --git a/packages/llm/test/provider/anthropic-messages-cache.recorded.test.ts b/packages/llm/test/provider/anthropic-messages-cache.recorded.test.ts new file mode 100644 index 0000000000..b048d53ba0 --- /dev/null +++ b/packages/llm/test/provider/anthropic-messages-cache.recorded.test.ts @@ -0,0 +1,48 @@ +import { Redactor } from "@opencode-ai/http-recorder" +import { describe, expect } from "bun:test" +import { Effect } from "effect" +import { CacheHint, LLM } from "../../src" +import { LLMClient } from "../../src/route" +import * as AnthropicMessages from "../../src/protocols/anthropic-messages" +import { LARGE_CACHEABLE_SYSTEM } from "../recorded-scenarios" +import { recordedTests } from "../recorded-test" + +const model = AnthropicMessages.model({ + id: "claude-haiku-4-5-20251001", + apiKey: process.env.ANTHROPIC_API_KEY ?? "fixture", +}) + +// Two identical generations in a row. The first call writes the prefix into +// Anthropic's cache; the second should report a cache read against the same +// prefix. Cassette captures both interactions in order. +const cacheRequest = LLM.request({ + id: "recorded_anthropic_cache", + model, + system: [{ type: "text", text: LARGE_CACHEABLE_SYSTEM, cache: new CacheHint({ type: "ephemeral" }) }], + prompt: "Say hi.", + generation: { maxTokens: 16, temperature: 0 }, +}) + +const recorded = recordedTests({ + prefix: "anthropic-messages-cache", + provider: "anthropic", + protocol: "anthropic-messages", + requires: ["ANTHROPIC_API_KEY"], + options: { redactor: Redactor.defaults({ requestHeaders: { allow: ["content-type", "anthropic-version"] } }) }, +}) + +describe("Anthropic Messages cache recorded", () => { + recorded.effect.with("writes then reads cache_control on identical second call", { tags: ["cache"] }, () => + Effect.gen(function* () { + const first = yield* LLMClient.generate(cacheRequest) + // The first call may write the cache (cacheWriteInputTokens > 0) or it + // may be a fresh miss (both fields 0) depending on whether the prefix is + // already warm on Anthropic's side. The assertion that matters is that + // the SECOND call reports a non-zero cache read. + expect(first.usage?.cacheReadInputTokens ?? 0).toBeGreaterThanOrEqual(0) + + const second = yield* LLMClient.generate(cacheRequest) + expect(second.usage?.cacheReadInputTokens ?? 0).toBeGreaterThan(0) + }), + ) +}) diff --git a/packages/llm/test/provider/anthropic-messages.test.ts b/packages/llm/test/provider/anthropic-messages.test.ts index 85900a1143..2f2b2a3e86 100644 --- a/packages/llm/test/provider/anthropic-messages.test.ts +++ b/packages/llm/test/provider/anthropic-messages.test.ts @@ -374,4 +374,134 @@ describe("Anthropic Messages route", () => { expect(error.message).toContain("Anthropic Messages user messages only support text content for now") }), ) + + it.effect("maps ttlSeconds >= 3600 to cache_control ttl: '1h'", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + model, + system: { type: "text", text: "system", cache: new CacheHint({ type: "ephemeral", ttlSeconds: 3600 }) }, + prompt: "hi", + }), + ) + + expect(prepared.body).toMatchObject({ + system: [{ type: "text", text: "system", cache_control: { type: "ephemeral", ttl: "1h" } }], + }) + }), + ) + + it.effect("emits cache_control on tool definitions and tool-result blocks", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + model, + tools: [ + { + name: "lookup", + description: "lookup tool", + inputSchema: { type: "object", properties: {} }, + cache: new CacheHint({ type: "ephemeral" }), + }, + ], + messages: [ + LLM.user("What's the weather?"), + LLM.assistant([LLM.toolCall({ id: "call_1", name: "lookup", input: {} })]), + LLM.toolMessage({ + id: "call_1", + name: "lookup", + result: { temp: 72 }, + cache: new CacheHint({ type: "ephemeral" }), + }), + ], + }), + ) + + expect(prepared.body).toMatchObject({ + tools: [{ name: "lookup", cache_control: { type: "ephemeral" } }], + messages: [ + { role: "user", content: [{ type: "text", text: "What's the weather?" }] }, + { role: "assistant", content: [{ type: "tool_use", id: "call_1", name: "lookup" }] }, + { + role: "user", + content: [{ type: "tool_result", tool_use_id: "call_1", cache_control: { type: "ephemeral" } }], + }, + ], + }) + }), + ) + + it.effect("drops cache_control breakpoints past the 4-per-request cap", () => + Effect.gen(function* () { + const hint = new CacheHint({ type: "ephemeral" }) + const prepared = yield* LLMClient.prepare( + LLM.request({ + model, + system: [ + { type: "text", text: "a", cache: hint }, + { type: "text", text: "b", cache: hint }, + { type: "text", text: "c", cache: hint }, + { type: "text", text: "d", cache: hint }, + { type: "text", text: "e", cache: hint }, + { type: "text", text: "f", cache: hint }, + ], + prompt: "hi", + }), + ) + + const system = (prepared.body as { system: Array<{ cache_control?: unknown }> }).system + const marked = system.filter((part) => part.cache_control !== undefined) + expect(marked).toHaveLength(4) + expect(system[4]?.cache_control).toBeUndefined() + expect(system[5]?.cache_control).toBeUndefined() + }), + ) + + it.effect("spends breakpoint budget on tools before system before messages", () => + Effect.gen(function* () { + const hint = new CacheHint({ type: "ephemeral" }) + const prepared = yield* LLMClient.prepare( + LLM.request({ + model, + tools: [ + { + name: "t1", + description: "t1", + inputSchema: { type: "object", properties: {} }, + cache: hint, + }, + { + name: "t2", + description: "t2", + inputSchema: { type: "object", properties: {} }, + cache: hint, + }, + { + name: "t3", + description: "t3", + inputSchema: { type: "object", properties: {} }, + cache: hint, + }, + { + name: "t4", + description: "t4", + inputSchema: { type: "object", properties: {} }, + cache: hint, + }, + ], + system: [{ type: "text", text: "system-tail", cache: hint }], + messages: [LLM.user([{ type: "text", text: "message-tail", cache: hint }])], + }), + ) + + const body = prepared.body as { + tools: Array<{ cache_control?: unknown }> + system: Array<{ cache_control?: unknown }> + messages: Array<{ content: Array<{ cache_control?: unknown }> }> + } + expect(body.tools.every((t) => t.cache_control !== undefined)).toBe(true) + expect(body.system[0]?.cache_control).toBeUndefined() + expect(body.messages[0]?.content[0]?.cache_control).toBeUndefined() + }), + ) }) diff --git a/packages/llm/test/provider/bedrock-converse-cache.recorded.test.ts b/packages/llm/test/provider/bedrock-converse-cache.recorded.test.ts new file mode 100644 index 0000000000..23dd697b9a --- /dev/null +++ b/packages/llm/test/provider/bedrock-converse-cache.recorded.test.ts @@ -0,0 +1,50 @@ +import { describe, expect } from "bun:test" +import { Effect } from "effect" +import { CacheHint, LLM } from "../../src" +import { LLMClient } from "../../src/route" +import * as BedrockConverse from "../../src/protocols/bedrock-converse" +import { LARGE_CACHEABLE_SYSTEM } from "../recorded-scenarios" +import { recordedTests } from "../recorded-test" + +const RECORDING_REGION = process.env.BEDROCK_RECORDING_REGION ?? "us-east-1" + +// Use a Claude model on Bedrock — Nova has automatic prefix caching that +// doesn't reliably surface `cacheRead`/`cacheWrite` in usage, so the second +// call wouldn't deterministically prove cache mapping works. Override with +// BEDROCK_CACHE_MODEL_ID if your account has access elsewhere. +const model = BedrockConverse.model({ + id: process.env.BEDROCK_CACHE_MODEL_ID ?? "us.anthropic.claude-haiku-4-5-20251001-v1:0", + credentials: { + region: RECORDING_REGION, + accessKeyId: process.env.AWS_ACCESS_KEY_ID ?? "fixture", + secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY ?? "fixture", + sessionToken: process.env.AWS_SESSION_TOKEN, + }, +}) + +const cacheRequest = LLM.request({ + id: "recorded_bedrock_cache", + model, + system: [{ type: "text", text: LARGE_CACHEABLE_SYSTEM, cache: new CacheHint({ type: "ephemeral" }) }], + prompt: "Say hi.", + generation: { maxTokens: 16, temperature: 0 }, +}) + +const recorded = recordedTests({ + prefix: "bedrock-converse-cache", + provider: "amazon-bedrock", + protocol: "bedrock-converse", + requires: ["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY"], +}) + +describe("Bedrock Converse cache recorded", () => { + recorded.effect.with("writes then reads cachePoint on identical second call", { tags: ["cache"] }, () => + Effect.gen(function* () { + const first = yield* LLMClient.generate(cacheRequest) + expect(first.usage?.cacheReadInputTokens ?? 0).toBeGreaterThanOrEqual(0) + + const second = yield* LLMClient.generate(cacheRequest) + expect(second.usage?.cacheReadInputTokens ?? 0).toBeGreaterThan(0) + }), + ) +}) diff --git a/packages/llm/test/provider/bedrock-converse.test.ts b/packages/llm/test/provider/bedrock-converse.test.ts index 28be714bdf..d72e7f3116 100644 --- a/packages/llm/test/provider/bedrock-converse.test.ts +++ b/packages/llm/test/provider/bedrock-converse.test.ts @@ -440,6 +440,79 @@ describe("Bedrock Converse route", () => { expect(error.message).toContain("Bedrock Converse does not support media type application/x-tar") }), ) + + it.effect("maps ttlSeconds >= 3600 to cachePoint ttl: '1h'", () => + Effect.gen(function* () { + const cache = new CacheHint({ type: "ephemeral", ttlSeconds: 3600 }) + const prepared = yield* LLMClient.prepare( + LLM.request({ + model, + system: [{ type: "text", text: "system", cache }], + prompt: "hi", + }), + ) + + expect(prepared.body).toMatchObject({ + system: [{ text: "system" }, { cachePoint: { type: "default", ttl: "1h" } }], + }) + }), + ) + + it.effect("appends cachePoint after marked tool definitions and tool-result blocks", () => + Effect.gen(function* () { + const cache = new CacheHint({ type: "ephemeral" }) + const prepared = yield* LLMClient.prepare( + LLM.request({ + model, + tools: [ + { name: "lookup", description: "lookup", inputSchema: { type: "object", properties: {} }, cache }, + ], + messages: [ + LLM.user("What's the weather?"), + LLM.assistant([LLM.toolCall({ id: "call_1", name: "lookup", input: {} })]), + LLM.toolMessage({ id: "call_1", name: "lookup", result: { temp: 72 }, cache }), + ], + }), + ) + + expect(prepared.body).toMatchObject({ + toolConfig: { + tools: [{ toolSpec: { name: "lookup" } }, { cachePoint: { type: "default" } }], + }, + messages: [ + { role: "user", content: [{ text: "What's the weather?" }] }, + { role: "assistant", content: [{ toolUse: { toolUseId: "call_1" } }] }, + { + role: "user", + content: [{ toolResult: { toolUseId: "call_1" } }, { cachePoint: { type: "default" } }], + }, + ], + }) + }), + ) + + it.effect("drops cachePoint markers past the 4-per-request cap", () => + Effect.gen(function* () { + const cache = new CacheHint({ type: "ephemeral" }) + const prepared = yield* LLMClient.prepare( + LLM.request({ + model, + system: [ + { type: "text", text: "a", cache }, + { type: "text", text: "b", cache }, + { type: "text", text: "c", cache }, + { type: "text", text: "d", cache }, + { type: "text", text: "e", cache }, + { type: "text", text: "f", cache }, + ], + prompt: "hi", + }), + ) + + const system = (prepared.body as { system: Array<{ cachePoint?: unknown }> }).system + expect(system.filter((part) => "cachePoint" in part)).toHaveLength(4) + }), + ) }) // Live recorded integration tests. Run with `RECORD=true AWS_ACCESS_KEY_ID=... diff --git a/packages/llm/test/provider/gemini-cache.recorded.test.ts b/packages/llm/test/provider/gemini-cache.recorded.test.ts new file mode 100644 index 0000000000..145728fdc6 --- /dev/null +++ b/packages/llm/test/provider/gemini-cache.recorded.test.ts @@ -0,0 +1,47 @@ +import { describe, expect } from "bun:test" +import { Effect } from "effect" +import { LLM } from "../../src" +import { LLMClient } from "../../src/route" +import * as Gemini from "../../src/protocols/gemini" +import { LARGE_CACHEABLE_SYSTEM } from "../recorded-scenarios" +import { recordedTests } from "../recorded-test" + +const model = Gemini.model({ + id: "gemini-2.5-flash", + apiKey: process.env.GEMINI_API_KEY ?? "fixture", +}) + +// Gemini does implicit prefix caching on 2.5+ models above ~1024 tokens. The +// `CacheHint` is currently a no-op for Gemini (the explicit `CachedContent` +// API is out-of-band and intentionally not wired up). This test exists to +// pin the usage-parsing path: `cachedContentTokenCount` should surface as +// `cacheReadInputTokens` on the second identical call. +const cacheRequest = LLM.request({ + id: "recorded_gemini_cache", + model, + system: LARGE_CACHEABLE_SYSTEM, + prompt: "Say hi.", + generation: { maxTokens: 16, temperature: 0 }, +}) + +const recorded = recordedTests({ + prefix: "gemini-cache", + provider: "google", + protocol: "gemini", + requires: ["GEMINI_API_KEY"], +}) + +describe("Gemini cache recorded", () => { + recorded.effect.with("reports cachedContentTokenCount on identical second call", { tags: ["cache"] }, () => + Effect.gen(function* () { + const first = yield* LLMClient.generate(cacheRequest) + expect(first.usage?.cacheReadInputTokens ?? 0).toBeGreaterThanOrEqual(0) + + const second = yield* LLMClient.generate(cacheRequest) + // Implicit caching is best-effort on Gemini's side; we assert the field + // is at least populated and non-negative. When re-recording, verify the + // cassette shows > 0 in the second response's usage. + expect(second.usage?.cacheReadInputTokens ?? 0).toBeGreaterThanOrEqual(0) + }), + ) +}) diff --git a/packages/llm/test/provider/openai-responses-cache.recorded.test.ts b/packages/llm/test/provider/openai-responses-cache.recorded.test.ts new file mode 100644 index 0000000000..0ac3dfe2b9 --- /dev/null +++ b/packages/llm/test/provider/openai-responses-cache.recorded.test.ts @@ -0,0 +1,44 @@ +import { describe, expect } from "bun:test" +import { Effect } from "effect" +import { LLM } from "../../src" +import { LLMClient } from "../../src/route" +import * as OpenAIResponses from "../../src/protocols/openai-responses" +import { LARGE_CACHEABLE_SYSTEM } from "../recorded-scenarios" +import { recordedTests } from "../recorded-test" + +const model = OpenAIResponses.model({ + id: "gpt-4.1-mini", + apiKey: process.env.OPENAI_API_KEY ?? "fixture", +}) + +// OpenAI caches prefixes automatically once they cross the 1024-token threshold; +// `CacheHint` is a no-op for the wire body. The stable signal is the +// `prompt_cache_key` routing hint, which keeps repeated calls on the same shard +// so cache hits are observable. +const cacheRequest = LLM.request({ + id: "recorded_openai_responses_cache", + model, + system: LARGE_CACHEABLE_SYSTEM, + prompt: "Say hi.", + generation: { maxTokens: 16, temperature: 0 }, + providerOptions: { openai: { promptCacheKey: "recorded-cache-test" } }, +}) + +const recorded = recordedTests({ + prefix: "openai-responses-cache", + provider: "openai", + protocol: "openai-responses", + requires: ["OPENAI_API_KEY"], +}) + +describe("OpenAI Responses cache recorded", () => { + recorded.effect.with("reports cached_tokens on identical second call", { tags: ["cache"] }, () => + Effect.gen(function* () { + const first = yield* LLMClient.generate(cacheRequest) + expect(first.usage?.cacheReadInputTokens ?? 0).toBeGreaterThanOrEqual(0) + + const second = yield* LLMClient.generate(cacheRequest) + expect(second.usage?.cacheReadInputTokens ?? 0).toBeGreaterThan(0) + }), + ) +}) diff --git a/packages/llm/test/recorded-scenarios.ts b/packages/llm/test/recorded-scenarios.ts index 3fb3e0b9a9..2361bfdb78 100644 --- a/packages/llm/test/recorded-scenarios.ts +++ b/packages/llm/test/recorded-scenarios.ts @@ -6,6 +6,19 @@ import { tool } from "../src/tool" export const weatherToolName = "get_weather" +// A deterministic system prompt long enough to clear every supported provider's +// minimum cacheable-prefix threshold (Anthropic Haiku 3.5: 2048 tokens; Anthropic +// Opus/Haiku 4.5: 4096 tokens; OpenAI/Gemini/Bedrock: lower). Built by repeating +// a fixed sentence — the cassette replays bit-for-bit, so the exact text matters +// only when re-recording with `RECORD=true`. +export const LARGE_CACHEABLE_SYSTEM = (() => { + const sentence = + "You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. " + // ~100 chars per sentence × 250 repeats ≈ 25,000 chars ≈ 5k+ tokens, safely + // above every provider's threshold. + return sentence.repeat(250) +})() + export const weatherTool = LLM.toolDefinition({ name: weatherToolName, description: "Get current weather for a city.", From 5801cce1b597da59acdbe4a20e0a5b38e7fde568 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Mon, 11 May 2026 01:18:39 +0000 Subject: [PATCH 166/189] chore: generate --- packages/llm/src/protocols/bedrock-converse.ts | 5 +---- packages/llm/test/provider/bedrock-converse.test.ts | 4 +--- packages/llm/test/recorded-scenarios.ts | 3 +-- 3 files changed, 3 insertions(+), 9 deletions(-) diff --git a/packages/llm/src/protocols/bedrock-converse.ts b/packages/llm/src/protocols/bedrock-converse.ts index 34bb13e61a..e2ba1ff3be 100644 --- a/packages/llm/src/protocols/bedrock-converse.ts +++ b/packages/llm/src/protocols/bedrock-converse.ts @@ -225,10 +225,7 @@ const lowerToolSpec = (tool: ToolDefinition): BedrockToolSpec => ({ }, }) -const lowerTools = ( - breakpoints: BedrockCache.Breakpoints, - tools: ReadonlyArray, -): BedrockTool[] => { +const lowerTools = (breakpoints: BedrockCache.Breakpoints, tools: ReadonlyArray): BedrockTool[] => { const result: BedrockTool[] = [] for (const tool of tools) { result.push(lowerToolSpec(tool)) diff --git a/packages/llm/test/provider/bedrock-converse.test.ts b/packages/llm/test/provider/bedrock-converse.test.ts index d72e7f3116..afadd89ac7 100644 --- a/packages/llm/test/provider/bedrock-converse.test.ts +++ b/packages/llm/test/provider/bedrock-converse.test.ts @@ -464,9 +464,7 @@ describe("Bedrock Converse route", () => { const prepared = yield* LLMClient.prepare( LLM.request({ model, - tools: [ - { name: "lookup", description: "lookup", inputSchema: { type: "object", properties: {} }, cache }, - ], + tools: [{ name: "lookup", description: "lookup", inputSchema: { type: "object", properties: {} }, cache }], messages: [ LLM.user("What's the weather?"), LLM.assistant([LLM.toolCall({ id: "call_1", name: "lookup", input: {} })]), diff --git a/packages/llm/test/recorded-scenarios.ts b/packages/llm/test/recorded-scenarios.ts index 2361bfdb78..8a02bc3a0a 100644 --- a/packages/llm/test/recorded-scenarios.ts +++ b/packages/llm/test/recorded-scenarios.ts @@ -12,8 +12,7 @@ export const weatherToolName = "get_weather" // a fixed sentence — the cassette replays bit-for-bit, so the exact text matters // only when re-recording with `RECORD=true`. export const LARGE_CACHEABLE_SYSTEM = (() => { - const sentence = - "You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. " + const sentence = "You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. " // ~100 chars per sentence × 250 repeats ≈ 25,000 chars ≈ 5k+ tokens, safely // above every provider's threshold. return sentence.repeat(250) From effd96755e70ddb9e0955bc444860d5cc8829248 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sun, 10 May 2026 21:20:13 -0400 Subject: [PATCH 167/189] Use SyncEvent service at event call sites (#26782) --- .../opencode/src/control-plane/workspace.ts | 14 +- packages/opencode/src/session/compaction.ts | 32 ++- packages/opencode/src/session/processor.ts | 272 ++++++++++-------- packages/opencode/src/session/prompt.ts | 96 ++++--- packages/opencode/src/v2/session.ts | 32 +-- .../opencode/test/session/compaction.test.ts | 3 + .../test/session/processor-effect.test.ts | 2 + packages/opencode/test/session/prompt.test.ts | 2 + .../test/session/snapshot-tool-race.test.ts | 2 + 9 files changed, 250 insertions(+), 205 deletions(-) diff --git a/packages/opencode/src/control-plane/workspace.ts b/packages/opencode/src/control-plane/workspace.ts index e825eaddcf..00353dcca3 100644 --- a/packages/opencode/src/control-plane/workspace.ts +++ b/packages/opencode/src/control-plane/workspace.ts @@ -676,14 +676,12 @@ export const layer = Layer.effect( } if (input.workspaceID === null) { - yield* Effect.sync(() => - SyncEvent.run(Session.Event.Updated, { - sessionID: input.sessionID, - info: { - workspaceID: null, - }, - }), - ) + yield* sync.run(Session.Event.Updated, { + sessionID: input.sessionID, + info: { + workspaceID: null, + }, + }) log.info("session warp complete", { workspaceID: input.workspaceID, diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index 3b16a9a5d7..4eafbdf749 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -18,8 +18,9 @@ import { InstanceState } from "@/effect/instance-state" import { isOverflow as overflow, usable } from "./overflow" import { makeRuntime } from "@/effect/run-service" import { serviceUse } from "@/effect/service-use" -import { EventV2 } from "@/v2/event" +import { SyncEvent } from "@/sync" import { SessionEvent } from "@/v2/session-event" +import { Flag } from "@opencode-ai/core/flag/flag" const log = Log.create({ service: "session.compaction" }) @@ -219,6 +220,7 @@ export const layer: Layer.Layer< | Plugin.Service | SessionProcessor.Service | Provider.Service + | SyncEvent.Service > = Layer.effect( Service, Effect.gen(function* () { @@ -229,6 +231,7 @@ export const layer: Layer.Layer< const plugin = yield* Plugin.Service const processors = yield* SessionProcessor.Service const provider = yield* Provider.Service + const sync = yield* SyncEvent.Service const isOverflow = Effect.fn("SessionCompaction.isOverflow")(function* (input: { tokens: MessageV2.Assistant["tokens"] @@ -567,12 +570,14 @@ export const layer: Layer.Layer< parts: [], }, ) - EventV2.run(SessionEvent.Compaction.Ended.Sync, { - sessionID: input.sessionID, - timestamp: DateTime.makeUnsafe(Date.now()), - text: summary ?? "", - include: selected.tail_start_id, - }) + if (Flag.OPENCODE_EXPERIMENTAL_EVENT_SYSTEM) { + yield* sync.run(SessionEvent.Compaction.Ended.Sync, { + sessionID: input.sessionID, + timestamp: DateTime.makeUnsafe(Date.now()), + text: summary ?? "", + include: selected.tail_start_id, + }) + } yield* bus.publish(Event.Compacted, { sessionID: input.sessionID }) } return result @@ -601,11 +606,13 @@ export const layer: Layer.Layer< auto: input.auto, overflow: input.overflow, }) - EventV2.run(SessionEvent.Compaction.Started.Sync, { - sessionID: input.sessionID, - timestamp: DateTime.makeUnsafe(Date.now()), - reason: input.auto ? "auto" : "manual", - }) + if (Flag.OPENCODE_EXPERIMENTAL_EVENT_SYSTEM) { + yield* sync.run(SessionEvent.Compaction.Started.Sync, { + sessionID: input.sessionID, + timestamp: DateTime.makeUnsafe(Date.now()), + reason: input.auto ? "auto" : "manual", + }) + } }) return Service.of({ @@ -626,6 +633,7 @@ export const defaultLayer = Layer.suspend(() => Layer.provide(Plugin.defaultLayer), Layer.provide(Bus.layer), Layer.provide(Config.defaultLayer), + Layer.provide(SyncEvent.defaultLayer), ), ) diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 7df54d5451..579c4cc42c 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -21,10 +21,11 @@ import { Question } from "@/question" import { errorMessage } from "@/util/error" import * as Log from "@opencode-ai/core/util/log" import { isRecord } from "@/util/record" -import { EventV2 } from "@/v2/event" +import { SyncEvent } from "@/sync" import { SessionEvent } from "@/v2/session-event" import { Modelv2 } from "@/v2/model" import * as DateTime from "effect/DateTime" +import { Flag } from "@opencode-ai/core/flag/flag" const DOOM_LOOP_THRESHOLD = 3 const log = Log.create({ service: "session.processor" }) @@ -96,6 +97,7 @@ export const layer: Layer.Layer< | Image.Service | SessionSummary.Service | SessionStatus.Service + | SyncEvent.Service > = Layer.effect( Service, Effect.gen(function* () { @@ -111,6 +113,7 @@ export const layer: Layer.Layer< const scope = yield* Scope.Scope const status = yield* SessionStatus.Service const image = yield* Image.Service + const sync = yield* SyncEvent.Service const create = Effect.fn("SessionProcessor.create")(function* (input: Input) { // Pre-capture snapshot before the LLM stream starts. The AI SDK @@ -229,11 +232,13 @@ export const layer: Layer.Layer< case "reasoning-start": if (value.id in ctx.reasoningMap) return // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - EventV2.run(SessionEvent.Reasoning.Started.Sync, { - sessionID: ctx.sessionID, - reasoningID: value.id, - timestamp: DateTime.makeUnsafe(Date.now()), - }) + if (Flag.OPENCODE_EXPERIMENTAL_EVENT_SYSTEM) { + yield* sync.run(SessionEvent.Reasoning.Started.Sync, { + sessionID: ctx.sessionID, + reasoningID: value.id, + timestamp: DateTime.makeUnsafe(Date.now()), + }) + } ctx.reasoningMap[value.id] = { id: PartID.ascending(), messageID: ctx.assistantMessage.id, @@ -262,12 +267,14 @@ export const layer: Layer.Layer< case "reasoning-end": if (!(value.id in ctx.reasoningMap)) return // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - EventV2.run(SessionEvent.Reasoning.Ended.Sync, { - sessionID: ctx.sessionID, - reasoningID: value.id, - text: ctx.reasoningMap[value.id].text, - timestamp: DateTime.makeUnsafe(Date.now()), - }) + if (Flag.OPENCODE_EXPERIMENTAL_EVENT_SYSTEM) { + yield* sync.run(SessionEvent.Reasoning.Ended.Sync, { + sessionID: ctx.sessionID, + reasoningID: value.id, + text: ctx.reasoningMap[value.id].text, + timestamp: DateTime.makeUnsafe(Date.now()), + }) + } // oxlint-disable-next-line no-self-assign -- reactivity trigger ctx.reasoningMap[value.id].text = ctx.reasoningMap[value.id].text ctx.reasoningMap[value.id].time = { ...ctx.reasoningMap[value.id].time, end: Date.now() } @@ -281,12 +288,14 @@ export const layer: Layer.Layer< throw new Error(`Tool call not allowed while generating summary: ${value.toolName}`) } // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - EventV2.run(SessionEvent.Tool.Input.Started.Sync, { - sessionID: ctx.sessionID, - callID: value.id, - name: value.toolName, - timestamp: DateTime.makeUnsafe(Date.now()), - }) + if (Flag.OPENCODE_EXPERIMENTAL_EVENT_SYSTEM) { + yield* sync.run(SessionEvent.Tool.Input.Started.Sync, { + sessionID: ctx.sessionID, + callID: value.id, + name: value.toolName, + timestamp: DateTime.makeUnsafe(Date.now()), + }) + } const part = yield* session.updatePart({ id: ctx.toolcalls[value.id]?.partID ?? PartID.ascending(), messageID: ctx.assistantMessage.id, @@ -310,12 +319,14 @@ export const layer: Layer.Layer< case "tool-input-end": { // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - EventV2.run(SessionEvent.Tool.Input.Ended.Sync, { - sessionID: ctx.sessionID, - callID: value.id, - text: "", - timestamp: DateTime.makeUnsafe(Date.now()), - }) + if (Flag.OPENCODE_EXPERIMENTAL_EVENT_SYSTEM) { + yield* sync.run(SessionEvent.Tool.Input.Ended.Sync, { + sessionID: ctx.sessionID, + callID: value.id, + text: "", + timestamp: DateTime.makeUnsafe(Date.now()), + }) + } return } @@ -325,17 +336,19 @@ export const layer: Layer.Layer< } const toolCall = yield* readToolCall(value.toolCallId) // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - EventV2.run(SessionEvent.Tool.Called.Sync, { - sessionID: ctx.sessionID, - callID: value.toolCallId, - tool: value.toolName, - input: value.input, - provider: { - executed: toolCall?.part.metadata?.providerExecuted === true, - ...(value.providerMetadata ? { metadata: value.providerMetadata } : {}), - }, - timestamp: DateTime.makeUnsafe(Date.now()), - }) + if (Flag.OPENCODE_EXPERIMENTAL_EVENT_SYSTEM) { + yield* sync.run(SessionEvent.Tool.Called.Sync, { + sessionID: ctx.sessionID, + callID: value.toolCallId, + tool: value.toolName, + input: value.input, + provider: { + executed: toolCall?.part.metadata?.providerExecuted === true, + ...(value.providerMetadata ? { metadata: value.providerMetadata } : {}), + }, + timestamp: DateTime.makeUnsafe(Date.now()), + }) + } yield* updateToolCall(value.toolCallId, (match) => ({ ...match, tool: value.toolName, @@ -405,27 +418,29 @@ export const layer: Layer.Layer< attachments: attachments?.length ? attachments : undefined, } // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - EventV2.run(SessionEvent.Tool.Success.Sync, { - sessionID: ctx.sessionID, - callID: value.toolCallId, - structured: output.metadata, - content: [ - { - type: "text", - text: output.output, + if (Flag.OPENCODE_EXPERIMENTAL_EVENT_SYSTEM) { + yield* sync.run(SessionEvent.Tool.Success.Sync, { + sessionID: ctx.sessionID, + callID: value.toolCallId, + structured: output.metadata, + content: [ + { + type: "text", + text: output.output, + }, + ...(output.attachments?.map((item: MessageV2.FilePart) => ({ + type: "file", + uri: item.url, + mime: item.mime, + name: item.filename, + })) ?? []), + ], + provider: { + executed: toolCall?.part.metadata?.providerExecuted === true, }, - ...(output.attachments?.map((item: MessageV2.FilePart) => ({ - type: "file", - uri: item.url, - mime: item.mime, - name: item.filename, - })) ?? []), - ], - provider: { - executed: toolCall?.part.metadata?.providerExecuted === true, - }, - timestamp: DateTime.makeUnsafe(Date.now()), - }) + timestamp: DateTime.makeUnsafe(Date.now()), + }) + } yield* completeToolCall(value.toolCallId, output) return } @@ -433,18 +448,20 @@ export const layer: Layer.Layer< case "tool-error": { const toolCall = yield* readToolCall(value.toolCallId) // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - EventV2.run(SessionEvent.Tool.Failed.Sync, { - sessionID: ctx.sessionID, - callID: value.toolCallId, - error: { - type: "unknown", - message: errorMessage(value.error), - }, - provider: { - executed: toolCall?.part.metadata?.providerExecuted === true, - }, - timestamp: DateTime.makeUnsafe(Date.now()), - }) + if (Flag.OPENCODE_EXPERIMENTAL_EVENT_SYSTEM) { + yield* sync.run(SessionEvent.Tool.Failed.Sync, { + sessionID: ctx.sessionID, + callID: value.toolCallId, + error: { + type: "unknown", + message: errorMessage(value.error), + }, + provider: { + executed: toolCall?.part.metadata?.providerExecuted === true, + }, + timestamp: DateTime.makeUnsafe(Date.now()), + }) + } yield* failToolCall(value.toolCallId, value.error) return } @@ -456,17 +473,19 @@ export const layer: Layer.Layer< if (!ctx.snapshot) ctx.snapshot = yield* snapshot.track() if (!ctx.assistantMessage.summary) { // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - EventV2.run(SessionEvent.Step.Started.Sync, { - sessionID: ctx.sessionID, - agent: input.assistantMessage.agent, - model: { - id: Modelv2.ID.make(ctx.model.id), - providerID: Modelv2.ProviderID.make(ctx.model.providerID), - variant: Modelv2.VariantID.make(input.assistantMessage.variant ?? "default"), - }, - snapshot: ctx.snapshot, - timestamp: DateTime.makeUnsafe(Date.now()), - }) + if (Flag.OPENCODE_EXPERIMENTAL_EVENT_SYSTEM) { + yield* sync.run(SessionEvent.Step.Started.Sync, { + sessionID: ctx.sessionID, + agent: input.assistantMessage.agent, + model: { + id: Modelv2.ID.make(ctx.model.id), + providerID: Modelv2.ProviderID.make(ctx.model.providerID), + variant: Modelv2.VariantID.make(input.assistantMessage.variant ?? "default"), + }, + snapshot: ctx.snapshot, + timestamp: DateTime.makeUnsafe(Date.now()), + }) + } } yield* session.updatePart({ id: PartID.ascending(), @@ -486,14 +505,16 @@ export const layer: Layer.Layer< }) if (!ctx.assistantMessage.summary) { // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - EventV2.run(SessionEvent.Step.Ended.Sync, { - sessionID: ctx.sessionID, - finish: value.finishReason, - cost: usage.cost, - tokens: usage.tokens, - snapshot: completedSnapshot, - timestamp: DateTime.makeUnsafe(Date.now()), - }) + if (Flag.OPENCODE_EXPERIMENTAL_EVENT_SYSTEM) { + yield* sync.run(SessionEvent.Step.Ended.Sync, { + sessionID: ctx.sessionID, + finish: value.finishReason, + cost: usage.cost, + tokens: usage.tokens, + snapshot: completedSnapshot, + timestamp: DateTime.makeUnsafe(Date.now()), + }) + } } ctx.assistantMessage.finish = value.finishReason ctx.assistantMessage.cost += usage.cost @@ -541,10 +562,12 @@ export const layer: Layer.Layer< case "text-start": if (!ctx.assistantMessage.summary) { // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - EventV2.run(SessionEvent.Text.Started.Sync, { - sessionID: ctx.sessionID, - timestamp: DateTime.makeUnsafe(Date.now()), - }) + if (Flag.OPENCODE_EXPERIMENTAL_EVENT_SYSTEM) { + yield* sync.run(SessionEvent.Text.Started.Sync, { + sessionID: ctx.sessionID, + timestamp: DateTime.makeUnsafe(Date.now()), + }) + } } ctx.currentText = { id: PartID.ascending(), @@ -586,11 +609,13 @@ export const layer: Layer.Layer< )).text if (!ctx.assistantMessage.summary) { // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - EventV2.run(SessionEvent.Text.Ended.Sync, { - sessionID: ctx.sessionID, - text: ctx.currentText.text, - timestamp: DateTime.makeUnsafe(Date.now()), - }) + if (Flag.OPENCODE_EXPERIMENTAL_EVENT_SYSTEM) { + yield* sync.run(SessionEvent.Text.Ended.Sync, { + sessionID: ctx.sessionID, + text: ctx.currentText.text, + timestamp: DateTime.makeUnsafe(Date.now()), + }) + } } { const end = Date.now() @@ -680,14 +705,16 @@ export const layer: Layer.Layer< } if (!ctx.assistantMessage.summary) { // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - EventV2.run(SessionEvent.Step.Failed.Sync, { - sessionID: ctx.sessionID, - error: { - type: "unknown", - message: errorMessage(e), - }, - timestamp: DateTime.makeUnsafe(Date.now()), - }) + if (Flag.OPENCODE_EXPERIMENTAL_EVENT_SYSTEM) { + yield* sync.run(SessionEvent.Step.Failed.Sync, { + sessionID: ctx.sessionID, + error: { + type: "unknown", + message: errorMessage(e), + }, + timestamp: DateTime.makeUnsafe(Date.now()), + }) + } } ctx.assistantMessage.error = error yield* bus.publish(Session.Event.Error, { @@ -732,22 +759,28 @@ export const layer: Layer.Layer< parse, set: (info) => { // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - EventV2.run(SessionEvent.Retried.Sync, { - sessionID: ctx.sessionID, - attempt: info.attempt, - error: { - message: info.message, - isRetryable: true, - }, - timestamp: DateTime.makeUnsafe(Date.now()), - }) - return status.set(ctx.sessionID, { - type: "retry", - attempt: info.attempt, - message: info.message, - action: info.action, - next: info.next, - }) + const event = Flag.OPENCODE_EXPERIMENTAL_EVENT_SYSTEM + ? sync.run(SessionEvent.Retried.Sync, { + sessionID: ctx.sessionID, + attempt: info.attempt, + error: { + message: info.message, + isRetryable: true, + }, + timestamp: DateTime.makeUnsafe(Date.now()), + }) + : Effect.void + return event.pipe( + Effect.andThen( + status.set(ctx.sessionID, { + type: "retry", + attempt: info.attempt, + message: info.message, + action: info.action, + next: info.next, + }), + ), + ) }, }), ), @@ -788,6 +821,7 @@ export const defaultLayer = Layer.suspend(() => Layer.provide(Image.defaultLayer), Layer.provide(Bus.layer), Layer.provide(Config.defaultLayer), + Layer.provide(SyncEvent.defaultLayer), ), ) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 521bacbc2f..934427f569 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -54,7 +54,7 @@ import { InstanceState } from "@/effect/instance-state" import { TaskTool, type TaskPromptOps } from "@/tool/task" import { SessionRunState } from "./run-state" import { EffectBridge } from "@/effect/bridge" -import { EventV2 } from "@/v2/event" +import { SyncEvent } from "@/sync" import { SessionEvent } from "@/v2/session-event" import { Modelv2 } from "@/v2/model" import { AgentAttachment, FileAttachment, Source } from "@/v2/session-prompt" @@ -118,6 +118,7 @@ export const layer = Layer.effect( const summary = yield* SessionSummary.Service const sys = yield* SystemPrompt.Service const llm = yield* LLM.Service + const sync = yield* SyncEvent.Service const runner = Effect.fn("SessionPrompt.runner")(function* () { return yield* EffectBridge.make() }) @@ -809,12 +810,14 @@ NOTE: At any point in time through this workflow you should feel free to ask the }, } yield* sessions.updatePart(part) - EventV2.run(SessionEvent.Shell.Started.Sync, { - sessionID: input.sessionID, - timestamp: DateTime.makeUnsafe(started), - callID, - command: input.command, - }) + if (Flag.OPENCODE_EXPERIMENTAL_EVENT_SYSTEM) { + yield* sync.run(SessionEvent.Shell.Started.Sync, { + sessionID: input.sessionID, + timestamp: DateTime.makeUnsafe(started), + callID, + command: input.command, + }) + } return { msg, part, cwd: ctx.directory } }).pipe(Effect.ensuring(markReady)) @@ -830,12 +833,14 @@ NOTE: At any point in time through this workflow you should feel free to ask the output += "\n\n" + ["", "User aborted the command", ""].join("\n") } const completed = Date.now() - EventV2.run(SessionEvent.Shell.Ended.Sync, { - sessionID: input.sessionID, - timestamp: DateTime.makeUnsafe(completed), - callID: part.callID, - output, - }) + if (Flag.OPENCODE_EXPERIMENTAL_EVENT_SYSTEM) { + yield* sync.run(SessionEvent.Shell.Ended.Sync, { + sessionID: input.sessionID, + timestamp: DateTime.makeUnsafe(completed), + callID: part.callID, + output, + }) + } if (!msg.time.completed) { msg.time.completed = completed yield* sessions.updateMessage(msg) @@ -975,34 +980,26 @@ NOTE: At any point in time through this workflow you should feel free to ask the } if (current?.agent !== info.agent) { - EventV2.run( - SessionEvent.AgentSwitched.Sync, - { - sessionID: input.sessionID, - timestamp: DateTime.makeUnsafe(info.time.created), - agent: info.agent, - }, - { bypassExperimentalEventSystem: true }, - ) + yield* sync.run(SessionEvent.AgentSwitched.Sync, { + sessionID: input.sessionID, + timestamp: DateTime.makeUnsafe(info.time.created), + agent: info.agent, + }) } if ( current?.model?.providerID !== info.model.providerID || current.model.id !== info.model.modelID || (current.model.variant === "default" ? undefined : current.model.variant) !== info.model.variant ) { - EventV2.run( - SessionEvent.ModelSwitched.Sync, - { - sessionID: input.sessionID, - timestamp: DateTime.makeUnsafe(info.time.created), - model: { - id: Modelv2.ID.make(info.model.modelID), - providerID: Modelv2.ProviderID.make(info.model.providerID), - variant: Modelv2.VariantID.make(info.model.variant ?? "default"), - }, + yield* sync.run(SessionEvent.ModelSwitched.Sync, { + sessionID: input.sessionID, + timestamp: DateTime.makeUnsafe(info.time.created), + model: { + id: Modelv2.ID.make(info.model.modelID), + providerID: Modelv2.ProviderID.make(info.model.providerID), + variant: Modelv2.VariantID.make(info.model.variant ?? "default"), }, - { bypassExperimentalEventSystem: true }, - ) + }) } yield* Effect.addFinalizer(() => instruction.clear(info.id)) @@ -1371,23 +1368,27 @@ NOTE: At any point in time through this workflow you should feel free to ask the }, ) // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - EventV2.run(SessionEvent.Prompted.Sync, { - sessionID: input.sessionID, - timestamp: DateTime.makeUnsafe(info.time.created), - prompt: { - text: nextPrompt.text.join("\n"), - files: nextPrompt.files, - agents: nextPrompt.agents, - }, - }) - for (const text of nextPrompt.synthetic) { - // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - EventV2.run(SessionEvent.Synthetic.Sync, { + if (Flag.OPENCODE_EXPERIMENTAL_EVENT_SYSTEM) { + yield* sync.run(SessionEvent.Prompted.Sync, { sessionID: input.sessionID, timestamp: DateTime.makeUnsafe(info.time.created), - text, + prompt: { + text: nextPrompt.text.join("\n"), + files: nextPrompt.files, + agents: nextPrompt.agents, + }, }) } + for (const text of nextPrompt.synthetic) { + // TODO(v2): Temporary dual-write while migrating session messages to v2 events. + if (Flag.OPENCODE_EXPERIMENTAL_EVENT_SYSTEM) { + yield* sync.run(SessionEvent.Synthetic.Sync, { + sessionID: input.sessionID, + timestamp: DateTime.makeUnsafe(info.time.created), + text, + }) + } + } return { info, parts } }, Effect.scoped) @@ -1820,6 +1821,7 @@ export const defaultLayer = Layer.suspend(() => LLM.defaultLayer, Bus.layer, CrossSpawnSpawner.defaultLayer, + SyncEvent.defaultLayer, ), ), ), diff --git a/packages/opencode/src/v2/session.ts b/packages/opencode/src/v2/session.ts index 39deabfc7c..3b0b61dcbc 100644 --- a/packages/opencode/src/v2/session.ts +++ b/packages/opencode/src/v2/session.ts @@ -12,6 +12,7 @@ import { SessionEvent } from "./session-event" import { V2Schema } from "./schema" import { optionalOmitUndefined } from "@opencode-ai/core/schema" import { Modelv2 } from "./model" +import { SyncEvent } from "@/sync" export const Delivery = Schema.Literals(["immediate", "deferred"]).annotate({ identifier: "Session.Delivery", @@ -113,6 +114,7 @@ export class Service extends Context.Service()("@opencode/v2 export const layer = Layer.effect( Service, Effect.gen(function* () { + const sync = yield* SyncEvent.Service const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Message) const decode = (row: typeof SessionMessageTable.$inferSelect) => @@ -269,26 +271,18 @@ export const layer = Layer.effect( shell: Effect.fn("V2Session.shell")(function* (_input) {}), skill: Effect.fn("V2Session.skill")(function* (_input) {}), switchAgent: Effect.fn("V2Session.switchAgent")(function* (input) { - EventV2.run( - SessionEvent.AgentSwitched.Sync, - { - sessionID: input.sessionID, - timestamp: DateTime.makeUnsafe(Date.now()), - agent: input.agent, - }, - { bypassExperimentalEventSystem: true }, - ) + yield* sync.run(SessionEvent.AgentSwitched.Sync, { + sessionID: input.sessionID, + timestamp: DateTime.makeUnsafe(Date.now()), + agent: input.agent, + }) }), switchModel: Effect.fn("V2Session.switchModel")(function* (input) { - EventV2.run( - SessionEvent.ModelSwitched.Sync, - { - sessionID: input.sessionID, - timestamp: DateTime.makeUnsafe(Date.now()), - model: input.model, - }, - { bypassExperimentalEventSystem: true }, - ) + yield* sync.run(SessionEvent.ModelSwitched.Sync, { + sessionID: input.sessionID, + timestamp: DateTime.makeUnsafe(Date.now()), + model: input.model, + }) }), subagent: Effect.fn("V2Session.subagent")(function* (input) { const parent = yield* result.get(input.parentID) @@ -319,6 +313,6 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer +export const defaultLayer = layer.pipe(Layer.provide(SyncEvent.defaultLayer)) export * as SessionV2 from "./session" diff --git a/packages/opencode/test/session/compaction.test.ts b/packages/opencode/test/session/compaction.test.ts index b48e68b10f..8f987b4d10 100644 --- a/packages/opencode/test/session/compaction.test.ts +++ b/packages/opencode/test/session/compaction.test.ts @@ -27,6 +27,7 @@ import { ProviderTest } from "../fake/provider" import { testEffect } from "../lib/effect" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { TestConfig } from "../fixture/config" +import { SyncEvent } from "@/sync" void Log.init({ print: false }) @@ -223,6 +224,7 @@ const deps = Layer.mergeAll( Plugin.defaultLayer, Bus.layer, Config.defaultLayer, + SyncEvent.defaultLayer, ) const env = Layer.mergeAll( @@ -269,6 +271,7 @@ function compactionProcessLayer(options?: CompactionProcessOptions) { Layer.provide(status), Layer.provide(bus), Layer.provide(options?.config ?? Config.defaultLayer), + Layer.provide(SyncEvent.defaultLayer), ) } diff --git a/packages/opencode/test/session/processor-effect.test.ts b/packages/opencode/test/session/processor-effect.test.ts index a0736b459b..56ff102430 100644 --- a/packages/opencode/test/session/processor-effect.test.ts +++ b/packages/opencode/test/session/processor-effect.test.ts @@ -24,6 +24,7 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { provideTmpdirServer } from "../fixture/fixture" import { testEffect } from "../lib/effect" import { raw, reply, TestLLMServer } from "../lib/llm-server" +import { SyncEvent } from "@/sync" void Log.init({ print: false }) @@ -166,6 +167,7 @@ const deps = Layer.mergeAll( LLM.defaultLayer, Provider.defaultLayer, status, + SyncEvent.defaultLayer, ).pipe(Layer.provideMerge(infra)) const env = Layer.mergeAll( TestLLMServer.layer, diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index 98a69fce96..42c9a81cd2 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -50,6 +50,7 @@ import { Reference } from "../../src/reference/reference" import { provideTmpdirInstance, provideTmpdirServer } from "../fixture/fixture" import { testEffect } from "../lib/effect" import { reply, TestLLMServer } from "../lib/llm-server" +import { SyncEvent } from "@/sync" void Log.init({ print: false }) @@ -174,6 +175,7 @@ function makeHttp() { mcp, AppFileSystem.defaultLayer, status, + SyncEvent.defaultLayer, ).pipe(Layer.provideMerge(infra)) const question = Question.layer.pipe(Layer.provideMerge(deps)) const todo = Todo.layer.pipe(Layer.provideMerge(deps)) diff --git a/packages/opencode/test/session/snapshot-tool-race.test.ts b/packages/opencode/test/session/snapshot-tool-race.test.ts index 251a4acf3f..5c47df4c0d 100644 --- a/packages/opencode/test/session/snapshot-tool-race.test.ts +++ b/packages/opencode/test/session/snapshot-tool-race.test.ts @@ -58,6 +58,7 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Ripgrep } from "../../src/file/ripgrep" import { Format } from "../../src/format" import { Reference } from "../../src/reference/reference" +import { SyncEvent } from "@/sync" void Log.init({ print: false }) @@ -124,6 +125,7 @@ function makeHttp() { mcp, AppFileSystem.defaultLayer, status, + SyncEvent.defaultLayer, ).pipe(Layer.provideMerge(infra)) const question = Question.layer.pipe(Layer.provideMerge(deps)) const todo = Todo.layer.pipe(Layer.provideMerge(deps)) From 83cb0f60ec23c73a4a3fa21139bed71e2c76aec3 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sun, 10 May 2026 21:26:28 -0400 Subject: [PATCH 168/189] Drop EventV2 run facade (#26783) --- packages/opencode/src/v2/event.ts | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/packages/opencode/src/v2/event.ts b/packages/opencode/src/v2/event.ts index 0eb6af966c..14ee44dd52 100644 --- a/packages/opencode/src/v2/event.ts +++ b/packages/opencode/src/v2/event.ts @@ -1,7 +1,6 @@ import { Identifier } from "@/id/id" import { SyncEvent } from "@/sync" import { withStatics } from "@opencode-ai/core/schema" -import { Flag } from "@opencode-ai/core/flag/flag" import * as Schema from "effect/Schema" export const ID = Schema.String.pipe( @@ -41,14 +40,4 @@ export function define( - def: Def, - data: SyncEvent.Event["data"], - // Temporary escape hatch while the full v2 event system remains experimental. - options?: { publish?: boolean; bypassExperimentalEventSystem?: boolean }, -) { - if (!options?.bypassExperimentalEventSystem && !Flag.OPENCODE_EXPERIMENTAL_EVENT_SYSTEM) return - SyncEvent.run(def, data, options) -} - export * as EventV2 from "./event" From 4100fcbd171951d214ec616d59a4ff2b5a2814a5 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sun, 10 May 2026 21:40:12 -0400 Subject: [PATCH 169/189] disable image resizing --- packages/opencode/src/session/prompt.ts | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 934427f569..30446552cf 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -43,7 +43,6 @@ import { Shell } from "@/shell/shell" import { ShellID } from "@/tool/shell/id" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Truncate } from "@/tool/truncate" -import { Image } from "@/image/image" import { decodeDataUrl } from "@/util/data-url" import { Process } from "@/util/process" import { Cause, Effect, Exit, Latch, Layer, Option, Scope, Context, Schema, Types } from "effect" @@ -81,10 +80,10 @@ const elog = EffectLogger.create({ service: "session.prompt" }) export interface Interface { readonly cancel: (sessionID: SessionID) => Effect.Effect - readonly prompt: (input: PromptInput) => Effect.Effect + readonly prompt: (input: PromptInput) => Effect.Effect readonly loop: (input: LoopInput) => Effect.Effect readonly shell: (input: ShellInput) => Effect.Effect - readonly command: (input: CommandInput) => Effect.Effect + readonly command: (input: CommandInput) => Effect.Effect readonly resolvePromptParts: (template: string) => Effect.Effect } @@ -109,7 +108,6 @@ export const layer = Layer.effect( const lsp = yield* LSP.Service const registry = yield* ToolRegistry.Service const truncate = yield* Truncate.Service - const image = yield* Image.Service const spawner = yield* ChildProcessSpawner.ChildProcessSpawner const scope = yield* Scope.Scope const instruction = yield* Instruction.Service @@ -126,7 +124,7 @@ export const layer = Layer.effect( return { cancel: (sessionID: SessionID) => cancel(sessionID), resolvePromptParts: (template: string) => resolvePromptParts(template), - prompt: (input: PromptInput) => prompt(input).pipe(Effect.catch(Effect.die)), + prompt: (input: PromptInput) => prompt(input), } satisfies TaskPromptOps }) @@ -1292,9 +1290,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the { message: info, parts: resolvedParts }, ) - const parts = yield* Effect.forEach(resolvedParts, (part) => - part.type === "file" && part.mime.startsWith("image/") ? image.normalize(part) : Effect.succeed(part), - ) + const parts = resolvedParts const parsed = MessageV2.Info.zod.safeParse(info) if (!parsed.success) { @@ -1393,9 +1389,9 @@ NOTE: At any point in time through this workflow you should feel free to ask the return { info, parts } }, Effect.scoped) - const prompt: (input: PromptInput) => Effect.Effect = Effect.fn( - "SessionPrompt.prompt", - )(function* (input: PromptInput) { + const prompt: (input: PromptInput) => Effect.Effect = Effect.fn("SessionPrompt.prompt")(function* ( + input: PromptInput, + ) { const session = yield* sessions.get(input.sessionID).pipe(Effect.orDie) yield* revert.cleanup(session) const message = yield* createUserMessage(input) @@ -1813,7 +1809,6 @@ export const defaultLayer = Layer.suspend(() => Layer.provide(Session.defaultLayer), Layer.provide(SessionRevert.defaultLayer), Layer.provide(SessionSummary.defaultLayer), - Layer.provide(Image.defaultLayer), Layer.provide( Layer.mergeAll( Agent.defaultLayer, From 38e454011915c76e88a938fd61dd6d6d0c13f355 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Mon, 11 May 2026 01:41:26 +0000 Subject: [PATCH 170/189] chore: generate --- packages/opencode/src/session/prompt.ts | 36 ++++++++++++------------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 30446552cf..5414eba2e5 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1389,26 +1389,26 @@ NOTE: At any point in time through this workflow you should feel free to ask the return { info, parts } }, Effect.scoped) - const prompt: (input: PromptInput) => Effect.Effect = Effect.fn("SessionPrompt.prompt")(function* ( - input: PromptInput, - ) { - const session = yield* sessions.get(input.sessionID).pipe(Effect.orDie) - yield* revert.cleanup(session) - const message = yield* createUserMessage(input) - yield* sessions.touch(input.sessionID) + const prompt: (input: PromptInput) => Effect.Effect = Effect.fn("SessionPrompt.prompt")( + function* (input: PromptInput) { + const session = yield* sessions.get(input.sessionID).pipe(Effect.orDie) + yield* revert.cleanup(session) + const message = yield* createUserMessage(input) + yield* sessions.touch(input.sessionID) - const permissions: Permission.Ruleset = [] - for (const [t, enabled] of Object.entries(input.tools ?? {})) { - permissions.push({ permission: t, action: enabled ? "allow" : "deny", pattern: "*" }) - } - if (permissions.length > 0) { - session.permission = permissions - yield* sessions.setPermission({ sessionID: session.id, permission: permissions }) - } + const permissions: Permission.Ruleset = [] + for (const [t, enabled] of Object.entries(input.tools ?? {})) { + permissions.push({ permission: t, action: enabled ? "allow" : "deny", pattern: "*" }) + } + if (permissions.length > 0) { + session.permission = permissions + yield* sessions.setPermission({ sessionID: session.id, permission: permissions }) + } - if (input.noReply === true) return message - return yield* loop({ sessionID: input.sessionID }) - }) + if (input.noReply === true) return message + return yield* loop({ sessionID: input.sessionID }) + }, + ) const lastAssistant = Effect.fnUntraced(function* (sessionID: SessionID) { const match = yield* sessions.findMessage(sessionID, (m) => m.info.role !== "user") From 2703eff2e22b20a36d40b8c920556444c20a90cd Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sun, 10 May 2026 21:52:50 -0400 Subject: [PATCH 171/189] refactor(llm): normalize Usage as inclusive total + non-overlapping breakdown (#26735) --- .../llm/src/protocols/anthropic-messages.ts | 44 +++++++++---- .../llm/src/protocols/bedrock-converse.ts | 11 +++- packages/llm/src/protocols/gemini.ts | 23 +++++-- packages/llm/src/protocols/openai-chat.ts | 15 ++++- .../llm/src/protocols/openai-responses.ts | 14 ++++- packages/llm/src/protocols/shared.ts | 39 ++++++++++++ packages/llm/src/schema/events.ts | 61 ++++++++++++++++++- .../test/provider/anthropic-messages.test.ts | 15 +++-- packages/llm/test/provider/gemini.test.ts | 37 ++++++----- .../llm/test/provider/openai-chat.test.ts | 23 ++++--- .../test/provider/openai-responses.test.ts | 31 ++++++---- packages/llm/test/schema.test.ts | 30 ++++++++- 12 files changed, 275 insertions(+), 68 deletions(-) diff --git a/packages/llm/src/protocols/anthropic-messages.ts b/packages/llm/src/protocols/anthropic-messages.ts index a426807c02..d893888fd2 100644 --- a/packages/llm/src/protocols/anthropic-messages.ts +++ b/packages/llm/src/protocols/anthropic-messages.ts @@ -404,34 +404,56 @@ const mapFinishReason = (reason: string | null | undefined): FinishReason => { return "unknown" } +// Anthropic reports the non-overlapping breakdown natively — its +// `input_tokens` is the *non-cached* count per the Messages API docs, with +// cache reads and writes as separate fields. We sum them to derive the +// inclusive `inputTokens` the rest of the contract expects. Extended +// thinking tokens are *not* broken out by Anthropic — they're billed as +// part of `output_tokens`, so `reasoningTokens` stays `undefined` and +// `outputTokens` carries the combined total. const mapUsage = (usage: AnthropicUsage | undefined): Usage | undefined => { if (!usage) return undefined + const nonCached = usage.input_tokens + const cacheRead = usage.cache_read_input_tokens ?? undefined + const cacheWrite = usage.cache_creation_input_tokens ?? undefined + const inputTokens = ProviderShared.sumTokens(nonCached, cacheRead, cacheWrite) return new Usage({ - inputTokens: usage.input_tokens, + inputTokens, outputTokens: usage.output_tokens, - cacheReadInputTokens: usage.cache_read_input_tokens ?? undefined, - cacheWriteInputTokens: usage.cache_creation_input_tokens ?? undefined, - totalTokens: ProviderShared.totalTokens(usage.input_tokens, usage.output_tokens, undefined), - native: usage, + nonCachedInputTokens: nonCached, + cacheReadInputTokens: cacheRead, + cacheWriteInputTokens: cacheWrite, + totalTokens: ProviderShared.totalTokens(inputTokens, usage.output_tokens, undefined), + providerMetadata: { anthropic: usage }, }) } // Anthropic emits usage on `message_start` and again on `message_delta` — the // final delta carries the authoritative totals. Right-biased merge: each -// field prefers `right` when defined, falls back to `left`. `totalTokens` is -// recomputed from the merged input/output to stay consistent. +// field prefers `right` when defined, falls back to `left`. `inputTokens` is +// recomputed from the merged breakdown so the inclusive total stays +// consistent with `nonCached + cacheRead + cacheWrite`. const mergeUsage = (left: Usage | undefined, right: Usage | undefined) => { if (!left) return right if (!right) return left - const inputTokens = right.inputTokens ?? left.inputTokens + const nonCachedInputTokens = right.nonCachedInputTokens ?? left.nonCachedInputTokens + const cacheReadInputTokens = right.cacheReadInputTokens ?? left.cacheReadInputTokens + const cacheWriteInputTokens = right.cacheWriteInputTokens ?? left.cacheWriteInputTokens + const inputTokens = ProviderShared.sumTokens(nonCachedInputTokens, cacheReadInputTokens, cacheWriteInputTokens) const outputTokens = right.outputTokens ?? left.outputTokens return new Usage({ inputTokens, outputTokens, - cacheReadInputTokens: right.cacheReadInputTokens ?? left.cacheReadInputTokens, - cacheWriteInputTokens: right.cacheWriteInputTokens ?? left.cacheWriteInputTokens, + nonCachedInputTokens, + cacheReadInputTokens, + cacheWriteInputTokens, totalTokens: ProviderShared.totalTokens(inputTokens, outputTokens, undefined), - native: { ...left.native, ...right.native }, + providerMetadata: { + anthropic: { + ...(left.providerMetadata?.["anthropic"] ?? {}), + ...(right.providerMetadata?.["anthropic"] ?? {}), + }, + }, }) } diff --git a/packages/llm/src/protocols/bedrock-converse.ts b/packages/llm/src/protocols/bedrock-converse.ts index e2ba1ff3be..f561a6d7c5 100644 --- a/packages/llm/src/protocols/bedrock-converse.ts +++ b/packages/llm/src/protocols/bedrock-converse.ts @@ -395,15 +395,22 @@ const mapFinishReason = (reason: string): FinishReason => { return "unknown" } +// AWS Bedrock Converse reports `inputTokens` (inclusive total) with +// `cacheReadInputTokens` and `cacheWriteInputTokens` as subsets. Pass +// the total through and derive the non-cached breakdown. Bedrock does +// not break reasoning out of `outputTokens` for any current model. const mapUsage = (usage: BedrockUsageSchema | undefined): Usage | undefined => { if (!usage) return undefined + const cacheTotal = (usage.cacheReadInputTokens ?? 0) + (usage.cacheWriteInputTokens ?? 0) + const nonCached = ProviderShared.subtractTokens(usage.inputTokens, cacheTotal) return new Usage({ inputTokens: usage.inputTokens, outputTokens: usage.outputTokens, - totalTokens: ProviderShared.totalTokens(usage.inputTokens, usage.outputTokens, usage.totalTokens), + nonCachedInputTokens: nonCached, cacheReadInputTokens: usage.cacheReadInputTokens, cacheWriteInputTokens: usage.cacheWriteInputTokens, - native: usage, + totalTokens: ProviderShared.totalTokens(usage.inputTokens, usage.outputTokens, usage.totalTokens), + providerMetadata: { bedrock: usage }, }) } diff --git a/packages/llm/src/protocols/gemini.ts b/packages/llm/src/protocols/gemini.ts index 140da521a5..ff6f3f83ec 100644 --- a/packages/llm/src/protocols/gemini.ts +++ b/packages/llm/src/protocols/gemini.ts @@ -281,15 +281,30 @@ const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMReque // ============================================================================= // Stream Parsing // ============================================================================= +// Gemini reports `promptTokenCount` (inclusive total) with a +// `cachedContentTokenCount` subset. `candidatesTokenCount` is *exclusive* +// of `thoughtsTokenCount` — visible-only, not a total — so we sum the two +// to produce the inclusive `outputTokens` the rest of the contract expects. const mapUsage = (usage: GeminiUsage | undefined) => { if (!usage) return undefined + const cached = usage.cachedContentTokenCount + const nonCached = ProviderShared.subtractTokens(usage.promptTokenCount, cached) + // `candidatesTokenCount` is visible-only; sum with thoughts to produce the + // inclusive `outputTokens` the contract expects. Only compute the total + // when the visible component is reported — otherwise we'd fabricate an + // inclusive number from a partial breakdown. + const outputTokens = + usage.candidatesTokenCount !== undefined + ? usage.candidatesTokenCount + (usage.thoughtsTokenCount ?? 0) + : undefined return new Usage({ inputTokens: usage.promptTokenCount, - outputTokens: usage.candidatesTokenCount, + outputTokens, + nonCachedInputTokens: nonCached, + cacheReadInputTokens: cached, reasoningTokens: usage.thoughtsTokenCount, - cacheReadInputTokens: usage.cachedContentTokenCount, - totalTokens: ProviderShared.totalTokens(usage.promptTokenCount, usage.candidatesTokenCount, usage.totalTokenCount), - native: usage, + totalTokens: ProviderShared.totalTokens(usage.promptTokenCount, outputTokens, usage.totalTokenCount), + providerMetadata: { google: usage }, }) } diff --git a/packages/llm/src/protocols/openai-chat.ts b/packages/llm/src/protocols/openai-chat.ts index 5d42c0a4e9..133adb503b 100644 --- a/packages/llm/src/protocols/openai-chat.ts +++ b/packages/llm/src/protocols/openai-chat.ts @@ -290,15 +290,24 @@ const mapFinishReason = (reason: string | null | undefined): FinishReason => { return "unknown" } +// OpenAI Chat reports `prompt_tokens` (inclusive total) with a +// `cached_tokens` subset, and `completion_tokens` (inclusive total) with +// a `reasoning_tokens` subset. We pass the inclusive totals through and +// derive the non-cached breakdown so the `LLM.Usage` contract is +// satisfied on both sides. const mapUsage = (usage: OpenAIChatEvent["usage"]): Usage | undefined => { if (!usage) return undefined + const cached = usage.prompt_tokens_details?.cached_tokens + const reasoning = usage.completion_tokens_details?.reasoning_tokens + const nonCached = ProviderShared.subtractTokens(usage.prompt_tokens, cached) return new Usage({ inputTokens: usage.prompt_tokens, outputTokens: usage.completion_tokens, - reasoningTokens: usage.completion_tokens_details?.reasoning_tokens, - cacheReadInputTokens: usage.prompt_tokens_details?.cached_tokens, + nonCachedInputTokens: nonCached, + cacheReadInputTokens: cached, + reasoningTokens: reasoning, totalTokens: ProviderShared.totalTokens(usage.prompt_tokens, usage.completion_tokens, usage.total_tokens), - native: usage, + providerMetadata: { openai: usage }, }) } diff --git a/packages/llm/src/protocols/openai-responses.ts b/packages/llm/src/protocols/openai-responses.ts index 14dc32130c..035cc07713 100644 --- a/packages/llm/src/protocols/openai-responses.ts +++ b/packages/llm/src/protocols/openai-responses.ts @@ -276,15 +276,23 @@ const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request: // ============================================================================= // Stream Parsing // ============================================================================= +// OpenAI Responses reports `input_tokens` (inclusive total) with a +// `cached_tokens` subset, and `output_tokens` (inclusive total) with a +// `reasoning_tokens` subset. Pass the totals through and derive the +// non-cached breakdown. const mapUsage = (usage: OpenAIResponsesUsage | null | undefined) => { if (!usage) return undefined + const cached = usage.input_tokens_details?.cached_tokens + const reasoning = usage.output_tokens_details?.reasoning_tokens + const nonCached = ProviderShared.subtractTokens(usage.input_tokens, cached) return new Usage({ inputTokens: usage.input_tokens, outputTokens: usage.output_tokens, - reasoningTokens: usage.output_tokens_details?.reasoning_tokens, - cacheReadInputTokens: usage.input_tokens_details?.cached_tokens, + nonCachedInputTokens: nonCached, + cacheReadInputTokens: cached, + reasoningTokens: reasoning, totalTokens: ProviderShared.totalTokens(usage.input_tokens, usage.output_tokens, usage.total_tokens), - native: usage, + providerMetadata: { openai: usage }, }) } diff --git a/packages/llm/src/protocols/shared.ts b/packages/llm/src/protocols/shared.ts index c931353998..3b9886553a 100644 --- a/packages/llm/src/protocols/shared.ts +++ b/packages/llm/src/protocols/shared.ts @@ -42,6 +42,13 @@ export interface ToolAccumulator { * supplied total; otherwise falls back to `inputTokens + outputTokens` only * when at least one is defined. Returns `undefined` when neither input nor * output is known so routes don't publish a misleading `0`. + * + * Under the additive `LLM.Usage` contract, `inputTokens` and `outputTokens` + * are the non-cached input and visible output only. The provider-supplied + * `total` is the source of truth when present; the computed fallback + * under-counts cache and reasoning by design and exists mainly so + * Anthropic-style providers (which don't surface a total) still get a + * sensible aggregate on the input + output axes. */ export const totalTokens = ( inputTokens: number | undefined, @@ -53,6 +60,38 @@ export const totalTokens = ( return (inputTokens ?? 0) + (outputTokens ?? 0) } +/** + * Subtract `subtrahend` from `total`, clamping to zero if the provider + * reports a non-sensical breakdown (e.g. `cached_tokens > prompt_tokens`). + * Used by protocol mappers when deriving a non-overlapping breakdown field + * from a provider's inclusive total — `nonCachedInputTokens` from + * `inputTokens - cacheReadInputTokens - cacheWriteInputTokens`. + * + * If `total` is `undefined`, returns `undefined` (we don't fabricate + * counts). If `subtrahend` is `undefined`, returns `total` unchanged. The + * provider-native breakdown stays available on `Usage.native` for debugging. + */ +export const subtractTokens = ( + total: number | undefined, + subtrahend: number | undefined, +): number | undefined => { + if (total === undefined) return undefined + if (subtrahend === undefined) return total + return Math.max(0, total - subtrahend) +} + +/** + * Sum a list of optional token counts, returning `undefined` only when + * every value is `undefined` (so we don't fabricate a `0`). Used by + * protocol mappers to derive the inclusive `inputTokens` total from a + * provider that natively reports a non-overlapping breakdown + * (e.g. Anthropic, whose `input_tokens` is already non-cached only). + */ +export const sumTokens = (...values: ReadonlyArray): number | undefined => { + if (values.every((value) => value === undefined)) return undefined + return values.reduce((acc, value) => acc + (value ?? 0), 0) +} + export const eventError = (route: string, message: string, raw?: string) => new LLMError({ module: "ProviderShared", diff --git a/packages/llm/src/schema/events.ts b/packages/llm/src/schema/events.ts index d0befe246e..6e6bb1541b 100644 --- a/packages/llm/src/schema/events.ts +++ b/packages/llm/src/schema/events.ts @@ -3,15 +3,70 @@ import { ContentBlockID, FinishReason, ProtocolID, ProviderMetadata, ResponseID, import { ModelRef } from "./options" import { ToolResultValue } from "./messages" +/** + * Token usage reported by an LLM provider. + * + * **Inclusive totals** (match AI SDK / OpenAI / LangChain convention — a + * reader from any of those ecosystems sees the number they expect): + * + * - `inputTokens` — total prompt tokens, *including* cached reads/writes. + * - `outputTokens` — total output tokens, *including* reasoning. + * - `totalTokens` — provider-supplied total, or `inputTokens + outputTokens`. + * + * **Non-overlapping breakdown** (every field is independently meaningful; + * consumers never have to subtract): + * + * - `nonCachedInputTokens` — the "fresh" portion of the prompt. + * - `cacheReadInputTokens` — input tokens served from cache. + * - `cacheWriteInputTokens` — input tokens written to cache. + * - `reasoningTokens` — subset of `outputTokens` spent on hidden reasoning. + * + * **Invariant**: `nonCachedInputTokens + cacheReadInputTokens + + * cacheWriteInputTokens = inputTokens`, and `reasoningTokens ≤ outputTokens`. + * Each protocol mapper computes whichever side it doesn't get natively, + * with `Math.max(0, …)` clamping for defense against provider bugs. Because + * every breakdown field is stored independently, downstream consumers can + * read whatever they need (cost-by-category, context-pressure, AI-SDK-style + * inclusive total) without ever subtracting — eliminating the underflow + * class of bug where a clamped difference would silently store the wrong + * value. + * + * **Semantics by provider**: + * + * - OpenAI Chat / Responses / Gemini / Bedrock: provider reports inclusive + * `inputTokens` and an inclusive `outputTokens`; mapper subtracts to + * derive the breakdown. + * - Anthropic: provider reports the breakdown natively (`input_tokens` is + * non-cached only); mapper sums to derive the inclusive `inputTokens`. + * Anthropic does *not* break extended-thinking out of `output_tokens`, so + * `reasoningTokens` is `undefined` and `outputTokens` carries the + * combined total — a documented limitation of the Anthropic API. + * + * `providerMetadata` always carries the provider's raw usage payload — + * keyed by provider name (`{ openai: ... }`, `{ anthropic: ... }`, etc.) + * — for fields we don't normalize and for billing-level audit trails. + * Matches the same escape-hatch field on `LLMEvent`. + */ export class Usage extends Schema.Class("LLM.Usage")({ inputTokens: Schema.optional(Schema.Number), outputTokens: Schema.optional(Schema.Number), - reasoningTokens: Schema.optional(Schema.Number), + nonCachedInputTokens: Schema.optional(Schema.Number), cacheReadInputTokens: Schema.optional(Schema.Number), cacheWriteInputTokens: Schema.optional(Schema.Number), + reasoningTokens: Schema.optional(Schema.Number), totalTokens: Schema.optional(Schema.Number), - native: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), -}) {} + providerMetadata: Schema.optional(ProviderMetadata), +}) { + /** + * Visible output tokens — `outputTokens` minus `reasoningTokens`, clamped + * to zero. The one place subtraction happens in this contract; the clamp + * means a provider reporting `reasoningTokens > outputTokens` produces a + * harmless zero rather than a negative that crashes downstream schemas. + */ + get visibleOutputTokens() { + return Math.max(0, (this.outputTokens ?? 0) - (this.reasoningTokens ?? 0)) + } +} export const RequestStart = Schema.Struct({ type: Schema.tag("request-start"), diff --git a/packages/llm/test/provider/anthropic-messages.test.ts b/packages/llm/test/provider/anthropic-messages.test.ts index 2f2b2a3e86..3be041c94c 100644 --- a/packages/llm/test/provider/anthropic-messages.test.ts +++ b/packages/llm/test/provider/anthropic-messages.test.ts @@ -1,6 +1,6 @@ import { describe, expect } from "bun:test" import { Effect } from "effect" -import { CacheHint, LLM, LLMError } from "../../src" +import { CacheHint, LLM, LLMError, Usage } from "../../src" import { LLMClient } from "../../src/route" import * as AnthropicMessages from "../../src/protocols/anthropic-messages" import { it } from "../lib/effect" @@ -110,10 +110,11 @@ describe("Anthropic Messages route", () => { expect(response.text).toBe("Hello!") expect(response.reasoning).toBe("thinking") expect(response.usage).toMatchObject({ - inputTokens: 5, + inputTokens: 6, outputTokens: 2, + nonCachedInputTokens: 5, cacheReadInputTokens: 1, - totalTokens: 7, + totalTokens: 8, }) expect(response.events.find((event) => event.type === "reasoning-end")).toMatchObject({ providerMetadata: { anthropic: { signature: "sig_1" } }, @@ -152,7 +153,13 @@ describe("Anthropic Messages route", () => { { type: "request-finish", reason: "tool-calls", - usage: { inputTokens: 5, outputTokens: 1, totalTokens: 6, native: { input_tokens: 5, output_tokens: 1 } }, + usage: new Usage({ + inputTokens: 5, + outputTokens: 1, + nonCachedInputTokens: 5, + totalTokens: 6, + providerMetadata: { anthropic: { input_tokens: 5, output_tokens: 1 } }, + }), }, ]) }), diff --git a/packages/llm/test/provider/gemini.test.ts b/packages/llm/test/provider/gemini.test.ts index 9de4e0dc25..e0b3864a26 100644 --- a/packages/llm/test/provider/gemini.test.ts +++ b/packages/llm/test/provider/gemini.test.ts @@ -1,6 +1,6 @@ import { describe, expect } from "bun:test" import { Effect } from "effect" -import { LLM, LLMError } from "../../src" +import { LLM, LLMError, Usage } from "../../src" import { LLMClient } from "../../src/route" import * as Gemini from "../../src/protocols/gemini" import { it } from "../lib/effect" @@ -198,9 +198,10 @@ describe("Gemini route", () => { expect(response.reasoning).toBe("thinking") expect(response.usage).toMatchObject({ inputTokens: 5, - outputTokens: 2, - reasoningTokens: 1, + outputTokens: 3, + nonCachedInputTokens: 4, cacheReadInputTokens: 1, + reasoningTokens: 1, totalTokens: 7, }) expect(response.events).toEqual([ @@ -210,20 +211,23 @@ describe("Gemini route", () => { { type: "request-finish", reason: "stop", - usage: { + usage: new Usage({ inputTokens: 5, - outputTokens: 2, - reasoningTokens: 1, + outputTokens: 3, + nonCachedInputTokens: 4, cacheReadInputTokens: 1, + reasoningTokens: 1, totalTokens: 7, - native: { - promptTokenCount: 5, - candidatesTokenCount: 2, - totalTokenCount: 7, - thoughtsTokenCount: 1, - cachedContentTokenCount: 1, + providerMetadata: { + google: { + promptTokenCount: 5, + candidatesTokenCount: 2, + totalTokenCount: 7, + thoughtsTokenCount: 1, + cachedContentTokenCount: 1, + }, }, - }, + }), }, ]) }), @@ -257,12 +261,13 @@ describe("Gemini route", () => { { type: "request-finish", reason: "tool-calls", - usage: { + usage: new Usage({ inputTokens: 5, outputTokens: 1, + nonCachedInputTokens: 5, totalTokens: 6, - native: { promptTokenCount: 5, candidatesTokenCount: 1 }, - }, + providerMetadata: { google: { promptTokenCount: 5, candidatesTokenCount: 1 } }, + }), }, ]) }), diff --git a/packages/llm/test/provider/openai-chat.test.ts b/packages/llm/test/provider/openai-chat.test.ts index 8b0dfc2894..2c692dcd7d 100644 --- a/packages/llm/test/provider/openai-chat.test.ts +++ b/packages/llm/test/provider/openai-chat.test.ts @@ -1,7 +1,7 @@ import { describe, expect } from "bun:test" import { Effect, Schema, Stream } from "effect" import { HttpClientRequest } from "effect/unstable/http" -import { LLM, LLMError } from "../../src" +import { LLM, LLMError, Usage } from "../../src" import * as Azure from "../../src/providers/azure" import * as OpenAI from "../../src/providers/openai" import * as OpenAIChat from "../../src/protocols/openai-chat" @@ -230,20 +230,23 @@ describe("OpenAI Chat route", () => { { type: "request-finish", reason: "stop", - usage: { + usage: new Usage({ inputTokens: 5, outputTokens: 2, - reasoningTokens: 0, + nonCachedInputTokens: 4, cacheReadInputTokens: 1, + reasoningTokens: 0, totalTokens: 7, - native: { - prompt_tokens: 5, - completion_tokens: 2, - total_tokens: 7, - prompt_tokens_details: { cached_tokens: 1 }, - completion_tokens_details: { reasoning_tokens: 0 }, + providerMetadata: { + openai: { + prompt_tokens: 5, + completion_tokens: 2, + total_tokens: 7, + prompt_tokens_details: { cached_tokens: 1 }, + completion_tokens_details: { reasoning_tokens: 0 }, + }, }, - }, + }), }, ]) }), diff --git a/packages/llm/test/provider/openai-responses.test.ts b/packages/llm/test/provider/openai-responses.test.ts index 5141b44cc2..2319857ed1 100644 --- a/packages/llm/test/provider/openai-responses.test.ts +++ b/packages/llm/test/provider/openai-responses.test.ts @@ -1,7 +1,7 @@ import { describe, expect } from "bun:test" import { ConfigProvider, Effect, Layer, Stream } from "effect" import { Headers, HttpClientRequest } from "effect/unstable/http" -import { LLM, LLMError } from "../../src" +import { LLM, LLMError, Usage } from "../../src" import { Auth, LLMClient, RequestExecutor, WebSocketExecutor } from "../../src/route" import * as Azure from "../../src/providers/azure" import * as OpenAI from "../../src/providers/openai" @@ -342,20 +342,23 @@ describe("OpenAI Responses route", () => { type: "request-finish", reason: "stop", providerMetadata: { openai: { responseId: "resp_1", serviceTier: "default" } }, - usage: { + usage: new Usage({ inputTokens: 5, outputTokens: 2, - reasoningTokens: 0, + nonCachedInputTokens: 4, cacheReadInputTokens: 1, + reasoningTokens: 0, totalTokens: 7, - native: { - input_tokens: 5, - output_tokens: 2, - total_tokens: 7, - input_tokens_details: { cached_tokens: 1 }, - output_tokens_details: { reasoning_tokens: 0 }, + providerMetadata: { + openai: { + input_tokens: 5, + output_tokens: 2, + total_tokens: 7, + input_tokens_details: { cached_tokens: 1 }, + output_tokens_details: { reasoning_tokens: 0 }, + }, }, - }, + }), }, ]) }), @@ -411,7 +414,13 @@ describe("OpenAI Responses route", () => { { type: "request-finish", reason: "tool-calls", - usage: { inputTokens: 5, outputTokens: 1, totalTokens: 6, native: { input_tokens: 5, output_tokens: 1 } }, + usage: new Usage({ + inputTokens: 5, + outputTokens: 1, + nonCachedInputTokens: 5, + totalTokens: 6, + providerMetadata: { openai: { input_tokens: 5, output_tokens: 1 } }, + }), }, ]) }), diff --git a/packages/llm/test/schema.test.ts b/packages/llm/test/schema.test.ts index 46eb85b075..23bd9fd9bb 100644 --- a/packages/llm/test/schema.test.ts +++ b/packages/llm/test/schema.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test" import { Schema } from "effect" -import { ContentPart, LLMEvent, LLMRequest, ModelID, ModelLimits, ModelRef, ProviderID } from "../src/schema" +import { ContentPart, LLMEvent, LLMRequest, ModelID, ModelLimits, ModelRef, ProviderID, Usage } from "../src/schema" +import { ProviderShared } from "../src/protocols/shared" const model = new ModelRef({ id: ModelID.make("fake-model"), @@ -48,3 +49,30 @@ describe("llm schema", () => { expect(ContentPart.guards.media({ type: "text", text: "hi" })).toBe(false) }) }) + +describe("LLM.Usage", () => { + test("subtractTokens clamps non-sensical breakdowns to zero", () => { + // Defense against a provider reporting cached_tokens > prompt_tokens or + // reasoning_tokens > completion_tokens — the negative would otherwise + // round-trip through the pipeline and crash strict downstream schemas. + expect(ProviderShared.subtractTokens(5, 3)).toBe(2) + expect(ProviderShared.subtractTokens(5, 10)).toBe(0) + expect(ProviderShared.subtractTokens(5, undefined)).toBe(5) + expect(ProviderShared.subtractTokens(undefined, 3)).toBeUndefined() + expect(ProviderShared.subtractTokens(undefined, undefined)).toBeUndefined() + }) + + test("sumTokens returns undefined only when every input is undefined", () => { + expect(ProviderShared.sumTokens(1, 2, 3)).toBe(6) + expect(ProviderShared.sumTokens(1, undefined, 3)).toBe(4) + expect(ProviderShared.sumTokens(undefined, undefined, undefined)).toBeUndefined() + expect(ProviderShared.sumTokens()).toBeUndefined() + }) + + test("visibleOutputTokens clamps reasoning > output to zero", () => { + expect(new Usage({ outputTokens: 10, reasoningTokens: 4 }).visibleOutputTokens).toBe(6) + expect(new Usage({ outputTokens: 10 }).visibleOutputTokens).toBe(10) + expect(new Usage({ outputTokens: 4, reasoningTokens: 10 }).visibleOutputTokens).toBe(0) + expect(new Usage({}).visibleOutputTokens).toBe(0) + }) +}) From ddc02c2893b1a08888df5a662d96fbea8f969b78 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sun, 10 May 2026 21:53:50 -0400 Subject: [PATCH 172/189] Drop synchronous SyncEvent facades (#26789) --- .../opencode/src/control-plane/workspace.ts | 2 +- packages/opencode/src/sync/index.ts | 43 ++++++------------- packages/opencode/test/sync/index.test.ts | 25 ++++++++++- 3 files changed, 39 insertions(+), 31 deletions(-) diff --git a/packages/opencode/src/control-plane/workspace.ts b/packages/opencode/src/control-plane/workspace.ts index 00353dcca3..e7e65f8901 100644 --- a/packages/opencode/src/control-plane/workspace.ts +++ b/packages/opencode/src/control-plane/workspace.ts @@ -641,7 +641,7 @@ export const layer = Layer.effect( // "claim" this session so any future events coming from // the old workspace are ignored - SyncEvent.claim(input.sessionID, input.workspaceID ?? previous.projectID) + yield* sync.claim(input.sessionID, input.workspaceID ?? previous.projectID) } } diff --git a/packages/opencode/src/sync/index.ts b/packages/opencode/src/sync/index.ts index 931f5e84c8..5c29101b6c 100644 --- a/packages/opencode/src/sync/index.ts +++ b/packages/opencode/src/sync/index.ts @@ -10,7 +10,6 @@ import { EventID } from "./schema" import { Flag } from "@opencode-ai/core/flag/flag" import { Context, Effect, Layer, Schema as EffectSchema } from "effect" import type { DeepMutable } from "@opencode-ai/core/schema" -import { makeRuntime } from "@/effect/run-service" import { serviceUse } from "@/effect/service-use" import { InstanceState } from "@/effect/instance-state" @@ -63,6 +62,7 @@ export interface Interface { options?: { publish: boolean; ownerID?: string }, ) => Effect.Effect readonly remove: (aggregateID: string) => Effect.Effect + readonly claim: (aggregateID: string, ownerID: string) => Effect.Effect } export class Service extends Context.Service()("@opencode/SyncEvent") {} @@ -175,11 +175,24 @@ export const layer = Layer.effect(Service)( }) }) + const claim: Interface["claim"] = Effect.fn("SyncEvent.claim")((aggregateID, ownerID) => + Effect.sync(() => + Database.use((db) => + db + .update(EventSequenceTable) + .set({ owner_id: ownerID }) + .where(eq(EventSequenceTable.aggregate_id, aggregateID)) + .run(), + ), + ), + ) + return Service.of({ run, replay, replayAll, remove, + claim, }) }), ) @@ -188,8 +201,6 @@ export const defaultLayer = layer export const use = serviceUse(Service) -const runtime = makeRuntime(Service, defaultLayer) - export const registry = new Map() let projectors: Map | undefined const versions = new Map() @@ -336,32 +347,6 @@ function process( }) } -export function replay(event: SerializedEvent, options?: { publish: boolean; ownerID?: string }) { - return runtime.runSync((sync) => sync.replay(event, options)) -} - -export function replayAll(events: SerializedEvent[], options?: { publish: boolean; ownerID?: string }) { - return runtime.runSync((sync) => sync.replayAll(events, options)) -} - -export function run(def: Def, data: Event["data"], options?: { publish?: boolean }) { - return runtime.runSync((sync) => sync.run(def, data, options)) -} - -export function remove(aggregateID: string) { - return runtime.runSync((sync) => sync.remove(aggregateID)) -} - -export function claim(aggregateID: string, ownerID: string) { - Database.use((db) => - db - .update(EventSequenceTable) - .set({ owner_id: ownerID }) - .where(eq(EventSequenceTable.aggregate_id, aggregateID)) - .run(), - ) -} - export function effectPayloads() { return registry .entries() diff --git a/packages/opencode/test/sync/index.test.ts b/packages/opencode/test/sync/index.test.ts index 0986b39044..10f593a571 100644 --- a/packages/opencode/test/sync/index.test.ts +++ b/packages/opencode/test/sync/index.test.ts @@ -4,7 +4,7 @@ import { Effect, Layer, Schema } from "effect" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Bus } from "../../src/bus" import { SyncEvent } from "../../src/sync" -import { Database } from "@/storage/db" +import { Database, eq } from "@/storage/db" import { EventSequenceTable, EventTable } from "../../src/sync/event.sql" import { MessageID } from "../../src/session/schema" import { Flag } from "@opencode-ai/core/flag/flag" @@ -323,5 +323,28 @@ describe("SyncEvent", () => { }), ), ) + + it.live( + "claim updates the event sequence owner", + provideTmpdirInstance(() => + Effect.gen(function* () { + const { Created } = setup() + const id = MessageID.ascending() + + yield* SyncEvent.use.run(Created, { id, name: "claimed" }, { publish: false }) + yield* SyncEvent.use.claim(id, "owner-1") + yield* SyncEvent.use.claim(id, "owner-2") + + const row = Database.use((db) => + db + .select({ seq: EventSequenceTable.seq, ownerID: EventSequenceTable.owner_id }) + .from(EventSequenceTable) + .where(eq(EventSequenceTable.aggregate_id, id)) + .get(), + ) + expect(row).toEqual({ seq: 0, ownerID: "owner-2" }) + }), + ), + ) }) }) From 3c6be604ec9d6a2c3a60015946d4b9c0c3dd1da9 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Mon, 11 May 2026 01:54:13 +0000 Subject: [PATCH 173/189] chore: update nix node_modules hashes --- nix/hashes.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/nix/hashes.json b/nix/hashes.json index 4244e0c0e7..558264474a 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-baGxh+hk/rPhg0xI/OdMDz6dPwncgercYNBdTPnLX9o=", - "aarch64-linux": "sha256-VTWKq679B3Q4ZnAoQzC4VSCYA09wWecNJ+JajvjNB1U=", - "aarch64-darwin": "sha256-orf2zIBMTiiQrt/6qCzE+o0oKhv6u8zXF9DH1Bo3lbo=", - "x86_64-darwin": "sha256-1MZC1fadRoY4lhkmjlcUQTLYH9Q8pDI1bxd5f94f1xU=" + "x86_64-linux": "sha256-LTo0ohJN5hBOubqFLVL45unVEIwBDkACNVv64k2nkq4=", + "aarch64-linux": "sha256-oYKY2UJRWG2fhufW4aGujX/Poou93023ZF2Fu7oyYOw=", + "aarch64-darwin": "sha256-618c9vqKN5I+no1nzylctAiWvqw7Bsa+bzSTNwXmSQA=", + "x86_64-darwin": "sha256-1ro3/gH0FC0TWXwWT+k675xR396GE98HpnBEeuD4t6k=" } } From 1c49b2ed67fbc8c8ecf373972e615af60bf0201e Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Mon, 11 May 2026 01:55:22 +0000 Subject: [PATCH 174/189] chore: generate --- packages/llm/src/protocols/gemini.ts | 4 +--- packages/llm/src/protocols/shared.ts | 5 +---- 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/packages/llm/src/protocols/gemini.ts b/packages/llm/src/protocols/gemini.ts index ff6f3f83ec..0ee88f3beb 100644 --- a/packages/llm/src/protocols/gemini.ts +++ b/packages/llm/src/protocols/gemini.ts @@ -294,9 +294,7 @@ const mapUsage = (usage: GeminiUsage | undefined) => { // when the visible component is reported — otherwise we'd fabricate an // inclusive number from a partial breakdown. const outputTokens = - usage.candidatesTokenCount !== undefined - ? usage.candidatesTokenCount + (usage.thoughtsTokenCount ?? 0) - : undefined + usage.candidatesTokenCount !== undefined ? usage.candidatesTokenCount + (usage.thoughtsTokenCount ?? 0) : undefined return new Usage({ inputTokens: usage.promptTokenCount, outputTokens, diff --git a/packages/llm/src/protocols/shared.ts b/packages/llm/src/protocols/shared.ts index 3b9886553a..b8067bbe90 100644 --- a/packages/llm/src/protocols/shared.ts +++ b/packages/llm/src/protocols/shared.ts @@ -71,10 +71,7 @@ export const totalTokens = ( * counts). If `subtrahend` is `undefined`, returns `total` unchanged. The * provider-native breakdown stays available on `Usage.native` for debugging. */ -export const subtractTokens = ( - total: number | undefined, - subtrahend: number | undefined, -): number | undefined => { +export const subtractTokens = (total: number | undefined, subtrahend: number | undefined): number | undefined => { if (total === undefined) return undefined if (subtrahend === undefined) return total return Math.max(0, total - subtrahend) From 6f1f5944ce9d5ccf16bb2bf50c90c5eb0f02cc4b Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sun, 10 May 2026 21:57:18 -0400 Subject: [PATCH 175/189] Delete unused opencode Zod helpers (#26793) --- packages/core/src/effect-zod.ts | 2 +- packages/opencode/src/util/fn.ts | 21 --------------------- packages/opencode/src/util/update-schema.ts | 13 ------------- 3 files changed, 1 insertion(+), 35 deletions(-) delete mode 100644 packages/opencode/src/util/fn.ts delete mode 100644 packages/opencode/src/util/update-schema.ts diff --git a/packages/core/src/effect-zod.ts b/packages/core/src/effect-zod.ts index 1c88712d7d..42d89ec7d5 100644 --- a/packages/core/src/effect-zod.ts +++ b/packages/core/src/effect-zod.ts @@ -36,7 +36,7 @@ export function zod(schema: S): z.ZodType` extends * `object` via the brand and gets walked into the prototype by `DeepPartial`, - * `updateSchema`, etc.), and zod's inference through `z.ZodType` + * mapped-schema helpers, and zod's inference through `z.ZodType` * wrappers also can't reconstruct `T` cleanly. Consumers that care about the * post-`.omit()` shape should cast `c.req.valid(...)` to the expected type. */ diff --git a/packages/opencode/src/util/fn.ts b/packages/opencode/src/util/fn.ts deleted file mode 100644 index c75fc1bb54..0000000000 --- a/packages/opencode/src/util/fn.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { z } from "zod" - -export function fn(schema: T, cb: (input: z.infer) => Result) { - const result = (input: z.infer) => { - let parsed - try { - parsed = schema.parse(input) - } catch (e) { - console.trace("schema validation failure stack trace:") - if (e instanceof z.ZodError) { - console.error("schema validation issues:", JSON.stringify(e.issues, null, 2)) - } - throw e - } - - return cb(parsed) - } - result.force = (input: z.infer) => cb(input) - result.schema = schema - return result -} diff --git a/packages/opencode/src/util/update-schema.ts b/packages/opencode/src/util/update-schema.ts deleted file mode 100644 index f2246ece33..0000000000 --- a/packages/opencode/src/util/update-schema.ts +++ /dev/null @@ -1,13 +0,0 @@ -import z from "zod" - -export function updateSchema(schema: z.ZodObject) { - const next = {} as { - [K in keyof T]: z.ZodOptional> - } - - for (const [k, v] of Object.entries(schema.required().shape) as [keyof T & string, z.ZodTypeAny][]) { - next[k] = v.nullable() as unknown as (typeof next)[typeof k] - } - - return z.object(next) -} From ce66b191d112b271e752b6243d4341989e6d572d Mon Sep 17 00:00:00 2001 From: opencode Date: Mon, 11 May 2026 02:07:48 +0000 Subject: [PATCH 176/189] sync release versions for v1.14.48 --- bun.lock | 34 +++++++++++++------------- packages/app/package.json | 2 +- packages/console/app/package.json | 2 +- packages/console/core/package.json | 2 +- packages/console/function/package.json | 2 +- packages/console/mail/package.json | 2 +- packages/core/package.json | 2 +- packages/desktop/package.json | 2 +- packages/enterprise/package.json | 2 +- packages/extensions/zed/extension.toml | 12 ++++----- packages/function/package.json | 2 +- packages/http-recorder/package.json | 2 +- packages/llm/package.json | 2 +- packages/opencode/package.json | 2 +- packages/plugin/package.json | 2 +- packages/sdk/js/package.json | 2 +- packages/slack/package.json | 2 +- packages/ui/package.json | 2 +- packages/web/package.json | 2 +- sdks/vscode/package.json | 2 +- 20 files changed, 41 insertions(+), 41 deletions(-) diff --git a/bun.lock b/bun.lock index 8cd3ce0ff2..c3758e2326 100644 --- a/bun.lock +++ b/bun.lock @@ -29,7 +29,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "1.14.47", + "version": "1.14.48", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/core": "workspace:*", @@ -85,7 +85,7 @@ }, "packages/console/app": { "name": "@opencode-ai/console-app", - "version": "1.14.47", + "version": "1.14.48", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@ibm/plex": "6.4.1", @@ -120,7 +120,7 @@ }, "packages/console/core": { "name": "@opencode-ai/console-core", - "version": "1.14.47", + "version": "1.14.48", "dependencies": { "@aws-sdk/client-sts": "3.782.0", "@jsx-email/render": "1.1.1", @@ -147,7 +147,7 @@ }, "packages/console/function": { "name": "@opencode-ai/console-function", - "version": "1.14.47", + "version": "1.14.48", "dependencies": { "@ai-sdk/anthropic": "3.0.64", "@ai-sdk/openai": "3.0.48", @@ -171,7 +171,7 @@ }, "packages/console/mail": { "name": "@opencode-ai/console-mail", - "version": "1.14.47", + "version": "1.14.48", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", @@ -195,7 +195,7 @@ }, "packages/core": { "name": "@opencode-ai/core", - "version": "1.14.47", + "version": "1.14.48", "bin": { "opencode": "./bin/opencode", }, @@ -229,7 +229,7 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "1.14.47", + "version": "1.14.48", "dependencies": { "drizzle-orm": "catalog:", "effect": "catalog:", @@ -283,7 +283,7 @@ }, "packages/enterprise": { "name": "@opencode-ai/enterprise", - "version": "1.14.47", + "version": "1.14.48", "dependencies": { "@opencode-ai/core": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -313,7 +313,7 @@ }, "packages/function": { "name": "@opencode-ai/function", - "version": "1.14.47", + "version": "1.14.48", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "catalog:", @@ -329,7 +329,7 @@ }, "packages/http-recorder": { "name": "@opencode-ai/http-recorder", - "version": "1.14.47", + "version": "1.14.48", "dependencies": { "@effect/platform-node": "catalog:", "effect": "catalog:", @@ -342,7 +342,7 @@ }, "packages/llm": { "name": "@opencode-ai/llm", - "version": "1.14.47", + "version": "1.14.48", "dependencies": { "@smithy/eventstream-codec": "4.2.14", "@smithy/util-utf8": "4.2.2", @@ -360,7 +360,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.14.47", + "version": "1.14.48", "bin": { "opencode": "./bin/opencode", }, @@ -496,7 +496,7 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.14.47", + "version": "1.14.48", "dependencies": { "@opencode-ai/sdk": "workspace:*", "effect": "catalog:", @@ -534,7 +534,7 @@ }, "packages/sdk/js": { "name": "@opencode-ai/sdk", - "version": "1.14.47", + "version": "1.14.48", "dependencies": { "cross-spawn": "catalog:", }, @@ -549,7 +549,7 @@ }, "packages/slack": { "name": "@opencode-ai/slack", - "version": "1.14.47", + "version": "1.14.48", "dependencies": { "@opencode-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -584,7 +584,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.14.47", + "version": "1.14.48", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/core": "workspace:*", @@ -633,7 +633,7 @@ }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.14.47", + "version": "1.14.48", "dependencies": { "@astrojs/cloudflare": "12.6.3", "@astrojs/markdown-remark": "6.3.1", diff --git a/packages/app/package.json b/packages/app/package.json index dc6c2b2cd4..9eb4083725 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "1.14.47", + "version": "1.14.48", "description": "", "type": "module", "exports": { diff --git a/packages/console/app/package.json b/packages/console/app/package.json index 5338115728..7e736ca775 100644 --- a/packages/console/app/package.json +++ b/packages/console/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-app", - "version": "1.14.47", + "version": "1.14.48", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/console/core/package.json b/packages/console/core/package.json index 9621edae80..986103ece3 100644 --- a/packages/console/core/package.json +++ b/packages/console/core/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/console-core", - "version": "1.14.47", + "version": "1.14.48", "private": true, "type": "module", "license": "MIT", diff --git a/packages/console/function/package.json b/packages/console/function/package.json index 76b7f74ba2..41487f845a 100644 --- a/packages/console/function/package.json +++ b/packages/console/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-function", - "version": "1.14.47", + "version": "1.14.48", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/console/mail/package.json b/packages/console/mail/package.json index 9844662d55..6c101f051e 100644 --- a/packages/console/mail/package.json +++ b/packages/console/mail/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-mail", - "version": "1.14.47", + "version": "1.14.48", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", diff --git a/packages/core/package.json b/packages/core/package.json index 6cd7ecefeb..e2ffa31d8d 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.14.47", + "version": "1.14.48", "name": "@opencode-ai/core", "type": "module", "license": "MIT", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 99fe7422b0..0dfcd4544b 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop", "private": true, - "version": "1.14.47", + "version": "1.14.48", "type": "module", "license": "MIT", "homepage": "https://opencode.ai", diff --git a/packages/enterprise/package.json b/packages/enterprise/package.json index e738498984..88e5406cbf 100644 --- a/packages/enterprise/package.json +++ b/packages/enterprise/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/enterprise", - "version": "1.14.47", + "version": "1.14.48", "private": true, "type": "module", "license": "MIT", diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml index 03e179d170..9b77cf8b9f 100644 --- a/packages/extensions/zed/extension.toml +++ b/packages/extensions/zed/extension.toml @@ -1,7 +1,7 @@ id = "opencode" name = "OpenCode" description = "The open source coding agent." -version = "1.14.47" +version = "1.14.48" schema_version = 1 authors = ["Anomaly"] repository = "https://github.com/anomalyco/opencode" @@ -11,26 +11,26 @@ name = "OpenCode" icon = "./icons/opencode.svg" [agent_servers.opencode.targets.darwin-aarch64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.47/opencode-darwin-arm64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.48/opencode-darwin-arm64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.darwin-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.47/opencode-darwin-x64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.48/opencode-darwin-x64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-aarch64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.47/opencode-linux-arm64.tar.gz" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.48/opencode-linux-arm64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.47/opencode-linux-x64.tar.gz" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.48/opencode-linux-x64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.windows-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.47/opencode-windows-x64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.48/opencode-windows-x64.zip" cmd = "./opencode.exe" args = ["acp"] diff --git a/packages/function/package.json b/packages/function/package.json index 991a694372..b644ca7df5 100644 --- a/packages/function/package.json +++ b/packages/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/function", - "version": "1.14.47", + "version": "1.14.48", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/http-recorder/package.json b/packages/http-recorder/package.json index ef97dcc86f..18ea8b1759 100644 --- a/packages/http-recorder/package.json +++ b/packages/http-recorder/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.14.47", + "version": "1.14.48", "name": "@opencode-ai/http-recorder", "type": "module", "license": "MIT", diff --git a/packages/llm/package.json b/packages/llm/package.json index af206a18b7..4070681cf7 100644 --- a/packages/llm/package.json +++ b/packages/llm/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.14.47", + "version": "1.14.48", "name": "@opencode-ai/llm", "type": "module", "license": "MIT", diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 1e26e2be9b..e9b811fc5e 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.14.47", + "version": "1.14.48", "name": "opencode", "type": "module", "license": "MIT", diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 28aae738d7..a3ce97368d 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/plugin", - "version": "1.14.47", + "version": "1.14.48", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index fa3064c1d9..65fbf98f0e 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/sdk", - "version": "1.14.47", + "version": "1.14.48", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/slack/package.json b/packages/slack/package.json index e68b8aa7eb..55e09f5d36 100644 --- a/packages/slack/package.json +++ b/packages/slack/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/slack", - "version": "1.14.47", + "version": "1.14.48", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/ui/package.json b/packages/ui/package.json index a32f683d6b..12441c8d09 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/ui", - "version": "1.14.47", + "version": "1.14.48", "type": "module", "license": "MIT", "exports": { diff --git a/packages/web/package.json b/packages/web/package.json index 6207038e70..ec542077bf 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -2,7 +2,7 @@ "name": "@opencode-ai/web", "type": "module", "license": "MIT", - "version": "1.14.47", + "version": "1.14.48", "scripts": { "dev": "astro dev", "dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev", diff --git a/sdks/vscode/package.json b/sdks/vscode/package.json index 8e8728fe6a..01737ee4e5 100644 --- a/sdks/vscode/package.json +++ b/sdks/vscode/package.json @@ -2,7 +2,7 @@ "name": "opencode", "displayName": "opencode", "description": "opencode for VS Code", - "version": "1.14.47", + "version": "1.14.48", "publisher": "sst-dev", "repository": { "type": "git", From 942630eb4a40294e4c59262c836dc849098667a8 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sun, 10 May 2026 22:09:55 -0400 Subject: [PATCH 177/189] feat(llm): cache-policy auto-placement (#26786) --- packages/llm/README.md | 130 +++++++++ packages/llm/src/cache-policy.ts | 120 ++++++++ packages/llm/src/route/client.ts | 3 +- packages/llm/src/schema/messages.ts | 4 +- packages/llm/src/schema/options.ts | 32 +++ packages/llm/test/cache-policy.test.ts | 262 ++++++++++++++++++ ...ache-control-on-identical-second-call.json | 53 ++++ ...nttokencount-on-identical-second-call.json | 51 ++++ ...ached-tokens-on-identical-second-call.json | 51 ++++ .../anthropic-messages-cache.recorded.test.ts | 7 +- .../bedrock-converse-cache.recorded.test.ts | 3 + .../provider/gemini-cache.recorded.test.ts | 7 +- .../openai-responses-cache.recorded.test.ts | 3 + 13 files changed, 721 insertions(+), 5 deletions(-) create mode 100644 packages/llm/README.md create mode 100644 packages/llm/src/cache-policy.ts create mode 100644 packages/llm/test/cache-policy.test.ts create mode 100644 packages/llm/test/fixtures/recordings/anthropic-messages-cache/writes-then-reads-cache-control-on-identical-second-call.json create mode 100644 packages/llm/test/fixtures/recordings/gemini-cache/reports-cachedcontenttokencount-on-identical-second-call.json create mode 100644 packages/llm/test/fixtures/recordings/openai-responses-cache/reports-cached-tokens-on-identical-second-call.json diff --git a/packages/llm/README.md b/packages/llm/README.md new file mode 100644 index 0000000000..434ce369f8 --- /dev/null +++ b/packages/llm/README.md @@ -0,0 +1,130 @@ +# @opencode-ai/llm + +Schema-first LLM core for opencode. One typed request, response, event, and tool language; provider quirks live in adapters, not in calling code. + +```ts +import { Effect } from "effect" +import { LLM, LLMClient } from "@opencode-ai/llm" +import { OpenAI } from "@opencode-ai/llm/providers" + +const model = OpenAI.model("gpt-4o-mini", { apiKey: process.env.OPENAI_API_KEY }) + +const request = LLM.request({ + model, + system: "You are concise.", + prompt: "Say hello in one short sentence.", + generation: { maxTokens: 40 }, +}) + +const program = Effect.gen(function* () { + const response = yield* LLMClient.generate(request) + console.log(response.text) +}) +``` + +Run `LLMClient.stream(request)` instead of `generate` when you want incremental `LLMEvent`s. The event stream is provider-neutral — same shape across OpenAI Chat, OpenAI Responses, Anthropic Messages, Gemini, Bedrock Converse, and any OpenAI-compatible deployment. + +## Public API + +- **`LLM.request({...})`** — build a provider-neutral `LLMRequest`. Accepts ergonomic inputs (`system: string`, `prompt: string`) that normalize into the canonical Schema classes. +- **`LLM.generate` / `LLM.stream`** — re-exported from `LLMClient` for one-import use. +- **`LLM.user(...)` / `LLM.assistant(...)` / `LLM.toolMessage(...)`** — message constructors. +- **`LLM.toolCall(...)` / `LLM.toolResult(...)` / `LLM.toolDefinition(...)`** — tool-related parts. +- **`LLMClient.prepare(request)`** — compile a request through protocol body construction, validation, and HTTP preparation without sending. Useful for inspection and testing. +- **`LLMEvent.is.*`** — typed guards (`is.text`, `is.toolCall`, `is.requestFinish`, …) for filtering streams. + +## Caching + +Prompt caching is unified across providers. Mark content with a `CacheHint` and each protocol translates it to its wire format (`cache_control` on Anthropic, `cachePoint` on Bedrock; OpenAI's implicit caching needs no markers). + +### Auto placement + +The simplest path is `cache: "auto"` on the request: + +```ts +LLM.request({ + model, + system, + messages, + tools, + cache: "auto", +}) +``` + +`"auto"` places three breakpoints — last tool definition, last system part, latest user message. The last-user-message boundary is the load-bearing detail: in a tool-use loop, a single user turn expands into many assistant/tool round-trips, all sharing that prefix. Caching at that boundary lets every intra-turn API call hit. + +On OpenAI and Gemini `"auto"` is a no-op (their wire formats don't accept inline markers — both use implicit caching). On Anthropic and Bedrock it emits provider-native cache markers. + +### Granular policy + +```ts +cache: { + tools?: boolean, + system?: boolean, + messages?: "latest-user-message" | "latest-assistant" | { tail: number }, + ttlSeconds?: number, // ≥ 3600 → 1h on Anthropic/Bedrock; else 5m +} +``` + +### Manual hints + +Inline `CacheHint` on any text / system / tool / tool-result part overrides automatic placement. The auto policy preserves manual hints; it only fills gaps. + +```ts +LLM.request({ + model, + system: [ + { type: "text", text: "stable system prompt", cache: { type: "ephemeral" } }, + ], + ... +}) +``` + +### Provider behavior table + +| Protocol | `cache: "auto"` | +|---|---| +| Anthropic Messages | emits up to 3 `cache_control` markers (4-breakpoint cap enforced) | +| Bedrock Converse | emits up to 3 `cachePoint` blocks (4-breakpoint cap enforced) | +| OpenAI Chat / Responses | no-op (implicit caching above 1024 tokens) | +| Gemini | no-op (implicit caching on 2.5+; explicit `CachedContent` is out-of-band) | + +Normalized cache usage is read back into `response.usage.cacheReadInputTokens` and `cacheWriteInputTokens` across every provider. + +## Providers + +Each provider exports a `model(...)` helper that records identity, protocol, capabilities, auth, and defaults. + +```ts +import { Anthropic } from "@opencode-ai/llm/providers" + +const model = Anthropic.model("claude-sonnet-4-6", { + apiKey: process.env.ANTHROPIC_API_KEY, +}) +``` + +Included providers: OpenAI, Anthropic, Google (Gemini), Amazon Bedrock, Azure OpenAI, Cloudflare, GitHub Copilot, OpenRouter, xAI, plus generic OpenAI-compatible helpers for DeepSeek, Cerebras, Groq, Fireworks, Together, etc. + +## Provider options & HTTP overlays + +Three escape hatches in order of stability: + +1. **`generation`** — portable knobs (`maxTokens`, `temperature`, `topP`, `topK`, penalties, seed, stop). +2. **`providerOptions: { : {...} }`** — typed-at-the-facade provider-specific knobs (OpenAI `promptCacheKey`, Anthropic `thinking`, Gemini `thinkingConfig`, OpenRouter routing). +3. **`http: { body, headers, query }`** — last-resort serializable overlays merged into the final HTTP request. Reach for this only when a stable typed path doesn't yet exist. + +Model-level defaults are overridden by request-level values for each axis. + +## Routes + +Adding a new model or deployment is usually 5–15 lines using `Route.make({ protocol, transport, ... })`. The four orthogonal pieces are protocol (body construction + stream parsing), transport (endpoint + auth + framing + encoding), defaults, and capabilities. See `AGENTS.md` for the architectural detail. + +## Effect + +This package is built on Effect. Public methods return `Effect` or `Stream`; provide `LLMClient.layer` (the default registers every shipped route) for runtime dispatch. The example at `example/tutorial.ts` is a runnable walkthrough. + +## See also + +- `AGENTS.md` — architecture, route construction, contributor guide +- `example/tutorial.ts` — runnable end-to-end walkthrough +- `test/provider/*.test.ts` — fixture-first protocol tests; `*.recorded.test.ts` files cover live cassettes diff --git a/packages/llm/src/cache-policy.ts b/packages/llm/src/cache-policy.ts new file mode 100644 index 0000000000..46d924ab52 --- /dev/null +++ b/packages/llm/src/cache-policy.ts @@ -0,0 +1,120 @@ +// Apply an `LLMRequest.cache` policy by injecting `CacheHint`s onto the parts +// the policy designates. Runs once at compile time, before the per-protocol +// body builder, so the existing inline-hint lowering path handles the rest. +// +// The default `"auto"` shape places one breakpoint at the last tool definition, +// one at the last system part, and one at the latest user message. This +// matches what production agent harnesses (LangChain's caching middleware, +// kern-ai's 10x cost-reduction playbook) converge on for tool-use loops: the +// latest user message stays put while a single turn explodes into many +// assistant/tool round-trips, so caching at that boundary lets every +// intra-turn API call hit the prefix. +// +// Manual `cache: CacheHint` placements on individual parts are preserved — +// this function only fills gaps the caller left empty. +import { CacheHint, type CachePolicy, type CachePolicyObject } from "./schema/options" +import { LLMRequest, Message, ToolDefinition, type ContentPart } from "./schema/messages" + +const AUTO: CachePolicyObject = { + tools: true, + system: true, + messages: "latest-user-message", +} + +const NONE: CachePolicyObject = {} + +// Resolution rules: +// - undefined → "none" (opt-in default so the policy never changes wire +// shape for existing callers; downstream code can flip to +// `cache: "auto"` once they audit the placement choices). +// - "auto" → the recommended policy: tools + system + latest user msg. +// - "none" → no auto placement; manual `CacheHint`s still flow. +// - object form → exactly what the caller asked for. +const resolve = (policy: CachePolicy | undefined): CachePolicyObject => { + if (policy === undefined || policy === "none") return NONE + if (policy === "auto") return AUTO + return policy +} + +// Protocols whose wire format ignores inline cache markers (OpenAI's implicit +// prefix caching, Gemini's implicit + out-of-band CachedContent). Skip the +// whole policy pass for these — emitting hints would be harmless but pointless. +const RESPECTS_INLINE_HINTS = new Set(["anthropic-messages", "bedrock-converse"]) + +const makeHint = (ttlSeconds: number | undefined): CacheHint => + ttlSeconds !== undefined ? new CacheHint({ type: "ephemeral", ttlSeconds }) : new CacheHint({ type: "ephemeral" }) + +const markLastTool = ( + tools: ReadonlyArray, + hint: CacheHint, +): ReadonlyArray => { + if (tools.length === 0) return tools + const last = tools.length - 1 + if (tools[last]!.cache) return tools + return tools.map((tool, i) => (i === last ? new ToolDefinition({ ...tool, cache: hint }) : tool)) +} + +const markLastSystem = (system: LLMRequest["system"], hint: CacheHint): LLMRequest["system"] => { + if (system.length === 0) return system + const last = system.length - 1 + if (system[last]!.cache) return system + return system.map((part, i) => (i === last ? { ...part, cache: hint } : part)) +} + +const lastIndexOfRole = (messages: ReadonlyArray, role: Message["role"]): number => + messages.findLastIndex((m) => m.role === role) + +// Mark the last text part of `messages[index]`. If no text part exists, mark +// the last content part regardless of type — that's the breakpoint position +// in tool-result-only messages too. +const markMessageAt = ( + messages: ReadonlyArray, + index: number, + hint: CacheHint, +): ReadonlyArray => { + if (index < 0 || index >= messages.length) return messages + const target = messages[index]! + if (target.content.length === 0) return messages + const lastTextIndex = target.content.findLastIndex((part) => part.type === "text") + const markAt = lastTextIndex >= 0 ? lastTextIndex : target.content.length - 1 + const existing = target.content[markAt]! + if ("cache" in existing && existing.cache) return messages + const nextContent = target.content.map((part, i) => + i === markAt ? ({ ...part, cache: hint } as ContentPart) : part, + ) + const next = new Message({ ...target, content: nextContent }) + // Single pass over `messages`, substituting the one updated entry. Long + // conversations call this on every request, so avoid `.map()` here — its + // closure dispatch and identity copies show up in profiling. + const result = messages.slice() + result[index] = next + return result +} + +const markMessages = ( + messages: ReadonlyArray, + strategy: NonNullable, + hint: CacheHint, +): ReadonlyArray => { + if (messages.length === 0) return messages + if (strategy === "latest-user-message") return markMessageAt(messages, lastIndexOfRole(messages, "user"), hint) + if (strategy === "latest-assistant") return markMessageAt(messages, lastIndexOfRole(messages, "assistant"), hint) + const start = Math.max(0, messages.length - strategy.tail) + let next = messages + for (let i = start; i < messages.length; i++) next = markMessageAt(next, i, hint) + return next +} + +export const applyCachePolicy = (request: LLMRequest): LLMRequest => { + if (!RESPECTS_INLINE_HINTS.has(request.model.route)) return request + const policy = resolve(request.cache) + if (!policy.tools && !policy.system && !policy.messages) return request + + const hint = makeHint(policy.ttlSeconds) + const tools = policy.tools ? markLastTool(request.tools, hint) : request.tools + const system = policy.system ? markLastSystem(request.system, hint) : request.system + const messages = policy.messages ? markMessages(request.messages, policy.messages, hint) : request.messages + + if (tools === request.tools && system === request.system && messages === request.messages) return request + return LLMRequest.update(request, { tools, system, messages }) +} diff --git a/packages/llm/src/route/client.ts b/packages/llm/src/route/client.ts index 734eedff21..2d9de2fd39 100644 --- a/packages/llm/src/route/client.ts +++ b/packages/llm/src/route/client.ts @@ -8,6 +8,7 @@ import type { Transport, TransportRuntime } from "./transport" import { WebSocketExecutor } from "./transport" import type { Service as WebSocketExecutorService } from "./transport/websocket" import type { Protocol } from "./protocol" +import { applyCachePolicy } from "../cache-policy" import * as ProviderShared from "../protocols/shared" import * as ToolRuntime from "../tool-runtime" import type { Tools } from "../tool" @@ -400,7 +401,7 @@ export function make( // validated provider body plus transport-private prepared data, but does not // execute transport. const compile = Effect.fn("LLM.compile")(function* (request: LLMRequest) { - const resolved = resolveRequestOptions(request) + const resolved = applyCachePolicy(resolveRequestOptions(request)) const route = registeredRoute(resolved.model.route) if (!route) return yield* noRoute(resolved.model) diff --git a/packages/llm/src/schema/messages.ts b/packages/llm/src/schema/messages.ts index cc6b89a2c7..c38a66d33d 100644 --- a/packages/llm/src/schema/messages.ts +++ b/packages/llm/src/schema/messages.ts @@ -1,6 +1,6 @@ import { Schema } from "effect" import { JsonSchema, MessageRole, ProviderMetadata } from "./ids" -import { CacheHint, GenerationOptions, HttpOptions, ModelRef, ProviderOptions } from "./options" +import { CacheHint, CachePolicy, GenerationOptions, HttpOptions, ModelRef, ProviderOptions } from "./options" const isRecord = (value: unknown): value is Record => typeof value === "object" && value !== null && !Array.isArray(value) @@ -206,6 +206,7 @@ export class LLMRequest extends Schema.Class("LLM.Request")({ providerOptions: Schema.optional(ProviderOptions), http: Schema.optional(HttpOptions), responseFormat: Schema.optional(ResponseFormat), + cache: Schema.optional(CachePolicy), metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), }) {} @@ -223,6 +224,7 @@ export namespace LLMRequest { providerOptions: request.providerOptions, http: request.http, responseFormat: request.responseFormat, + cache: request.cache, metadata: request.metadata, }) diff --git a/packages/llm/src/schema/options.ts b/packages/llm/src/schema/options.ts index 9a618aa8ae..cead47ee44 100644 --- a/packages/llm/src/schema/options.ts +++ b/packages/llm/src/schema/options.ts @@ -200,3 +200,35 @@ export class CacheHint extends Schema.Class("LLM.CacheHint")({ type: Schema.Literals(["ephemeral", "persistent"]), ttlSeconds: Schema.optional(Schema.Number), }) {} + +// Auto-placement policy for prompt caching. The protocol-neutral lowering step +// reads this and injects `CacheHint`s at the configured boundaries; the +// per-protocol body builders then translate those hints into wire markers as +// usual. `"auto"` is the recommended default for agent loops — it places one +// breakpoint at the last tool definition, one at the last system part, and one +// at the latest user message. The combination of provider invalidation +// hierarchy (tools → system → messages) and Anthropic/Bedrock's 20-block +// lookback means three trailing breakpoints reliably cover the static prefix. +// +// Pass `"none"` to opt out entirely (the legacy behavior). Pass the granular +// object form to override individual choices. +export const CachePolicyObject = Schema.Struct({ + tools: Schema.optional(Schema.Boolean), + system: Schema.optional(Schema.Boolean), + messages: Schema.optional( + Schema.Union([ + Schema.Literal("latest-user-message"), + Schema.Literal("latest-assistant"), + Schema.Struct({ tail: Schema.Number }), + ]), + ), + ttlSeconds: Schema.optional(Schema.Number), +}) +export type CachePolicyObject = Schema.Schema.Type + +export const CachePolicy = Schema.Union([ + Schema.Literal("auto"), + Schema.Literal("none"), + CachePolicyObject, +]) +export type CachePolicy = Schema.Schema.Type diff --git a/packages/llm/test/cache-policy.test.ts b/packages/llm/test/cache-policy.test.ts new file mode 100644 index 0000000000..5cc931fbed --- /dev/null +++ b/packages/llm/test/cache-policy.test.ts @@ -0,0 +1,262 @@ +import { describe, expect, test } from "bun:test" +import { Effect } from "effect" +import { CacheHint, LLM } from "../src" +import { LLMClient } from "../src/route" +import * as AnthropicMessages from "../src/protocols/anthropic-messages" +import * as BedrockConverse from "../src/protocols/bedrock-converse" +import * as Gemini from "../src/protocols/gemini" +import * as OpenAIChat from "../src/protocols/openai-chat" +import { applyCachePolicy } from "../src/cache-policy" +import { it } from "./lib/effect" + +const anthropicModel = AnthropicMessages.model({ + id: "claude-sonnet-4-5", + baseURL: "https://api.anthropic.test/v1/", + headers: { "x-api-key": "test" }, +}) + +const bedrockModel = BedrockConverse.model({ + id: "anthropic.claude-3-5-sonnet-20241022-v2:0", + credentials: { region: "us-east-1", accessKeyId: "fixture", secretAccessKey: "fixture" }, +}) + +const openaiModel = OpenAIChat.model({ + id: "gpt-4o-mini", + baseURL: "https://api.openai.test/v1/", + headers: { authorization: "Bearer test" }, +}) + +const geminiModel = Gemini.model({ + id: "gemini-2.5-flash", + baseURL: "https://generativelanguage.test/v1beta/", + headers: { "x-goog-api-key": "test" }, +}) + +describe("applyCachePolicy", () => { + it.effect("undefined cache leaves the request untouched (opt-in default)", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + model: anthropicModel, + system: "You are concise.", + prompt: "hi", + }), + ) + + expect(prepared.body).toMatchObject({ + system: [{ type: "text", text: "You are concise.", cache_control: undefined }], + }) + }), + ) + + it.effect("'auto' marks the last tool, last system part, and latest user message on Anthropic", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + model: anthropicModel, + system: "Sys A", + tools: [{ name: "t1", description: "t1", inputSchema: { type: "object", properties: {} } }], + messages: [ + LLM.user("first user"), + LLM.assistant("assistant reply"), + LLM.user("latest user message"), + ], + cache: "auto", + }), + ) + + expect(prepared.body).toMatchObject({ + tools: [{ name: "t1", cache_control: { type: "ephemeral" } }], + system: [{ type: "text", text: "Sys A", cache_control: { type: "ephemeral" } }], + messages: [ + { role: "user", content: [{ type: "text", text: "first user" }] }, + { role: "assistant", content: [{ type: "text", text: "assistant reply" }] }, + { + role: "user", + content: [{ type: "text", text: "latest user message", cache_control: { type: "ephemeral" } }], + }, + ], + }) + }), + ) + + it.effect("'auto' is a no-op on OpenAI (implicit caching protocol)", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + model: openaiModel, + system: "Sys", + prompt: "hi", + cache: "auto", + }), + ) + + const body = prepared.body as { messages: Array<{ content: unknown }> } + // OpenAI doesn't accept cache_control on messages — policy must skip. + const flat = JSON.stringify(body) + expect(flat).not.toContain("cache_control") + expect(flat).not.toContain("cachePoint") + }), + ) + + it.effect("'auto' is a no-op on Gemini (out-of-band caching protocol)", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + model: geminiModel, + system: "Sys", + prompt: "hi", + cache: "auto", + }), + ) + + const flat = JSON.stringify(prepared.body) + expect(flat).not.toContain("cache_control") + expect(flat).not.toContain("cachePoint") + }), + ) + + it.effect("'auto' on Bedrock emits cachePoint markers in the right places", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + model: bedrockModel, + system: "Sys", + tools: [{ name: "t1", description: "t1", inputSchema: { type: "object", properties: {} } }], + messages: [LLM.user("first user"), LLM.assistant("reply"), LLM.user("latest user")], + cache: "auto", + }), + ) + + expect(prepared.body).toMatchObject({ + toolConfig: { + tools: [{ toolSpec: { name: "t1" } }, { cachePoint: { type: "default" } }], + }, + system: [{ text: "Sys" }, { cachePoint: { type: "default" } }], + messages: [ + { role: "user", content: [{ text: "first user" }] }, + { role: "assistant", content: [{ text: "reply" }] }, + { role: "user", content: [{ text: "latest user" }, { cachePoint: { type: "default" } }] }, + ], + }) + }), + ) + + it.effect("'none' disables auto placement even when manual hints exist", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + model: anthropicModel, + system: "Sys", + tools: [{ name: "t1", description: "t1", inputSchema: { type: "object", properties: {} } }], + prompt: "hi", + cache: "none", + }), + ) + + expect(prepared.body).toMatchObject({ + tools: [{ name: "t1", cache_control: undefined }], + system: [{ type: "text", text: "Sys", cache_control: undefined }], + }) + }), + ) + + it.effect("granular object form: tools-only marks just tools", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + model: anthropicModel, + system: "Sys", + tools: [{ name: "t1", description: "t1", inputSchema: { type: "object", properties: {} } }], + prompt: "hi", + cache: { tools: true }, + }), + ) + + expect(prepared.body).toMatchObject({ + tools: [{ name: "t1", cache_control: { type: "ephemeral" } }], + system: [{ type: "text", text: "Sys", cache_control: undefined }], + }) + }), + ) + + it.effect("auto policy preserves manual CacheHints on other parts", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + model: anthropicModel, + system: [ + { type: "text", text: "first system", cache: new CacheHint({ type: "ephemeral", ttlSeconds: 3600 }) }, + { type: "text", text: "last system" }, + ], + prompt: "hi", + cache: "auto", + }), + ) + + const body = prepared.body as { system: Array<{ text: string; cache_control?: unknown }> } + expect(body.system[0]?.cache_control).toEqual({ type: "ephemeral", ttl: "1h" }) + expect(body.system[1]?.cache_control).toEqual({ type: "ephemeral" }) + }), + ) + + it.effect("ttlSeconds in the policy flows through to wire markers", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + model: anthropicModel, + system: "Sys", + prompt: "hi", + cache: { system: true, ttlSeconds: 3600 }, + }), + ) + + expect(prepared.body).toMatchObject({ + system: [{ type: "text", text: "Sys", cache_control: { type: "ephemeral", ttl: "1h" } }], + }) + }), + ) + + it.effect("messages: { tail: 2 } marks the last 2 message boundaries", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + model: anthropicModel, + messages: [LLM.user("u1"), LLM.assistant("a1"), LLM.user("u2"), LLM.assistant("a2")], + cache: { messages: { tail: 2 } }, + }), + ) + + const body = prepared.body as { messages: Array<{ content: Array<{ cache_control?: unknown }> }> } + expect(body.messages[0]?.content[0]?.cache_control).toBeUndefined() + expect(body.messages[1]?.content[0]?.cache_control).toBeUndefined() + expect(body.messages[2]?.content[0]?.cache_control).toEqual({ type: "ephemeral" }) + expect(body.messages[3]?.content[0]?.cache_control).toEqual({ type: "ephemeral" }) + }), + ) + + it.effect("'latest-assistant' marks the last assistant message", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + model: anthropicModel, + messages: [LLM.user("u1"), LLM.assistant("a1"), LLM.user("u2")], + cache: { messages: "latest-assistant" }, + }), + ) + + const body = prepared.body as { messages: Array<{ content: Array<{ cache_control?: unknown }> }> } + expect(body.messages[0]?.content[0]?.cache_control).toBeUndefined() + expect(body.messages[1]?.content[0]?.cache_control).toEqual({ type: "ephemeral" }) + expect(body.messages[2]?.content[0]?.cache_control).toBeUndefined() + }), + ) + + test("returns the same request reference when policy is a no-op (pure function)", () => { + const request = LLM.request({ + model: anthropicModel, + prompt: "hi", + }) + expect(applyCachePolicy(request)).toBe(request) + }) +}) diff --git a/packages/llm/test/fixtures/recordings/anthropic-messages-cache/writes-then-reads-cache-control-on-identical-second-call.json b/packages/llm/test/fixtures/recordings/anthropic-messages-cache/writes-then-reads-cache-control-on-identical-second-call.json new file mode 100644 index 0000000000..697faea283 --- /dev/null +++ b/packages/llm/test/fixtures/recordings/anthropic-messages-cache/writes-then-reads-cache-control-on-identical-second-call.json @@ -0,0 +1,53 @@ +{ + "version": 1, + "metadata": { + "name": "anthropic-messages-cache/writes-then-reads-cache-control-on-identical-second-call", + "recordedAt": "2026-05-11T01:52:54.319Z", + "tags": [ + "prefix:anthropic-messages-cache", + "provider:anthropic", + "protocol:anthropic-messages", + "cache" + ] + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://api.anthropic.com/v1/messages", + "headers": { + "anthropic-version": "2023-06-01", + "content-type": "application/json" + }, + "body": "{\"model\":\"claude-haiku-4-5-20251001\",\"system\":[{\"type\":\"text\",\"text\":\"You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. \",\"cache_control\":{\"type\":\"ephemeral\"}}],\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Say hi.\"}]}],\"stream\":true,\"max_tokens\":16,\"temperature\":0}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream; charset=utf-8" + }, + "body": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_01NSbhSJdF1R6Uz81RRKxd55\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":9,\"cache_creation_input_tokens\":5752,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":5752,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":2,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}} }\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"} }\n\nevent: ping\ndata: {\"type\": \"ping\"}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"Hi.\"} }\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0 }\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null},\"usage\":{\"input_tokens\":9,\"cache_creation_input_tokens\":5752,\"cache_read_input_tokens\":0,\"output_tokens\":5} }\n\nevent: message_stop\ndata: {\"type\":\"message_stop\" }\n\n" + } + }, + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://api.anthropic.com/v1/messages", + "headers": { + "anthropic-version": "2023-06-01", + "content-type": "application/json" + }, + "body": "{\"model\":\"claude-haiku-4-5-20251001\",\"system\":[{\"type\":\"text\",\"text\":\"You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. \",\"cache_control\":{\"type\":\"ephemeral\"}}],\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Say hi.\"}]}],\"stream\":true,\"max_tokens\":16,\"temperature\":0}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream; charset=utf-8" + }, + "body": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_01W9dNB2vnT7HoPQmDfKyniu\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":9,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":5752,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":2,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}} }\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"} }\n\nevent: ping\ndata: {\"type\": \"ping\"}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"Hi.\"} }\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0 }\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null},\"usage\":{\"input_tokens\":9,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":5752,\"output_tokens\":5} }\n\nevent: message_stop\ndata: {\"type\":\"message_stop\" }\n\n" + } + } + ] +} diff --git a/packages/llm/test/fixtures/recordings/gemini-cache/reports-cachedcontenttokencount-on-identical-second-call.json b/packages/llm/test/fixtures/recordings/gemini-cache/reports-cachedcontenttokencount-on-identical-second-call.json new file mode 100644 index 0000000000..55c7c6c321 --- /dev/null +++ b/packages/llm/test/fixtures/recordings/gemini-cache/reports-cachedcontenttokencount-on-identical-second-call.json @@ -0,0 +1,51 @@ +{ + "version": 1, + "metadata": { + "name": "gemini-cache/reports-cachedcontenttokencount-on-identical-second-call", + "recordedAt": "2026-05-11T01:55:40.600Z", + "tags": [ + "prefix:gemini-cache", + "provider:google", + "protocol:gemini", + "cache" + ] + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse", + "headers": { + "content-type": "application/json" + }, + "body": "{\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"Say hi.\"}]}],\"systemInstruction\":{\"parts\":[{\"text\":\"You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. \"}]},\"generationConfig\":{\"maxOutputTokens\":16,\"temperature\":0}}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream" + }, + "body": "" + } + }, + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse", + "headers": { + "content-type": "application/json" + }, + "body": "{\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"Say hi.\"}]}],\"systemInstruction\":{\"parts\":[{\"text\":\"You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. \"}]},\"generationConfig\":{\"maxOutputTokens\":16,\"temperature\":0}}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream" + }, + "body": "" + } + } + ] +} diff --git a/packages/llm/test/fixtures/recordings/openai-responses-cache/reports-cached-tokens-on-identical-second-call.json b/packages/llm/test/fixtures/recordings/openai-responses-cache/reports-cached-tokens-on-identical-second-call.json new file mode 100644 index 0000000000..a9e7acbba0 --- /dev/null +++ b/packages/llm/test/fixtures/recordings/openai-responses-cache/reports-cached-tokens-on-identical-second-call.json @@ -0,0 +1,51 @@ +{ + "version": 1, + "metadata": { + "name": "openai-responses-cache/reports-cached-tokens-on-identical-second-call", + "recordedAt": "2026-05-11T01:41:58.951Z", + "tags": [ + "prefix:openai-responses-cache", + "provider:openai", + "protocol:openai-responses", + "cache" + ] + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://api.openai.com/v1/responses", + "headers": { + "content-type": "application/json" + }, + "body": "{\"model\":\"gpt-4.1-mini\",\"input\":[{\"role\":\"system\",\"content\":\"You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. \"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Say hi.\"}]}],\"prompt_cache_key\":\"recorded-cache-test\",\"max_output_tokens\":16,\"temperature\":0,\"stream\":true}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream; charset=utf-8" + }, + "body": "event: response.created\ndata: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_00b4acfe385b75d6006a0133e252e4819faecb37d96affd4bf\",\"object\":\"response\",\"created_at\":1778463714,\"status\":\"in_progress\",\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":16,\"max_tool_calls\":null,\"model\":\"gpt-4.1-mini-2025-04-14\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":\"recorded-cache-test\",\"prompt_cache_retention\":\"in_memory\",\"reasoning\":{\"effort\":null,\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":true,\"temperature\":0.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":\"auto\",\"tools\":[],\"top_logprobs\":0,\"top_p\":1.0,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":0}\n\nevent: response.in_progress\ndata: {\"type\":\"response.in_progress\",\"response\":{\"id\":\"resp_00b4acfe385b75d6006a0133e252e4819faecb37d96affd4bf\",\"object\":\"response\",\"created_at\":1778463714,\"status\":\"in_progress\",\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":16,\"max_tool_calls\":null,\"model\":\"gpt-4.1-mini-2025-04-14\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":\"recorded-cache-test\",\"prompt_cache_retention\":\"in_memory\",\"reasoning\":{\"effort\":null,\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":true,\"temperature\":0.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":\"auto\",\"tools\":[],\"top_logprobs\":0,\"top_p\":1.0,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":1}\n\nevent: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"item\":{\"id\":\"msg_00b4acfe385b75d6006a0133e42ad8819f83824a88e1160e09\",\"type\":\"message\",\"status\":\"in_progress\",\"content\":[],\"role\":\"assistant\"},\"output_index\":0,\"sequence_number\":2}\n\nevent: response.content_part.added\ndata: {\"type\":\"response.content_part.added\",\"content_index\":0,\"item_id\":\"msg_00b4acfe385b75d6006a0133e42ad8819f83824a88e1160e09\",\"output_index\":0,\"part\":{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"\"},\"sequence_number\":3}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"Hi\",\"item_id\":\"msg_00b4acfe385b75d6006a0133e42ad8819f83824a88e1160e09\",\"logprobs\":[],\"obfuscation\":\"NSLkknb2f6J7MB\",\"output_index\":0,\"sequence_number\":4}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\".\",\"item_id\":\"msg_00b4acfe385b75d6006a0133e42ad8819f83824a88e1160e09\",\"logprobs\":[],\"obfuscation\":\"ywmEAhs1uKOLkln\",\"output_index\":0,\"sequence_number\":5}\n\nevent: response.output_text.done\ndata: {\"type\":\"response.output_text.done\",\"content_index\":0,\"item_id\":\"msg_00b4acfe385b75d6006a0133e42ad8819f83824a88e1160e09\",\"logprobs\":[],\"output_index\":0,\"sequence_number\":6,\"text\":\"Hi.\"}\n\nevent: response.content_part.done\ndata: {\"type\":\"response.content_part.done\",\"content_index\":0,\"item_id\":\"msg_00b4acfe385b75d6006a0133e42ad8819f83824a88e1160e09\",\"output_index\":0,\"part\":{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"Hi.\"},\"sequence_number\":7}\n\nevent: response.output_item.done\ndata: {\"type\":\"response.output_item.done\",\"item\":{\"id\":\"msg_00b4acfe385b75d6006a0133e42ad8819f83824a88e1160e09\",\"type\":\"message\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"Hi.\"}],\"role\":\"assistant\"},\"output_index\":0,\"sequence_number\":8}\n\nevent: response.completed\ndata: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_00b4acfe385b75d6006a0133e252e4819faecb37d96affd4bf\",\"object\":\"response\",\"created_at\":1778463714,\"status\":\"completed\",\"background\":false,\"completed_at\":1778463716,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":16,\"max_tool_calls\":null,\"model\":\"gpt-4.1-mini-2025-04-14\",\"moderation\":null,\"output\":[{\"id\":\"msg_00b4acfe385b75d6006a0133e42ad8819f83824a88e1160e09\",\"type\":\"message\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"Hi.\"}],\"role\":\"assistant\"}],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":\"recorded-cache-test\",\"prompt_cache_retention\":\"in_memory\",\"reasoning\":{\"effort\":null,\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"default\",\"store\":true,\"temperature\":0.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":\"auto\",\"tools\":[],\"top_logprobs\":0,\"top_p\":1.0,\"truncation\":\"disabled\",\"usage\":{\"input_tokens\":4765,\"input_tokens_details\":{\"cached_tokens\":0},\"output_tokens\":3,\"output_tokens_details\":{\"reasoning_tokens\":0},\"total_tokens\":4768},\"user\":null,\"metadata\":{}},\"sequence_number\":9}\n\n" + } + }, + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://api.openai.com/v1/responses", + "headers": { + "content-type": "application/json" + }, + "body": "{\"model\":\"gpt-4.1-mini\",\"input\":[{\"role\":\"system\",\"content\":\"You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. \"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Say hi.\"}]}],\"prompt_cache_key\":\"recorded-cache-test\",\"max_output_tokens\":16,\"temperature\":0,\"stream\":true}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream; charset=utf-8" + }, + "body": "event: response.created\ndata: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_06a66d5dbf005c28006a0133e48a28819d957163a92a5a56cc\",\"object\":\"response\",\"created_at\":1778463716,\"status\":\"in_progress\",\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":16,\"max_tool_calls\":null,\"model\":\"gpt-4.1-mini-2025-04-14\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":\"recorded-cache-test\",\"prompt_cache_retention\":\"in_memory\",\"reasoning\":{\"effort\":null,\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":true,\"temperature\":0.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":\"auto\",\"tools\":[],\"top_logprobs\":0,\"top_p\":1.0,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":0}\n\nevent: response.in_progress\ndata: {\"type\":\"response.in_progress\",\"response\":{\"id\":\"resp_06a66d5dbf005c28006a0133e48a28819d957163a92a5a56cc\",\"object\":\"response\",\"created_at\":1778463716,\"status\":\"in_progress\",\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":16,\"max_tool_calls\":null,\"model\":\"gpt-4.1-mini-2025-04-14\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":\"recorded-cache-test\",\"prompt_cache_retention\":\"in_memory\",\"reasoning\":{\"effort\":null,\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":true,\"temperature\":0.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":\"auto\",\"tools\":[],\"top_logprobs\":0,\"top_p\":1.0,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":1}\n\nevent: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"item\":{\"id\":\"msg_06a66d5dbf005c28006a0133e6a2b0819d90b31eabe0bb0568\",\"type\":\"message\",\"status\":\"in_progress\",\"content\":[],\"role\":\"assistant\"},\"output_index\":0,\"sequence_number\":2}\n\nevent: response.content_part.added\ndata: {\"type\":\"response.content_part.added\",\"content_index\":0,\"item_id\":\"msg_06a66d5dbf005c28006a0133e6a2b0819d90b31eabe0bb0568\",\"output_index\":0,\"part\":{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"\"},\"sequence_number\":3}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"Hi\",\"item_id\":\"msg_06a66d5dbf005c28006a0133e6a2b0819d90b31eabe0bb0568\",\"logprobs\":[],\"obfuscation\":\"qLgi78ygFGnuw7\",\"output_index\":0,\"sequence_number\":4}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\".\",\"item_id\":\"msg_06a66d5dbf005c28006a0133e6a2b0819d90b31eabe0bb0568\",\"logprobs\":[],\"obfuscation\":\"dyQaYugaXCUfkYH\",\"output_index\":0,\"sequence_number\":5}\n\nevent: response.output_text.done\ndata: {\"type\":\"response.output_text.done\",\"content_index\":0,\"item_id\":\"msg_06a66d5dbf005c28006a0133e6a2b0819d90b31eabe0bb0568\",\"logprobs\":[],\"output_index\":0,\"sequence_number\":6,\"text\":\"Hi.\"}\n\nevent: response.content_part.done\ndata: {\"type\":\"response.content_part.done\",\"content_index\":0,\"item_id\":\"msg_06a66d5dbf005c28006a0133e6a2b0819d90b31eabe0bb0568\",\"output_index\":0,\"part\":{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"Hi.\"},\"sequence_number\":7}\n\nevent: response.output_item.done\ndata: {\"type\":\"response.output_item.done\",\"item\":{\"id\":\"msg_06a66d5dbf005c28006a0133e6a2b0819d90b31eabe0bb0568\",\"type\":\"message\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"Hi.\"}],\"role\":\"assistant\"},\"output_index\":0,\"sequence_number\":8}\n\nevent: response.completed\ndata: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_06a66d5dbf005c28006a0133e48a28819d957163a92a5a56cc\",\"object\":\"response\",\"created_at\":1778463716,\"status\":\"completed\",\"background\":false,\"completed_at\":1778463718,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":16,\"max_tool_calls\":null,\"model\":\"gpt-4.1-mini-2025-04-14\",\"moderation\":null,\"output\":[{\"id\":\"msg_06a66d5dbf005c28006a0133e6a2b0819d90b31eabe0bb0568\",\"type\":\"message\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"Hi.\"}],\"role\":\"assistant\"}],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":\"recorded-cache-test\",\"prompt_cache_retention\":\"in_memory\",\"reasoning\":{\"effort\":null,\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"default\",\"store\":true,\"temperature\":0.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":\"auto\",\"tools\":[],\"top_logprobs\":0,\"top_p\":1.0,\"truncation\":\"disabled\",\"usage\":{\"input_tokens\":4765,\"input_tokens_details\":{\"cached_tokens\":4608},\"output_tokens\":3,\"output_tokens_details\":{\"reasoning_tokens\":0},\"total_tokens\":4768},\"user\":null,\"metadata\":{}},\"sequence_number\":9}\n\n" + } + } + ] +} diff --git a/packages/llm/test/provider/anthropic-messages-cache.recorded.test.ts b/packages/llm/test/provider/anthropic-messages-cache.recorded.test.ts index b048d53ba0..cee31de19b 100644 --- a/packages/llm/test/provider/anthropic-messages-cache.recorded.test.ts +++ b/packages/llm/test/provider/anthropic-messages-cache.recorded.test.ts @@ -28,7 +28,12 @@ const recorded = recordedTests({ provider: "anthropic", protocol: "anthropic-messages", requires: ["ANTHROPIC_API_KEY"], - options: { redactor: Redactor.defaults({ requestHeaders: { allow: ["content-type", "anthropic-version"] } }) }, + // Two identical requests in one cassette — match by recording order so the + // second call replays the cached-hit interaction. + options: { + dispatch: "sequential", + redactor: Redactor.defaults({ requestHeaders: { allow: ["content-type", "anthropic-version"] } }), + }, }) describe("Anthropic Messages cache recorded", () => { diff --git a/packages/llm/test/provider/bedrock-converse-cache.recorded.test.ts b/packages/llm/test/provider/bedrock-converse-cache.recorded.test.ts index 23dd697b9a..400e38849e 100644 --- a/packages/llm/test/provider/bedrock-converse-cache.recorded.test.ts +++ b/packages/llm/test/provider/bedrock-converse-cache.recorded.test.ts @@ -35,6 +35,9 @@ const recorded = recordedTests({ provider: "amazon-bedrock", protocol: "bedrock-converse", requires: ["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY"], + // Two identical requests in one cassette — match by recording order so the + // second call replays the cached-hit interaction. + options: { dispatch: "sequential" }, }) describe("Bedrock Converse cache recorded", () => { diff --git a/packages/llm/test/provider/gemini-cache.recorded.test.ts b/packages/llm/test/provider/gemini-cache.recorded.test.ts index 145728fdc6..c3b3e55b36 100644 --- a/packages/llm/test/provider/gemini-cache.recorded.test.ts +++ b/packages/llm/test/provider/gemini-cache.recorded.test.ts @@ -8,7 +8,7 @@ import { recordedTests } from "../recorded-test" const model = Gemini.model({ id: "gemini-2.5-flash", - apiKey: process.env.GEMINI_API_KEY ?? "fixture", + apiKey: process.env.GOOGLE_GENERATIVE_AI_API_KEY ?? process.env.GEMINI_API_KEY ?? "fixture", }) // Gemini does implicit prefix caching on 2.5+ models above ~1024 tokens. The @@ -28,7 +28,10 @@ const recorded = recordedTests({ prefix: "gemini-cache", provider: "google", protocol: "gemini", - requires: ["GEMINI_API_KEY"], + requires: ["GOOGLE_GENERATIVE_AI_API_KEY"], + // Two identical requests in one cassette — match by recording order so the + // second call replays the cached-hit interaction. + options: { dispatch: "sequential" }, }) describe("Gemini cache recorded", () => { diff --git a/packages/llm/test/provider/openai-responses-cache.recorded.test.ts b/packages/llm/test/provider/openai-responses-cache.recorded.test.ts index 0ac3dfe2b9..5a38898c0f 100644 --- a/packages/llm/test/provider/openai-responses-cache.recorded.test.ts +++ b/packages/llm/test/provider/openai-responses-cache.recorded.test.ts @@ -29,6 +29,9 @@ const recorded = recordedTests({ provider: "openai", protocol: "openai-responses", requires: ["OPENAI_API_KEY"], + // Two identical requests in one cassette — match by recording order so the + // second call replays the cached-hit interaction, not the cold-miss one. + options: { dispatch: "sequential" }, }) describe("OpenAI Responses cache recorded", () => { From 02cb7e7b71c560fb29d36f0bd56149ab6a5fa3ee Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Mon, 11 May 2026 02:11:07 +0000 Subject: [PATCH 178/189] chore: generate --- packages/llm/README.md | 12 ++++++------ packages/llm/src/cache-policy.ts | 15 +++------------ packages/llm/src/schema/options.ts | 6 +----- packages/llm/test/cache-policy.test.ts | 6 +----- ...ds-cache-control-on-identical-second-call.json | 7 +------ ...ontenttokencount-on-identical-second-call.json | 7 +------ ...ts-cached-tokens-on-identical-second-call.json | 7 +------ 7 files changed, 14 insertions(+), 46 deletions(-) diff --git a/packages/llm/README.md b/packages/llm/README.md index 434ce369f8..e164c4bf53 100644 --- a/packages/llm/README.md +++ b/packages/llm/README.md @@ -82,12 +82,12 @@ LLM.request({ ### Provider behavior table -| Protocol | `cache: "auto"` | -|---|---| -| Anthropic Messages | emits up to 3 `cache_control` markers (4-breakpoint cap enforced) | -| Bedrock Converse | emits up to 3 `cachePoint` blocks (4-breakpoint cap enforced) | -| OpenAI Chat / Responses | no-op (implicit caching above 1024 tokens) | -| Gemini | no-op (implicit caching on 2.5+; explicit `CachedContent` is out-of-band) | +| Protocol | `cache: "auto"` | +| ----------------------- | ------------------------------------------------------------------------- | +| Anthropic Messages | emits up to 3 `cache_control` markers (4-breakpoint cap enforced) | +| Bedrock Converse | emits up to 3 `cachePoint` blocks (4-breakpoint cap enforced) | +| OpenAI Chat / Responses | no-op (implicit caching above 1024 tokens) | +| Gemini | no-op (implicit caching on 2.5+; explicit `CachedContent` is out-of-band) | Normalized cache usage is read back into `response.usage.cacheReadInputTokens` and `cacheWriteInputTokens` across every provider. diff --git a/packages/llm/src/cache-policy.ts b/packages/llm/src/cache-policy.ts index 46d924ab52..b9dca4e88f 100644 --- a/packages/llm/src/cache-policy.ts +++ b/packages/llm/src/cache-policy.ts @@ -44,10 +44,7 @@ const RESPECTS_INLINE_HINTS = new Set(["anthropic-messages", "bedrock-converse"] const makeHint = (ttlSeconds: number | undefined): CacheHint => ttlSeconds !== undefined ? new CacheHint({ type: "ephemeral", ttlSeconds }) : new CacheHint({ type: "ephemeral" }) -const markLastTool = ( - tools: ReadonlyArray, - hint: CacheHint, -): ReadonlyArray => { +const markLastTool = (tools: ReadonlyArray, hint: CacheHint): ReadonlyArray => { if (tools.length === 0) return tools const last = tools.length - 1 if (tools[last]!.cache) return tools @@ -67,11 +64,7 @@ const lastIndexOfRole = (messages: ReadonlyArray, role: Message["role"] // Mark the last text part of `messages[index]`. If no text part exists, mark // the last content part regardless of type — that's the breakpoint position // in tool-result-only messages too. -const markMessageAt = ( - messages: ReadonlyArray, - index: number, - hint: CacheHint, -): ReadonlyArray => { +const markMessageAt = (messages: ReadonlyArray, index: number, hint: CacheHint): ReadonlyArray => { if (index < 0 || index >= messages.length) return messages const target = messages[index]! if (target.content.length === 0) return messages @@ -79,9 +72,7 @@ const markMessageAt = ( const markAt = lastTextIndex >= 0 ? lastTextIndex : target.content.length - 1 const existing = target.content[markAt]! if ("cache" in existing && existing.cache) return messages - const nextContent = target.content.map((part, i) => - i === markAt ? ({ ...part, cache: hint } as ContentPart) : part, - ) + const nextContent = target.content.map((part, i) => (i === markAt ? ({ ...part, cache: hint } as ContentPart) : part)) const next = new Message({ ...target, content: nextContent }) // Single pass over `messages`, substituting the one updated entry. Long // conversations call this on every request, so avoid `.map()` here — its diff --git a/packages/llm/src/schema/options.ts b/packages/llm/src/schema/options.ts index cead47ee44..0f40196f7d 100644 --- a/packages/llm/src/schema/options.ts +++ b/packages/llm/src/schema/options.ts @@ -226,9 +226,5 @@ export const CachePolicyObject = Schema.Struct({ }) export type CachePolicyObject = Schema.Schema.Type -export const CachePolicy = Schema.Union([ - Schema.Literal("auto"), - Schema.Literal("none"), - CachePolicyObject, -]) +export const CachePolicy = Schema.Union([Schema.Literal("auto"), Schema.Literal("none"), CachePolicyObject]) export type CachePolicy = Schema.Schema.Type diff --git a/packages/llm/test/cache-policy.test.ts b/packages/llm/test/cache-policy.test.ts index 5cc931fbed..640556105e 100644 --- a/packages/llm/test/cache-policy.test.ts +++ b/packages/llm/test/cache-policy.test.ts @@ -56,11 +56,7 @@ describe("applyCachePolicy", () => { model: anthropicModel, system: "Sys A", tools: [{ name: "t1", description: "t1", inputSchema: { type: "object", properties: {} } }], - messages: [ - LLM.user("first user"), - LLM.assistant("assistant reply"), - LLM.user("latest user message"), - ], + messages: [LLM.user("first user"), LLM.assistant("assistant reply"), LLM.user("latest user message")], cache: "auto", }), ) diff --git a/packages/llm/test/fixtures/recordings/anthropic-messages-cache/writes-then-reads-cache-control-on-identical-second-call.json b/packages/llm/test/fixtures/recordings/anthropic-messages-cache/writes-then-reads-cache-control-on-identical-second-call.json index 697faea283..8cf2be05c1 100644 --- a/packages/llm/test/fixtures/recordings/anthropic-messages-cache/writes-then-reads-cache-control-on-identical-second-call.json +++ b/packages/llm/test/fixtures/recordings/anthropic-messages-cache/writes-then-reads-cache-control-on-identical-second-call.json @@ -3,12 +3,7 @@ "metadata": { "name": "anthropic-messages-cache/writes-then-reads-cache-control-on-identical-second-call", "recordedAt": "2026-05-11T01:52:54.319Z", - "tags": [ - "prefix:anthropic-messages-cache", - "provider:anthropic", - "protocol:anthropic-messages", - "cache" - ] + "tags": ["prefix:anthropic-messages-cache", "provider:anthropic", "protocol:anthropic-messages", "cache"] }, "interactions": [ { diff --git a/packages/llm/test/fixtures/recordings/gemini-cache/reports-cachedcontenttokencount-on-identical-second-call.json b/packages/llm/test/fixtures/recordings/gemini-cache/reports-cachedcontenttokencount-on-identical-second-call.json index 55c7c6c321..0145756887 100644 --- a/packages/llm/test/fixtures/recordings/gemini-cache/reports-cachedcontenttokencount-on-identical-second-call.json +++ b/packages/llm/test/fixtures/recordings/gemini-cache/reports-cachedcontenttokencount-on-identical-second-call.json @@ -3,12 +3,7 @@ "metadata": { "name": "gemini-cache/reports-cachedcontenttokencount-on-identical-second-call", "recordedAt": "2026-05-11T01:55:40.600Z", - "tags": [ - "prefix:gemini-cache", - "provider:google", - "protocol:gemini", - "cache" - ] + "tags": ["prefix:gemini-cache", "provider:google", "protocol:gemini", "cache"] }, "interactions": [ { diff --git a/packages/llm/test/fixtures/recordings/openai-responses-cache/reports-cached-tokens-on-identical-second-call.json b/packages/llm/test/fixtures/recordings/openai-responses-cache/reports-cached-tokens-on-identical-second-call.json index a9e7acbba0..25b561197c 100644 --- a/packages/llm/test/fixtures/recordings/openai-responses-cache/reports-cached-tokens-on-identical-second-call.json +++ b/packages/llm/test/fixtures/recordings/openai-responses-cache/reports-cached-tokens-on-identical-second-call.json @@ -3,12 +3,7 @@ "metadata": { "name": "openai-responses-cache/reports-cached-tokens-on-identical-second-call", "recordedAt": "2026-05-11T01:41:58.951Z", - "tags": [ - "prefix:openai-responses-cache", - "provider:openai", - "protocol:openai-responses", - "cache" - ] + "tags": ["prefix:openai-responses-cache", "provider:openai", "protocol:openai-responses", "cache"] }, "interactions": [ { From cfbf5d1c6f866d5673a6b2940211d05feaf717ad Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Mon, 11 May 2026 02:20:35 +0000 Subject: [PATCH 179/189] chore: update nix node_modules hashes --- nix/hashes.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/nix/hashes.json b/nix/hashes.json index 558264474a..4244e0c0e7 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-LTo0ohJN5hBOubqFLVL45unVEIwBDkACNVv64k2nkq4=", - "aarch64-linux": "sha256-oYKY2UJRWG2fhufW4aGujX/Poou93023ZF2Fu7oyYOw=", - "aarch64-darwin": "sha256-618c9vqKN5I+no1nzylctAiWvqw7Bsa+bzSTNwXmSQA=", - "x86_64-darwin": "sha256-1ro3/gH0FC0TWXwWT+k675xR396GE98HpnBEeuD4t6k=" + "x86_64-linux": "sha256-baGxh+hk/rPhg0xI/OdMDz6dPwncgercYNBdTPnLX9o=", + "aarch64-linux": "sha256-VTWKq679B3Q4ZnAoQzC4VSCYA09wWecNJ+JajvjNB1U=", + "aarch64-darwin": "sha256-orf2zIBMTiiQrt/6qCzE+o0oKhv6u8zXF9DH1Bo3lbo=", + "x86_64-darwin": "sha256-1MZC1fadRoY4lhkmjlcUQTLYH9Q8pDI1bxd5f94f1xU=" } } From 721ff5121e56891ee1650715a637364a513c2ab6 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Mon, 11 May 2026 04:27:46 +0200 Subject: [PATCH 180/189] fix prompt history behaviour and session line up/down commands (#26797) --- .../cli/cmd/tui/component/prompt/index.tsx | 32 +++++++------------ .../src/cli/cmd/tui/routes/session/index.tsx | 4 +-- 2 files changed, 14 insertions(+), 22 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index f1ce2b6765..f3217fcbab 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -926,13 +926,7 @@ export function Prompt(props: PromptProps) { target: inputTarget, enabled: (() => { cursorVersion() - return ( - inputTarget() !== undefined && - !props.disabled && - !auto()?.visible && - input !== undefined && - (input.cursorOffset === 0 || input.visualCursor.visualRow === 0) - ) + return inputTarget() !== undefined && !props.disabled && !auto()?.visible && input !== undefined })(), commands: [ { @@ -941,12 +935,12 @@ export function Prompt(props: PromptProps) { category: "Prompt", run() { if (input.cursorOffset !== 0) { - input.cursorOffset = 0 - return + if (input.scrollY + input.visualCursor.visualRow === 0) input.cursorOffset = 0 + return false } const item = history.move(-1, input.plainText) - if (!item) return + if (!item) return false input.setText(item.input) setStore("prompt", item) setStore("mode", item.mode ?? "normal") @@ -964,13 +958,7 @@ export function Prompt(props: PromptProps) { target: inputTarget, enabled: (() => { cursorVersion() - return ( - inputTarget() !== undefined && - !props.disabled && - !auto()?.visible && - input !== undefined && - (input.cursorOffset === input.plainText.length || input.visualCursor.visualRow === input.height - 1) - ) + return inputTarget() !== undefined && !props.disabled && !auto()?.visible && input !== undefined })(), commands: [ { @@ -979,12 +967,16 @@ export function Prompt(props: PromptProps) { category: "Prompt", run() { if (input.cursorOffset !== input.plainText.length) { - input.cursorOffset = input.plainText.length - return + if ( + input.scrollY + input.visualCursor.visualRow === + Math.max(0, input.editorView.getTotalVirtualLineCount() - 1) + ) + input.cursorOffset = input.plainText.length + return false } const item = history.move(1, input.plainText) - if (!item) return + if (!item) return false input.setText(item.input) setStore("prompt", item) setStore("mode", item.mode ?? "normal") diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index b0b48ec42c..b2ee3af622 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -746,7 +746,7 @@ export function Session() { title: "Line up", value: "session.line.up", category: "Session", - enabled: false, + hidden: true, run: () => { scroll.scrollBy(-1) dialog.clear() @@ -756,7 +756,7 @@ export function Session() { title: "Line down", value: "session.line.down", category: "Session", - enabled: false, + hidden: true, run: () => { scroll.scrollBy(1) dialog.clear() From 9b369ee815a6f5ea6695972d7275d7aabf058b5f Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sun, 10 May 2026 22:46:01 -0400 Subject: [PATCH 181/189] chore(llm): make cache: 'auto' the default (#26798) --- packages/llm/README.md | 17 ++++++++--------- packages/llm/src/cache-policy.ts | 12 ++++++------ packages/llm/test/cache-policy.test.ts | 8 ++++++-- .../anthropic-messages-cache.recorded.test.ts | 3 +++ .../test/provider/anthropic-messages.test.ts | 4 ++++ .../bedrock-converse-cache.recorded.test.ts | 3 +++ .../llm/test/provider/bedrock-converse.test.ts | 8 ++++++++ packages/llm/test/recorded-scenarios.ts | 3 +++ 8 files changed, 41 insertions(+), 17 deletions(-) diff --git a/packages/llm/README.md b/packages/llm/README.md index e164c4bf53..321bf715bb 100644 --- a/packages/llm/README.md +++ b/packages/llm/README.md @@ -35,26 +35,25 @@ Run `LLMClient.stream(request)` instead of `generate` when you want incremental ## Caching -Prompt caching is unified across providers. Mark content with a `CacheHint` and each protocol translates it to its wire format (`cache_control` on Anthropic, `cachePoint` on Bedrock; OpenAI's implicit caching needs no markers). +Prompt caching is **on by default**. Every `LLMRequest` resolves to `cache: "auto"` unless the caller opts out with `cache: "none"`. Each protocol translates `CacheHint`s to its wire format (`cache_control` on Anthropic, `cachePoint` on Bedrock; OpenAI and Gemini do implicit caching server-side and don't need inline markers — auto is a no-op there). ### Auto placement -The simplest path is `cache: "auto"` on the request: +`"auto"` places three breakpoints — last tool definition, last system part, latest user message. The last-user-message boundary is the load-bearing detail: in a tool-use loop, a single user turn expands into many assistant/tool round-trips, all sharing that prefix. Caching at that boundary lets every intra-turn API call hit. + +The math justifies the default: Anthropic's 5-minute cache write is 1.25× base, read is 0.1×, so a single reuse within 5 minutes already wins. One-shot completions below the per-model minimum-cacheable-token threshold silently no-op on the wire, so the worst case is harmless. + +### Opting out ```ts LLM.request({ model, system, - messages, - tools, - cache: "auto", + prompt: "one-off question", + cache: "none", }) ``` -`"auto"` places three breakpoints — last tool definition, last system part, latest user message. The last-user-message boundary is the load-bearing detail: in a tool-use loop, a single user turn expands into many assistant/tool round-trips, all sharing that prefix. Caching at that boundary lets every intra-turn API call hit. - -On OpenAI and Gemini `"auto"` is a no-op (their wire formats don't accept inline markers — both use implicit caching). On Anthropic and Bedrock it emits provider-native cache markers. - ### Granular policy ```ts diff --git a/packages/llm/src/cache-policy.ts b/packages/llm/src/cache-policy.ts index b9dca4e88f..6ab7a049fe 100644 --- a/packages/llm/src/cache-policy.ts +++ b/packages/llm/src/cache-policy.ts @@ -24,15 +24,15 @@ const AUTO: CachePolicyObject = { const NONE: CachePolicyObject = {} // Resolution rules: -// - undefined → "none" (opt-in default so the policy never changes wire -// shape for existing callers; downstream code can flip to -// `cache: "auto"` once they audit the placement choices). -// - "auto" → the recommended policy: tools + system + latest user msg. +// - undefined → "auto" — caching is on by default. The math favors it: +// Anthropic 5m-cache write is 1.25x base, read is 0.1x, +// so a single reuse within 5 minutes already wins. +// - "auto" → tools + system + latest user msg. // - "none" → no auto placement; manual `CacheHint`s still flow. // - object form → exactly what the caller asked for. const resolve = (policy: CachePolicy | undefined): CachePolicyObject => { - if (policy === undefined || policy === "none") return NONE - if (policy === "auto") return AUTO + if (policy === undefined || policy === "auto") return AUTO + if (policy === "none") return NONE return policy } diff --git a/packages/llm/test/cache-policy.test.ts b/packages/llm/test/cache-policy.test.ts index 640556105e..e742ca5e69 100644 --- a/packages/llm/test/cache-policy.test.ts +++ b/packages/llm/test/cache-policy.test.ts @@ -33,7 +33,7 @@ const geminiModel = Gemini.model({ }) describe("applyCachePolicy", () => { - it.effect("undefined cache leaves the request untouched (opt-in default)", () => + it.effect("undefined cache resolves to 'auto' (the recommended default)", () => Effect.gen(function* () { const prepared = yield* LLMClient.prepare( LLM.request({ @@ -43,8 +43,11 @@ describe("applyCachePolicy", () => { }), ) + // No explicit cache field → auto policy fires → last system part + latest + // user message both get cache_control markers. expect(prepared.body).toMatchObject({ - system: [{ type: "text", text: "You are concise.", cache_control: undefined }], + system: [{ type: "text", text: "You are concise.", cache_control: { type: "ephemeral" } }], + messages: [{ role: "user", content: [{ type: "text", text: "hi", cache_control: { type: "ephemeral" } }] }], }) }), ) @@ -252,6 +255,7 @@ describe("applyCachePolicy", () => { const request = LLM.request({ model: anthropicModel, prompt: "hi", + cache: "none", }) expect(applyCachePolicy(request)).toBe(request) }) diff --git a/packages/llm/test/provider/anthropic-messages-cache.recorded.test.ts b/packages/llm/test/provider/anthropic-messages-cache.recorded.test.ts index cee31de19b..cb144b1a5d 100644 --- a/packages/llm/test/provider/anthropic-messages-cache.recorded.test.ts +++ b/packages/llm/test/provider/anthropic-messages-cache.recorded.test.ts @@ -20,6 +20,9 @@ const cacheRequest = LLM.request({ model, system: [{ type: "text", text: LARGE_CACHEABLE_SYSTEM, cache: new CacheHint({ type: "ephemeral" }) }], prompt: "Say hi.", + // Manual hint on the system part is the only marker we want here — skip the + // auto-policy's latest-user-message breakpoint so the cassette body matches. + cache: "none", generation: { maxTokens: 16, temperature: 0 }, }) diff --git a/packages/llm/test/provider/anthropic-messages.test.ts b/packages/llm/test/provider/anthropic-messages.test.ts index 3be041c94c..a867d16591 100644 --- a/packages/llm/test/provider/anthropic-messages.test.ts +++ b/packages/llm/test/provider/anthropic-messages.test.ts @@ -18,6 +18,9 @@ const request = LLM.request({ model, system: { type: "text", text: "You are concise.", cache: new CacheHint({ type: "ephemeral" }) }, prompt: "Say hello.", + // This fixture predates the `cache: "auto"` default; pin the policy off so + // existing wire-shape assertions only see the manual hint on the system part. + cache: "none", generation: { maxTokens: 20, temperature: 0 }, }) @@ -48,6 +51,7 @@ describe("Anthropic Messages route", () => { LLM.assistant([LLM.toolCall({ id: "call_1", name: "lookup", input: { query: "weather" } })]), LLM.toolMessage({ id: "call_1", name: "lookup", result: { forecast: "sunny" } }), ], + cache: "none", }), ) diff --git a/packages/llm/test/provider/bedrock-converse-cache.recorded.test.ts b/packages/llm/test/provider/bedrock-converse-cache.recorded.test.ts index 400e38849e..16c44099ce 100644 --- a/packages/llm/test/provider/bedrock-converse-cache.recorded.test.ts +++ b/packages/llm/test/provider/bedrock-converse-cache.recorded.test.ts @@ -27,6 +27,9 @@ const cacheRequest = LLM.request({ model, system: [{ type: "text", text: LARGE_CACHEABLE_SYSTEM, cache: new CacheHint({ type: "ephemeral" }) }], prompt: "Say hi.", + // Manual hint on the system part is the only marker we want here — skip the + // auto-policy's latest-user-message breakpoint so the cassette body matches. + cache: "none", generation: { maxTokens: 16, temperature: 0 }, }) diff --git a/packages/llm/test/provider/bedrock-converse.test.ts b/packages/llm/test/provider/bedrock-converse.test.ts index afadd89ac7..208b565272 100644 --- a/packages/llm/test/provider/bedrock-converse.test.ts +++ b/packages/llm/test/provider/bedrock-converse.test.ts @@ -63,6 +63,9 @@ const baseRequest = LLM.request({ model, system: "You are concise.", prompt: "Say hello.", + // Wire-shape assertions in this file predate the `cache: "auto"` default; + // pin the policy off so they only exercise the lowering path itself. + cache: "none", generation: { maxTokens: 64, temperature: 0 }, }) @@ -125,6 +128,7 @@ describe("Bedrock Converse route", () => { LLM.assistant([LLM.toolCall({ id: "tool_1", name: "lookup", input: { query: "weather" } })]), LLM.toolMessage({ id: "tool_1", name: "lookup", result: { forecast: "sunny" } }), ], + cache: "none", }), ) @@ -339,6 +343,7 @@ describe("Bedrock Converse route", () => { { type: "media", mediaType: "image/webp", data: "DDDD" }, ]), ], + cache: "none", }), ) @@ -470,6 +475,7 @@ describe("Bedrock Converse route", () => { LLM.assistant([LLM.toolCall({ id: "call_1", name: "lookup", input: {} })]), LLM.toolMessage({ id: "call_1", name: "lookup", result: { temp: 72 }, cache }), ], + cache: "none", }), ) @@ -555,6 +561,7 @@ describe("Bedrock Converse recorded", () => { model: recordedModel(), system: "Reply with the single word 'Hello'.", prompt: "Say hello.", + cache: "none", generation: { maxTokens: 16, temperature: 0 }, }), ) @@ -577,6 +584,7 @@ describe("Bedrock Converse recorded", () => { prompt: "Call get_weather with city exactly Paris.", tools: [weatherTool], toolChoice: LLM.toolChoice(weatherTool), + cache: "none", generation: { maxTokens: 80, temperature: 0 }, }), ) diff --git a/packages/llm/test/recorded-scenarios.ts b/packages/llm/test/recorded-scenarios.ts index 8a02bc3a0a..127a444a16 100644 --- a/packages/llm/test/recorded-scenarios.ts +++ b/packages/llm/test/recorded-scenarios.ts @@ -51,6 +51,7 @@ export const textRequest = (input: { model: input.model, system: "You are concise.", prompt: input.prompt ?? "Reply with exactly: Hello!", + cache: "none", generation: input.temperature === false ? { maxTokens: input.maxTokens ?? 20 } @@ -70,6 +71,7 @@ export const weatherToolRequest = (input: { prompt: "Call get_weather with city exactly Paris.", tools: [weatherTool], toolChoice: LLM.toolChoice(weatherTool), + cache: "none", generation: input.temperature === false ? { maxTokens: input.maxTokens ?? 80 } @@ -88,6 +90,7 @@ export const weatherToolLoopRequest = (input: { model: input.model, system: input.system ?? "Use the get_weather tool, then answer in one short sentence.", prompt: "What is the weather in Paris?", + cache: "none", generation: input.temperature === false ? { maxTokens: input.maxTokens ?? 80 } From 274033cd52464c5fe8eadde8e2b7fdd516b4549f Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sun, 10 May 2026 22:59:20 -0400 Subject: [PATCH 182/189] Validate prompt messages with Effect Schema (#26796) --- packages/opencode/src/session/message-v2.ts | 33 +++++---------------- packages/opencode/src/session/prompt.ts | 15 ++++++---- 2 files changed, 16 insertions(+), 32 deletions(-) diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 3eb6f07b82..a8c8dabc86 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -23,7 +23,7 @@ import type { SystemError } from "bun" import type { Provider } from "@/provider/provider" import { ModelID, ProviderID } from "@/provider/schema" import { Effect, Schema, Types } from "effect" -import { zod, ZodOverride } from "@opencode-ai/core/effect-zod" +import { zod } from "@opencode-ai/core/effect-zod" import { NonNegativeInt, withStatics } from "@opencode-ai/core/schema" import { namedSchemaError } from "@/util/named-schema-error" import * as EffectLogger from "@opencode-ai/core/effect/logger" @@ -402,7 +402,7 @@ export const User = Schema.Struct({ .pipe(withStatics((s) => ({ zod: zod(s) }))) export type User = Types.DeepMutable> -const _Part = Schema.Union([ +export const Part = Schema.Union([ TextPart, SubtaskPart, ReasoningPart, @@ -416,22 +416,6 @@ const _Part = Schema.Union([ RetryPart, CompactionPart, ]).annotate({ discriminator: "type", identifier: "Part" }) -export const Part = Object.assign(_Part, { - zod: zod(_Part) as unknown as z.ZodType< - | TextPart - | SubtaskPart - | ReasoningPart - | FilePart - | ToolPart - | StepStartPart - | StepFinishPart - | SnapshotPart - | PatchPart - | AgentPart - | RetryPart - | CompactionPart - >, -}) export type Part = | TextPart | SubtaskPart @@ -573,15 +557,12 @@ export type Assistant = Omit, -}) +export const Info = Schema.Union([User, Assistant]).annotate({ discriminator: "role", identifier: "Message" }) export type Info = User | Assistant const UpdatedEventSchema = Schema.Struct({ sessionID: SessionID, - info: _Info, + info: Info, }) const RemovedEventSchema = Schema.Struct({ @@ -591,7 +572,7 @@ const RemovedEventSchema = Schema.Struct({ const PartUpdatedEventSchema = Schema.Struct({ sessionID: SessionID, - part: _Part, + part: Part, time: NonNegativeInt, }) @@ -639,8 +620,8 @@ export const Event = { } export const WithParts = Schema.Struct({ - info: _Info, - parts: Schema.Array(_Part), + info: Info, + parts: Schema.Array(Part), }).pipe(withStatics((s) => ({ zod: zod(s) }))) export type WithParts = { info: Info diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 5414eba2e5..7f4f608556 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -65,6 +65,9 @@ import { SessionTable } from "./session.sql" // @ts-ignore globalThis.AI_SDK_LOG_WARNINGS = false +const decodeMessageInfo = Schema.decodeUnknownExit(MessageV2.Info) +const decodeMessagePart = Schema.decodeUnknownExit(MessageV2.Part) + const STRUCTURED_OUTPUT_DESCRIPTION = `Use this tool to return your final response in the requested structured format. IMPORTANT: @@ -1292,26 +1295,26 @@ NOTE: At any point in time through this workflow you should feel free to ask the const parts = resolvedParts - const parsed = MessageV2.Info.zod.safeParse(info) - if (!parsed.success) { + const parsed = decodeMessageInfo(info, { errors: "all", propertyOrder: "original" }) + if (Exit.isFailure(parsed)) { log.error("invalid user message before save", { sessionID: input.sessionID, messageID: info.id, agent: info.agent, model: info.model, - issues: parsed.error.issues, + cause: Cause.pretty(parsed.cause), }) } parts.forEach((part, index) => { - const p = MessageV2.Part.zod.safeParse(part) - if (p.success) return + const p = decodeMessagePart(part, { errors: "all", propertyOrder: "original" }) + if (Exit.isSuccess(p)) return log.error("invalid user part before save", { sessionID: input.sessionID, messageID: info.id, partID: part.id, partType: part.type, index, - issues: p.error.issues, + cause: Cause.pretty(p.cause), part, }) }) From 7235c9c9b81f630cde1e8e7c381ad83a84fa82b2 Mon Sep 17 00:00:00 2001 From: Dax Date: Sun, 10 May 2026 23:23:01 -0400 Subject: [PATCH 183/189] Trace data migrations (#26809) --- packages/opencode/src/data-migration.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/src/data-migration.ts b/packages/opencode/src/data-migration.ts index c3e5a9d2b0..0a2973de5d 100644 --- a/packages/opencode/src/data-migration.ts +++ b/packages/opencode/src/data-migration.ts @@ -36,7 +36,7 @@ export const layer = Layer.effect( if (completed) continue log.info("running data migration", { name: migration.name }) - yield* migration.run + yield* migration.run.pipe(Effect.withSpan("DataMigration", { attributes: { name: migration.name } })) Database.use((db) => db .insert(DataMigrationTable) From 518264fcd9aa7f9ac0a67fc73a47a140520c2cae Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Mon, 11 May 2026 00:26:36 -0500 Subject: [PATCH 184/189] fix(opencode): fix full session fork (#26811) --- .../opencode/src/project/instance-store.ts | 6 ++--- .../routes/instance/httpapi/groups/session.ts | 4 ++-- .../instance/httpapi/handlers/session.ts | 23 ++++++++++++++++--- .../test/server/httpapi-session.test.ts | 1 - 4 files changed, 25 insertions(+), 9 deletions(-) diff --git a/packages/opencode/src/project/instance-store.ts b/packages/opencode/src/project/instance-store.ts index 4fa1c3dfff..9707305f93 100644 --- a/packages/opencode/src/project/instance-store.ts +++ b/packages/opencode/src/project/instance-store.ts @@ -86,7 +86,7 @@ export const layer: Layer.Layer runDisposers(ctx.directory)) yield* emitDisposed({ directory: ctx.directory, project: ctx.project.id }) }) @@ -109,7 +109,7 @@ export const layer: Layer.Layer() } cache.set(directory, entry) yield* Effect.gen(function* () { - yield* Effect.logInfo("creating instance", { directory }) + yield* Effect.logInfo("creating instance").pipe(Effect.annotateLogs("directory", directory)) yield* completeLoad(directory, input, entry) }).pipe(Effect.forkIn(scope, { startImmediately: true })) return yield* restore(Deferred.await(entry.deferred)) @@ -125,7 +125,7 @@ export const layer: Layer.Layer() } cache.set(directory, entry) yield* Effect.gen(function* () { - yield* Effect.logInfo("reloading instance", { directory }) + yield* Effect.logInfo("reloading instance").pipe(Effect.annotateLogs("directory", directory)) if (previous) { yield* Deferred.await(previous.deferred).pipe(Effect.ignore) yield* Effect.promise(() => runDisposers(directory)) diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts index ea68e76caf..2053aba3b4 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts @@ -236,9 +236,9 @@ export const SessionApi = HttpApi.make("session") HttpApiEndpoint.post("fork", SessionPaths.fork, { params: { sessionID: SessionID }, query: WorkspaceRoutingQuery, - payload: ForkPayload, + payload: Schema.optional(ForkPayload), success: described(Session.Info, "200"), - error: ApiNotFoundError, + error: [HttpApiError.BadRequest, ApiNotFoundError], }).annotateMerge( OpenApi.annotations({ identifier: "session.fork", diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts index 6aa87ee84e..9230a6fe57 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts @@ -187,13 +187,30 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", const fork = Effect.fn("SessionHttpApi.fork")(function* (ctx: { params: { sessionID: SessionID } - payload: typeof ForkPayload.Type + payload?: typeof ForkPayload.Type }) { return yield* SessionError.mapStorageNotFound( - session.fork({ sessionID: ctx.params.sessionID, messageID: ctx.payload.messageID }), + session.fork({ sessionID: ctx.params.sessionID, messageID: ctx.payload?.messageID }), ) }) + const forkRaw = Effect.fn("SessionHttpApi.forkRaw")(function* (ctx: { + params: { sessionID: SessionID } + request: HttpServerRequest.HttpServerRequest + }) { + const body = yield* Effect.orDie(ctx.request.text) + if (body.trim().length === 0) return yield* fork({ params: ctx.params }) + + const json = yield* Effect.try({ + try: () => JSON.parse(body) as unknown, + catch: () => new HttpApiError.BadRequest({}), + }) + const payload = yield* Schema.decodeUnknownEffect(ForkPayload)(json).pipe( + Effect.mapError(() => new HttpApiError.BadRequest({})), + ) + return yield* fork({ params: ctx.params, payload }) + }) + const abort = Effect.fn("SessionHttpApi.abort")(function* (ctx: { params: { sessionID: SessionID } }) { yield* promptSvc.cancel(ctx.params.sessionID) return true @@ -373,7 +390,7 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", .handleRaw("create", createRaw) .handle("remove", remove) .handle("update", update) - .handle("fork", fork) + .handleRaw("fork", forkRaw) .handle("abort", abort) .handle("init", init) .handle("share", share) diff --git a/packages/opencode/test/server/httpapi-session.test.ts b/packages/opencode/test/server/httpapi-session.test.ts index 24c845183d..210863e0c9 100644 --- a/packages/opencode/test/server/httpapi-session.test.ts +++ b/packages/opencode/test/server/httpapi-session.test.ts @@ -356,7 +356,6 @@ describe("session HttpApi", () => { const forked = yield* requestJson(pathFor(SessionPaths.fork, { sessionID: created.id }), { method: "POST", headers, - body: JSON.stringify({}), }) expect(forked.id).not.toBe(created.id) From b1cb71856eda027cced3c93915544d2d1faea2e9 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Mon, 11 May 2026 05:27:40 +0000 Subject: [PATCH 185/189] chore: generate --- packages/sdk/js/src/v2/gen/types.gen.ts | 4 ++++ packages/sdk/openapi.json | 10 ++++++++++ 2 files changed, 14 insertions(+) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 6b0f4c6f88..5bba8efc0b 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -5590,6 +5590,10 @@ export type SessionForkData = { } export type SessionForkErrors = { + /** + * Bad request + */ + 400: BadRequestError /** * NotFoundError */ diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 40b33147bf..fb29d68ed2 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -5536,6 +5536,16 @@ } } }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, "404": { "description": "NotFoundError", "content": { From 5d6f2a1524ba37560fe6cdea5aa684dc196dbc99 Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Mon, 11 May 2026 15:15:03 +0800 Subject: [PATCH 186/189] fix(ui): use part_text_accum_delta to prevent markdown cutoff during streaming (#26822) --- .../app/src/context/global-sync/child-store.ts | 1 + .../context/global-sync/event-reducer.test.ts | 1 + .../src/context/global-sync/event-reducer.ts | 17 +++++++++++++++++ .../context/global-sync/session-cache.test.ts | 5 +++++ .../src/context/global-sync/session-cache.ts | 4 ++++ packages/app/src/context/global-sync/types.ts | 3 +++ packages/ui/src/components/message-part.tsx | 5 +++-- packages/ui/src/context/data.tsx | 3 +++ 8 files changed, 37 insertions(+), 2 deletions(-) diff --git a/packages/app/src/context/global-sync/child-store.ts b/packages/app/src/context/global-sync/child-store.ts index 0138310cdc..737c6bedc9 100644 --- a/packages/app/src/context/global-sync/child-store.ts +++ b/packages/app/src/context/global-sync/child-store.ts @@ -231,6 +231,7 @@ export function createChildStoreManager(input: { limit: 5, message: {}, part: {}, + part_text_accum_delta: {}, }) children[key] = child disposers.set(key, dispose) diff --git a/packages/app/src/context/global-sync/event-reducer.test.ts b/packages/app/src/context/global-sync/event-reducer.test.ts index 892129788e..f02ac5c7be 100644 --- a/packages/app/src/context/global-sync/event-reducer.test.ts +++ b/packages/app/src/context/global-sync/event-reducer.test.ts @@ -81,6 +81,7 @@ const baseState = (input: Partial = {}) => limit: 10, message: {}, part: {}, + part_text_accum_delta: {}, ...input, }) as State diff --git a/packages/app/src/context/global-sync/event-reducer.ts b/packages/app/src/context/global-sync/event-reducer.ts index 5f43c341bc..13d34ef6c5 100644 --- a/packages/app/src/context/global-sync/event-reducer.ts +++ b/packages/app/src/context/global-sync/event-reducer.ts @@ -211,6 +211,12 @@ export function applyDirectoryEvent(input: { const result = Binary.search(messages, props.messageID, (m) => m.id) if (result.found) messages.splice(result.index, 1) } + const parts = draft.part[props.messageID] + if (parts) { + for (const part of parts) { + delete draft.part_text_accum_delta[part.id] + } + } delete draft.part[props.messageID] }), ) @@ -219,6 +225,11 @@ export function applyDirectoryEvent(input: { case "message.part.updated": { const part = (event.properties as { part: Part }).part if (SKIP_PARTS.has(part.type)) break + input.setStore( + produce((draft) => { + delete draft.part_text_accum_delta[part.id] + }), + ) const parts = input.store.part[part.messageID] if (!parts) { input.setStore("part", part.messageID, [part]) @@ -240,6 +251,11 @@ export function applyDirectoryEvent(input: { } case "message.part.removed": { const props = event.properties as { messageID: string; partID: string } + input.setStore( + produce((draft) => { + delete draft.part_text_accum_delta[props.partID] + }), + ) const parts = input.store.part[props.messageID] if (!parts) break const result = Binary.search(parts, props.partID, (p) => p.id) @@ -263,6 +279,7 @@ export function applyDirectoryEvent(input: { if (!parts) break const result = Binary.search(parts, props.partID, (p) => p.id) if (!result.found) break + input.setStore("part_text_accum_delta", props.partID, (existing) => (existing ?? "") + props.delta) input.setStore( "part", props.messageID, diff --git a/packages/app/src/context/global-sync/session-cache.test.ts b/packages/app/src/context/global-sync/session-cache.test.ts index 472ac219e9..4b2be505ea 100644 --- a/packages/app/src/context/global-sync/session-cache.test.ts +++ b/packages/app/src/context/global-sync/session-cache.test.ts @@ -39,6 +39,7 @@ describe("app session cache", () => { part: Record permission: Record question: Record + part_text_accum_delta: Record } = { session_status: { ses_1: { type: "busy" } as SessionStatus }, session_diff: { ses_1: [] }, @@ -47,12 +48,14 @@ describe("app session cache", () => { part: { msg_1: [part("prt_1", "ses_1", "msg_1")] }, permission: { ses_1: [] as PermissionRequest[] }, question: { ses_1: [] as QuestionRequest[] }, + part_text_accum_delta: { prt_1: "streamed text" }, } dropSessionCaches(store, ["ses_1"]) expect(store.message.ses_1).toBeUndefined() expect(store.part.msg_1).toBeUndefined() + expect(store.part_text_accum_delta.prt_1).toBeUndefined() expect(store.todo.ses_1).toBeUndefined() expect(store.session_diff.ses_1).toBeUndefined() expect(store.session_status.ses_1).toBeUndefined() @@ -70,6 +73,7 @@ describe("app session cache", () => { part: Record permission: Record question: Record + part_text_accum_delta: Record } = { session_status: {}, session_diff: {}, @@ -78,6 +82,7 @@ describe("app session cache", () => { part: { [m.id]: [part("prt_1", "ses_1", m.id)] }, permission: {}, question: {}, + part_text_accum_delta: {}, } dropSessionCaches(store, ["ses_1"]) diff --git a/packages/app/src/context/global-sync/session-cache.ts b/packages/app/src/context/global-sync/session-cache.ts index 6f4d81062b..05cdc84643 100644 --- a/packages/app/src/context/global-sync/session-cache.ts +++ b/packages/app/src/context/global-sync/session-cache.ts @@ -18,6 +18,7 @@ type SessionCache = { part: Record permission: Record question: Record + part_text_accum_delta: Record } export function dropSessionCaches(store: SessionCache, sessionIDs: Iterable) { @@ -27,6 +28,9 @@ export function dropSessionCaches(store: SessionCache, sessionIDs: Iterable stale.has(part?.sessionID ?? ""))) continue + for (const part of parts) { + delete store.part_text_accum_delta[part.id] + } delete store.part[key] } diff --git a/packages/app/src/context/global-sync/types.ts b/packages/app/src/context/global-sync/types.ts index e3ec83c5ee..6bf42a0737 100644 --- a/packages/app/src/context/global-sync/types.ts +++ b/packages/app/src/context/global-sync/types.ts @@ -72,6 +72,9 @@ export type State = { part: { [messageID: string]: Part[] } + part_text_accum_delta: { + [partID: string]: string + } } export type VcsCache = { diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 137f689756..7a7d5b15fa 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -1461,7 +1461,7 @@ PART_MAPPING["text"] = function TextPartDisplay(props) { const streaming = createMemo( () => props.message.role === "assistant" && typeof (props.message as AssistantMessage).time.completed !== "number", ) - const text = () => (part().text ?? "").trim() + const text = () => (data.store.part_text_accum_delta?.[part().id] ?? part().text ?? "").trim() const isLastTextPart = createMemo(() => { const last = (data.store.part?.[props.message.id] ?? []) .filter((item): item is TextPart => item?.type === "text" && !!item.text?.trim()) @@ -1521,11 +1521,12 @@ PART_MAPPING["text"] = function TextPartDisplay(props) { } PART_MAPPING["reasoning"] = function ReasoningPartDisplay(props) { + const data = useData() const part = () => props.part as ReasoningPart const streaming = createMemo( () => props.message.role === "assistant" && typeof (props.message as AssistantMessage).time.completed !== "number", ) - const text = () => part().text.trim() + const text = () => (data.store.part_text_accum_delta?.[part().id] ?? part().text).trim() return ( diff --git a/packages/ui/src/context/data.tsx b/packages/ui/src/context/data.tsx index 632bed0cfa..3d015257f3 100644 --- a/packages/ui/src/context/data.tsx +++ b/packages/ui/src/context/data.tsx @@ -24,6 +24,9 @@ type Data = { part: { [messageID: string]: Part[] } + part_text_accum_delta?: { + [partID: string]: string + } } export type NavigateToSessionFn = (sessionID: string) => void From 7e997cfba418f481e08b06214907ebf938c5f6dd Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Mon, 11 May 2026 12:57:26 +0530 Subject: [PATCH 187/189] refactor(scout): resolve configured reference mentions (#26701) --- packages/opencode/src/agent/agent.ts | 71 -------- packages/opencode/src/session/prompt.ts | 170 +++++++++++++++++- .../src/v2/session-message-updater.ts | 1 + packages/opencode/src/v2/session-message.ts | 1 + packages/opencode/src/v2/session-prompt.ts | 13 ++ packages/opencode/test/agent/agent.test.ts | 52 +----- packages/opencode/test/session/prompt.test.ts | 97 ++++++++++ .../test/session/snapshot-tool-race.test.ts | 1 + packages/sdk/js/src/v2/gen/types.gen.ts | 14 ++ 9 files changed, 304 insertions(+), 116 deletions(-) diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index 5917240cdb..777f6e6d17 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -26,7 +26,6 @@ import * as Option from "effect/Option" import * as OtelTracer from "@effect/opentelemetry/Tracer" import { zod } from "@opencode-ai/core/effect-zod" import { withStatics, type DeepMutable } from "@opencode-ai/core/schema" -import { Reference } from "@/reference/reference" export const Info = Schema.Struct({ name: Schema.String, @@ -301,76 +300,6 @@ export const layer = Layer.effect( item.permission = Permission.merge(item.permission, Permission.fromConfig(value.permission ?? {})) } - function referencePrompt(reference: Reference.Resolved) { - if (reference.kind === "local") { - return [ - `You are configured reference @${reference.name}, a read-only research agent for external reference material.`, - `Local directory: ${reference.path}`, - `Inspect this directory as the primary reference source. Prefer repo_overview with path ${JSON.stringify(reference.path)} before broader searches. Do not edit files.`, - `Return exact absolute file paths for findings whenever possible.`, - ].join("\n\n") - } - - if (reference.kind === "invalid") { - return [ - `You are configured reference @${reference.name}, but this reference is not usable yet.`, - `Configured repository: ${reference.repository}`, - `Problem: ${reference.message}`, - `Explain this configuration problem if invoked. Do not edit files or attempt fallback clones.`, - ].join("\n\n") - } - - return [ - `You are configured reference @${reference.name}, a read-only research agent for external reference material.`, - `Repository: ${reference.repository}`, - ...(reference.branch ? [`Branch/ref: ${reference.branch}`] : []), - `Cached directory: ${reference.path}`, - `OpenCode materializes this configured repository before use. Do not call repo_clone for this reference.`, - `Inspect the cached directory as the primary reference source. Prefer repo_overview with path ${JSON.stringify(reference.path)} before broader searches, then use Glob, Grep, and Read inside that directory. Do not edit files.`, - `Return exact absolute file paths for findings whenever possible.`, - ].join("\n\n") - } - - function referenceDescription(reference: Reference.Resolved) { - if (reference.kind === "local") return `Scout reference for local directory ${reference.path}` - if (reference.kind === "git") return `Scout reference for repository ${reference.repository}` - return `Invalid Scout reference for repository ${reference.repository}` - } - - if (Flag.OPENCODE_EXPERIMENTAL_SCOUT) { - const resolvedReferences = Reference.resolveAll({ - references: cfg.reference ?? {}, - directory: ctx.directory, - worktree: ctx.worktree, - }) - for (const resolved of resolvedReferences) { - if (agents[resolved.name]) continue - const localPath = resolved.kind === "invalid" ? undefined : resolved.path - agents[resolved.name] = { - name: resolved.name, - description: referenceDescription(resolved), - permission: Permission.merge( - agents.scout.permission, - Permission.fromConfig({ - repo_clone: "deny", - ...(localPath - ? { - external_directory: { - [localPath]: "allow", - [path.join(localPath, "*")]: "allow", - }, - } - : {}), - }), - ), - prompt: referencePrompt(resolved), - options: { reference: cfg.reference?.[resolved.name], resolved }, - mode: "subagent", - native: false, - } - } - } - // Ensure Truncate.GLOB is allowed unless explicitly configured for (const name in agents) { const agent = agents[name] diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 7f4f608556..3b919e2f0a 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -56,7 +56,8 @@ import { EffectBridge } from "@/effect/bridge" import { SyncEvent } from "@/sync" import { SessionEvent } from "@/v2/session-event" import { Modelv2 } from "@/v2/model" -import { AgentAttachment, FileAttachment, Source } from "@/v2/session-prompt" +import { AgentAttachment, FileAttachment, ReferenceAttachment, Source } from "@/v2/session-prompt" +import { Reference } from "@/reference/reference" import * as DateTime from "effect/DateTime" import { eq } from "@/storage/db" import * as Database from "@/storage/db" @@ -81,6 +82,45 @@ const STRUCTURED_OUTPUT_SYSTEM_PROMPT = `IMPORTANT: The user has requested struc const log = Log.create({ service: "session.prompt" }) const elog = EffectLogger.create({ service: "session.prompt" }) +type ReferencePromptMetadata = { + name: string + kind: "local" | "git" | "invalid" + path?: string + repository?: string + branch?: string + target?: string + targetPath?: string + problem?: string + source: { value: string; start: number; end: number } +} + +function stringField(record: Record, key: string) { + return typeof record[key] === "string" ? record[key] : undefined +} + +function referencePromptMetadata(input: unknown): ReferencePromptMetadata | undefined { + if (!input || typeof input !== "object" || Array.isArray(input)) return + const record = input as Record + const name = stringField(record, "name") + const kind = stringField(record, "kind") + if (!name || (kind !== "local" && kind !== "git" && kind !== "invalid")) return + if (!record.source || typeof record.source !== "object" || Array.isArray(record.source)) return + const source = record.source as Record + const value = stringField(source, "value") + if (!value || typeof source.start !== "number" || typeof source.end !== "number") return + return { + name, + kind, + path: stringField(record, "path"), + repository: stringField(record, "repository"), + branch: stringField(record, "branch"), + target: stringField(record, "target"), + targetPath: stringField(record, "targetPath"), + problem: stringField(record, "problem"), + source: { value, start: source.start, end: source.end }, + } +} + export interface Interface { readonly cancel: (sessionID: SessionID) => Effect.Effect readonly prompt: (input: PromptInput) => Effect.Effect @@ -119,6 +159,7 @@ export const layer = Layer.effect( const summary = yield* SessionSummary.Service const sys = yield* SystemPrompt.Service const llm = yield* LLM.Service + const references = yield* Reference.Service const sync = yield* SyncEvent.Service const runner = Effect.fn("SessionPrompt.runner")(function* () { return yield* EffectBridge.make() @@ -141,12 +182,116 @@ export const layer = Layer.effect( const parts: Types.DeepMutable = [{ type: "text", text: template }] const files = ConfigMarkdown.files(template) const seen = new Set() + const mentionSource = (match: RegExpMatchArray) => { + const start = match.index ?? 0 + return { value: match[0], start, end: start + match[0].length } + } + const referenceTextPart = (input: { + reference: Reference.Resolved + source: ReturnType + target?: string + targetPath?: string + problem?: string + }): MessageV2.TextPartInput => { + const metadata: ReferencePromptMetadata = { + name: input.reference.name, + kind: input.reference.kind, + ...(input.reference.kind === "invalid" + ? { repository: input.reference.repository } + : { path: input.reference.path }), + ...(input.reference.kind === "git" + ? { repository: input.reference.repository, branch: input.reference.branch } + : {}), + ...(input.target === undefined ? {} : { target: input.target }), + ...(input.targetPath ? { targetPath: input.targetPath } : {}), + problem: input.problem ?? (input.reference.kind === "invalid" ? input.reference.message : undefined), + source: input.source, + } + const label = metadata.target === undefined ? `@${metadata.name}` : `@${metadata.name}/${metadata.target}` + return { + type: "text", + synthetic: true, + text: [ + `Referenced configured reference ${label}.`, + ...(metadata.kind === "local" ? ["Kind: local directory"] : []), + ...(metadata.kind === "git" ? ["Kind: git repository"] : []), + ...(metadata.repository ? [`Repository: ${metadata.repository}`] : []), + ...(metadata.branch ? [`Branch/ref: ${metadata.branch}`] : []), + ...(metadata.path ? [`Reference root: ${metadata.path}`] : []), + ...(metadata.targetPath ? [`Resolved path: ${metadata.targetPath}`] : []), + ...(metadata.problem + ? [`Problem: ${metadata.problem}`] + : [ + "For targeted context, inspect the reference path directly with Read, Glob, and Grep. For broader research, call the task tool with subagent scout and include this reference path.", + ]), + ].join("\n"), + metadata: { reference: metadata }, + } + } yield* Effect.forEach( files, Effect.fnUntraced(function* (match) { const name = match[1] + if (!name) return if (seen.has(name)) return seen.add(name) + + const slash = name.indexOf("/") + const alias = slash === -1 ? name : name.slice(0, slash) + const reference = yield* references.get(alias) + if (reference) { + const source = mentionSource(match) + if (reference.kind === "invalid") { + parts.push( + referenceTextPart({ reference, source, target: slash === -1 ? undefined : name.slice(slash + 1) }), + ) + return + } + + yield* references.ensure(reference.path) + if (slash === -1) { + parts.push(referenceTextPart({ reference, source })) + return + } + + const target = name.slice(slash + 1) + const targetPath = path.resolve(reference.path, target) + if (!AppFileSystem.contains(reference.path, targetPath)) { + parts.push( + referenceTextPart({ + reference, + source, + target, + targetPath, + problem: `Path escapes configured reference @${alias}: ${target}`, + }), + ) + return + } + + const info = yield* fsys.stat(targetPath).pipe(Effect.option) + if (Option.isNone(info)) { + parts.push( + referenceTextPart({ + reference, + source, + target, + targetPath, + problem: `Path does not exist inside configured reference @${alias}: ${target}`, + }), + ) + return + } + + parts.push({ + type: "file", + url: pathToFileURL(targetPath).href, + filename: name, + mime: info.value.type === "Directory" ? "application/x-directory" : "text/plain", + }) + return + } + const filepath = name.startsWith("~/") ? path.join(os.homedir(), name.slice(2)) : path.resolve(ctx.worktree, name) @@ -1326,6 +1471,26 @@ NOTE: At any point in time through this workflow you should feel free to ask the if (part.type === "text") { if (part.synthetic) result.synthetic.push(part.text) else result.text.push(part.text) + const reference = referencePromptMetadata(part.metadata?.reference) + if (reference) { + result.references.push( + new ReferenceAttachment({ + name: reference.name, + kind: reference.kind, + uri: reference.path ? pathToFileURL(reference.path).href : undefined, + repository: reference.repository, + branch: reference.branch, + target: reference.target, + targetUri: reference.targetPath ? pathToFileURL(reference.targetPath).href : undefined, + problem: reference.problem, + source: new Source({ + start: reference.source.start, + end: reference.source.end, + text: reference.source.value, + }), + }), + ) + } } if (part.type === "file") { result.files.push( @@ -1363,6 +1528,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the text: [] as string[], files: [] as FileAttachment[], agents: [] as AgentAttachment[], + references: [] as ReferenceAttachment[], synthetic: [] as string[], }, ) @@ -1375,6 +1541,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the text: nextPrompt.text.join("\n"), files: nextPrompt.files, agents: nextPrompt.agents, + references: nextPrompt.references, }, }) } @@ -1817,6 +1984,7 @@ export const defaultLayer = Layer.suspend(() => Agent.defaultLayer, SystemPrompt.defaultLayer, LLM.defaultLayer, + Reference.defaultLayer, Bus.layer, CrossSpawnSpawner.defaultLayer, SyncEvent.defaultLayer, diff --git a/packages/opencode/src/v2/session-message-updater.ts b/packages/opencode/src/v2/session-message-updater.ts index 80ecb1011e..bbdf59c555 100644 --- a/packages/opencode/src/v2/session-message-updater.ts +++ b/packages/opencode/src/v2/session-message-updater.ts @@ -123,6 +123,7 @@ export function update(adapter: Adapter, event: SessionEvent.Eve text: event.data.prompt.text, files: event.data.prompt.files, agents: event.data.prompt.agents, + references: event.data.prompt.references, time: { created: event.data.timestamp }, }), ) diff --git a/packages/opencode/src/v2/session-message.ts b/packages/opencode/src/v2/session-message.ts index 024e28c450..62fc75fc83 100644 --- a/packages/opencode/src/v2/session-message.ts +++ b/packages/opencode/src/v2/session-message.ts @@ -34,6 +34,7 @@ export class User extends Schema.Class("Session.Message.User")({ text: Prompt.fields.text, files: Prompt.fields.files, agents: Prompt.fields.agents, + references: Prompt.fields.references, type: Schema.Literal("user"), time: Schema.Struct({ created: V2Schema.DateTimeUtcFromMillis, diff --git a/packages/opencode/src/v2/session-prompt.ts b/packages/opencode/src/v2/session-prompt.ts index 86d8e52eb7..14167fc288 100644 --- a/packages/opencode/src/v2/session-prompt.ts +++ b/packages/opencode/src/v2/session-prompt.ts @@ -29,8 +29,21 @@ export class AgentAttachment extends Schema.Class("Prompt.Agent source: Source.pipe(Schema.optional), }) {} +export class ReferenceAttachment extends Schema.Class("Prompt.ReferenceAttachment")({ + name: Schema.String, + kind: Schema.Literals(["local", "git", "invalid"]), + uri: Schema.String.pipe(Schema.optional), + repository: Schema.String.pipe(Schema.optional), + branch: Schema.String.pipe(Schema.optional), + target: Schema.String.pipe(Schema.optional), + targetUri: Schema.String.pipe(Schema.optional), + problem: Schema.String.pipe(Schema.optional), + source: Source.pipe(Schema.optional), +}) {} + export class Prompt extends Schema.Class("Prompt")({ text: Schema.String, files: Schema.Array(FileAttachment).pipe(Schema.optional), agents: Schema.Array(AgentAttachment).pipe(Schema.optional), + references: Schema.Array(ReferenceAttachment).pipe(Schema.optional), }) {} diff --git a/packages/opencode/test/agent/agent.test.ts b/packages/opencode/test/agent/agent.test.ts index cb6f60503f..df68fdfdc6 100644 --- a/packages/opencode/test/agent/agent.test.ts +++ b/packages/opencode/test/agent/agent.test.ts @@ -141,16 +141,12 @@ test("scout agent allows repo cloning and repo cache reads", async () => { }) }) -test("reference config creates scout-backed subagents", async () => { +test("reference config does not create subagents", async () => { await withExperimentalScout(true, async () => { await using tmp = await tmpdir({ config: { reference: { effect: "github.com/effect/effect-smol", - effectDev: { - repository: "https://github.com/effect/effect-smol", - branch: "dev", - }, effectFull: { repository: "Effect-TS/effect", branch: "main", @@ -165,45 +161,13 @@ test("reference config creates scout-backed subagents", async () => { await WithInstance.provide({ directory: tmp.path, fn: async () => { - const effect = await load(tmp.path, (svc) => svc.get("effect")) - const effectDev = await load(tmp.path, (svc) => svc.get("effectDev")) - const effectFull = await load(tmp.path, (svc) => svc.get("effectFull")) - const local = await load(tmp.path, (svc) => svc.get("localdocs")) - const localFull = await load(tmp.path, (svc) => svc.get("localdocsFull")) - - expect(effect).toBeDefined() - expect(effect?.mode).toBe("subagent") - expect(effect?.prompt).toContain("Repository: github.com/effect/effect-smol") - expect(effect?.prompt).toContain( - `Cached directory: ${path.join(Global.Path.repos, "github.com", "effect", "effect-smol")}`, - ) - expect(effect?.prompt).toContain("Do not call repo_clone") - expect(evalPerm(effect, "repo_clone")).toBe("deny") - - expect(effectDev).toBeDefined() - expect(effectDev?.prompt).toContain("Problem: Reference conflicts with @effect") - expect(effectDev?.prompt).not.toContain("Cached directory:") - - expect(effectFull).toBeDefined() - expect(effectFull?.mode).toBe("subagent") - expect(effectFull?.prompt).toContain("Repository: Effect-TS/effect") - expect(effectFull?.prompt).toContain("Branch/ref: main") - expect(evalPerm(effectFull, "repo_clone")).toBe("deny") - - expect(local).toBeDefined() - expect(local?.mode).toBe("subagent") - expect(local?.prompt).toContain(`Local directory: ${path.resolve(tmp.path, "../docs")}`) - expect( - Permission.evaluate( - "external_directory", - path.join(path.resolve(tmp.path, "../docs"), "README.md"), - local!.permission, - ).action, - ).toBe("allow") - - expect(localFull).toBeDefined() - expect(localFull?.mode).toBe("subagent") - expect(localFull?.prompt).toContain(`Local directory: ${path.resolve(tmp.path, "../local-docs")}`) + const agents = await load(tmp.path, (svc) => svc.list()) + const names = agents.map((agent) => agent.name) + expect(names).toContain("scout") + expect(names).not.toContain("effect") + expect(names).not.toContain("effectFull") + expect(names).not.toContain("localdocs") + expect(names).not.toContain("localdocsFull") }, }) }) diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index 42c9a81cd2..cb771aee35 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -2,6 +2,7 @@ import { NodeFileSystem } from "@effect/platform-node" import { FetchHttpClient } from "effect/unstable/http" import { expect } from "bun:test" import { Cause, Effect, Exit, Fiber, Layer } from "effect" +import fs from "fs/promises" import path from "path" import { fileURLToPath } from "url" import { NamedError } from "@opencode-ai/core/util/error" @@ -203,6 +204,7 @@ function makeHttp() { SessionPrompt.layer.pipe( Layer.provide(SessionRevert.defaultLayer), Layer.provide(Image.defaultLayer), + Layer.provide(Reference.defaultLayer), Layer.provide(summary), Layer.provideMerge(run), Layer.provideMerge(compact), @@ -1791,6 +1793,101 @@ it.live("keeps stored part order stable when file resolution is async", () => ), ) +it.live("resolves configured reference mentions before workspace paths and agents", () => + provideTmpdirInstance( + (dir) => + Effect.gen(function* () { + const docs = path.join(dir, "external-docs") + yield* Effect.promise(() => fs.mkdir(path.join(docs, "guide"), { recursive: true })) + yield* Effect.promise(() => fs.mkdir(path.join(dir, "docs"), { recursive: true })) + yield* Effect.promise(() => Bun.write(path.join(docs, "README.md"), "reference readme")) + yield* Effect.promise(() => Bun.write(path.join(docs, "guide", "intro.md"), "reference intro")) + yield* Effect.promise(() => Bun.write(path.join(dir, "docs", "README.md"), "workspace readme")) + + const prompt = yield* SessionPrompt.Service + const parts = yield* prompt.resolvePromptParts( + "Use @docs and @docs/README.md and @docs/guide and @docs/missing.md and @docs/README.md and @build", + ) + const references = parts.filter( + (part): part is MessageV2.TextPartInput => + part.type === "text" && part.synthetic === true && part.text.startsWith("Referenced configured reference "), + ) + const files = parts.filter((part): part is MessageV2.FilePartInput => part.type === "file") + const agents = parts.filter((part): part is MessageV2.AgentPartInput => part.type === "agent") + const bare = references.find((part) => part.text.includes("@docs.")) + const missing = references.find((part) => part.text.includes("@docs/missing.md")) + const guide = files.find((part) => part.filename === "docs/guide") + + expect(references.length).toBe(2) + expect(bare?.metadata?.reference).toMatchObject({ + name: "docs", + kind: "local", + path: docs, + }) + expect(missing?.text).toContain("Path does not exist inside configured reference @docs") + expect(missing?.metadata?.reference).toMatchObject({ + target: "missing.md", + targetPath: path.join(docs, "missing.md"), + }) + + expect(files.length).toBe(2) + expect(files.map((file) => fileURLToPath(file.url)).sort()).toEqual( + [path.join(docs, "README.md"), path.join(docs, "guide")].sort(), + ) + expect(guide?.mime).toBe("application/x-directory") + expect(agents.map((agent) => agent.name)).toEqual(["build"]) + }), + { + git: true, + config: { + ...cfg, + reference: { + docs: "./external-docs", + }, + }, + }, + ), +) + +it.live("injects metadata for bare configured reference mentions", () => + provideTmpdirInstance( + (dir) => + Effect.gen(function* () { + const docs = path.join(dir, "external-docs") + yield* Effect.promise(() => fs.mkdir(docs, { recursive: true })) + + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const session = yield* sessions.create({}) + const message = yield* prompt.prompt({ + sessionID: session.id, + noReply: true, + parts: yield* prompt.resolvePromptParts("Use @docs for context"), + }) + + const stored = MessageV2.get({ sessionID: session.id, messageID: message.info.id }) + const synthetic = stored.parts + .filter((part): part is MessageV2.TextPart => part.type === "text" && part.synthetic === true) + const reference = synthetic.find((part) => part.text.startsWith("Referenced configured reference @docs.")) + + expect(reference?.metadata?.reference).toMatchObject({ name: "docs", kind: "local", path: docs }) + expect(synthetic.some((part) => part.text.includes(`Reference root: ${docs}`))).toBe(true) + expect(synthetic.some((part) => part.text.includes("subagent scout"))).toBe(true) + + yield* sessions.remove(session.id) + }), + { + git: true, + config: { + ...cfg, + reference: { + docs: "./external-docs", + }, + }, + }, + ), +) + // Special characters in filenames it.live("handles filenames with # character", () => diff --git a/packages/opencode/test/session/snapshot-tool-race.test.ts b/packages/opencode/test/session/snapshot-tool-race.test.ts index 5c47df4c0d..8640612e98 100644 --- a/packages/opencode/test/session/snapshot-tool-race.test.ts +++ b/packages/opencode/test/session/snapshot-tool-race.test.ts @@ -154,6 +154,7 @@ function makeHttp() { SessionPrompt.layer.pipe( Layer.provide(SessionRevert.defaultLayer), Layer.provide(Image.defaultLayer), + Layer.provide(Reference.defaultLayer), Layer.provide(SessionSummary.defaultLayer), Layer.provideMerge(run), Layer.provideMerge(compact), diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 5bba8efc0b..da80645ad7 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -771,6 +771,7 @@ export type Prompt = { text: string files?: Array agents?: Array + references?: Array } export type GlobalEvent = { @@ -2718,6 +2719,18 @@ export type PromptAgentAttachment = { source?: PromptSource } +export type PromptReferenceAttachment = { + name: string + kind: "local" | "git" | "invalid" + uri?: string + repository?: string + branch?: string + target?: string + targetUri?: string + problem?: string + source?: PromptSource +} + export type EventSessionNextPrompted = { id: string type: "session.next.prompted" @@ -3121,6 +3134,7 @@ export type SessionMessageUser = { text: string files?: Array agents?: Array + references?: Array type: "user" } From 3bd98ea05500c8b93b0270b9131cb53d94a635f5 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Mon, 11 May 2026 07:28:32 +0000 Subject: [PATCH 188/189] chore: generate --- packages/opencode/test/session/prompt.test.ts | 5 +- packages/sdk/openapi.json | 47 +++++++++++++++++++ 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index cb771aee35..f5c1674658 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -1866,8 +1866,9 @@ it.live("injects metadata for bare configured reference mentions", () => }) const stored = MessageV2.get({ sessionID: session.id, messageID: message.info.id }) - const synthetic = stored.parts - .filter((part): part is MessageV2.TextPart => part.type === "text" && part.synthetic === true) + const synthetic = stored.parts.filter( + (part): part is MessageV2.TextPart => part.type === "text" && part.synthetic === true, + ) const reference = synthetic.find((part) => part.text.startsWith("Referenced configured reference @docs.")) expect(reference?.metadata?.reference).toMatchObject({ name: "docs", kind: "local", path: docs }) diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index fb29d68ed2..df0427f455 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -11120,6 +11120,12 @@ "items": { "$ref": "#/components/schemas/PromptAgentAttachment" } + }, + "references": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PromptReferenceAttachment" + } } }, "required": ["text"], @@ -17039,6 +17045,41 @@ "required": ["name"], "additionalProperties": false }, + "PromptReferenceAttachment": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "kind": { + "type": "string", + "enum": ["local", "git", "invalid"] + }, + "uri": { + "type": "string" + }, + "repository": { + "type": "string" + }, + "branch": { + "type": "string" + }, + "target": { + "type": "string" + }, + "targetUri": { + "type": "string" + }, + "problem": { + "type": "string" + }, + "source": { + "$ref": "#/components/schemas/PromptSource" + } + }, + "required": ["name", "kind"], + "additionalProperties": false + }, "EventSessionNextPrompted": { "type": "object", "properties": { @@ -18231,6 +18272,12 @@ "$ref": "#/components/schemas/PromptAgentAttachment" } }, + "references": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PromptReferenceAttachment" + } + }, "type": { "type": "string", "enum": ["user"] From 2d0d3d596ec06e0d83216ece48001f190cc4581b Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Mon, 11 May 2026 13:40:36 +0530 Subject: [PATCH 189/189] feat(compaction): serialize compaction tail (#26830) --- packages/opencode/src/config/config.ts | 4 +- packages/opencode/src/session/compaction.ts | 60 +++++++++++-------- packages/opencode/src/session/message-v2.ts | 42 +------------ .../opencode/test/session/compaction.test.ts | 50 +++++++++------- .../test/session/messages-pagination.test.ts | 16 ++--- 5 files changed, 77 insertions(+), 95 deletions(-) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 41ccac7492..114a388036 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -273,10 +273,10 @@ export const Info = Schema.Struct({ }), tail_turns: Schema.optional(NonNegativeInt).annotate({ description: - "Number of recent user turns, including their following assistant/tool responses, to keep verbatim during compaction (default: 2)", + "Number of recent user turns, including their following assistant/tool responses, to serialize into the compaction summary (default: 2)", }), preserve_recent_tokens: Schema.optional(NonNegativeInt).annotate({ - description: "Maximum number of tokens from recent turns to preserve verbatim after compaction", + description: "Maximum number of tokens from recent turns to serialize into the compaction summary", }), reserved: Schema.optional(NonNegativeInt).annotate({ description: "Token buffer for compaction. Leaves enough window to avoid overflow during compaction.", diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index 4eafbdf749..3ca4f074f9 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -79,12 +79,10 @@ Rules: type Turn = { start: number end: number - id: MessageID } type Tail = { start: number - id: MessageID } type CompletedCompaction = { @@ -121,19 +119,41 @@ function completedCompactions(messages: MessageV2.WithParts[]) { }) } -function buildPrompt(input: { previousSummary?: string; context: string[] }) { +function buildPrompt(input: { previousSummary?: string; context: string[]; tail?: string }) { + const source = input.tail + ? "the conversation history above and the serialized recent conversation tail below" + : "the conversation history above" const anchor = input.previousSummary ? [ - "Update the anchored summary below using the conversation history above.", + `Update the anchored summary below using ${source}.`, "Preserve still-true details, remove stale details, and merge in the new facts.", "", input.previousSummary, "", ].join("\n") - : "Create a new anchored summary from the conversation history above." - return [anchor, SUMMARY_TEMPLATE, ...input.context].join("\n\n") + : `Create a new anchored summary from ${source}.` + const tail = input.tail + ? [ + "Fold this serialized recent conversation tail into the summary; it is not provider message history.", + "", + input.tail, + "", + ].join("\n") + : undefined + return [anchor, ...(tail ? [tail] : []), SUMMARY_TEMPLATE, ...input.context].join("\n\n") } +const serialize = Effect.fn("SessionCompaction.serialize")(function* (input: { + messages: MessageV2.WithParts[] + model: Provider.Model +}) { + const messages = yield* MessageV2.toModelMessagesEffect(input.messages, input.model, { + stripMedia: true, + toolOutputMaxChars: TOOL_OUTPUT_MAX_CHARS, + }) + return messages.length ? JSON.stringify(messages, null, 2) : undefined +}) + function preserveRecentBudget(input: { cfg: Config.Info; model: Provider.Model }) { return ( input.cfg.compaction?.preserve_recent_tokens ?? @@ -150,7 +170,6 @@ function turns(messages: MessageV2.WithParts[]) { result.push({ start: i, end: messages.length, - id: msg.info.id, }) } for (let i = 0; i < result.length - 1; i++) { @@ -177,7 +196,6 @@ function splitTurn(input: { if (size > input.budget) continue return { start, - id: input.messages[start]!.info.id, } satisfies Tail } return undefined @@ -244,8 +262,7 @@ export const layer: Layer.Layer< messages: MessageV2.WithParts[] model: Provider.Model }) { - const msgs = yield* MessageV2.toModelMessagesEffect(input.messages, input.model) - return Token.estimate(JSON.stringify(msgs)) + return Token.estimate((yield* serialize(input)) ?? "") }) const select = Effect.fn("SessionCompaction.select")(function* (input: { @@ -254,10 +271,10 @@ export const layer: Layer.Layer< model: Provider.Model }) { const limit = input.cfg.compaction?.tail_turns ?? DEFAULT_TAIL_TURNS - if (limit <= 0) return { head: input.messages, tail_start_id: undefined } + if (limit <= 0) return { head: input.messages, tail: [] } const budget = preserveRecentBudget({ cfg: input.cfg, model: input.model }) const all = turns(input.messages) - if (!all.length) return { head: input.messages, tail_start_id: undefined } + if (!all.length) return { head: input.messages, tail: [] } const recent = all.slice(-limit) const sizes = yield* Effect.forEach( recent, @@ -276,7 +293,7 @@ export const layer: Layer.Layer< const size = sizes[i] if (total + size <= budget) { total += size - keep = { start: turn.start, id: turn.id } + keep = { start: turn.start } continue } const remaining = budget - total @@ -292,10 +309,10 @@ export const layer: Layer.Layer< break } - if (!keep || keep.start === 0) return { head: input.messages, tail_start_id: undefined } + if (!keep) return { head: input.messages, tail: [] } return { head: input.messages.slice(0, keep.start), - tail_start_id: keep.id, + tail: input.messages.slice(keep.start), } }) @@ -406,7 +423,10 @@ export const layer: Layer.Layer< { sessionID: input.sessionID }, { context: [], prompt: undefined }, ) - const nextPrompt = compacting.prompt ?? buildPrompt({ previousSummary, context: compacting.context }) + const tailMessages = structuredClone(selected.tail) + yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: tailMessages }) + const tail = yield* serialize({ messages: tailMessages, model }) + const nextPrompt = compacting.prompt ?? buildPrompt({ previousSummary, context: compacting.context, tail }) const msgs = structuredClone(selected.head) yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs }) const modelMessages = yield* MessageV2.toModelMessagesEffect(msgs, model, { @@ -473,13 +493,6 @@ export const layer: Layer.Layer< return "stop" } - if (compactionPart && selected.tail_start_id && compactionPart.tail_start_id !== selected.tail_start_id) { - yield* session.updatePart({ - ...compactionPart, - tail_start_id: selected.tail_start_id, - }) - } - if (result === "continue" && input.auto) { if (replay) { const original = replay.info @@ -575,7 +588,6 @@ export const layer: Layer.Layer< sessionID: input.sessionID, timestamp: DateTime.makeUnsafe(Date.now()), text: summary ?? "", - include: selected.tail_start_id, }) } yield* bus.publish(Event.Compacted, { sessionID: input.sessionID }) diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index a8c8dabc86..e3539021b0 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -840,12 +840,13 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* ( return part.metadata?.anthropic?.signature != null }) for (const part of msg.parts) { + if (msg.info.summary && part.type !== "text") continue if (part.type === "text") { const text = part.text === "" && hasSignedReasoning ? " " : part.text assistantMessage.parts.push({ type: "text", text, - ...(differentModel ? {} : { providerMetadata: part.metadata }), + ...(differentModel || msg.info.summary ? {} : { providerMetadata: part.metadata }), }) } if (part.type === "step-start") @@ -1071,53 +1072,16 @@ export function get(input: { sessionID: SessionID; messageID: MessageID }): With export function filterCompacted(msgs: Iterable) { const result = [] as WithParts[] const completed = new Set() - let retain: MessageID | undefined for (const msg of msgs) { result.push(msg) - if (retain) { - if (msg.info.id === retain) break - continue - } if (msg.info.role === "user" && completed.has(msg.info.id)) { - const part = msg.parts.find((item): item is CompactionPart => item.type === "compaction") - if (!part) continue - if (!part.tail_start_id) break - retain = part.tail_start_id - if (msg.info.id === retain) break + if (msg.parts.some((item): item is CompactionPart => item.type === "compaction")) break continue } - if (msg.info.role === "user" && completed.has(msg.info.id) && msg.parts.some((part) => part.type === "compaction")) - break if (msg.info.role === "assistant" && msg.info.summary && msg.info.finish && !msg.info.error) completed.add(msg.info.parentID) } result.reverse() - const compactionIndex = result.findLastIndex( - (msg) => - msg.info.role === "user" && - msg.parts.some((item): item is CompactionPart => item.type === "compaction" && item.tail_start_id !== undefined), - ) - const compaction = result[compactionIndex] - const part = compaction?.parts.find( - (item): item is CompactionPart => item.type === "compaction" && item.tail_start_id !== undefined, - ) - const summaryIndex = compaction - ? result.findIndex( - (msg, index) => - index > compactionIndex && - msg.info.role === "assistant" && - msg.info.summary && - msg.info.parentID === compaction.info.id, - ) - : -1 - const tailIndex = part?.tail_start_id ? result.findIndex((msg) => msg.info.id === part.tail_start_id) : -1 - if (tailIndex >= 0 && tailIndex < compactionIndex && summaryIndex > compactionIndex) { - return [ - ...result.slice(compactionIndex, summaryIndex + 1), - ...result.slice(tailIndex, compactionIndex), - ...result.slice(summaryIndex + 1), - ] - } return result } diff --git a/packages/opencode/test/session/compaction.test.ts b/packages/opencode/test/session/compaction.test.ts index 8f987b4d10..13400d79c8 100644 --- a/packages/opencode/test/session/compaction.test.ts +++ b/packages/opencode/test/session/compaction.test.ts @@ -926,12 +926,12 @@ describe("session.compaction.process", () => { ) itCompaction.instance( - "persists tail_start_id for retained recent turns", + "does not persist tail_start_id for serialized recent turns", Effect.gen(function* () { const ssn = yield* SessionNs.Service const session = yield* ssn.create({}) yield* createUserMessage(session.id, "first") - const keep = yield* createUserMessage(session.id, "second") + yield* createUserMessage(session.id, "second") yield* createUserMessage(session.id, "third") yield* createSummaryCompaction(session.id) @@ -947,18 +947,18 @@ describe("session.compaction.process", () => { const part = yield* readCompactionPart(session.id) expect(part?.type).toBe("compaction") - expect(part?.tail_start_id).toBe(keep.id) + expect(part?.tail_start_id).toBeUndefined() }).pipe(withCompaction({ config: cfg({ tail_turns: 2, preserve_recent_tokens: 10_000 }) })), ) itCompaction.instance( - "shrinks retained tail to fit preserve token budget", + "does not persist tail_start_id when shrinking serialized tail", Effect.gen(function* () { const ssn = yield* SessionNs.Service const session = yield* ssn.create({}) yield* createUserMessage(session.id, "first") yield* createUserMessage(session.id, "x".repeat(2_000)) - const keep = yield* createUserMessage(session.id, "tiny") + yield* createUserMessage(session.id, "tiny") yield* createSummaryCompaction(session.id) const msgs = yield* ssn.messages({ sessionID: session.id }) @@ -973,7 +973,7 @@ describe("session.compaction.process", () => { const part = yield* readCompactionPart(session.id) expect(part?.type).toBe("compaction") - expect(part?.tail_start_id).toBe(keep.id) + expect(part?.tail_start_id).toBeUndefined() }).pipe(withCompaction({ config: cfg({ tail_turns: 2, preserve_recent_tokens: 100 }) })), ) @@ -1005,7 +1005,7 @@ describe("session.compaction.process", () => { ) itCompaction.instance( - "falls back to full summary when retained tail media exceeds preserve token budget", + "serializes retained tail media as text in the summary input", () => { const stub = llm() let captured = "" @@ -1078,15 +1078,16 @@ describe("session.compaction.process", () => { const part = yield* readCompactionPart(session.id) expect(part?.type).toBe("compaction") - expect(part?.tail_start_id).toBe(keep.id) + expect(part?.tail_start_id).toBeUndefined() expect(captured).toContain("zzzz") - expect(captured).not.toContain("keep tail") + expect(captured).toContain("keep tail") const filtered = MessageV2.filterCompacted(MessageV2.stream(session.id)) - expect(filtered.map((msg) => msg.info.id).slice(0, 3)).toEqual([parent!, expect.any(String), keep.id]) + expect(filtered.map((msg) => msg.info.id)).toEqual([parent!, expect.any(String)]) expect(filtered[1]?.info.role).toBe("assistant") expect(filtered[1]?.info.role === "assistant" ? filtered[1].info.summary : false).toBe(true) expect(filtered.map((msg) => msg.info.id)).not.toContain(large.id) + expect(filtered.map((msg) => msg.info.id)).not.toContain(keep.id) }).pipe(withCompaction({ llm: stub.layer, config: cfg({ tail_turns: 1, preserve_recent_tokens: 100 }) })) }, { git: true }, @@ -1353,13 +1354,13 @@ describe("session.compaction.process", () => { ) itCompaction.instance( - "summarizes only the head while keeping recent tail out of summary input", + "summarizes the head while serializing recent tail into summary input", () => { const stub = llm() - let captured = "" + let captured: LLM.StreamInput["messages"] = [] stub.push( reply("summary", (input) => { - captured = JSON.stringify(input.messages) + captured = input.messages }), ) return Effect.gen(function* () { @@ -1380,10 +1381,15 @@ describe("session.compaction.process", () => { auto: false, }) - expect(captured).toContain("older context") - expect(captured).not.toContain("keep this turn") - expect(captured).not.toContain("and this one too") - expect(captured).not.toContain("What did we do so far?") + const head = JSON.stringify(captured.slice(0, -1)) + const prompt = JSON.stringify(captured.at(-1)) + expect(head).toContain("older context") + expect(head).not.toContain("keep this turn") + expect(head).not.toContain("and this one too") + expect(prompt).toContain("keep this turn") + expect(prompt).toContain("and this one too") + expect(prompt).toContain("recent-conversation-tail") + expect(prompt).not.toContain("What did we do so far?") }).pipe(withCompaction({ llm: stub.layer })) }, { git: true }, @@ -1431,7 +1437,7 @@ describe("session.compaction.process", () => { { git: true }, ) - itCompaction.instance("keeps recent pre-compaction turns across repeated compactions", () => { + itCompaction.instance("does not replay recent pre-compaction turns across repeated compactions", () => { const stub = llm() stub.push(reply("summary one")) stub.push(reply("summary two")) @@ -1462,8 +1468,8 @@ describe("session.compaction.process", () => { expect(ids).not.toContain(u1.id) expect(ids).not.toContain(u2.id) - expect(ids).toContain(u3.id) - expect(ids).toContain(u4.id) + expect(ids).not.toContain(u3.id) + expect(ids).not.toContain(u4.id) expect(filtered.some((msg) => msg.info.role === "assistant" && msg.info.summary)).toBe(true) expect( filtered.some((msg) => msg.info.role === "user" && msg.parts.some((part) => part.type === "compaction")), @@ -1472,7 +1478,7 @@ describe("session.compaction.process", () => { }) itCompaction.instance( - "ignores previous summaries when sizing the retained tail", + "ignores previous summaries when sizing the serialized tail", Effect.gen(function* () { const ssn = yield* SessionNs.Service const test = yield* TestInstance @@ -1511,7 +1517,7 @@ describe("session.compaction.process", () => { const part = yield* readCompactionPart(session.id) expect(part?.type).toBe("compaction") - expect(part?.tail_start_id).toBe(keep.id) + expect(part?.tail_start_id).toBeUndefined() }).pipe(withCompaction({ config: cfg({ tail_turns: 2, preserve_recent_tokens: 500 }) })), ) }) diff --git a/packages/opencode/test/session/messages-pagination.test.ts b/packages/opencode/test/session/messages-pagination.test.ts index 05ec2bad49..86e1d85d0d 100644 --- a/packages/opencode/test/session/messages-pagination.test.ts +++ b/packages/opencode/test/session/messages-pagination.test.ts @@ -785,7 +785,7 @@ describe("MessageV2.filterCompacted", () => { }) }) - test("retains original tail when compaction stores tail_start_id", async () => { + test("ignores original tail when compaction stores tail_start_id", async () => { await WithInstance.provide({ directory: root, fn: async () => { @@ -834,14 +834,14 @@ describe("MessageV2.filterCompacted", () => { const result = MessageV2.filterCompacted(MessageV2.stream(session.id)) - expect(result.map((item) => item.info.id)).toEqual([c1, s1, u2, a2, u3, a3]) + expect(result.map((item) => item.info.id)).toEqual([c1, s1, u3, a3]) await svc.remove(session.id) }, }) }) - test("fork remaps compaction tail_start_id for filterCompacted", async () => { + test("fork keeps legacy tail_start_id without replaying the tail", async () => { await WithInstance.provide({ directory: root, fn: async () => { @@ -889,7 +889,7 @@ describe("MessageV2.filterCompacted", () => { }) const parentFiltered = MessageV2.filterCompacted(MessageV2.stream(session.id)) - expect(parentFiltered.map((item) => item.info.id)).toEqual([c1, s1, u2, a2, u3, a3]) + expect(parentFiltered.map((item) => item.info.id)).toEqual([c1, s1, u3, a3]) const forked = await svc.fork({ sessionID: session.id }) const childFiltered = MessageV2.filterCompacted(MessageV2.stream(forked.id)) @@ -899,7 +899,7 @@ describe("MessageV2.filterCompacted", () => { expect(tailPart?.type).toBe("compaction") if (!tailPart || tailPart.type !== "compaction") throw new Error("Expected forked compaction part") expect(tailPart.tail_start_id).toBeDefined() - expect(childFiltered.some((m) => m.info.id === tailPart.tail_start_id)).toBe(true) + expect(childFiltered.some((m) => m.info.id === tailPart.tail_start_id)).toBe(false) await svc.remove(forked.id) await svc.remove(session.id) @@ -907,7 +907,7 @@ describe("MessageV2.filterCompacted", () => { }) }) - test("retains an assistant tail when compaction starts inside a turn", async () => { + test("does not replay an assistant tail when compaction starts inside a turn", async () => { await WithInstance.provide({ directory: root, fn: async () => { @@ -964,7 +964,7 @@ describe("MessageV2.filterCompacted", () => { const result = MessageV2.filterCompacted(MessageV2.stream(session.id)) - expect(result.map((item) => item.info.id)).toEqual([c1, s1, a3, u3, a4]) + expect(result.map((item) => item.info.id)).toEqual([c1, s1, u3, a4]) await svc.remove(session.id) }, @@ -1041,7 +1041,7 @@ describe("MessageV2.filterCompacted", () => { const result = MessageV2.filterCompacted(MessageV2.stream(session.id)) - expect(result.map((item) => item.info.id)).toEqual([c2, s2, u3, a3, u4, a4]) + expect(result.map((item) => item.info.id)).toEqual([c2, s2, u4, a4]) await svc.remove(session.id) },