diff --git a/README.md b/README.md index 79003c4..8819a36 100644 --- a/README.md +++ b/README.md @@ -208,7 +208,7 @@ Supported providers: any provider listed by `opencode providers list` (e.g. `ant If `opencodeProvider` and `opencodeModel` are set, they take precedence over the manual `memoryProvider` settings below. -**Follow the session model:** set `"opencodeModel": "inherit"` to capture each prompt with the exact provider/model opencode used to answer it (recorded per message via the `chat.params` hook). This is useful if you switch models often or use several local backends; captures then always use the model that produced the conversation, and pinned ids can’t become stale. `opencodeProvider` is still required as the normal config gate, and is used if a prompt has no recorded model. +**Follow the session model:** set `"opencodeModel": "inherit"` to use a concrete OpenCode model at call time instead of a pinned id. For **auto-capture**, each prompt is recorded via the `chat.params` hook and the capture request reuses that prompt's provider/model. For **profile learning** and other structured-output paths (which are not tied to a single user message), `inherit` falls back to the most recent model in OpenCode's `model.json` recent list (preferring the configured `opencodeProvider`). Sending the literal model id `inherit` is never valid and previously caused `ProviderModelNotFoundError: Model not found: /inherit` on those paths. `opencodeProvider` is still required as the normal config gate. **Fallback:** Manual API configuration (if not using opencodeProvider): diff --git a/src/services/ai/opencode-provider.ts b/src/services/ai/opencode-provider.ts index b6efd2b..2d53976 100644 --- a/src/services/ai/opencode-provider.ts +++ b/src/services/ai/opencode-provider.ts @@ -16,6 +16,9 @@ * older SDK builds that do not expose the v2 session methods. */ +import { existsSync, readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; import type { z } from "zod"; import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"; import { @@ -75,6 +78,57 @@ export interface StructuredOutputOptions { retryCount?: number; } +/** + * Resolve `opencodeModel: "inherit"` to a concrete provider/model. + * + * Prefer an explicit prompt-recorded model (auto-capture path). Otherwise fall + * back to OpenCode's recent model list so profile-learning / conflict / dedup + * paths don't send the literal model id "inherit" (ProviderModelNotFoundError). + */ +export function resolveOpencodeModelRef(opts: { + providerID: string; + modelID: string; + prompt?: { providerId?: string | null; modelId?: string | null }; +}): { providerID: string; modelID: string } { + if (opts.modelID !== "inherit") { + return { providerID: opts.providerID, modelID: opts.modelID }; + } + + if (opts.prompt?.providerId && opts.prompt?.modelId) { + return { providerID: opts.prompt.providerId, modelID: opts.prompt.modelId }; + } + + const recent = readRecentOpencodeModel(opts.providerID); + if (recent) return recent; + + throw new Error( + "opencode-mem: opencodeModel is 'inherit' but no session model was recorded and no recent OpenCode model is available" + ); +} + +function readRecentOpencodeModel( + preferredProvider?: string +): { providerID: string; modelID: string } | undefined { + try { + // OpenCode state path mirrors `opencode debug paths` → state. + const stateDir = process.env.XDG_STATE_HOME || join(homedir(), ".local", "state"); + const modelPath = join(stateDir, "opencode", "model.json"); + if (!existsSync(modelPath)) return undefined; + const raw = JSON.parse(readFileSync(modelPath, "utf8")) as { + recent?: Array<{ providerID?: string; modelID?: string }>; + }; + const recent = Array.isArray(raw.recent) ? raw.recent : []; + const match = + (preferredProvider + ? recent.find((r) => r.providerID === preferredProvider && r.modelID) + : undefined) ?? recent.find((r) => r.providerID && r.modelID); + if (!match?.providerID || !match.modelID) return undefined; + return { providerID: match.providerID, modelID: match.modelID }; + } catch { + return undefined; + } +} + /** * Generate one structured-output completion via opencode's HTTP API. * Throws on: session.create failure, prompt failure, AssistantMessage.error @@ -82,8 +136,12 @@ export interface StructuredOutputOptions { * or final Zod validation failure. */ export async function generateStructuredOutput(opts: StructuredOutputOptions): Promise { - const { client, providerID, modelID, systemPrompt, userPrompt, schema, directory, retryCount } = - opts; + const resolved = resolveOpencodeModelRef({ + providerID: opts.providerID, + modelID: opts.modelID, + }); + const { client, systemPrompt, userPrompt, schema, directory, retryCount } = opts; + const { providerID, modelID } = resolved; const jsonSchema = ( diff --git a/tests/opencode-provider.test.ts b/tests/opencode-provider.test.ts index babc713..6a3f6b0 100644 --- a/tests/opencode-provider.test.ts +++ b/tests/opencode-provider.test.ts @@ -774,3 +774,137 @@ describe("generateStructuredOutput regression tests (issue #110)", () => { expect(typeof mod.createV2Client).toBe("function"); }); }); + +describe("resolveOpencodeModelRef / inherit", () => { + const originalStateHome = process.env.XDG_STATE_HOME; + let tempRoot: string | undefined; + + beforeEach(async () => { + const { mkdtempSync, mkdirSync, writeFileSync } = await import("node:fs"); + const { tmpdir } = await import("node:os"); + const { join } = await import("node:path"); + tempRoot = mkdtempSync(join(tmpdir(), "opencode-mem-inherit-")); + process.env.XDG_STATE_HOME = tempRoot; + }); + + afterEach(async () => { + if (originalStateHome === undefined) { + delete process.env.XDG_STATE_HOME; + } else { + process.env.XDG_STATE_HOME = originalStateHome; + } + if (tempRoot) { + const { rmSync } = await import("node:fs"); + rmSync(tempRoot, { recursive: true, force: true }); + tempRoot = undefined; + } + }); + + it("passes concrete model ids through unchanged", async () => { + const { resolveOpencodeModelRef } = await import("../src/services/ai/opencode-provider.js"); + expect(resolveOpencodeModelRef({ providerID: "local", modelID: "h100/qwen:latest" })).toEqual({ + providerID: "local", + modelID: "h100/qwen:latest", + }); + }); + + it("prefers the recorded prompt model when modelID is inherit", async () => { + const { resolveOpencodeModelRef } = await import("../src/services/ai/opencode-provider.js"); + expect( + resolveOpencodeModelRef({ + providerID: "local", + modelID: "inherit", + prompt: { providerId: "anthropic", modelId: "claude-haiku-4-5-20251001" }, + }) + ).toEqual({ + providerID: "anthropic", + modelID: "claude-haiku-4-5-20251001", + }); + }); + + it("falls back to OpenCode recent model.json when inherit has no prompt model", async () => { + const { mkdirSync, writeFileSync } = await import("node:fs"); + const { join } = await import("node:path"); + const stateDir = join(tempRoot!, "opencode"); + mkdirSync(stateDir, { recursive: true }); + writeFileSync( + join(stateDir, "model.json"), + JSON.stringify({ + recent: [ + { providerID: "openai", modelID: "gpt-4o-mini" }, + { providerID: "local", modelID: "h100/qwen:latest" }, + ], + }) + ); + + const { resolveOpencodeModelRef } = await import("../src/services/ai/opencode-provider.js"); + expect(resolveOpencodeModelRef({ providerID: "local", modelID: "inherit" })).toEqual({ + providerID: "local", + modelID: "h100/qwen:latest", + }); + }); + + it("throws a clear error when inherit has no prompt model and no recent list", async () => { + const { resolveOpencodeModelRef } = await import("../src/services/ai/opencode-provider.js"); + expect(() => resolveOpencodeModelRef({ providerID: "local", modelID: "inherit" })).toThrow( + /no session model was recorded and no recent OpenCode model is available/ + ); + }); + + it("expands inherit before POST /session/{id}/message so OpenCode never sees modelID=inherit", async () => { + const { mkdirSync, writeFileSync } = await import("node:fs"); + const { join } = await import("node:path"); + const stateDir = join(tempRoot!, "opencode"); + mkdirSync(stateDir, { recursive: true }); + writeFileSync( + join(stateDir, "model.json"), + JSON.stringify({ + recent: [{ providerID: "local", modelID: "h100/qwen:latest" }], + }) + ); + + const mock = installFetchMock((call) => { + if ( + call.method === "POST" && + call.url.includes("/session") && + !call.url.includes("/message") + ) { + return { body: { id: "ses_inherit" } }; + } + if (call.method === "POST" && call.url.includes("/session/ses_inherit/message")) { + return { + body: { + info: { + structured: { topic: "ok", count: 1 }, + }, + }, + }; + } + if (call.method === "DELETE") { + return { body: true }; + } + throw new Error(`unexpected fetch: ${call.method} ${call.url}`); + }); + + try { + const client = createV2Client("http://127.0.0.1:9999"); + const result = await generateStructuredOutput({ + client, + providerID: "local", + modelID: "inherit", + systemPrompt: "s", + userPrompt: "u", + schema, + }); + expect(result).toEqual({ topic: "ok", count: 1 }); + + const promptCall = mock.calls.find((c) => c.method === "POST" && c.url.includes("/message")); + expect(promptCall?.body).toMatchObject({ + model: { providerID: "local", modelID: "h100/qwen:latest" }, + }); + expect(JSON.stringify(promptCall?.body)).not.toContain('"inherit"'); + } finally { + mock.restore(); + } + }); +});