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 +6 -2
- package/package.json +1 -1
- package/src/activation/question-hooks.ts +10 -9
- package/src/constants/options.ts +2 -0
- package/src/constants/types.ts +2 -0
- package/src/question-tool.ts +43 -12
- package/src/translator/index.ts +3 -0
- package/src/translator/provider.ts +55 -1
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
|

|
|
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": "
|
|
36
|
-
"
|
|
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
|
@@ -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, {
|
|
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
|
-
|
|
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:
|
|
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) {
|
package/src/constants/options.ts
CHANGED
|
@@ -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,
|
package/src/constants/types.ts
CHANGED
|
@@ -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
|
}
|
package/src/question-tool.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/translator/index.ts
CHANGED
|
@@ -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
|
|
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(
|