From 36f1dcbcc257801716667234d20088e40bd5bd4e Mon Sep 17 00:00:00 2001 From: EyJunge1 <149941075+EyJunge1@users.noreply.github.com> Date: Wed, 28 Oct 2026 21:49:21 +0100 Subject: [PATCH] fix(ai): remember host session model for opencodeModel inherit OpenCode v2 no longer writes model.json, so profile learning and other non-prompt structured-output paths had no fallback for inherit. Keep the last chat.params / V2 context model in-process and prefer it before the legacy model.json read. --- .github/i18n/README.ar.md | 2 +- .github/i18n/README.de.md | 2 +- .github/i18n/README.nl.md | 2 +- .github/i18n/README.tr.md | 2 +- .github/i18n/README.zh.md | 2 +- README.md | 2 +- src/index.ts | 8 ++++++ src/services/ai/opencode-provider.ts | 30 ++++++++++++++++++-- tests/opencode-provider.test.ts | 42 +++++++++++++++++++++++----- 9 files changed, 76 insertions(+), 16 deletions(-) diff --git a/.github/i18n/README.ar.md b/.github/i18n/README.ar.md index db45afeb..d151b7ac 100644 --- a/.github/i18n/README.ar.md +++ b/.github/i18n/README.ar.md @@ -450,7 +450,7 @@ memory({ mode: "import", inputPath: "./memories.json" }); إذا عُيِّن `opencodeProvider` و`opencodeModel`، يتقدمان على إعدادات `memoryProvider` اليدوية أدناه. -**اتبع نموذج الجلسة:** عيّن `"opencodeModel": "inherit"` لاستخدام نموذج OpenCode ملموس وقت الاستدعاء بدل معرّف مثبت. لـ **الالتقاط التلقائي**، تُسجَّل كل مطالبة عبر خطاف `chat.params` ويعيد طلب الالتقاط استخدام مزود/نموذج تلك المطالبة. لـ **تعلّم الملف** ومسارات المخرجات المنظمة الأخرى (غير المرتبطة برسالة مستخدم واحدة)، يرجع `inherit` إلى أحدث نموذج في قائمة `model.json` الحديثة في OpenCode (مفضّلًا `opencodeProvider` المُعَد). إرسال معرّف النموذج الحرفي `inherit` غير صالح أبدًا وتسبب سابقًا في `ProviderModelNotFoundError: Model not found: /inherit` على تلك المسارات. ما زال `opencodeProvider` مطلوبًا كبوابة الإعداد العادية. +**اتبع نموذج الجلسة:** عيّن `"opencodeModel": "inherit"` لاستخدام نموذج OpenCode ملموس وقت الاستدعاء بدل معرّف مثبت. لـ **الالتقاط التلقائي**، تُسجَّل كل مطالبة عبر خطاف `chat.params` ويعيد طلب الالتقاط استخدام مزود/نموذج تلك المطالبة. لـ **تعلّم الملف** ومسارات المخرجات المنظمة الأخرى (غير المرتبطة برسالة مستخدم واحدة)، يعيد `inherit` أولاً استخدام آخر نموذج أبلغ به المضيف عبر `chat.params` / خطاف سياق V2، ثم يرجع إلى قائمة `model.json` الحديثة في OpenCode إن وُجدت (مفضّلًا `opencodeProvider` المُعَد). OpenCode v2 لم يعد يكتب `model.json`، لذا نموذج الجلسة داخل العملية هو الاحتياطي الأساسي هناك. إرسال معرّف النموذج الحرفي `inherit` غير صالح أبدًا وتسبب سابقًا في `ProviderModelNotFoundError: Model not found: /inherit` على تلك المسارات. ما زال `opencodeProvider` مطلوبًا كبوابة الإعداد العادية. **الرجوع:** إعداد API يدوي (إن لم تستخدم opencodeProvider): diff --git a/.github/i18n/README.de.md b/.github/i18n/README.de.md index d5a29054..b1107c93 100644 --- a/.github/i18n/README.de.md +++ b/.github/i18n/README.de.md @@ -451,7 +451,7 @@ Unterstützte Provider: jeder von `opencode providers list` gelistete Provider ( Wenn `opencodeProvider` und `opencodeModel` gesetzt sind, haben sie Vorrang vor den manuellen `memoryProvider`-Einstellungen unten. -**Sitzungsmodell folgen:** `"opencodeModel": "inherit"` setzen, um zur Aufrufzeit ein konkretes OpenCode-Modell statt einer gepinnten ID zu nutzen. Für **Auto-Capture** wird jeder Prompt über den `chat.params`-Hook aufgezeichnet und der Capture-Request wiederverwendet Provider/Modell dieses Prompts. Für **Profil-Lernen** und andere Structured-Output-Pfade (die nicht an eine einzelne User-Nachricht gebunden sind) fällt `inherit` auf das neueste Modell in der Recent-Liste von OpenCodes `model.json` zurück (bevorzugt den konfigurierten `opencodeProvider`). Das wörtliche Modell-ID `inherit` zu senden ist nie gültig und verursachte zuvor `ProviderModelNotFoundError: Model not found: /inherit` auf diesen Pfaden. `opencodeProvider` bleibt als normale Config-Gate erforderlich. +**Sitzungsmodell folgen:** `"opencodeModel": "inherit"` setzen, um zur Aufrufzeit ein konkretes OpenCode-Modell statt einer gepinnten ID zu nutzen. Für **Auto-Capture** wird jeder Prompt über den `chat.params`-Hook aufgezeichnet und der Capture-Request wiederverwendet Provider/Modell dieses Prompts. Für **Profil-Lernen** und andere Structured-Output-Pfade (die nicht an eine einzelne User-Nachricht gebunden sind) nutzt `inherit` zuerst das zuletzt vom Host über `chat.params` / den V2-Context-Hook gemeldete Modell und fällt danach auf die Recent-Liste in OpenCodes `model.json` zurück, falls vorhanden (bevorzugt den konfigurierten `opencodeProvider`). OpenCode v2 schreibt `model.json` nicht mehr; dort ist das In-Process-Sitzungsmodell der primäre Fallback. Das wörtliche Modell-ID `inherit` zu senden ist nie gültig und verursachte zuvor `ProviderModelNotFoundError: Model not found: /inherit` auf diesen Pfaden. `opencodeProvider` bleibt als normale Config-Gate erforderlich. **Fallback:** Manuelle API-Konfiguration (wenn opencodeProvider nicht genutzt wird): diff --git a/.github/i18n/README.nl.md b/.github/i18n/README.nl.md index 4006e850..38f52543 100644 --- a/.github/i18n/README.nl.md +++ b/.github/i18n/README.nl.md @@ -452,7 +452,7 @@ Ondersteunde providers: elke provider uit `opencode providers list` (bijv. `anth Als `opencodeProvider` en `opencodeModel` zijn ingesteld, hebben ze voorrang op de handmatige `memoryProvider`-instellingen hieronder. -**Volg het sessiermodel:** stel `"opencodeModel": "inherit"` in om op call-tijd een concreet OpenCode-model te gebruiken in plaats van een vastgezette id. Voor **auto-capture** wordt elke prompt via de `chat.params`-hook vastgelegd en hergebruikt het capture-verzoek provider/model van die prompt. Voor **profiel-leren** en andere structured-output-paden (die niet aan één gebruikersbericht zijn gebonden) valt `inherit` terug op het meest recente model in OpenCode’s `model.json`-recentelijst (met voorkeur voor de geconfigureerde `opencodeProvider`). Het letterlijke model-id `inherit` versturen is nooit geldig en veroorzaakte eerder `ProviderModelNotFoundError: Model not found: /inherit` op die paden. `opencodeProvider` blijft vereist als normale configgate. +**Volg het sessiermodel:** stel `"opencodeModel": "inherit"` in om op call-tijd een concreet OpenCode-model te gebruiken in plaats van een vastgezette id. Voor **auto-capture** wordt elke prompt via de `chat.params`-hook vastgelegd en hergebruikt het capture-verzoek provider/model van die prompt. Voor **profiel-leren** en andere structured-output-paden (die niet aan één gebruikersbericht zijn gebonden) hergebruikt `inherit` eerst het laatste model dat de host via `chat.params` / de V2-context-hook meldde, en valt daarna terug op OpenCode’s `model.json`-recentelijst indien aanwezig (met voorkeur voor de geconfigureerde `opencodeProvider`). OpenCode v2 schrijft `model.json` niet meer; daar is het in-process-sessiemodel de primaire fallback. Het letterlijke model-id `inherit` versturen is nooit geldig en veroorzaakte eerder `ProviderModelNotFoundError: Model not found: /inherit` op die paden. `opencodeProvider` blijft vereist als normale configgate. **Fallback:** Handmatige API-configuratie (als je geen opencodeProvider gebruikt): diff --git a/.github/i18n/README.tr.md b/.github/i18n/README.tr.md index b96be180..093df533 100644 --- a/.github/i18n/README.tr.md +++ b/.github/i18n/README.tr.md @@ -452,7 +452,7 @@ Desteklenen sağlayıcılar: `opencode providers list` tarafından listelenen he `opencodeProvider` ve `opencodeModel` ayarlanmışsa, aşağıdaki manuel `memoryProvider` ayarlarına göre önceliklidirler. -**Oturum modelini izle:** çağrı anında sabitlenmiş bir kimlik yerine somut bir OpenCode modeli kullanmak için `"opencodeModel": "inherit"` ayarlayın. **Otomatik yakalama** için her prompt `chat.params` kancası üzerinden kaydedilir ve yakalama isteği o prompt’un sağlayıcı/modelini yeniden kullanır. **Profil öğrenme** ve diğer yapılandırılmış çıktı yolları için (tek bir kullanıcı mesajına bağlı değildir), `inherit` OpenCode’un `model.json` son liste içindeki en son modele düşer (yapılandırılmış `opencodeProvider` tercih edilir). Literâl model kimliği `inherit` göndermek asla geçerli değildir ve daha önce bu yollarda `ProviderModelNotFoundError: Model not found: /inherit`’e neden olmuştur. `opencodeProvider` yine de normal yapılandırma kapısı olarak gereklidir. +**Oturum modelini izle:** çağrı anında sabitlenmiş bir kimlik yerine somut bir OpenCode modeli kullanmak için `"opencodeModel": "inherit"` ayarlayın. **Otomatik yakalama** için her prompt `chat.params` kancası üzerinden kaydedilir ve yakalama isteği o prompt’un sağlayıcı/modelini yeniden kullanır. **Profil öğrenme** ve diğer yapılandırılmış çıktı yolları için (tek bir kullanıcı mesajına bağlı değildir), `inherit` önce host’un `chat.params` / V2 context kancasında bildirdiği son modeli yeniden kullanır; yoksa OpenCode’un `model.json` son listesine düşer (yapılandırılmış `opencodeProvider` tercih edilir). OpenCode v2 artık `model.json` yazmaz; orada birincil geri dönüş süreç içi oturum modelidir. Literâl model kimliği `inherit` göndermek asla geçerli değildir ve daha önce bu yollarda `ProviderModelNotFoundError: Model not found: /inherit`’e neden olmuştur. `opencodeProvider` yine de normal yapılandırma kapısı olarak gereklidir. **Yedek:** Manuel API yapılandırması (opencodeProvider kullanılmıyorsa): diff --git a/.github/i18n/README.zh.md b/.github/i18n/README.zh.md index 681c8435..1909fc55 100644 --- a/.github/i18n/README.zh.md +++ b/.github/i18n/README.zh.md @@ -446,7 +446,7 @@ memory({ mode: "import", inputPath: "./memories.json" }); 若设置了 `opencodeProvider` 与 `opencodeModel`,它们优先于下方的手动 `memoryProvider` 设置。 -**跟随会话模型:** 设置 `"opencodeModel": "inherit"` 以在调用时使用具体的 OpenCode 模型,而非固定 id。对于**自动捕获**,每个提示通过 `chat.params` 钩子记录,捕获请求重用该提示的提供商/模型。对于**画像学习**及其他结构化输出路径(不绑定到单条用户消息),`inherit` 回退到 OpenCode `model.json` 最近列表中的最新模型(优先使用已配置的 `opencodeProvider`)。发送字面模型 id `inherit` 永远无效,此前会在这些路径上导致 `ProviderModelNotFoundError: Model not found: /inherit`。`opencodeProvider` 仍作为正常配置门控是必需的。 +**跟随会话模型:** 设置 `"opencodeModel": "inherit"` 以在调用时使用具体的 OpenCode 模型,而非固定 id。对于**自动捕获**,每个提示通过 `chat.params` 钩子记录,捕获请求重用该提示的提供商/模型。对于**画像学习**及其他结构化输出路径(不绑定到单条用户消息),`inherit` 先复用主机在 `chat.params` / V2 context 钩子上报的最近模型,再回退到 OpenCode `model.json` 最近列表(若存在;优先使用已配置的 `opencodeProvider`)。OpenCode v2 不再写入 `model.json`,因此进程内会话模型是主要回退。发送字面模型 id `inherit` 永远无效,此前会在这些路径上导致 `ProviderModelNotFoundError: Model not found: /inherit`。`opencodeProvider` 仍作为正常配置门控是必需的。 **回退:** 手动 API 配置(若不使用 opencodeProvider): diff --git a/README.md b/README.md index 23d368ed..99974438 100644 --- a/README.md +++ b/README.md @@ -494,7 +494,7 @@ If `autoCaptureEnabled` is `true` (default) but neither `opencodeProvider`/`open If `opencodeProvider` and `opencodeModel` are set, they take precedence over the manual `memoryProvider` settings below by default (`"autoCaptureProviderOrder": "opencode-first"`). Set `"autoCaptureProviderOrder": "external-first"` to prefer the manual API when both are configured (useful when the host session API is busy/slow); the other channel is used as fallback. Lower `"opencodeTimeoutMs"` to fail over from the opencode channel sooner. -**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. +**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` first reuses the last model the host reported on `chat.params` / the V2 context hook, then falls back to OpenCode's `model.json` recent list when present (preferring the configured `opencodeProvider`). OpenCode v2 no longer writes `model.json`, so the in-process session model is the primary fallback there. 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/index.ts b/src/index.ts index 3cc45e47..6a61ebb8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -27,6 +27,7 @@ import { loadOpencodeProvider } from "./services/ai/opencode-provider-loader.js" import { isInternalStructuredSession, noteStructuredOutputStep, + rememberSessionModel, STRUCTURED_OUTPUT_AGENT, STRUCTURED_OUTPUT_MAX_STEPS, STRUCTURED_OUTPUT_TOOLS, @@ -832,6 +833,13 @@ export const OpenCodeMemPlugin: Plugin = async (ctx: PluginInput) => { "chat.params": async (input, output) => { applyStructuredOutputChatParams(input, output); + const sessionModel = input?.model; + if (typeof sessionModel?.providerID === "string" && typeof sessionModel?.id === "string") { + // Keep the host-resolved model for inherit on idle paths (profile + // learning, dedup, …). OpenCode v2 no longer writes model.json (#453). + rememberSessionModel(sessionModel.providerID, sessionModel.id); + } + if (!isConfigured() || CONFIG.opencodeModel !== "inherit") return; try { diff --git a/src/services/ai/opencode-provider.ts b/src/services/ai/opencode-provider.ts index 329756f5..49712ce5 100644 --- a/src/services/ai/opencode-provider.ts +++ b/src/services/ai/opencode-provider.ts @@ -377,12 +377,32 @@ export interface StructuredOutputOptions { retryCount?: number; } +/** Last host-reported session model from `chat.params` / the V2 context hook. */ +let rememberedSessionModel: { providerID: string; modelID: string } | undefined; + +/** + * Remember the concrete model the host is using for the current session. + * OpenCode v2 no longer writes `model.json`, so profile-learning and other + * non-prompt structured-output paths need this in-process fallback for + * `opencodeModel: "inherit"` (issue #453). + */ +export function rememberSessionModel(providerID: string, modelID: string): void { + if (!providerID || !modelID || modelID === "inherit") return; + rememberedSessionModel = { providerID, modelID }; +} + +/** Test helper: drop the in-process session model between cases. */ +export function clearRememberedSessionModelForTests(): void { + rememberedSessionModel = undefined; +} + /** * 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). + * Prefer an explicit prompt-recorded model (auto-capture path). Otherwise use + * the last host-reported session model, then OpenCode's recent `model.json` + * list (v1 hosts), so profile-learning / conflict / dedup paths don't send the + * literal model id "inherit" (ProviderModelNotFoundError). */ export function resolveOpencodeModelRef(opts: { providerID: string; @@ -397,6 +417,10 @@ export function resolveOpencodeModelRef(opts: { return { providerID: opts.prompt.providerId, modelID: opts.prompt.modelId }; } + if (rememberedSessionModel) { + return rememberedSessionModel; + } + const recent = readRecentOpencodeModel(opts.providerID); if (recent) return recent; diff --git a/tests/opencode-provider.test.ts b/tests/opencode-provider.test.ts index b0833cdf..f8303057 100644 --- a/tests/opencode-provider.test.ts +++ b/tests/opencode-provider.test.ts @@ -25,6 +25,9 @@ import { STRUCTURED_OUTPUT_PERMISSIONS, STRUCTURED_OUTPUT_TOOLS, takeStructuredOutputStepAbortError, + rememberSessionModel, + clearRememberedSessionModelForTests, + resolveOpencodeModelRef, } from "../src/services/ai/opencode-provider.js"; const schema = z.object({ @@ -1054,9 +1057,11 @@ describe("resolveOpencodeModelRef / inherit", () => { const { join } = await import("node:path"); tempRoot = mkdtempSync(join(tmpdir(), "opencode-mem-inherit-")); process.env.XDG_STATE_HOME = tempRoot; + clearRememberedSessionModelForTests(); }); afterEach(async () => { + clearRememberedSessionModelForTests(); if (originalStateHome === undefined) { delete process.env.XDG_STATE_HOME; } else { @@ -1069,16 +1074,15 @@ describe("resolveOpencodeModelRef / inherit", () => { } }); - it("passes concrete model ids through unchanged", async () => { - const { resolveOpencodeModelRef } = await import("../src/services/ai/opencode-provider.js"); + it("passes concrete model ids through unchanged", () => { 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"); + it("prefers the recorded prompt model when modelID is inherit", () => { + rememberSessionModel("opencode-go", "should-not-win"); expect( resolveOpencodeModelRef({ providerID: "local", @@ -1091,6 +1095,32 @@ describe("resolveOpencodeModelRef / inherit", () => { }); }); + it("uses the remembered host session model before model.json", 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: "from-model-json" }], + }) + ); + rememberSessionModel("opencode-go", "host-session-model"); + expect(resolveOpencodeModelRef({ providerID: "local", modelID: "inherit" })).toEqual({ + providerID: "opencode-go", + modelID: "host-session-model", + }); + }); + + it("ignores blank or literal inherit values when remembering the session model", () => { + rememberSessionModel("opencode-go", "inherit"); + rememberSessionModel("", "ignored"); + expect(() => resolveOpencodeModelRef({ providerID: "local", modelID: "inherit" })).toThrow( + /no session model was recorded and no recent OpenCode model is available/ + ); + }); + 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"); @@ -1106,15 +1136,13 @@ describe("resolveOpencodeModelRef / inherit", () => { }) ); - 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"); + it("throws a clear error when inherit has no prompt model and no recent list", () => { expect(() => resolveOpencodeModelRef({ providerID: "local", modelID: "inherit" })).toThrow( /no session model was recorded and no recent OpenCode model is available/ );