From 0e69f565f0ff89b43cf35f4f99f12a058befddef Mon Sep 17 00:00:00 2001 From: MemScribe Maintainers Date: Wed, 29 Jul 2026 09:34:07 +0800 Subject: [PATCH] fix(opencode): reuse host OAuth transport Signed-off-by: MemScribe Maintainers --- CHANGELOG.md | 13 ++ packages/core/src/extract.test.ts | 24 ++-- packages/core/src/extract.ts | 11 +- packages/memflywheel/src/opencode-pi-model.ts | 20 ++- .../memflywheel/src/opencode-port.test.ts | 117 ++++++++++++++++++ packages/memflywheel/src/opencode-port.ts | 70 ++++++++++- packages/sdk/src/agent-runner.test.ts | 24 ++++ packages/sdk/src/agent-runner.ts | 2 +- packages/sdk/src/index.test.ts | 11 +- 9 files changed, 260 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 17df993..9e9c14e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,19 @@ for published packages. ## [Unreleased] +### Fixed + +- Reused OpenCode's host-owned OpenAI OAuth `fetch` transport for background + memory agent loops without reading, copying, or persisting OAuth tokens. +- Reported OpenCode background lifecycle failures through the host logger instead + of silently losing extraction errors. +- Removed extraction's catch-all `Failed` result so provider and Agent Loop + failures reach the host unchanged while the memory cursor remains retryable. +- Made host-resolved request options authoritative over Pi Agent Core defaults + on every background turn. +- Kept OpenCode model output limits as metadata without reintroducing a + `max_output_tokens` request field that the host OAuth hook explicitly removed. + ## [0.1.1] - 2026-07-28 ### Added diff --git a/packages/core/src/extract.test.ts b/packages/core/src/extract.test.ts index 8d723a9..851b930 100644 --- a/packages/core/src/extract.test.ts +++ b/packages/core/src/extract.test.ts @@ -465,21 +465,23 @@ test("runExtractionSession returns Skipped when the agent writes nothing", async } }); -test("runExtractionSession returns Failed and does NOT advance cursor when agent throws", async () => { +test("runExtractionSession exposes agent failure and does NOT advance cursor", async () => { const root = await makeRoot(); try { const ctx = ctxFor(root); const cursorStore = createMemoryCursorStore(); - const result = await runExtractionSession({ - ctx, - agent: async () => { - throw new Error("llm down"); - }, - messages: [{ role: "user", text: "hi" }], - sessionId: "s2", - cursorStore, - }); - assert.equal(result, ExtractionResult.Failed); + await assert.rejects( + runExtractionSession({ + ctx, + agent: async () => { + throw new Error("llm down"); + }, + messages: [{ role: "user", text: "hi" }], + sessionId: "s2", + cursorStore, + }), + /llm down/, + ); assert.equal(cursorStore.get("s2"), null); } finally { await cleanup(root); diff --git a/packages/core/src/extract.ts b/packages/core/src/extract.ts index ec662a7..0cd0a65 100644 --- a/packages/core/src/extract.ts +++ b/packages/core/src/extract.ts @@ -42,7 +42,6 @@ export const EXTRACTION_MAX_MESSAGES = 40; export enum ExtractionResult { Completed = "completed", Skipped = "skipped", - Failed = "failed", } /** @@ -442,8 +441,8 @@ export interface RunExtractionSessionOptions { * 8 advance cursor ONLY on success; stamp .last-extraction * 9 release the root lock * - * A thrown agent runner (e.g. network failure) ⇒ Failed, and the cursor does - * NOT advance, so the window is retried next turn. Per-tool failures are + * A thrown agent runner (e.g. network failure) escapes unchanged, and the cursor + * does NOT advance, so the window is retried next turn. Per-tool failures are * non-fatal (handlers return results) and simply leave files unchanged. */ export async function runExtractionSession( @@ -473,11 +472,7 @@ export async function runExtractionSession( const toolCtx = createMemoryFileToolContext({ ctx, refuseSecrets, sourceRef }); const tools = createFileTools(); - try { - await agent({ toolCtx, tools, messages: selected, manifest, root: ctx.root }); - } catch { - return ExtractionResult.Failed; - } + await agent({ toolCtx, tools, messages: selected, manifest, root: ctx.root }); await relocateRootFiles(ctx); const after = await scanMemoryFiles(ctx.root); diff --git a/packages/memflywheel/src/opencode-pi-model.ts b/packages/memflywheel/src/opencode-pi-model.ts index ac533be..65a82a4 100644 --- a/packages/memflywheel/src/opencode-pi-model.ts +++ b/packages/memflywheel/src/opencode-pi-model.ts @@ -22,6 +22,8 @@ export interface OpenCodePiModelConfig { readonly input: ("text" | "image")[]; readonly contextWindow: number; readonly maxTokens: number; + readonly requestMaxTokens?: number; + readonly fetch?: typeof globalThis.fetch; readonly compat?: OpenAICompletionsCompat; readonly thinkingLevelMap?: ThinkingLevelMap; readonly temperature?: number; @@ -30,6 +32,19 @@ export interface OpenCodePiModelConfig { const zeroCost = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }; +function withProviderFetch(fetchImpl: typeof globalThis.fetch, run: () => T): T { + // Provider SDKs capture the default fetch while streamSimple constructs their + // client. Restore it before any asynchronous network work can interleave. + const descriptor = Object.getOwnPropertyDescriptor(globalThis, "fetch"); + globalThis.fetch = fetchImpl; + try { + return run(); + } finally { + if (descriptor) Object.defineProperty(globalThis, "fetch", descriptor); + else delete (globalThis as { fetch?: typeof globalThis.fetch }).fetch; + } +} + async function loadApi(api: Api): Promise { switch (api) { case "openai-completions": @@ -80,11 +95,12 @@ export function createPiAiModelBinding(config: OpenCodePiModelConfig): PiAgentMo ...(config.headers ? { headers: config.headers } : {}), ...(config.env ? { env: config.env } : {}), ...(config.temperature === undefined ? {} : { temperature: config.temperature }), - maxTokens: config.maxTokens, + ...(config.requestMaxTokens === undefined ? {} : { maxTokens: config.requestMaxTokens }), }, streamFn: async (activeModel, context, options) => { const api = await loadApi(activeModel.api); - return api.streamSimple(activeModel, context, options); + const stream = () => api.streamSimple(activeModel, context, options); + return config.fetch ? withProviderFetch(config.fetch, stream) : stream(); }, }; } diff --git a/packages/memflywheel/src/opencode-port.test.ts b/packages/memflywheel/src/opencode-port.test.ts index 26cee74..811b463 100644 --- a/packages/memflywheel/src/opencode-port.test.ts +++ b/packages/memflywheel/src/opencode-port.test.ts @@ -4,6 +4,7 @@ import assert from "node:assert/strict"; import { hostMessagesFromOpenCodeSessionMessages, configureOpenCodeMemoryPermission, + createOpenCodeHostModel, createOpenCodeHarnessPort, piApiForOpenCodeTransport, } from "./opencode-port.js"; @@ -140,6 +141,34 @@ test("OpenCode buffers text-complete without blocking output, then serializes id assert.equal(turns.length, 2); }); +test("OpenCode reports background lifecycle failures through the host logger", async () => { + const logs: unknown[] = []; + const port = createOpenCodeHarnessPort({ + app: { log: async (entry) => void logs.push(entry) }, + session: { + messages: async () => ({ + data: [{ info: { role: "user" }, parts: [{ type: "text", text: "remember tea" }] }], + }), + }, + }); + port.lifecycle.onTurnEnd(async () => { + throw new Error("OAuth transport failed"); + }); + + await assert.rejects( + port.hooks.event({ event: { type: "session.idle", properties: { sessionID: "oc-fail" } } }), + /OAuth transport failed/, + ); + assert.deepEqual(logs[0], { + body: { + service: "memflywheel", + level: "error", + message: "OpenCode background lifecycle failed", + extra: { sessionId: "oc-fail", error: "Error: OAuth transport failed" }, + }, + }); +}); + test("OpenCode port fails before chat.params supplies the active model", async () => { const port = createOpenCodeHarnessPort({}); await assert.rejects( @@ -183,6 +212,94 @@ test("OpenCode resolves DeepSeek and Anthropic directly into pi-ai model binding } }); +test("OpenCode reuses its host-owned OpenAI OAuth transport without reading tokens", () => { + const hostFetch: typeof globalThis.fetch = async () => new Response("unused"); + const binding = createOpenCodeHostModel( + { + model: { + ...openCodeModel("@ai-sdk/openai", "gpt-5.5", ""), + providerID: "openai", + api: { id: "gpt-5.5", npm: "@ai-sdk/openai" }, + }, + provider: { id: "openai", source: "custom", options: {} }, + }, + { options: { apiKey: "oauth", fetch: hostFetch } }, + ); + + assert.equal(binding.model.api, "openai-responses"); + assert.equal(binding.model.baseUrl, "https://api.openai.com/v1"); + assert.equal(binding.request?.fetch, undefined); + assert.equal(binding.request?.maxTokens, undefined); + assert.equal(typeof binding.request?.onPayload, "function"); + assert.equal(binding.request?.access, undefined); + assert.equal(binding.request?.refresh, undefined); +}); + +test("OpenCode OAuth transport drives the registry pi-ai provider and omits host-owned limits", async () => { + const originalFetch = globalThis.fetch; + let requestPayload: Record | undefined; + const hostFetch: typeof globalThis.fetch = async (_input, init) => { + requestPayload = JSON.parse(String(init?.body)) as Record; + const sse = `data: ${JSON.stringify({ + type: "response.completed", + response: { + id: "resp_memflywheel_test", + status: "completed", + usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 }, + }, + })}\n\n`; + return new Response(sse, { + status: 200, + headers: { "content-type": "text/event-stream" }, + }); + }; + const binding = createOpenCodeHostModel( + { + model: { + ...openCodeModel("@ai-sdk/openai", "gpt-5.5", ""), + providerID: "openai", + api: { id: "gpt-5.5", npm: "@ai-sdk/openai" }, + }, + provider: { id: "openai", source: "custom", options: {} }, + }, + { options: { apiKey: "oauth", fetch: hostFetch } }, + ); + + const stream = await binding.streamFn( + binding.model, + { + systemPrompt: "test", + messages: [{ role: "user", content: "test", timestamp: Date.now() }], + }, + { ...binding.request, apiKey: "oauth" }, + ); + assert.equal(globalThis.fetch, originalFetch); + const result = await stream.result(); + + assert.equal(result.stopReason, "stop"); + assert.equal(requestPayload?.model, "gpt-5.5"); + assert.equal(requestPayload?.max_output_tokens, undefined); + assert.equal(globalThis.fetch, originalFetch); +}); + +test("OpenCode rejects an OpenAI model with neither endpoint nor host transport", () => { + assert.throws( + () => + createOpenCodeHostModel( + { + model: { + ...openCodeModel("@ai-sdk/openai", "gpt-5.5", ""), + providerID: "openai", + api: { id: "gpt-5.5", npm: "@ai-sdk/openai" }, + }, + provider: { id: "openai", source: "custom", options: {} }, + }, + { options: {} }, + ), + /neither a resolved endpoint nor host fetch/, + ); +}); + test("OpenCode transport mapping is exact and rejects unknown transports", () => { assert.deepEqual( [ diff --git a/packages/memflywheel/src/opencode-port.ts b/packages/memflywheel/src/opencode-port.ts index ecfc248..1dd8bb5 100644 --- a/packages/memflywheel/src/opencode-port.ts +++ b/packages/memflywheel/src/opencode-port.ts @@ -15,6 +15,16 @@ import { createPiAiModelBinding } from "./opencode-pi-model.js"; type RawRecord = Record; export interface OpenCodeClientLike { + readonly app?: { + readonly log?: (options: { + readonly body: { + readonly service: string; + readonly level: "error"; + readonly message: string; + readonly extra?: RawRecord; + }; + }) => Promise; + }; readonly session?: { readonly messages?: (options: unknown) => Promise; }; @@ -123,6 +133,12 @@ function readNumber(value: unknown, key: string): number | undefined { return typeof raw === "number" && Number.isFinite(raw) ? raw : undefined; } +function readFetch(value: unknown, key: string): typeof globalThis.fetch | undefined { + if (!isRecord(value)) return undefined; + const raw = value[key]; + return typeof raw === "function" ? (raw as typeof globalThis.fetch) : undefined; +} + function stringRecord(value: unknown): Record { if (!isRecord(value)) return {}; return Object.fromEntries( @@ -241,6 +257,13 @@ function openCodePiRequestOptions( return {}; } +function omitMaxOutputTokens(payload: unknown): unknown { + if (!isRecord(payload)) throw new Error("pi-ai produced a non-object OpenAI payload."); + const next = { ...payload }; + delete next.max_output_tokens; + return next; +} + function openCodePiBaseUrl( api: ReturnType, endpoint: string, @@ -257,6 +280,10 @@ const OPENAI_COMPATIBLE_TRANSPORT = { supportsStrictMode: false, }; +// OpenCode's OAuth fetch rewrites this ordinary Responses URL to its host-owned +// endpoint. MemFlywheel never receives or persists the underlying OAuth tokens. +const OPENAI_RESPONSES_REQUEST_ORIGIN = "https://api.openai.com/v1"; + /** Build a pi-ai completion from OpenCode's resolved model and credential context. */ export function createOpenCodeHostModel( input: { readonly model: unknown; readonly provider: unknown }, @@ -280,9 +307,16 @@ export function createOpenCodeHostModel( const endpoint = readString(providerOptions, "baseURL") ?? (api ? readString(api, "url") : undefined); + const hostFetch = readFetch(providerOptions, "fetch"); const apiKey = readString(providerOptions, "apiKey") ?? readString(providerInfo, "key"); - if (!endpoint && piApi !== "bedrock-converse-stream" && piApi !== "google-vertex") { - throw new Error(`OpenCode model ${modelId} has no resolved endpoint.`); + const hostTransportOwnsEndpoint = piApi === "openai-responses" && hostFetch !== undefined; + if ( + !endpoint && + !hostTransportOwnsEndpoint && + piApi !== "bedrock-converse-stream" && + piApi !== "google-vertex" + ) { + throw new Error(`OpenCode model ${modelId} has neither a resolved endpoint nor host fetch.`); } const capabilities = isRecord(input.model.capabilities) ? input.model.capabilities : {}; const inputCapabilities = isRecord(capabilities.input) ? capabilities.input : {}; @@ -300,7 +334,10 @@ export function createOpenCodeHostModel( provider: providerId, model: api ? (readString(api, "id") ?? modelId) : modelId, name: readString(input.model, "name") ?? modelId, - baseUrl: openCodePiBaseUrl(piApi, endpoint ?? ""), + baseUrl: openCodePiBaseUrl( + piApi, + endpoint ?? (hostTransportOwnsEndpoint ? OPENAI_RESPONSES_REQUEST_ORIGIN : ""), + ), apiKey, headers: { ...modelHeaders, ...providerHeaders }, env: openCodeProviderEnv(providerOptions), @@ -308,6 +345,8 @@ export function createOpenCodeHostModel( input: ["text", ...(inputCapabilities.image === true ? (["image"] as const) : [])], contextWindow, maxTokens, + requestMaxTokens: output.maxOutputTokens, + fetch: hostFetch, ...(piApi === "openai-completions" ? { compat: OPENAI_COMPATIBLE_TRANSPORT, @@ -315,7 +354,12 @@ export function createOpenCodeHostModel( } : {}), temperature: output.temperature, - requestOptions: openCodePiRequestOptions(piApi, providerOptions), + requestOptions: { + ...openCodePiRequestOptions(piApi, providerOptions), + ...(piApi === "openai-responses" && hostFetch && output.maxOutputTokens === undefined + ? { onPayload: omitMaxOutputTokens } + : {}), + }, }); } @@ -484,6 +528,17 @@ export function createOpenCodeHarnessPort( } }; + const reportLifecycleFailure = async (sessionId: string, error: unknown): Promise => { + await client.app?.log?.({ + body: { + service: "memflywheel", + level: "error", + message: "OpenCode background lifecycle failed", + extra: { sessionId, error: String(error) }, + }, + }); + }; + const hooks: OpenCodeHooks = { async config(config) { configureOpenCodeMemoryPermission(config, options.root ?? defaultOpenCodeMemFlywheelRoot()); @@ -493,7 +548,12 @@ export function createOpenCodeHarnessPort( const sessionId = extractSessionId(event); if (sessionId) lastSessionId = sessionId; if (type === "session.idle" && sessionId) { - await dispatchCompletedTurn(sessionId); + try { + await dispatchCompletedTurn(sessionId); + } catch (error) { + await reportLifecycleFailure(sessionId, error); + throw error; + } } if (type === "session.deleted" && sessionId) { for (const handler of sessionEndHandlers) await handler({ sessionId }); diff --git a/packages/sdk/src/agent-runner.test.ts b/packages/sdk/src/agent-runner.test.ts index 9f3b4df..5b7be80 100644 --- a/packages/sdk/src/agent-runner.test.ts +++ b/packages/sdk/src/agent-runner.test.ts @@ -25,3 +25,27 @@ test("runMemoryAgent releases pi-ai resources for its isolated session", async ( unregister(); } }); + +test("host request options override undefined Agent Core defaults", async () => { + const scripted = scriptedBinding([stopTurn]); + let receivedTemperature: unknown; + + await runMemoryAgent({ + resolveModel: async () => { + const binding = await scripted.resolveModel(); + return { + ...binding, + request: { temperature: 0.25 }, + streamFn: (model, context, options) => { + receivedTemperature = (options as Record | undefined)?.temperature; + return binding.streamFn(model, context, options); + }, + }; + }, + tools: [], + systemPrompt: "test", + userMessage: "test", + }); + + assert.equal(receivedTemperature, 0.25); +}); diff --git a/packages/sdk/src/agent-runner.ts b/packages/sdk/src/agent-runner.ts index f58ba37..2a8c93e 100644 --- a/packages/sdk/src/agent-runner.ts +++ b/packages/sdk/src/agent-runner.ts @@ -106,7 +106,7 @@ export async function runMemoryAgent(options: RunMemoryAgentOptions): Promise - binding.streamFn(model, context, { ...binding.request, ...streamOptions }), + binding.streamFn(model, context, { ...streamOptions, ...binding.request }), getApiKey: binding.getApiKey, sessionId: binding.sessionId, transport: binding.transport, diff --git a/packages/sdk/src/index.test.ts b/packages/sdk/src/index.test.ts index 9b3aafd..82ed52c 100644 --- a/packages/sdk/src/index.test.ts +++ b/packages/sdk/src/index.test.ts @@ -568,7 +568,7 @@ test("onTurnEnd learning loop infers completed turns from a resumed transcript", } }); -test("agent that throws yields Failed and writes nothing; cursor does not advance", async () => { +test("agent failure escapes, writes nothing, and leaves the cursor retryable", async () => { const root = await tempRoot(); try { let calls = 0; @@ -578,15 +578,16 @@ test("agent that throws yields Failed and writes nothing; cursor does not advanc }; const scribe = createMemFlywheel({ root, agent: fn }); await scribe.onSessionStart("s1"); - const turn = await scribe.onTurnEnd("s1", userTurn("anything worth remembering")); - assert.equal(turn.result, ExtractionResult.Failed); + await assert.rejects( + scribe.onTurnEnd("s1", userTurn("anything worth remembering")), + /llm down/, + ); const index = await readFile(path.join(root, "MEMORY.md"), "utf8").catch(() => ""); assert.doesNotMatch(index, /\.md/); // Cursor did not advance: the next turn retries the same window. - const turn2 = await scribe.onTurnEnd("s1", []); - assert.equal(turn2.result, ExtractionResult.Failed); + await assert.rejects(scribe.onTurnEnd("s1", []), /llm down/); assert.equal(calls, 2); } finally { await rm(root, { recursive: true, force: true });