opencode-translate 1.0.0 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # opencode-translate
2
2
 
3
+ > Use opencode in your native language. LLM always hears English.
4
+
3
5
  ![banner](https://github.com/user-attachments/assets/0bb6739c-abc5-4c3e-837e-2aaf5533a359)
4
6
 
5
7
  ## Demo
@@ -32,8 +34,9 @@ Add to `~/.config/opencode/opencode.jsonc`:
32
34
  {
33
35
  "plugin": [
34
36
  ["opencode-translate", {
35
- "model": "anthropic/claude-haiku-4-5", // model to use for translation
36
- "lang": "Korean" // language you speak
37
+ "model": "openai/gpt-5.4-mini", // model to use for translation
38
+ "variant": "minimal", // optional model variant / thinking effort
39
+ "lang": "Korean" // language you speak
37
40
  }]
38
41
  ]
39
42
  }
@@ -54,6 +57,7 @@ All subsequent messages in the same session are translated automatically — no
54
57
  | Option | Type | Default | Description |
55
58
  | --- | --- | --- | --- |
56
59
  | `model` | string | required | Translator model in `provider/model-id` form |
60
+ | `variant` | string | optional | Translator model variant / thinking effort (for example, `"minimal"`, `"high"`, or `"max"`) |
57
61
  | `lang` | string | required | Language you speak (e.g. `"Korean"`, `"Japanese"`) |
58
62
  | `trigger` | string[] | `["$en"]` | Keywords that activate translation |
59
63
  | `verbose` | boolean | `false` | Print translation logs |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-translate",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
4
  "description": "OpenCode plugin that lets the user chat in a configured language while the main chat loop only sees English.",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -4,6 +4,7 @@ import {
4
4
  isQuestionArgs,
5
5
  type QuestionSnapshot,
6
6
  type QuestionToolOutput,
7
+ restoreQuestionArgs,
7
8
  restoreQuestionOutput,
8
9
  snapshotQuestions,
9
10
  translateQuestionArgs,
@@ -58,7 +59,11 @@ export function createToolExecuteBeforeHook(ctx: HookContext): NonNullable<Hooks
58
59
  }
59
60
  }
60
61
 
61
- questionSnapshots.set(input.callID, { original, translated: snapshotQuestions(args) })
62
+ questionSnapshots.set(input.callID, {
63
+ original,
64
+ translated: snapshotQuestions(args),
65
+ userLanguage: activeState.translate_user_lang,
66
+ })
62
67
  pruneQuestionSnapshots()
63
68
  } catch (error) {
64
69
  await logError(ctx.client, error)
@@ -73,10 +78,9 @@ export function createToolExecuteAfterHook(ctx: HookContext): NonNullable<Hooks[
73
78
  const snapshot = questionSnapshots.get(input.callID)
74
79
  if (!snapshot) return
75
80
  questionSnapshots.delete(input.callID)
81
+ if (isQuestionArgs(input.args)) restoreQuestionArgs(input.args, snapshot.original)
76
82
 
77
- const resolved = await resolveSessionState(ctx.client, ctx.directory, input.sessionID)
78
- const activeState = resolved.state
79
- if (!activeState || activeState.translate_user_lang === LLM_LANGUAGE) {
83
+ if (snapshot.userLanguage === LLM_LANGUAGE) {
80
84
  await restoreQuestionOutput(output as QuestionToolOutput, snapshot)
81
85
  return
82
86
  }
@@ -85,15 +89,12 @@ export function createToolExecuteAfterHook(ctx: HookContext): NonNullable<Hooks[
85
89
  translateCustomAnswer: (text: string) =>
86
90
  ctx.translator.translateText({
87
91
  text,
88
- sourceLanguage: activeState.translate_user_lang,
92
+ sourceLanguage: snapshot.userLanguage,
89
93
  targetLanguage: LLM_LANGUAGE,
90
94
  direction: "inbound",
91
95
  }),
92
96
  onTranslationError: async (error) => {
93
- await logError(
94
- ctx.client,
95
- buildInboundTranslationError(activeState.translate_user_lang, normalizeReason(error)),
96
- )
97
+ await logError(ctx.client, buildInboundTranslationError(snapshot.userLanguage, normalizeReason(error)))
97
98
  },
98
99
  })
99
100
  } catch (error) {
@@ -21,6 +21,7 @@ export function resolveOptions(options: Record<string, unknown>): ResolvedTransl
21
21
  `[${PLUGIN_NAME}:INVALID_OPTIONS] options.lang is required. Set it to the user's language, e.g. "Korean" or "Japanese".`,
22
22
  )
23
23
  }
