Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: <provider>/inherit` on those paths. `opencodeProvider` is still required as the normal config gate.

**Fallback:** Manual API configuration (if not using opencodeProvider):

Expand Down
62 changes: 60 additions & 2 deletions src/services/ai/opencode-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -75,15 +78,70 @@ export interface StructuredOutputOptions<T> {
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
* (StructuredOutputError / ApiError / ...), missing `info.structured`,
* or final Zod validation failure.
*/
export async function generateStructuredOutput<T>(opts: StructuredOutputOptions<T>): Promise<T> {
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 =
(
Expand Down
134 changes: 134 additions & 0 deletions tests/opencode-provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
});
});