opencode-translate 0.2.2 → 0.3.0

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
@@ -58,7 +58,7 @@ Add it to `~/.config/opencode/opencode.json`:
58
58
  {
59
59
  "plugin": [
60
60
  ["opencode-translate", {
61
- "translatorModel": "anthropic/claude-haiku-4-5",
61
+ "model": "anthropic/claude-haiku-4-5",
62
62
  "triggerKeywords": ["$en"],
63
63
  "lang": "Korean",
64
64
  "verbose": false
@@ -84,12 +84,14 @@ $en 프로젝트 루트의 package.json을 읽고 요약해줘
84
84
 
85
85
  | Option | Type | Default |
86
86
  | --- | --- | --- |
87
- | `translatorModel` | string | `anthropic/claude-haiku-4-5` |
87
+ | `model` | string | Required |
88
88
  | `triggerKeywords` | string[] | `[$en]` |
89
89
  | `lang` | string | Required |
90
90
  | `apiKey` | string | `undefined` |
91
91
  | `verbose` | boolean | `false` |
92
92
 
93
+ Set `model` to the translator model in `provider/model-id` form, such as `"anthropic/claude-haiku-4-5"`.
94
+
93
95
  Set `lang` to the full name of the language the user reads and writes, such as `"Korean"`, `"Japanese"`, or `"Brazilian Portuguese"`. The value is injected directly into translation prompts, so language code mapping is not required.
94
96
 
95
97
  ## Privacy
@@ -97,13 +99,19 @@ Set `lang` to the full name of the language the user reads and writes, such as `
97
99
  Using this plugin means text goes to two model providers per turn:
98
100
 
99
101
  - the normal OpenCode chat provider
100
- - the configured `translatorModel` provider
102
+ - the configured `model` provider
101
103
 
102
104
  If you need strict single-provider or self-hosted-only behavior, do not enable this plugin.
103
105
 
106
+ ## Authentication
107
+
108
+ 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.
109
+
110
+ 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.
111
+
104
112
  ## Anthropic OAuth Support
105
113
 
106
- If `translatorModel` uses Anthropic and OpenCode auth is backed by Anthropic OAuth (Claude Pro/Max), the plugin reuses those OAuth credentials for translation requests.
114
+ If `model` uses Anthropic and OpenCode auth is backed by Anthropic OAuth (Claude Pro/Max), the plugin reuses those OAuth credentials for translation requests.
107
115
 
108
116
  Anthropic's `/v1/messages` endpoint rejects OAuth-authenticated requests that do not match the Claude Code CLI fingerprint (response: `429 rate_limit_error` with an empty `"Error"` message). To pass, the plugin applies the same transformation that `@ex-machina/opencode-anthropic-auth` uses for OpenCode's main chat, but only for its own translator requests:
109
117
 
@@ -121,15 +129,15 @@ Tradeoffs:
121
129
  - OpenCode upstream removed Anthropic OAuth support for legal / policy reasons. Installing this plugin reintroduces an equivalent code path in your environment.
122
130
  - Translator requests contribute to your Claude Pro/Max rate limit alongside OpenCode's main chat.
123
131
 
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.
132
+ If you prefer a plain API key, set `ANTHROPIC_API_KEY`, use `opencode auth login anthropic`, or pass `apiKey` in plugin options.
125
133
 
126
134
  ## OpenAI OAuth Support
127
135
 
128
- If `translatorModel` uses OpenAI and OpenCode auth is backed by the ChatGPT/Codex OAuth flow, the plugin reuses those OAuth credentials for translation requests.
136
+ If `model` uses OpenAI and OpenCode auth is backed by the ChatGPT/Codex OAuth flow, the plugin reuses those OAuth credentials for translation requests.
129
137
 
130
138
  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
139
 
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.
140
+ If you prefer a plain API key, set `OPENAI_API_KEY`, use `opencode auth login openai`, or pass `apiKey` in plugin options.
133
141
 
134
142
  ## Manual Smoke Test
135
143
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-translate",
3
- "version": "0.2.2",
3
+ "version": "0.3.0",
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",
@@ -8,7 +8,7 @@ import {
8
8
  import { createSyntheticPartID } from "../translator"
9
9
 
10
10
  export function createActivationBannerText(options: ResolvedTranslateOptions): string {
11
- const { modelID } = parseTranslatorModel(options.translatorModel)
11
+ const { modelID } = parseTranslatorModel(options.model)
12
12
  return `✓ Translation mode enabled · translator: ${modelID} · language: ${options.lang}`
13
13
  }
14
14
 
package/src/auth/index.ts CHANGED
@@ -34,6 +34,10 @@ function isMissingCredentialError(error: unknown): boolean {
34
34
  )
35
35
  }
36
36
 
37
+ function hasOAuthRequestAdapter(providerID: string): boolean {
38
+ return providerID === "anthropic" || providerID === "openai" || providerID === "github-copilot"
39
+ }
40
+
37
41
  async function refreshProviderOAuth(
38
42
  providerID: string,
39
43
  info: OAuthInfo,
@@ -93,10 +97,19 @@ export function createCredentialResolver(
93
97
  return refreshPromise
94
98
  }
95
99
 
96
- 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 {
97
109
  return {
98
110
  providerID,
99
111
  provider,
112
+ authInfo,
100
113
  apiKey: "",
101
114
  fetch: buildOAuthFetch({ ...runtime, providerID, resolveOAuth, packageVersion: deps.packageVersion }),
102
115
  mode: "oauth",
@@ -109,35 +122,48 @@ export function createCredentialResolver(
109
122
  if (cached) return cached
110
123
 
111
124
  const provider = await getProvider(client, providerID)
125
+ const authInfo = await resolveAuthInfo(providerID)
112
126
  if (options.apiKey) {
113
- const resolved = { providerID, provider, apiKey: options.apiKey, mode: "apiKey" as const }
127
+ const resolved = { providerID, provider, authInfo, apiKey: options.apiKey, mode: "apiKey" as const }
114
128
  credentialCache.set(providerID, resolved)
115
129
  return resolved
116
130
  }
117
131
 
118
132
  const providerKey = normalizeProviderKey(provider?.key)
119
- if ((provider?.source === "api" || provider?.source === "env") && providerKey) {
120
- 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 }
135
+ credentialCache.set(providerID, resolved)
136
+ return resolved
137
+ }
138
+
139
+ if (authInfo?.type === "api" && authInfo.key) {
140
+ const resolved = { providerID, provider, authInfo, apiKey: authInfo.key, mode: "apiKey" as const }
121
141
  credentialCache.set(providerID, resolved)
122
142
  return resolved
123
143
  }
124
144
 
125
- if (provider?.source === "custom" || provider?.key === OAUTH_DUMMY_KEY) {
145
+ if (provider?.source === "custom" || provider?.key === OAUTH_DUMMY_KEY || hasOAuthRequestAdapter(providerID)) {
126
146
  const oauthInfo = await resolveOAuth(providerID)
127
147
  if (oauthInfo) {
128
- const resolved = credentialFromOAuth(providerID, provider)
148
+ const resolved = credentialFromOAuth(providerID, provider, oauthInfo)
129
149
  credentialCache.set(providerID, resolved)
130
150
  return resolved
131
151
  }
132
152
  }
133
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
+
134
160
  if (provider?.key === undefined && (provider?.env.length ?? 0) > 1) {
135
- const resolved = { providerID, provider, mode: "default" as const }
161
+ const resolved = { providerID, provider, authInfo, mode: "default" as const }
136
162
  credentialCache.set(providerID, resolved)
137
163
  return resolved
138
164
  }
139
165
 
140
- return { providerID, provider, mode: "default" }
166
+ return { providerID, provider, authInfo, mode: "default" }
141
167
  }
142
168
 
143
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
 
@@ -1,7 +1,20 @@
1
- import { AUTH_ENV_FALLBACK, DEFAULT_TRANSLATOR_MODEL, DEFAULT_TRIGGER_KEYWORDS, PLUGIN_NAME } from "./plugin"
1
+ import { AUTH_ENV_FALLBACK, DEFAULT_TRIGGER_KEYWORDS, PLUGIN_NAME } from "./plugin"
2
2
  import type { ProviderInfo, ResolvedTranslateOptions } from "./types"
3
3
 
4
4
  export function resolveOptions(options: Record<string, unknown>): ResolvedTranslateOptions {
5
+ const model = typeof options.model === "string" ? options.model.trim() : ""
6
+ if (!model) {
7
+ throw new Error(
8
+ `[${PLUGIN_NAME}:INVALID_OPTIONS] options.model is required. Set it to the translator model, e.g. "anthropic/claude-haiku-4-5".`,
9
+ )
10
+ }
11
+ const slash = model.indexOf("/")
12
+ if (slash < 1 || slash === model.length - 1) {
13
+ throw new Error(
14
+ `[${PLUGIN_NAME}:INVALID_OPTIONS] options.model must be in provider/model-id form, e.g. "anthropic/claude-haiku-4-5".`,
15
+ )
16
+ }
17
+
5
18
  const lang = typeof options.lang === "string" ? options.lang.trim() : ""
6
19
  if (!lang) {
7
20
  throw new Error(
@@ -14,10 +27,7 @@ export function resolveOptions(options: Record<string, unknown>): ResolvedTransl
14
27
  : DEFAULT_TRIGGER_KEYWORDS
15
28
 
16
29
  return {
17
- translatorModel:
18
- typeof options.translatorModel === "string" && options.translatorModel.includes("/")
19
- ? options.translatorModel
20
- : DEFAULT_TRANSLATOR_MODEL,
30
+ model,
21
31
  triggerKeywords: triggerKeywords.length > 0 ? triggerKeywords : [...DEFAULT_TRIGGER_KEYWORDS],
22
32
  lang,
23
33
  apiKey: typeof options.apiKey === "string" && options.apiKey.length > 0 ? options.apiKey : undefined,
@@ -1,7 +1,6 @@
1
1
  export const PLUGIN_NAME = "opencode-translate"
2
2
  export const SPEC_VERSION = 2
3
3
  export const LLM_LANGUAGE = "English"
4
- export const DEFAULT_TRANSLATOR_MODEL = "anthropic/claude-haiku-4-5"
5
4
  export const DEFAULT_TRIGGER_KEYWORDS = ["$en"]
6
5
  export const OAUTH_DUMMY_KEY = "opencode-oauth-dummy-key"
7
6
  export const NONCE_PATTERN = /^[0-9a-f]{32}$/
@@ -5,7 +5,7 @@ export type FetchLike = (input: RequestInfo | URL, init?: RequestInit) => Promis
5
5
  type ProviderSource = "env" | "config" | "custom" | "api"
6
6
 
7
7
  export interface ResolvedTranslateOptions {
8
- translatorModel: string
8
+ model: string
9
9
  triggerKeywords: string[]
10
10
  lang: string
11
11
  apiKey?: string
@@ -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 {
@@ -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"
@@ -69,11 +70,13 @@ export function createTranslator(
69
70
  if (input.sourceLanguage === input.targetLanguage) return input.text
70
71
 
71
72
  const startedAt = now()
72
- const { providerID, modelID } = parseTranslatorModel(options.translatorModel)
73
- 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) as LanguageModel
73
+ const { providerID, modelID } = parseTranslatorModel(options.model)
74
+ const credentials = await credentialResolver.resolve(options.model)
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 {
@@ -81,7 +84,7 @@ export function createTranslator(
81
84
  generateTextImpl({
82
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
89
  }),
87
90
  timeoutMs,
@@ -110,7 +113,7 @@ export function createTranslator(
110
113
  chars_out: translated.length,
111
114
  ms: now() - startedAt,
112
115
  cached: false,
113
- model: options.translatorModel,
116
+ model: options.model,
114
117
  },
115
118
  },
116
119
  })
@@ -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"))