24
+ const variant = typeof options.variant === "string" ? options.variant.trim() : ""
24
25
 
25
26
  const rawTrigger = Array.isArray(options.trigger)
26
27
  ? options.trigger
@@ -31,6 +32,7 @@ export function resolveOptions(options: Record<string, unknown>): ResolvedTransl
31
32
 
32
33
  return {
33
34
  model,
35
+ ...(variant ? { variant } : {}),
34
36
  trigger: trigger.length > 0 ? trigger : [...DEFAULT_TRIGGER],
35
37
  lang,
36
38
  verbose: options.verbose === true,
@@ -6,6 +6,7 @@ type ProviderSource = "env" | "config" | "custom" | "api"
6
6
 
7
7
  export interface ResolvedTranslateOptions {
8
8
  model: string
9
+ variant?: string
9
10
  trigger: string[]
10
11
  lang: string
11
12
  verbose: boolean
@@ -67,6 +68,7 @@ export interface ProviderModelInfo {
67
68
  }
68
69
  headers?: Record<string, string>
69
70
  options?: Record<string, unknown>
71
+ variants?: Record<string, Record<string, unknown>>
70
72
  capabilities?: {
71
73
  temperature?: boolean
72
74
  }
@@ -29,6 +29,7 @@ export interface QuestionArgs {
29
29
  export interface QuestionSnapshot {
30
30
  original: TextRecord[]
31
31
  translated: TextRecord[]
32
+ userLanguage: string
32
33
  }
33
34
 
34
35
  export interface QuestionToolOutput {
@@ -56,6 +57,10 @@ export function snapshotQuestions(args: QuestionArgs): TextRecord[] {
56
57
  return args.questions.map(cloneQuestion)
57
58
  }
58
59
 
60
+ export function restoreQuestionArgs(args: QuestionArgs, original: readonly TextRecord[]): void {
61
+ args.questions.splice(0, args.questions.length, ...original.map(cloneQuestion))
62
+ }
63
+
59
64
  export function isQuestionArgs(value: unknown): value is QuestionArgs {
60
65
  if (!value || typeof value !== "object") return false
61
66
  const questions = (value as Record<string, unknown>).questions
@@ -142,31 +147,55 @@ async function restoreLabel(
142
147
  }
143
148
  }
144
149
 
145
- // Reconstruct the exact output string the question tool would have produced
146
- // if it had been called with the original English args. Mirrors the format
147
- // in `packages/opencode/src/tool/question.ts` (as of opencode 1.14.x).
148
- export async function buildRestoredOutput(
150
+ async function restoreQuestionAnswers(
149
151
  original: readonly TextRecord[],
150
152
  translated: readonly TextRecord[],
151
153
  answers: readonly (readonly string[])[],
152
154
  options: RestoreQuestionOutputOptions = {},
153
- ): Promise<string> {
154
- const formattedParts = await Promise.all(
155
+ ): Promise<string[][]> {
156
+ return Promise.all(
155
157
  original.map(async (q, i) => {
156
158
  const selected = answers[i] ?? []
157
159
  const translatedOptions = translated[i]?.options ?? []
158
160
  const originalOptions = q.options
159
- const restored = await Promise.all(
160
- selected.map((label) => restoreLabel(label, translatedOptions, originalOptions, options)),
161
- )
162
- const rendered = restored.length > 0 ? restored.join(", ") : "Unanswered"
163
- return `"${q.question}"="${rendered}"`
161
+ return Promise.all(selected.map((label) => restoreLabel(label, translatedOptions, originalOptions, options)))
164
162
  }),
165
163
  )
164
+ }
165
+
166
+ function formatRestoredOutput(original: readonly TextRecord[], answers: readonly (readonly string[])[]): string {
167
+ const formattedParts = original.map((q, i) => {
168
+ const restored = answers[i] ?? []
169
+ const rendered = restored.length > 0 ? restored.join(", ") : "Unanswered"
170
+ return `"${q.question}"="${rendered}"`
171
+ })
166
172
  const formatted = formattedParts.join(", ")
167
173
  return `User has answered your questions: ${formatted}. You can now continue with the user's answers in mind.`
168
174
  }
169
175
 
176
+ // Reconstruct the exact output string the question tool would have produced
177
+ // if it had been called with the original English args. Mirrors the format
178
+ // in `packages/opencode/src/tool/question.ts` (as of opencode 1.14.x).
179
+ export async function buildRestoredOutput(
180
+ original: readonly TextRecord[],
181
+ translated: readonly TextRecord[],
182
+ answers: readonly (readonly string[])[],
183
+ options: RestoreQuestionOutputOptions = {},
184
+ ): Promise<string> {
185
+ const restoredAnswers = await restoreQuestionAnswers(original, translated, answers, options)
186
+ return formatRestoredOutput(original, restoredAnswers)
187
+ }
188
+
189
+ function mutableMetadata(output: QuestionToolOutput): Record<string, unknown> {
190
+ if (output.metadata && typeof output.metadata === "object" && !Array.isArray(output.metadata)) {
191
+ return output.metadata as Record<string, unknown>
192
+ }
193
+
194
+ const metadata: Record<string, unknown> = {}
195
+ output.metadata = metadata
196
+ return metadata
197
+ }
198
+
170
199
  export async function restoreQuestionOutput(
171
200
  output: QuestionToolOutput,
172
201
  snapshot: QuestionSnapshot,
@@ -175,5 +204,7 @@ export async function restoreQuestionOutput(
175
204
  if (typeof output.output !== "string") return
176
205
  const answersRaw = (output.metadata as { answers?: readonly (readonly string[])[] } | undefined)?.answers
177
206
  const answers = Array.isArray(answersRaw) ? answersRaw : []
178
- output.output = await buildRestoredOutput(snapshot.original, snapshot.translated, answers, options)
207
+ const restoredAnswers = await restoreQuestionAnswers(snapshot.original, snapshot.translated, answers, options)
208
+ output.output = formatRestoredOutput(snapshot.original, restoredAnswers)
209
+ mutableMetadata(output).answers = restoredAnswers
179
210
  }
@@ -13,6 +13,7 @@ import { buildSystemPrompt, buildUserPrompt, unwrapEchoedTextEnvelope } from "..
13
13
  import { __resetSyntheticPartIDForTest } from "./part-id"
14
14
  import {
15
15
  __resetProviderFactoryCacheForTest,
16
+ buildVariantProviderOptions,
16
17
  instantiateModel,
17
18
  instantiateProvider,
18
19
  loadFactory,
@@ -73,6 +74,7 @@ export function createTranslator(
73
74
  const { providerID, modelID } = parseTranslatorModel(options.model)
74
75
  const credentials = await credentialResolver.resolve(options.model)
75
76
  const modelInfo = resolveModelInfo(credentials.provider, modelID)
77
+ const variantProviderOptions = buildVariantProviderOptions(providerID, modelID, modelInfo, options.variant)
76
78
  const factory = await loadFactory(providerID, modelInfo)
77
79
  const provider = instantiateProvider(factory, providerID, credentials, modelInfo)
78
80
  const providerOptions = { ...(credentials.provider?.options ?? {}), ...(modelInfo.options ?? {}) }
@@ -85,6 +87,7 @@ export function createTranslator(
85
87
  model,
86
88
  system: buildSystemPrompt(input),
87
89
  ...(supportsTemperature(providerID, modelID, modelInfo) ? { temperature: 0 } : {}),
90
+ ...(variantProviderOptions ? { providerOptions: variantProviderOptions } : {}),
88
91
  prompt: buildUserPrompt(input),
89
92
  }),
90
93
  timeoutMs,
@@ -1,4 +1,4 @@
1
- import type { AuthInfo, FetchLike, ProviderInfo, ProviderModelInfo } from "../constants"
1
+ import { type AuthInfo, type FetchLike, PLUGIN_NAME, type ProviderInfo, type ProviderModelInfo } from "../constants"
2
2
 
3
3
  const providerFactoryCache = new Map<string, unknown>()
4
4
 
@@ -23,6 +23,26 @@ const CREATE_EXPORT_FALLBACK: Record<string, string[]> = {
23
23
  "@openrouter/ai-sdk-provider": ["createOpenRouter", "openrouter"],
24
24
  }
25
25
 
26
+ const PROVIDER_OPTIONS_KEY: Record<string, string> = {
27
+ "@ai-sdk/amazon-bedrock": "bedrock",
28
+ "@ai-sdk/amazon-bedrock/mantle": "openai",
29
+ "@ai-sdk/anthropic": "anthropic",
30
+ "@ai-sdk/azure": "openai",
31
+ "@ai-sdk/gateway": "gateway",
32
+ "@ai-sdk/github-copilot": "openai",
33
+ "@ai-sdk/google": "google",
34
+ "@ai-sdk/google-vertex": "vertex",
35
+ "@ai-sdk/google-vertex/anthropic": "anthropic",
36
+ "@ai-sdk/openai": "openai",
37
+ "@openrouter/ai-sdk-provider": "openrouter",
38
+ "ai-gateway-provider": "openaiCompatible",
39
+ }
40
+
41
+ type JsonValue = null | string | number | boolean | JsonObject | JsonArray
42
+ type JsonObject = { [key: string]: JsonValue | undefined }
43
+ type JsonArray = JsonValue[]
44
+ type VariantProviderOptions = Record<string, JsonObject>
45
+
26
46
  interface ProviderCredentials {
27
47
  provider?: ProviderInfo
28
48
  authInfo?: AuthInfo
@@ -73,6 +93,40 @@ export function resolveModelInfo(provider: ProviderInfo | undefined, modelID: st
73
93
  return provider?.models?.[modelID] ?? { id: modelID, api: { id: modelID } }
74
94
  }
75
95
 
96
+ function sdkProviderOptionsKey(providerID: string, model?: ProviderModelInfo): string {
97
+ const packageName = model?.api?.npm
98
+ if (packageName && PROVIDER_OPTIONS_KEY[packageName]) return PROVIDER_OPTIONS_KEY[packageName]
99
+ if (packageName === "@ai-sdk/openai-compatible" || packageName === "@ai-sdk/openai") return providerID.split(".")[0]
100
+ return providerID
101
+ }
102
+
103
+ function invalidVariantError(providerID: string, modelID: string, model: ProviderModelInfo, variant: string) {
104
+ const variants = Object.keys(model.variants ?? {}).sort()
105
+ const modelName = `${providerID}/${modelID}`
106
+ if (variants.length === 0) {
107
+ return new Error(
108
+ `[${PLUGIN_NAME}:INVALID_VARIANT] options.variant "${variant}" is not available for "${modelName}". This model has no configurable variants.`,
109
+ )
110
+ }
111
+ return new Error(
112
+ `[${PLUGIN_NAME}:INVALID_VARIANT] options.variant "${variant}" is not available for "${modelName}". Available variants: ${variants.join(", ")}.`,
113
+ )
114
+ }
115
+
116
+ export function buildVariantProviderOptions(
117
+ providerID: string,
118
+ modelID: string,
119
+ model: ProviderModelInfo,
120
+ variant?: string,
121
+ ): VariantProviderOptions | undefined {
122
+ if (!variant) return undefined
123
+ const selected = model.variants?.[variant]
124
+ if (!selected) throw invalidVariantError(providerID, modelID, model, variant)
125
+ const providerOptions = selected as JsonObject
126
+ if (model.api?.npm === "@ai-sdk/azure") return { openai: providerOptions, azure: providerOptions }
127
+ return { [sdkProviderOptionsKey(providerID, model)]: providerOptions }
128
+ }
129
+
76
130
  function headerRecord(value: unknown): Record<string, string> {
77
131
  if (!value || typeof value !== "object" || Array.isArray(value)) return {}
78
132
  return Object.fromEntries(