opencode-translate 0.2.1 → 0.2.3

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
@@ -44,7 +44,7 @@ Hooks never throw. If the translator fails (network error, auth failure, provide
44
44
  3. Falls back to sending the original (untranslated) user text to the model.
45
45
  4. On activation-turn failure, it also rolls back activation so the next turn retries cleanly.
46
46
 
47
- A stalled provider request is additionally bounded by a 60s hard timeout per translation call, so a hung upstream cannot block the OpenCode session.
47
+ A stalled provider request is additionally bounded by a 180s hard timeout per translation call, so a hung upstream cannot block the OpenCode session.
48
48
 
49
49
  ## Install
50
50
 
@@ -101,6 +101,12 @@ Using this plugin means text goes to two model providers per turn:
101
101
 
102
102
  If you need strict single-provider or self-hosted-only behavior, do not enable this plugin.
103
103
 
104
+ ## Authentication
105
+
106
+ Translator requests are made through the AI SDK directly, but provider setup mirrors OpenCode's resolved provider metadata from `client.provider.list()`: provider/model package metadata, configured provider options, model headers, resolved env/API keys, and plugin-auth-loader options that OpenCode exposes there.
107
+
108
+ For OAuth records that OpenCode does not expose through the plugin SDK, the plugin reads OpenCode auth content/files and adapts the request only where direct AI SDK calls need it.
109
+
104
110
  ## Anthropic OAuth Support
105
111
 
106
112
  If `translatorModel` uses Anthropic and OpenCode auth is backed by Anthropic OAuth (Claude Pro/Max), the plugin reuses those OAuth credentials for translation requests.
@@ -121,7 +127,7 @@ Tradeoffs:
121
127
  - OpenCode upstream removed Anthropic OAuth support for legal / policy reasons. Installing this plugin reintroduces an equivalent code path in your environment.
122
128
  - Translator requests contribute to your Claude Pro/Max rate limit alongside OpenCode's main chat.
123
129
 
124
- If you prefer a plain API key, set `ANTHROPIC_API_KEY` in the environment or pass `apiKey` in plugin options. The plugin prefers explicit `apiKey`, then `ANTHROPIC_API_KEY`, then OAuth.
130
+ If you prefer a plain API key, set `ANTHROPIC_API_KEY`, use `opencode auth login anthropic`, or pass `apiKey` in plugin options.
125
131
 
126
132
  ## OpenAI OAuth Support
127
133
 
@@ -129,7 +135,7 @@ If `translatorModel` uses OpenAI and OpenCode auth is backed by the ChatGPT/Code
129
135
 
130
136
  For OAuth-backed OpenAI requests, the plugin routes the OpenAI AI SDK request to `https://chatgpt.com/backend-api/codex/responses`, adds the required Codex beta/originator headers, normalizes the request body to Codex's expected typed `input` shape, and converts Codex SSE responses back to JSON for translation calls. This supports models such as `openai/gpt-5.5` when your ChatGPT plan has access.
131
137
 
132
- If you prefer a plain API key, set `OPENAI_API_KEY` in the environment or pass `apiKey` in plugin options. The plugin prefers explicit `apiKey`, then `OPENAI_API_KEY`, then OAuth.
138
+ If you prefer a plain API key, set `OPENAI_API_KEY`, use `opencode auth login openai`, or pass `apiKey` in plugin options.
133
139
 
134
140
  ## Manual Smoke Test
135
141
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-translate",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
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",
@@ -19,7 +19,7 @@ export function __resetActivationCacheForTest() {
19
19
  export function createHooks(ctx: PluginInput, rawOptions: PluginOptions = {}, deps: HookDependencies = {}): Hooks {
20
20
  if (process.env.OPENCODE_TRANSLATE_DISABLE === "1") return {}
21
21
 
22
- const client = ctx.client as unknown as PluginClientLike
22
+ const client = ctx.client as PluginClientLike
23
23
  const options = resolveOptions(rawOptions)
24
24
  const hookContext: HookContext = {
25
25
  client,
@@ -12,8 +12,19 @@ import { logError } from "./logging"
12
12
  import { resolveSessionState } from "./state"
13
13
  import { type HookContext, QUESTION_TOOL_ID } from "./types"
14
14
 
15
+ const QUESTION_SNAPSHOT_LIMIT = 1_000
16
+
15
17
  const questionSnapshots = new Map<string, QuestionSnapshot>()
16
18
 
19
+ function pruneQuestionSnapshots() {
20
+ while (questionSnapshots.size > QUESTION_SNAPSHOT_LIMIT) {
21
+ for (const callID of questionSnapshots.keys()) {
22
+ questionSnapshots.delete(callID)
23
+ break
24
+ }
25
+ }
26
+ }
27
+
17
28
  export function resetQuestionSnapshots() {
18
29
  questionSnapshots.clear()
19
30
  }
@@ -26,8 +37,8 @@ export function createToolExecuteBeforeHook(ctx: HookContext): NonNullable<Hooks
26
37
  const activeState = resolved.state
27
38
  if (!activeState) return
28
39
 
29
- const args = output.args as unknown
30
- if (!isQuestionArgs(args)) return
40
+ if (!isQuestionArgs(output.args)) return
41
+ const args = output.args
31
42
 
32
43
  const original = snapshotQuestions(args)
33
44
  if (activeState.translate_user_lang !== LLM_LANGUAGE) {
@@ -48,6 +59,7 @@ export function createToolExecuteBeforeHook(ctx: HookContext): NonNullable<Hooks
48
59
  }
49
60
 
50
61
  questionSnapshots.set(input.callID, { original, translated: snapshotQuestions(args) })
62
+ pruneQuestionSnapshots()
51
63
  } catch (error) {
52
64
  await logError(ctx.client, error)
53
65
  }
@@ -64,24 +76,20 @@ export function createToolExecuteAfterHook(ctx: HookContext): NonNullable<Hooks[
64
76
 
65
77
  const resolved = await resolveSessionState(ctx.client, ctx.directory, input.sessionID)
66
78
  const activeState = resolved.state
67
- const translateCustomAnswer =
68
- activeState && activeState.translate_user_lang !== LLM_LANGUAGE
69
- ? (text: string) =>
70
- ctx.translator.translateText({
71
- text,
72
- sourceLanguage: activeState.translate_user_lang,
73
- targetLanguage: LLM_LANGUAGE,
74
- direction: "inbound",
75
- })
76
- : undefined
79
+ if (!activeState || activeState.translate_user_lang === LLM_LANGUAGE) {
80
+ await restoreQuestionOutput(output as QuestionToolOutput, snapshot)
81
+ return
82
+ }
77
83
 
78
84
  await restoreQuestionOutput(output as QuestionToolOutput, snapshot, {
79
- ...(translateCustomAnswer ? { translateCustomAnswer } : {}),
85
+ translateCustomAnswer: (text: string) =>
86
+ ctx.translator.translateText({
87
+ text,
88
+ sourceLanguage: activeState.translate_user_lang,
89
+ targetLanguage: LLM_LANGUAGE,
90
+ direction: "inbound",
91
+ }),
80
92
  onTranslationError: async (error) => {
81
- if (!activeState) {
82
- await logError(ctx.client, error)
83
- return
84
- }
85
93
  await logError(
86
94
  ctx.client,
87
95
  buildInboundTranslationError(activeState.translate_user_lang, normalizeReason(error)),
package/src/auth/index.ts CHANGED
@@ -18,12 +18,8 @@ import { refreshAnthropic, refreshOpenAI } from "./refresh"
18
18
  import { ensureOAuthInfo, normalizeProviderKey, readAuthMap } from "./store"
19
19
  import type { AuthDependencies, AuthRuntime, ResolvedCredential } from "./types"
20
20
 
21
- const credentialCache = new Map<string, ResolvedCredential>()
22
- const oauthRefreshInflight = new Map<string, Promise<OAuthInfo>>()
23
-
24
21
  export function __resetAuthCachesForTest() {
25
- credentialCache.clear()
26
- oauthRefreshInflight.clear()
22
+ // Resolver instances own their caches; this remains as a stable test helper.
27
23
  }
28
24
 
29
25
  function isMissingCredentialError(error: unknown): boolean {
@@ -38,6 +34,10 @@ function isMissingCredentialError(error: unknown): boolean {
38
34
  )
39
35
  }
40
36
 
37
+ function hasOAuthRequestAdapter(providerID: string): boolean {
38
+ return providerID === "anthropic" || providerID === "openai" || providerID === "github-copilot"
39
+ }
40
+
41
41
  async function refreshProviderOAuth(
42
42
  providerID: string,
43
43
  info: OAuthInfo,
@@ -72,6 +72,8 @@ export function createCredentialResolver(
72
72
  options: ResolvedTranslateOptions,
73
73
  deps: AuthDependencies = {},
74
74
  ) {
75
+ const credentialCache = new Map<string, ResolvedCredential>()
76
+ const oauthRefreshInflight = new Map<string, Promise<OAuthInfo>>()
75
77
  const runtime: AuthRuntime = {
76
78
  fetchImpl: deps.fetchImpl ?? fetch,
77
79
  sleep: deps.sleep ?? ((ms: number) => sleep(ms)),
@@ -84,20 +86,30 @@ export function createCredentialResolver(
84
86
  if (!info) return undefined
85
87
  if (info.expires >= now() + 60_000) return info
86
88
 
87
- const existing = oauthRefreshInflight.get(providerID)
89
+ const inflightKey = `${providerID}:${info.refresh}`
90
+ const existing = oauthRefreshInflight.get(inflightKey)
88
91
  if (existing) return existing
89
92
 
90
93
  const refreshPromise = refreshProviderOAuth(providerID, info, client, runtime).finally(() => {
91
- oauthRefreshInflight.delete(providerID)
94
+ oauthRefreshInflight.delete(inflightKey)
92
95
  })
93
- oauthRefreshInflight.set(providerID, refreshPromise)
96
+ oauthRefreshInflight.set(inflightKey, refreshPromise)
94
97
  return refreshPromise
95
98
  }
96
99
 
97
- function credentialFromOAuth(providerID: string, provider?: ProviderInfo): ResolvedCredential {
100
+ async function resolveAuthInfo(providerID: string) {
101
+ return (await readAuthMap(deps))?.[providerID]
102
+ }
103
+
104
+ function credentialFromOAuth(
105
+ providerID: string,
106
+ provider: ProviderInfo | undefined,
107
+ authInfo: OAuthInfo,
108
+ ): ResolvedCredential {
98
109
  return {
99
110
  providerID,
100
111
  provider,
112
+ authInfo,
101
113
  apiKey: "",
102
114
  fetch: buildOAuthFetch({ ...runtime, providerID, resolveOAuth, packageVersion: deps.packageVersion }),
103
115
  mode: "oauth",
@@ -110,35 +122,48 @@ export function createCredentialResolver(
110
122
  if (cached) return cached
111
123
 
112
124
  const provider = await getProvider(client, providerID)
125
+ const authInfo = await resolveAuthInfo(providerID)
113
126
  if (options.apiKey) {
114
- const resolved = { providerID, provider, apiKey: options.apiKey, mode: "apiKey" as const }
127
+ const resolved = { providerID, provider, authInfo, apiKey: options.apiKey, mode: "apiKey" as const }
115
128
  credentialCache.set(providerID, resolved)
116
129
  return resolved
117
130
  }
118
131
 
119
132
  const providerKey = normalizeProviderKey(provider?.key)
120
- if ((provider?.source === "api" || provider?.source === "env") && providerKey) {
121
- const resolved = { providerID, provider, apiKey: providerKey, mode: "apiKey" as const }
133
+ if (providerKey) {
134
+ const resolved = { providerID, provider, authInfo, apiKey: providerKey, mode: "apiKey" as const }
122
135
  credentialCache.set(providerID, resolved)
123
136
  return resolved
124
137
  }
125
138
 
126
- if (provider?.source === "custom" || provider?.key === OAUTH_DUMMY_KEY) {
139
+ if (authInfo?.type === "api" && authInfo.key) {
140
+ const resolved = { providerID, provider, authInfo, apiKey: authInfo.key, mode: "apiKey" as const }
141
+ credentialCache.set(providerID, resolved)
142
+ return resolved
143
+ }
144
+
145
+ if (provider?.source === "custom" || provider?.key === OAUTH_DUMMY_KEY || hasOAuthRequestAdapter(providerID)) {
127
146
  const oauthInfo = await resolveOAuth(providerID)
128
147
  if (oauthInfo) {
129
- const resolved = credentialFromOAuth(providerID, provider)
148
+ const resolved = credentialFromOAuth(providerID, provider, oauthInfo)
130
149
  credentialCache.set(providerID, resolved)
131
150
  return resolved
132
151
  }
133
152
  }
134
153
 
154
+ if (authInfo?.type === "oauth" && authInfo.access && provider?.options?.apiKey === undefined) {
155
+ const resolved = { providerID, provider, authInfo, apiKey: authInfo.access, mode: "oauth" as const }
156
+ credentialCache.set(providerID, resolved)
157
+ return resolved
158
+ }
159
+
135
160
  if (provider?.key === undefined && (provider?.env.length ?? 0) > 1) {
136
- const resolved = { providerID, provider, mode: "default" as const }
161
+ const resolved = { providerID, provider, authInfo, mode: "default" as const }
137
162
  credentialCache.set(providerID, resolved)
138
163
  return resolved
139
164
  }
140
165
 
141
- return { providerID, provider, mode: "default" }
166
+ return { providerID, provider, authInfo, mode: "default" }
142
167
  }
143
168
 
144
169
  return {
package/src/auth/store.ts CHANGED
@@ -1,17 +1,21 @@
1
- import { readFile, stat } from "node:fs/promises"
1
+ import { readFile } from "node:fs/promises"
2
2
  import os from "node:os"
3
3
  import path from "node:path"
4
4
  import { type AuthInfo, OAUTH_DUMMY_KEY, type OAuthInfo } from "../constants"
5
5
  import type { AuthDependencies } from "./types"
6
6
 
7
- // Mirrors opencode's xdg-basedir auth location; see packages/opencode/src/global/index.ts.
8
- function authFilePath(): string {
7
+ // Mirrors opencode's xdg-basedir data location; see packages/core/src/global.ts.
8
+ function dataHome(): string {
9
9
  const xdgDataHome = process.env.XDG_DATA_HOME
10
- if (xdgDataHome) return path.join(xdgDataHome, "opencode", "auth.json")
11
- if (process.platform === "win32") {
12
- return path.join(process.env.LOCALAPPDATA || path.join(os.homedir(), "AppData", "Local"), "opencode", "auth.json")
13
- }
14
- return path.join(os.homedir(), ".local", "share", "opencode", "auth.json")
10
+ if (xdgDataHome) return xdgDataHome
11
+ if (process.platform === "darwin") return path.join(os.homedir(), "Library", "Application Support")
12
+ if (process.platform === "win32") return process.env.LOCALAPPDATA || path.join(os.homedir(), "AppData", "Local")
13
+ return path.join(os.homedir(), ".local", "share")
14
+ }
15
+
16
+ function authFilePaths(): string[] {
17
+ const root = path.join(dataHome(), "opencode")
18
+ return [path.join(root, "auth.json"), path.join(root, "auth-v2.json")]
15
19
  }
16
20
 
17
21
  export function normalizeProviderKey(value: string | undefined): string | undefined {
@@ -23,23 +27,74 @@ export function ensureOAuthInfo(value: AuthInfo | undefined): OAuthInfo | undefi
23
27
  return value && value.type === "oauth" ? value : undefined
24
28
  }
25
29
 
30
+ function isAuthInfo(value: unknown): value is AuthInfo {
31
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false
32
+ const record = value as Record<string, unknown>
33
+ if (record.type === "api") return typeof record.key === "string"
34
+ if (record.type === "oauth") {
35
+ return typeof record.access === "string" && typeof record.refresh === "string" && typeof record.expires === "number"
36
+ }
37
+ if (record.type === "wellknown") return typeof record.key === "string" && typeof record.token === "string"
38
+ return false
39
+ }
40
+
41
+ function normalizeAuthMap(raw: unknown): Record<string, AuthInfo> | undefined {
42
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined
43
+ const record = raw as Record<string, unknown>
44
+
45
+ if (
46
+ record.version === 2 &&
47
+ record.accounts &&
48
+ typeof record.accounts === "object" &&
49
+ !Array.isArray(record.accounts)
50
+ ) {
51
+ const accounts = record.accounts as Record<string, unknown>
52
+ const active =
53
+ record.active && typeof record.active === "object" && !Array.isArray(record.active) ? record.active : {}
54
+ const result: Record<string, AuthInfo> = {}
55
+
56
+ for (const [serviceID, accountID] of Object.entries(active as Record<string, unknown>)) {
57
+ if (typeof accountID !== "string") continue
58
+ const account = accounts[accountID]
59
+ if (!account || typeof account !== "object" || Array.isArray(account)) continue
60
+ const credential = (account as Record<string, unknown>).credential
61
+ if (isAuthInfo(credential)) result[serviceID] = credential
62
+ }
63
+
64
+ for (const account of Object.values(accounts)) {
65
+ if (!account || typeof account !== "object" || Array.isArray(account)) continue
66
+ const accountRecord = account as Record<string, unknown>
67
+ const serviceID = accountRecord.serviceID
68
+ const credential = accountRecord.credential
69
+ if (typeof serviceID === "string" && result[serviceID] === undefined && isAuthInfo(credential)) {
70
+ result[serviceID] = credential
71
+ }
72
+ }
73
+
74
+ return result
75
+ }
76
+
77
+ const result: Record<string, AuthInfo> = {}
78
+ for (const [providerID, info] of Object.entries(record)) {
79
+ if (isAuthInfo(info)) result[providerID] = info
80
+ }
81
+ return result
82
+ }
83
+
26
84
  export async function readAuthMap(deps: AuthDependencies): Promise<Record<string, AuthInfo> | undefined> {
27
85
  if (process.env.OPENCODE_AUTH_CONTENT) {
28
86
  try {
29
- const parsed = JSON.parse(process.env.OPENCODE_AUTH_CONTENT) as Record<string, AuthInfo>
30
- if (parsed && typeof parsed === "object") return parsed
87
+ return normalizeAuthMap(JSON.parse(process.env.OPENCODE_AUTH_CONTENT))
31
88
  } catch {}
32
89
  return undefined
33
90
  }
34
91
 
35
- const filePath = authFilePath()
36
- try {
37
- const fileStat = await (deps.stat ?? stat)(filePath)
38
- if ((fileStat.mode & 0o777) !== 0o600) return undefined
39
- const raw = await (deps.readFile ?? readFile)(filePath, "utf8")
40
- const parsed = JSON.parse(raw) as Record<string, AuthInfo>
41
- return parsed && typeof parsed === "object" ? parsed : undefined
42
- } catch {
43
- return undefined
92
+ for (const filePath of authFilePaths()) {
93
+ try {
94
+ const raw = await (deps.readFile ?? readFile)(filePath, "utf8")
95
+ const parsed = normalizeAuthMap(JSON.parse(raw))
96
+ if (parsed) return parsed
97
+ } catch {}
44
98
  }
99
+ return undefined
45
100
  }
package/src/auth/types.ts CHANGED
@@ -1,8 +1,9 @@
1
- import type { FetchLike, OAuthInfo, ProviderInfo } from "../constants"
1
+ import type { AuthInfo, FetchLike, OAuthInfo, ProviderInfo } from "../constants"
2
2
 
3
3
  export interface ResolvedCredential {
4
4
  providerID: string
5
5
  provider?: ProviderInfo
6
+ authInfo?: AuthInfo
6
7
  apiKey?: string
7
8
  fetch?: FetchLike
8
9
  mode: "apiKey" | "oauth" | "default"
@@ -13,7 +14,6 @@ export interface AuthDependencies {
13
14
  now?: () => number
14
15
  sleep?: (ms: number) => Promise<void>
15
16
  readFile?: (filePath: string, encoding: BufferEncoding) => Promise<string>
16
- stat?: (filePath: string) => Promise<{ mode: number }>
17
17
  packageVersion?: string
18
18
  }
19
19
 
@@ -59,13 +59,27 @@ export interface MessageWithPartsLike {
59
59
  parts: TextPartLike[]
60
60
  }
61
61
 
62
+ export interface ProviderModelInfo {
63
+ id?: string
64
+ api?: {
65
+ id?: string
66
+ url?: string
67
+ npm?: string
68
+ }
69
+ headers?: Record<string, string>
70
+ options?: Record<string, unknown>
71
+ capabilities?: {
72
+ temperature?: boolean
73
+ }
74
+ }
75
+
62
76
  export interface ProviderInfo {
63
77
  id: string
64
78
  source: ProviderSource
65
79
  env: string[]
66
80
  key?: string
67
81
  options?: Record<string, unknown>
68
- models?: Record<string, unknown>
82
+ models?: Record<string, ProviderModelInfo>
69
83
  }
70
84
 
71
85
  interface ProviderListResponseLike {
@@ -76,35 +76,47 @@ export function isQuestionArgs(value: unknown): value is QuestionArgs {
76
76
  return true
77
77
  }
78
78
 
79
- async function assignTranslation(
80
- container: Record<string, string>,
81
- key: string,
82
- translate: (text: string) => Promise<string>,
83
- ): Promise<void> {
84
- const original = container[key]
85
- if (!original || original.length === 0) return
86
- const translated = await translate(original)
87
- container[key] = unwrapEchoedTextEnvelope(translated)
79
+ async function translatedDisplayText(text: string, translate: (text: string) => Promise<string>): Promise<string> {
80
+ if (text.length === 0) return text
81
+ return unwrapEchoedTextEnvelope(await translate(text))
88
82
  }
89
83
 
90
- // Translate every display-facing string in `args` in parallel. The caller can
91
- // snapshot the translated form afterward with `snapshotQuestions`.
84
+ // Translate every display-facing string in parallel, then commit the translated
85
+ // clone only after every translation succeeds.
92
86
  export async function translateQuestionArgs(
93
87
  args: QuestionArgs,
94
88
  translate: (text: string) => Promise<string>,
95
89
  ): Promise<void> {
90
+ const translatedQuestions = snapshotQuestions(args)
96
91
  const jobs: Promise<void>[] = []
97
92
 
98
- for (const q of args.questions) {
99
- jobs.push(assignTranslation(q as unknown as Record<string, string>, "question", translate))
100
- jobs.push(assignTranslation(q as unknown as Record<string, string>, "header", translate))
93
+ for (const q of translatedQuestions) {
94
+ jobs.push(
95
+ (async () => {
96
+ q.question = await translatedDisplayText(q.question, translate)
97
+ })(),
98
+ )
99
+ jobs.push(
100
+ (async () => {
101
+ q.header = await translatedDisplayText(q.header, translate)
102
+ })(),
103
+ )
101
104
  for (const option of q.options) {
102
- jobs.push(assignTranslation(option as unknown as Record<string, string>, "label", translate))
103
- jobs.push(assignTranslation(option as unknown as Record<string, string>, "description", translate))
105
+ jobs.push(
106
+ (async () => {
107
+ option.label = await translatedDisplayText(option.label, translate)
108
+ })(),
109
+ )
110
+ jobs.push(
111
+ (async () => {
112
+ option.description = await translatedDisplayText(option.description, translate)
113
+ })(),
114
+ )
104
115
  }
105
116
  }
106
117
 
107
118
  await Promise.all(jobs)
119
+ args.questions.splice(0, args.questions.length, ...translatedQuestions)
108
120
  }
109
121
 
110
122
  // Given a user-selected label, find the matching translated option and return
@@ -1,5 +1,5 @@
1
1
  import { setTimeout as sleep } from "node:timers/promises"
2
- import { generateText } from "ai"
2
+ import { generateText, type LanguageModel } from "ai"
3
3
  import { createCredentialResolver } from "../auth"
4
4
  import {
5
5
  buildAuthUnavailableError,
@@ -16,6 +16,7 @@ import {
16
16
  instantiateModel,
17
17
  instantiateProvider,
18
18
  loadFactory,
19
+ resolveModelInfo,
19
20
  supportsTemperature,
20
21
  } from "./provider"
21
22
  import { withRetry } from "./retry"
@@ -71,22 +72,24 @@ export function createTranslator(
71
72
  const startedAt = now()
72
73
  const { providerID, modelID } = parseTranslatorModel(options.translatorModel)
73
74
  const credentials = await credentialResolver.resolve(options.translatorModel)
74
- const factory = await loadFactory(providerID)
75
- const provider = instantiateProvider(factory, providerID, credentials)
76
- const model = instantiateModel(provider, modelID)
75
+ const modelInfo = resolveModelInfo(credentials.provider, modelID)
76
+ const factory = await loadFactory(providerID, modelInfo)
77
+ const provider = instantiateProvider(factory, providerID, credentials, modelInfo)
78
+ const providerOptions = { ...(credentials.provider?.options ?? {}), ...(modelInfo.options ?? {}) }
79
+ const model = instantiateModel(provider, modelID, providerID, modelInfo, providerOptions) as LanguageModel
77
80
 
78
81
  const rawTranslated = await withRetry(async () => {
79
82
  try {
80
- const result = (await withTimeout(
83
+ const result = await withTimeout(
81
84
  generateTextImpl({
82
- model: model as never,
85
+ model,
83
86
  system: buildSystemPrompt(input),
84
- ...(supportsTemperature(providerID, modelID) ? { temperature: 0 } : {}),
87
+ ...(supportsTemperature(providerID, modelID, modelInfo) ? { temperature: 0 } : {}),
85
88
  prompt: buildUserPrompt(input),
86
- }) as Promise<{ text: string }>,
89
+ }),
87
90
  timeoutMs,
88
91
  "Translator generateText",
89
- )) as { text: string }
92
+ )
90
93
  return result.text
91
94
  } catch (error) {
92
95
  if (isAuthMessage(error)) throw error
@@ -1,80 +1,356 @@
1
- import type { FetchLike } from "../constants"
1
+ import type { AuthInfo, FetchLike, ProviderInfo, ProviderModelInfo } from "../constants"
2
2
 
3
3
  const providerFactoryCache = new Map<string, unknown>()
4
4
 
5
+ const PROVIDER_PACKAGE_FALLBACK: Record<string, string> = {
6
+ anthropic: "@ai-sdk/anthropic",
7
+ openai: "@ai-sdk/openai",
8
+ google: "@ai-sdk/google",
9
+ "google-vertex": "@ai-sdk/google-vertex",
10
+ "amazon-bedrock": "@ai-sdk/amazon-bedrock",
11
+ "github-copilot": "@ai-sdk/openai-compatible",
12
+ }
13
+
14
+ const CREATE_EXPORT_FALLBACK: Record<string, string[]> = {
15
+ "@ai-sdk/amazon-bedrock": ["createAmazonBedrock", "bedrock"],
16
+ "@ai-sdk/anthropic": ["createAnthropic", "anthropic"],
17
+ "@ai-sdk/azure": ["createAzure", "azure"],
18
+ "@ai-sdk/gateway": ["createGateway", "gateway"],
19
+ "@ai-sdk/google": ["createGoogleGenerativeAI", "google"],
20
+ "@ai-sdk/google-vertex": ["createVertex", "vertex"],
21
+ "@ai-sdk/openai": ["createOpenAI", "openai"],
22
+ "@ai-sdk/openai-compatible": ["createOpenAICompatible"],
23
+ "@openrouter/ai-sdk-provider": ["createOpenRouter", "openrouter"],
24
+ }
25
+
26
+ interface ProviderCredentials {
27
+ provider?: ProviderInfo
28
+ authInfo?: AuthInfo
29
+ apiKey?: string
30
+ fetch?: FetchLike
31
+ }
32
+
5
33
  export function __resetProviderFactoryCacheForTest() {
6
34
  providerFactoryCache.clear()
7
35
  }
8
36
 
9
- export async function loadFactory(providerID: string): Promise<unknown> {
10
- const cached = providerFactoryCache.get(providerID)
37
+ function providerPackage(providerID: string, model?: ProviderModelInfo): string {
38
+ const packageName = model?.api?.npm || PROVIDER_PACKAGE_FALLBACK[providerID]
39
+ if (!packageName) throw new Error(`Unsupported translator provider "${providerID}"`)
40
+ return packageName
41
+ }
42
+
43
+ function pickFactory(mod: Record<string, unknown>, packageName: string): unknown {
44
+ for (const key of CREATE_EXPORT_FALLBACK[packageName] ?? []) {
45
+ if (typeof mod[key] === "function") return mod[key]
46
+ }
47
+ const createKey = Object.keys(mod).find((key) => key.startsWith("create") && typeof mod[key] === "function")
48
+ return createKey ? mod[createKey] : undefined
49
+ }
50
+
51
+ export async function loadFactory(providerID: string, model?: ProviderModelInfo): Promise<unknown> {
52
+ const packageName = providerPackage(providerID, model)
53
+ const cached = providerFactoryCache.get(packageName)
11
54
  if (cached) return cached
12
55
 
13
- let factory: unknown
14
- if (providerID === "anthropic") {
15
- const mod = await import("@ai-sdk/anthropic")
16
- factory = mod.createAnthropic ?? mod.anthropic
17
- } else if (providerID === "openai") {
18
- const mod = await import("@ai-sdk/openai")
19
- factory = mod.createOpenAI ?? mod.openai
20
- } else if (providerID === "google") {
21
- const mod = await import("@ai-sdk/google")
22
- factory = mod.createGoogleGenerativeAI ?? mod.google
23
- } else if (providerID === "google-vertex") {
24
- const mod = await import("@ai-sdk/google-vertex")
25
- factory = mod.createVertex ?? mod.vertex
26
- } else if (providerID === "amazon-bedrock") {
27
- const mod = await import("@ai-sdk/amazon-bedrock")
28
- factory = mod.createAmazonBedrock ?? mod.bedrock
29
- } else if (providerID === "github-copilot") {
30
- const mod = await import("@ai-sdk/openai-compatible")
31
- factory = mod.createOpenAICompatible
32
- } else {
33
- throw new Error(`Unsupported translator provider "${providerID}"`)
56
+ let mod: Record<string, unknown>
57
+ try {
58
+ mod = (await import(packageName)) as Record<string, unknown>
59
+ } catch (error) {
60
+ throw new Error(`Unable to load provider package "${packageName}" for "${providerID}": ${String(error)}`)
34
61
  }
62
+ const factory = pickFactory(mod, packageName)
35
63
 
36
64
  if (typeof factory !== "function") {
37
- throw new Error(`Unable to load provider factory for "${providerID}"`)
65
+ throw new Error(`Unable to load provider factory from "${packageName}" for "${providerID}"`)
38
66
  }
39
67
 
40
- providerFactoryCache.set(providerID, factory)
68
+ providerFactoryCache.set(packageName, factory)
41
69
  return factory
42
70
  }
43
71
 
72
+ export function resolveModelInfo(provider: ProviderInfo | undefined, modelID: string): ProviderModelInfo {
73
+ return provider?.models?.[modelID] ?? { id: modelID, api: { id: modelID } }
74
+ }
75
+
76
+ function headerRecord(value: unknown): Record<string, string> {
77
+ if (!value || typeof value !== "object" || Array.isArray(value)) return {}
78
+ return Object.fromEntries(
79
+ Object.entries(value as Record<string, unknown>).filter((entry): entry is [string, string] => {
80
+ return typeof entry[1] === "string"
81
+ }),
82
+ )
83
+ }
84
+
85
+ function substitutionVars(options: Record<string, unknown>, authInfo?: AuthInfo): Record<string, string | undefined> {
86
+ const metadata = authInfo?.type === "api" ? authInfo.metadata : undefined
87
+ const location =
88
+ stringOption(options.location) ?? process.env.GOOGLE_VERTEX_LOCATION ?? process.env.GOOGLE_CLOUD_LOCATION
89
+ const vertexEndpoint =
90
+ location === "global" ? "aiplatform.googleapis.com" : location ? `${location}-aiplatform.googleapis.com` : undefined
91
+ return {
92
+ ...process.env,
93
+ AZURE_RESOURCE_NAME:
94
+ stringOption(options.resourceName) ?? metadata?.resourceName ?? process.env.AZURE_RESOURCE_NAME,
95
+ GOOGLE_VERTEX_PROJECT:
96
+ stringOption(options.project) ??
97
+ process.env.GOOGLE_VERTEX_PROJECT ??
98
+ process.env.GOOGLE_CLOUD_PROJECT ??
99
+ process.env.GCP_PROJECT ??
100
+ process.env.GCLOUD_PROJECT,
101
+ GOOGLE_VERTEX_LOCATION: location,
102
+ GOOGLE_VERTEX_ENDPOINT: vertexEndpoint ?? process.env.GOOGLE_VERTEX_ENDPOINT,
103
+ CLOUDFLARE_ACCOUNT_ID: metadata?.accountId ?? process.env.CLOUDFLARE_ACCOUNT_ID,
104
+ CLOUDFLARE_GATEWAY_ID: metadata?.gatewayId ?? process.env.CLOUDFLARE_GATEWAY_ID,
105
+ }
106
+ }
107
+
108
+ function stringOption(value: unknown): string | undefined {
109
+ return typeof value === "string" && value.length > 0 ? value : undefined
110
+ }
111
+
112
+ function resolveBaseURL(baseURL: unknown, apiURL: unknown, options: Record<string, unknown>, authInfo?: AuthInfo) {
113
+ let url = stringOption(baseURL) ?? stringOption(apiURL)
114
+ if (!url) return undefined
115
+ const vars = substitutionVars(options, authInfo)
116
+ url = url.replace(/\$\{([^}]+)\}/g, (match, key) => vars[String(key)] ?? match)
117
+ return url
118
+ }
119
+
120
+ function wrapSSE(response: Response, ms: number, controller: AbortController) {
121
+ if (typeof ms !== "number" || ms <= 0) return response
122
+ if (!response.body) return response
123
+ if (!response.headers.get("content-type")?.includes("text/event-stream")) return response
124
+
125
+ const reader = response.body.getReader()
126
+ const body = new ReadableStream<Uint8Array>({
127
+ async pull(ctrl) {
128
+ const part = await new Promise<Awaited<ReturnType<typeof reader.read>>>((resolve, reject) => {
129
+ const id = setTimeout(() => {
130
+ const error = new Error("SSE read timed out")
131
+ controller.abort(error)
132
+ void reader.cancel(error)
133
+ reject(error)
134
+ }, ms)
135
+
136
+ reader.read().then(
137
+ (value) => {
138
+ clearTimeout(id)
139
+ resolve(value)
140
+ },
141
+ (error) => {
142
+ clearTimeout(id)
143
+ reject(error)
144
+ },
145
+ )
146
+ })
147
+
148
+ if (part.done) {
149
+ ctrl.close()
150
+ return
151
+ }
152
+
153
+ ctrl.enqueue(part.value)
154
+ },
155
+ async cancel(reason) {
156
+ controller.abort(reason)
157
+ await reader.cancel(reason)
158
+ },
159
+ })
160
+
161
+ return new Response(body, {
162
+ headers: new Headers(response.headers),
163
+ status: response.status,
164
+ statusText: response.statusText,
165
+ })
166
+ }
167
+
168
+ function anySignal(signals: AbortSignal[]): AbortSignal | undefined {
169
+ if (signals.length === 0) return undefined
170
+ if (signals.length === 1) return signals[0]
171
+ const signalAny = (AbortSignal as typeof AbortSignal & { any?: (signals: AbortSignal[]) => AbortSignal }).any
172
+ return signalAny ? signalAny(signals) : signals[0]
173
+ }
174
+
175
+ function stripOpenAIItemIDs(packageName: string, init: RequestInit) {
176
+ if (packageName !== "@ai-sdk/openai" && packageName !== "@ai-sdk/azure") return
177
+ if (!init.body || init.method !== "POST" || typeof init.body !== "string") return
178
+ try {
179
+ const body = JSON.parse(init.body) as Record<string, unknown>
180
+ if (body.store === true || !Array.isArray(body.input)) return
181
+ for (const item of body.input) {
182
+ if (item && typeof item === "object" && !Array.isArray(item)) delete (item as Record<string, unknown>).id
183
+ }
184
+ init.body = JSON.stringify(body)
185
+ } catch {}
186
+ }
187
+
188
+ function withOpenCodeFetch(config: Record<string, unknown>, packageName: string) {
189
+ const configuredFetch = typeof config.fetch === "function" ? (config.fetch as FetchLike) : undefined
190
+ const chunkTimeout = typeof config.chunkTimeout === "number" ? config.chunkTimeout : undefined
191
+ delete config.chunkTimeout
192
+
193
+ config.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
194
+ const requestInit = { ...(init ?? {}) }
195
+ const signals: AbortSignal[] = []
196
+ const chunkController = chunkTimeout && chunkTimeout > 0 ? new AbortController() : undefined
197
+ if (requestInit.signal) signals.push(requestInit.signal)
198
+ if (chunkController) signals.push(chunkController.signal)
199
+ if (typeof config.timeout === "number" && config.timeout > 0) signals.push(AbortSignal.timeout(config.timeout))
200
+ const signal = anySignal(signals)
201
+ if (signal) requestInit.signal = signal
202
+ stripOpenAIItemIDs(packageName, requestInit)
203
+
204
+ const response = await (configuredFetch ?? fetch)(input, { ...requestInit, timeout: false } as RequestInit)
205
+ return chunkController && chunkTimeout ? wrapSSE(response, chunkTimeout, chunkController) : response
206
+ }
207
+ }
208
+
209
+ function providerConfig(
210
+ providerID: string,
211
+ credentials: ProviderCredentials,
212
+ model?: ProviderModelInfo,
213
+ ): Record<string, unknown> {
214
+ const provider = credentials.provider
215
+ const packageName = providerPackage(providerID, model)
216
+ const config: Record<string, unknown> = { ...(provider?.options ?? {}) }
217
+
218
+ if (providerID === "google-vertex" && !packageName.includes("@ai-sdk/openai-compatible")) delete config.fetch
219
+ if (packageName.includes("@ai-sdk/openai-compatible") && config.includeUsage !== false) config.includeUsage = true
220
+
221
+ const baseURL = resolveBaseURL(config.baseURL, model?.api?.url, config, credentials.authInfo)
222
+ if (baseURL !== undefined) config.baseURL = baseURL
223
+ if (credentials.apiKey !== undefined) config.apiKey = credentials.apiKey
224
+ if (credentials.fetch) config.fetch = credentials.fetch
225
+ if (model?.headers) config.headers = { ...headerRecord(config.headers), ...model.headers }
226
+ if (providerID === "github-copilot" && config.baseURL === undefined) config.baseURL = "https://api.githubcopilot.com"
227
+ if (
228
+ providerID === "amazon-bedrock" &&
229
+ credentials.authInfo?.type === "api" &&
230
+ !process.env.AWS_BEARER_TOKEN_BEDROCK
231
+ ) {
232
+ process.env.AWS_BEARER_TOKEN_BEDROCK = credentials.authInfo.key
233
+ }
234
+
235
+ withOpenCodeFetch(config, packageName)
236
+ return { name: providerID, ...config }
237
+ }
238
+
44
239
  export function instantiateProvider(
45
240
  factory: unknown,
46
241
  providerID: string,
47
- credentials: { apiKey?: string; fetch?: FetchLike },
242
+ credentials: ProviderCredentials,
243
+ model?: ProviderModelInfo,
48
244
  ): unknown {
49
245
  if (typeof factory !== "function") throw new Error(`Invalid provider factory for "${providerID}"`)
246
+ return (factory as (config: Record<string, unknown>) => unknown)(providerConfig(providerID, credentials, model))
247
+ }
50
248
 
51
- const config = {
52
- ...(credentials.apiKey !== undefined ? { apiKey: credentials.apiKey } : {}),
53
- ...(credentials.fetch ? { fetch: credentials.fetch } : {}),
54
- }
249
+ function shouldUseCopilotResponsesApi(modelID: string): boolean {
250
+ const match = /^gpt-(\d+)/.exec(modelID)
251
+ if (!match) return false
252
+ return Number(match[1]) >= 5 && !modelID.startsWith("gpt-5-mini")
253
+ }
55
254
 
56
- if (providerID === "github-copilot") {
57
- return (factory as (config: Record<string, unknown>) => unknown)({
58
- ...config,
59
- name: "github-copilot",
60
- baseURL: "https://api.githubcopilot.com",
61
- })
62
- }
255
+ function selectAzureLanguageModel(record: Record<string, unknown>, modelID: string, useChat: boolean): unknown {
256
+ if (useChat && typeof record.chat === "function") return (record.chat as (id: string) => unknown)(modelID)
257
+ if (typeof record.responses === "function") return (record.responses as (id: string) => unknown)(modelID)
258
+ if (typeof record.messages === "function") return (record.messages as (id: string) => unknown)(modelID)
259
+ if (typeof record.chat === "function") return (record.chat as (id: string) => unknown)(modelID)
260
+ if (typeof record.languageModel === "function") return (record.languageModel as (id: string) => unknown)(modelID)
261
+ }
262
+
263
+ function bedrockModelID(modelID: string, region: unknown): string {
264
+ const crossRegionPrefixes = ["global.", "us.", "eu.", "jp.", "apac.", "au."]
265
+ if (crossRegionPrefixes.some((prefix) => modelID.startsWith(prefix))) return modelID
266
+ if (typeof region !== "string") return modelID
63
267
 
64
- return (factory as (config: Record<string, unknown>) => unknown)(config)
268
+ let regionPrefix = region.split("-")[0]
269
+ if (regionPrefix === "us") {
270
+ const modelRequiresPrefix = [
271
+ "nova-micro",
272
+ "nova-lite",
273
+ "nova-pro",
274
+ "nova-premier",
275
+ "nova-2",
276
+ "claude",
277
+ "deepseek",
278
+ ].some((value) => modelID.includes(value))
279
+ if (modelRequiresPrefix && !region.startsWith("us-gov")) return `${regionPrefix}.${modelID}`
280
+ }
281
+ if (regionPrefix === "eu") {
282
+ const regionRequiresPrefix = [
283
+ "eu-west-1",
284
+ "eu-west-2",
285
+ "eu-west-3",
286
+ "eu-north-1",
287
+ "eu-central-1",
288
+ "eu-south-1",
289
+ "eu-south-2",
290
+ ].some((value) => region.includes(value))
291
+ const modelRequiresPrefix = ["claude", "nova-lite", "nova-micro", "llama3", "pixtral"].some((value) =>
292
+ modelID.includes(value),
293
+ )
294
+ if (regionRequiresPrefix && modelRequiresPrefix) return `${regionPrefix}.${modelID}`
295
+ }
296
+ if (regionPrefix === "ap") {
297
+ const isAustraliaRegion = ["ap-southeast-2", "ap-southeast-4"].includes(region)
298
+ const isTokyoRegion = region === "ap-northeast-1"
299
+ if (
300
+ isAustraliaRegion &&
301
+ ["anthropic.claude-sonnet-4-5", "anthropic.claude-haiku"].some((value) => modelID.includes(value))
302
+ ) {
303
+ regionPrefix = "au"
304
+ return `${regionPrefix}.${modelID}`
305
+ }
306
+ const modelRequiresPrefix = ["claude", "nova-lite", "nova-micro", "nova-pro"].some((value) =>
307
+ modelID.includes(value),
308
+ )
309
+ if (modelRequiresPrefix) return `${isTokyoRegion ? "jp" : "apac"}.${modelID}`
310
+ }
311
+ return modelID
65
312
  }
66
313
 
67
- export function instantiateModel(provider: unknown, modelID: string): unknown {
314
+ export function instantiateModel(
315
+ provider: unknown,
316
+ modelID: string,
317
+ providerID?: string,
318
+ model?: ProviderModelInfo,
319
+ providerOptions?: Record<string, unknown>,
320
+ ): unknown {
321
+ const apiID = model?.api?.id || model?.id || modelID
68
322
  if (typeof provider === "function") return provider(modelID)
69
323
  if (provider && typeof provider === "object") {
70
324
  const record = provider as Record<string, unknown>
325
+ if ((providerID === "openai" || providerID === "xai") && typeof record.responses === "function") {
326
+ return (record.responses as (id: string) => unknown)(apiID)
327
+ }
328
+ if (
329
+ providerID === "github-copilot" &&
330
+ typeof record.responses === "function" &&
331
+ typeof record.chat === "function"
332
+ ) {
333
+ return shouldUseCopilotResponsesApi(apiID)
334
+ ? (record.responses as (id: string) => unknown)(apiID)
335
+ : (record.chat as (id: string) => unknown)(apiID)
336
+ }
337
+ if (providerID === "azure" || providerID === "azure-cognitive-services") {
338
+ const selected = selectAzureLanguageModel(record, apiID, providerOptions?.useCompletionUrls === true)
339
+ if (selected) return selected
340
+ }
341
+ if (providerID === "amazon-bedrock" && typeof record.languageModel === "function") {
342
+ return (record.languageModel as (id: string) => unknown)(bedrockModelID(apiID, providerOptions?.region))
343
+ }
71
344
  if (typeof record.chatModel === "function") return (record.chatModel as (id: string) => unknown)(modelID)
72
- if (typeof record.languageModel === "function") return (record.languageModel as (id: string) => unknown)(modelID)
345
+ if (typeof record.languageModel === "function") return (record.languageModel as (id: string) => unknown)(apiID)
346
+ if (typeof record.chat === "function") return (record.chat as (id: string) => unknown)(apiID)
347
+ if (typeof record.responses === "function") return (record.responses as (id: string) => unknown)(apiID)
73
348
  }
74
349
  throw new Error(`Unable to instantiate model "${modelID}"`)
75
350
  }
76
351
 
77
- export function supportsTemperature(providerID: string, modelID: string): boolean {
352
+ export function supportsTemperature(providerID: string, modelID: string, model?: ProviderModelInfo): boolean {
353
+ if (typeof model?.capabilities?.temperature === "boolean") return model.capabilities.temperature
78
354
  if (providerID !== "openai") return true
79
355
  if (modelID.startsWith("o1") || modelID.startsWith("o3") || modelID.startsWith("o4-mini")) return false
80
356
  return !(modelID.startsWith("gpt-5") && !modelID.startsWith("gpt-5-chat"))