opencode-translate 0.2.2 → 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 +8 -2
- package/package.json +1 -1
- package/src/auth/index.ts +34 -8
- package/src/auth/store.ts +74 -19
- package/src/auth/types.ts +2 -2
- package/src/constants/types.ts +15 -1
- package/src/translator/index.ts +7 -4
- package/src/translator/provider.ts +318 -42
package/README.md
CHANGED
|
@@ -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`
|
|
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`
|
|
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
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
|
|
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 (
|
|
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
|
|
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
|
|
8
|
-
function
|
|
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
|
|
11
|
-
if (process.platform === "
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
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
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
|
package/src/constants/types.ts
CHANGED
|
@@ -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,
|
|
82
|
+
models?: Record<string, ProviderModelInfo>
|
|
69
83
|
}
|
|
70
84
|
|
|
71
85
|
interface ProviderListResponseLike {
|
package/src/translator/index.ts
CHANGED
|
@@ -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,9 +72,11 @@ 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
|
|
75
|
-
const
|
|
76
|
-
const
|
|
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,
|
|
@@ -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
|
-
|
|
10
|
-
const
|
|
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
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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(
|
|
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:
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
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(
|
|
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)(
|
|
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"))
|