Skip to content

Commit eee598a

Browse files
committed
release: v2.0.8 - route Console providers by ID instead of by error wording
v2.0.6 and v2.0.7 each fixed a case where the session-metadata fallback failed to trigger because isFreeTierSessionRequired/isSessionMetadataRequired didn't match Console's latest rejection wording. Message matching is inherently reactive: it can only ever catch wording Console has already shipped, and each new tier or phrasing needs its own plugin release. Replace that as the primary path with something that can't go stale: the configured provider ID is already known before any request is sent, and every Console tier observed so far -- Zen "opencode", Go "opencode-go" -- needs session metadata unconditionally. isSessionOnlyProvider() checks the provider ID at translator creation time and, for those providers, skips the stateless attempt entirely rather than sending a request known to be rejected and parsing the rejection. - Add isSessionOnlyProvider(providerID): true for "opencode" and any "opencode-*" provider. sessionRequired now starts true for these instead of false, so ctx.generate.text() is never called for them. - Keep isSessionMetadataRequired() (the message matcher from v2.0.6/2.0.7) as a safety net for providers isSessionOnlyProvider doesn't recognize -- a not-yet-seen Console tier, or an unrelated provider that happens to need session metadata for its own reasons. Wording drift there still self-heals the way it does today; it just isn't the primary mechanism for Console's own providers anymore. - Add unit coverage: isSessionOnlyProvider's id matching (including that it doesn't false-positive on providers that merely contain "opencode"), and an end-to-end test asserting ctx.generate.text() is never invoked for a session-only provider (the mock throws if it's called at all). - Verified against the real opencode-go/gpt-5.6-luna backend: translation succeeds with no stateless attempt in the request path.
1 parent 8e4e6b2 commit eee598a

4 files changed

Lines changed: 67 additions & 25 deletions

File tree

README.md

Lines changed: 19 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -137,21 +137,28 @@ errors through another path.
137137

138138
### OpenCode session-metadata-only translation models
139139

140-
OpenCode can reject stateless generation because `ctx.generate.text()` omits the session request metadata some
141-
providers require. Console has phrased this rejection differently by tier and release: the free Zen tier says
142-
`OpenCode's free tier can only be used in OpenCode.` (also seen as `...from within OpenCode.`), while the paid Go
143-
tier says `Request is missing x-opencode-session and cannot be routed efficiently.` The plugin matches on stable
144-
signals -- the free-tier phrase, or the literal `x-opencode-session` header name -- rather than either exact
145-
sentence. This was reproduced with `opencode/muse-spark-1.3-contributor-free` (Zen) and `opencode-go/gpt-5.6-luna`
146-
(Go): normal session generation succeeds on both, but `ctx.generate.text()` lacks the session request metadata
147-
either provider accepts.
148-
149-
On this specific rejection, the plugin switches to public session-aware generation using a reusable **Translation
150-
helper** session for the configured model and location. The helper may appear in the session list. Translation prompts
151-
are transient: they do not append messages to either the helper or your chat, and each generation receives only the
140+
`ctx.generate.text()` omits the session request metadata every Console tier requires (Zen `opencode` and Go
141+
`opencode-go`, and presumably any tier Console adds later). The plugin recognizes this **upfront, from the
142+
configured provider ID** -- `isSessionOnlyProvider()` -- and routes those providers straight to session-aware
143+
generation without ever attempting the stateless call. This is deliberate: Console has phrased the rejection
144+
differently by tier and release (the free Zen tier: `OpenCode's free tier can only be used in/from within
145+
OpenCode.`; the paid Go tier: `Request is missing x-opencode-session and cannot be routed efficiently.`), and a
146+
provider-ID check that's known before the request is sent can't be broken by wording changes on Console's side --
147+
unlike matching the rejection message, which has already needed two updates.
148+
149+
Matching the rejection message (`isSessionMetadataRequired()`) still exists as a safety net for providers
150+
`isSessionOnlyProvider()` doesn't recognize: on that specific rejection, mid-translation, the plugin switches the
151+
same way. Both paths converge on public session-aware generation using a reusable **Translation helper** session
152+
for the configured model and location. The helper may appear in the session list. Translation prompts are
153+
transient: they do not append messages to either the helper or your chat, and each generation receives only the
152154
current translation prompt plus OpenCode's system instructions and tool definitions. The helper ID survives
153155
plugin/server restarts. Other authentication or model-selection failures still report their original error.
154156

157+
This was verified with `opencode/muse-spark-1.3-contributor-free` (Zen) and `opencode-go/gpt-5.6-luna` (Go): normal
158+
session generation succeeds on both, `ctx.generate.text()` lacks the session request metadata either provider
159+
accepts, and with the provider ID recognized upfront, the plugin never attempts the stateless call in the first
160+
place for either one.
161+
155162
Tool definitions are deliberately kept in the helper session's requests. Console's free tier reads a request with an
156163
emptied tool list as non-agent traffic and rejects it with the same `free tier can only be used within OpenCode`
157164
error, even when the request otherwise carries correct session headers -- confirmed by comparing requests with and

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "opencode-translate",
3-
"version": "2.0.7",
3+
"version": "2.0.8",
44
"description": "OpenCode plugin that lets the user chat in a configured language while the main chat loop only sees English.",
55
"type": "module",
66
"main": "dist/index.js",

src/translator.ts

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,24 @@ export interface Translator {
1515
texts(texts: readonly string[], sourceLanguage: string, targetLanguage: string): Promise<string[]>
1616
}
1717

18-
// Console rejects stateless generation whenever it can't see real OpenCode
19-
// session metadata, but has worded the rejection differently by tier and
20-
// release: the free Zen tier ("...free tier can only be used in/from within
21-
// OpenCode.") and the paid Go tier ("Request is missing x-opencode-session
22-
// and cannot be routed efficiently. Please see .../docs/go/#where-can-i-use-it.").
23-
// Match on stable signals -- the literal header name, or the free-tier phrase
24-
// -- rather than either exact sentence, so wording drift on either tier does
25-
// not silently disable the session fallback below.
18+
// Every Console tier (Zen "opencode", Go "opencode-go", and whatever tier
19+
// comes next) requires real OpenCode session metadata that ctx.generate.text()
20+
// never sends. Known upfront from the configured provider ID -- not observed
21+
// from a failure -- so these providers skip the stateless attempt entirely
22+
// and go straight to the session path below. This is what actually makes the
23+
// fallback robust: it does not depend on Console's error wording at all.
24+
export function isSessionOnlyProvider(providerID: string): boolean {
25+
return providerID === "opencode" || providerID.startsWith("opencode-")
26+
}
27+
28+
// Safety net for session-metadata requirements on providers isSessionOnlyProvider
29+
// doesn't (yet) recognize. Console has worded this rejection differently by tier
30+
// and release: the free Zen tier ("...free tier can only be used in/from within
31+
// OpenCode.") and the paid Go tier ("Request is missing x-opencode-session and
32+
// cannot be routed efficiently. Please see .../docs/go/#where-can-i-use-it.").
33+
// Match on stable signals -- the literal header name, or the free-tier phrase --
34+
// rather than either exact sentence, so wording drift keeps landing here even
35+
// when it silently changes again.
2636
export function isSessionMetadataRequired(message: string): boolean {
2737
return /free tier can only be used\b.*\bopencode/i.test(message) || /x-opencode-session/i.test(message)
2838
}
@@ -36,7 +46,7 @@ export function createTranslator(
3646
): Translator {
3747
const { providerID, modelID } = parseTranslatorModel(options.model)
3848
const model = { providerID, id: modelID, ...(options.variant ? { variant: options.variant } : {}) }
39-
let sessionRequired = false
49+
let sessionRequired = isSessionOnlyProvider(providerID)
4050
let helper: Promise<string> | undefined
4151

4252
function helperSession() {
@@ -95,8 +105,8 @@ export function createTranslator(
95105
return await ctx.generate.text({ model, prompt }, { signal: abort })
96106
} catch (error) {
97107
const message = error && typeof error === "object" && "message" in error ? String(error.message) : String(error)
98-
// OpenCode's stateless path omits the session metadata some providers
99-
// require. Use a real OpenCode session request rather than fabricating headers.
108+
// Reached only for providers isSessionOnlyProvider didn't flag upfront.
109+
// Use a real OpenCode session request rather than fabricating headers.
100110
if (!isSessionMetadataRequired(message)) throw error
101111
abort.throwIfAborted()
102112
sessionRequired = true

test/translator.test.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,33 @@
11
import { expect, test } from "bun:test"
22
import { resolveOptions } from "../src/constants"
3-
import { createTranslator, isSessionMetadataRequired } from "../src/translator"
3+
import { createTranslator, isSessionMetadataRequired, isSessionOnlyProvider } from "../src/translator"
44
import { host, requestContext } from "./helpers"
55

6+
test("session-only providers are recognized by ID alone", () => {
7+
expect(isSessionOnlyProvider("opencode")).toBe(true)
8+
expect(isSessionOnlyProvider("opencode-go")).toBe(true)
9+
expect(isSessionOnlyProvider("opencode-anything-future")).toBe(true)
10+
expect(isSessionOnlyProvider("openai")).toBe(false)
11+
expect(isSessionOnlyProvider("anthropic")).toBe(false)
12+
// Must not match unrelated providers that merely contain the substring.
13+
expect(isSessionOnlyProvider("my-opencode-fork")).toBe(false)
14+
})
15+
16+
test("session-only providers skip the stateless attempt entirely, regardless of wording", async () => {
17+
const h = host({ model: "opencode/muse-spark-1.3-contributor-free" })
18+
h.generate(async () => {
19+
throw new Error("stateless generate.text must never be called for a session-only provider")
20+
})
21+
h.sessionGenerate(async () => "안녕하세요")
22+
const translator = createTranslator(h.ctx, resolveOptions(h.ctx.options), new AbortController().signal)
23+
expect(await translator.text("Hello", "English", "Korean")).toBe("안녕하세요")
24+
expect(h.requests).toHaveLength(0)
25+
expect(h.createdSessions).toHaveLength(1)
26+
expect(h.createdSessions[0]).toMatchObject({
27+
model: { providerID: "opencode", id: "muse-spark-1.3-contributor-free" },
28+
})
29+
})
30+
631
test("session metadata requirement is detected across Console's observed wordings", () => {
732
// Free Zen tier.
833
expect(isSessionMetadataRequired("OpenCode's free tier can only be used in OpenCode.")).toBe(true)

0 commit comments

Comments
 (0)