mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-22 18:33:32 +00:00
feat(llm): add compatible and vertex provider entrypoints (#36900)
This commit is contained in:
parent
22334b94c8
commit
4f1298063b
22 changed files with 860 additions and 53 deletions
1
bun.lock
1
bun.lock
|
|
@ -570,6 +570,7 @@
|
|||
"@smithy/util-utf8": "4.2.2",
|
||||
"aws4fetch": "1.0.20",
|
||||
"effect": "catalog:",
|
||||
"google-auth-library": "10.5.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@clack/prompts": "1.0.0-alpha.1",
|
||||
|
|
|
|||
|
|
@ -191,11 +191,7 @@ export const fromCatalogModel = (
|
|||
const packageName = ProviderV2.packageName(resolved.package)
|
||||
const key = apiKey(resolved, credential)
|
||||
|
||||
if (
|
||||
OpenAICodex.isChatGPT(credential) &&
|
||||
!ProviderV2.isAISDK(resolved.package) &&
|
||||
isNativeOpenAI(resolved.package)
|
||||
) {
|
||||
if (OpenAICodex.isChatGPT(credential) && !ProviderV2.isAISDK(resolved.package) && isNativeOpenAI(resolved.package)) {
|
||||
return Effect.succeed(codexModel(resolved, credential, key))
|
||||
}
|
||||
|
||||
|
|
@ -243,11 +239,10 @@ export const fromCatalogModel = (
|
|||
const module = yield* (dependencies.loadPackage ?? ProviderV2.loadPackage)(specifier).pipe(
|
||||
Effect.mapError(() => unsupported(resolved)),
|
||||
)
|
||||
const configured = { ...resolved.settings, ...credential?.metadata }
|
||||
const settings = {
|
||||
...resolved.settings,
|
||||
...(credential?.type === "key" ? { apiKey: credential.key } : {}),
|
||||
...(credential?.type === "oauth" ? { apiKey: credential.access } : {}),
|
||||
...credential?.metadata,
|
||||
...(credential ? withoutNativeAuthSettings(configured) : configured),
|
||||
...nativeCredentialSettings(specifier, credential),
|
||||
headers: resolved.headers,
|
||||
body: resolved.body,
|
||||
limits: { context: resolved.limit.context, output: resolved.limit.output },
|
||||
|
|
@ -264,6 +259,27 @@ const isNativeOpenAI = (packageName: string | undefined) =>
|
|||
packageName === "@opencode-ai/llm/providers/openai" ||
|
||||
packageName?.startsWith("@opencode-ai/llm/providers/openai/") === true
|
||||
|
||||
const nativeCredentialSettings = (specifier: string, credential: Credential.Value | undefined) => {
|
||||
if (!credential) return {}
|
||||
if (credential.type === "key") return { apiKey: credential.key }
|
||||
if (
|
||||
specifier === "@opencode-ai/llm/providers/anthropic" ||
|
||||
specifier === "@opencode-ai/llm/providers/anthropic-compatible"
|
||||
)
|
||||
return { authToken: credential.access }
|
||||
if (
|
||||
specifier === "@opencode-ai/llm/providers/google-vertex" ||
|
||||
specifier.startsWith("@opencode-ai/llm/providers/google-vertex/")
|
||||
)
|
||||
return { accessToken: credential.access }
|
||||
return { apiKey: credential.access }
|
||||
}
|
||||
|
||||
const withoutNativeAuthSettings = (settings: Record<string, unknown>) => {
|
||||
const { accessToken: _accessToken, apiKey: _apiKey, authToken: _authToken, ...rest } = settings
|
||||
return rest
|
||||
}
|
||||
|
||||
const codexModel = (
|
||||
model: ModelV2.Info,
|
||||
credential: Credential.Value | undefined,
|
||||
|
|
|
|||
|
|
@ -540,6 +540,42 @@ describe("SessionRunnerModel", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
it.effect("maps OAuth credentials to native provider auth settings", () =>
|
||||
Effect.gen(function* () {
|
||||
const native = yield* SessionRunnerModel.fromCatalogModel(
|
||||
model(ProviderV2.aisdk("@ai-sdk/openai"), {
|
||||
settings: { baseURL: "https://openai.example/v1" },
|
||||
}),
|
||||
)
|
||||
const credential = Credential.OAuth.make({
|
||||
type: "oauth",
|
||||
methodID: Integration.MethodID.make("device"),
|
||||
access: "oauth-token",
|
||||
refresh: "refresh",
|
||||
expires: Date.now() + 60_000,
|
||||
})
|
||||
const packages = [
|
||||
["@opencode-ai/llm/providers/google-vertex", "accessToken"],
|
||||
["@opencode-ai/llm/providers/google-vertex/anthropic", "accessToken"],
|
||||
["@opencode-ai/llm/providers/anthropic", "authToken"],
|
||||
["@opencode-ai/llm/providers/anthropic-compatible", "authToken"],
|
||||
] as const
|
||||
|
||||
yield* Effect.forEach(packages, ([specifier, key]) =>
|
||||
SessionRunnerModel.fromCatalogModel(model(specifier, { settings: { apiKey: "configured-key" } }), credential, {
|
||||
loadPackage: () =>
|
||||
Effect.succeed({
|
||||
model: (modelID, settings) => {
|
||||
expect(settings).toMatchObject({ [key]: "oauth-token" })
|
||||
expect(settings).not.toHaveProperty("apiKey")
|
||||
return Model.make({ id: modelID, provider: "package-provider", route: native.route })
|
||||
},
|
||||
}),
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("loads arbitrary AISDK packages through the injected AISDK loader", () =>
|
||||
Effect.gen(function* () {
|
||||
const native = yield* SessionRunnerModel.fromCatalogModel(
|
||||
|
|
|
|||
|
|
@ -104,7 +104,7 @@ const gateway = CloudflareAIGateway.configure({
|
|||
}).model("workers-ai/@cf/meta/llama-3.1-8b-instruct")
|
||||
```
|
||||
|
||||
Included providers: OpenAI, Anthropic, Google (Gemini), Amazon Bedrock, Azure OpenAI, Cloudflare AI Gateway, Cloudflare Workers AI, GitHub Copilot, OpenRouter, xAI, plus generic OpenAI-compatible Chat helpers for DeepSeek, Cerebras, Groq, Fireworks, Together, etc. and a generic Responses entrypoint.
|
||||
Included providers: OpenAI, Anthropic, Google (Gemini), Google Vertex Gemini and Anthropic, Amazon Bedrock, Azure OpenAI, Cloudflare AI Gateway, Cloudflare Workers AI, GitHub Copilot, OpenRouter, xAI, plus generic OpenAI-compatible Chat and Responses entrypoints and an Anthropic Messages-compatible entrypoint.
|
||||
|
||||
### Package-like entrypoints
|
||||
|
||||
|
|
@ -126,8 +126,27 @@ OpenAI Chat and OpenAI Responses are separate semantic entrypoints:
|
|||
- `@opencode-ai/llm/providers/openai/chat`
|
||||
- `@opencode-ai/llm/providers/openai/responses`
|
||||
- `@opencode-ai/llm/providers/openai-compatible/responses`
|
||||
- `@opencode-ai/llm/providers/anthropic-compatible`
|
||||
- `@opencode-ai/llm/providers/google-vertex`
|
||||
- `@opencode-ai/llm/providers/google-vertex/anthropic`
|
||||
|
||||
Responses HTTP versus WebSocket is a scoped `transport` setting on the OpenAI Responses entrypoint, not another entrypoint. Azure follows the same Chat/Responses split at `providers/azure/chat` and `providers/azure/responses`. Generic OpenAI-compatible Chat remains at `providers/openai-compatible`; compatible Responses is separate at `providers/openai-compatible/responses`. Anthropic, Google Gemini, and Amazon Bedrock expose their single native API through their existing provider paths.
|
||||
Responses HTTP versus WebSocket is a scoped `transport` setting on the OpenAI Responses entrypoint, not another entrypoint. Azure follows the same Chat/Responses split at `providers/azure/chat` and `providers/azure/responses`. Generic OpenAI-compatible Chat remains at `providers/openai-compatible`; compatible Responses is separate at `providers/openai-compatible/responses`. Generic Anthropic Messages-compatible providers use `providers/anthropic-compatible`, which the named Anthropic provider composes. Google Gemini and Amazon Bedrock expose their single native API through their existing provider paths.
|
||||
|
||||
Vertex Gemini and Vertex Anthropic are separate products with separate entrypoints. Both accept `project`, `location`, and an optional `accessToken`; when no explicit token or auth override is supplied they lazily use Google Application Default Credentials. Vertex Gemini instead selects express mode when `apiKey` or `GOOGLE_VERTEX_API_KEY` is present.
|
||||
|
||||
Tuned Vertex Gemini deployments use model ids shaped like `endpoints/1234567890` and require OAuth or ADC; Vertex express-mode API keys support publisher models only.
|
||||
|
||||
```ts
|
||||
import { model } from "@opencode-ai/llm/providers/google-vertex"
|
||||
|
||||
model("gemini-3.5-flash", { project: "my-project", location: "global" })
|
||||
```
|
||||
|
||||
```ts
|
||||
import { model } from "@opencode-ai/llm/providers/google-vertex/anthropic"
|
||||
|
||||
model("claude-sonnet-4-6", { project: "my-project", location: "global" })
|
||||
```
|
||||
|
||||
Provider facades such as `OpenAI.configure(...).responses(...)` remain the direct application API. Package-like entrypoints are the self-similar loading contract used when a catalog selects behavior by export path.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# LLM Provider Parity Status
|
||||
|
||||
Last reviewed: 2026-07-08
|
||||
Last reviewed: 2026-07-15
|
||||
|
||||
This file tracks the gap between the native `@opencode-ai/llm` package and the AI SDK provider packages that opencode still depends on for many catalog/runtime paths.
|
||||
|
||||
|
|
@ -20,8 +20,11 @@ This file tracks the gap between the native `@opencode-ai/llm` package and the A
|
|||
| OpenAI Responses WebSocket | `src/protocols/openai-responses.ts`, `src/route/transport/websocket.ts` | Present as `OpenAI.responsesWebSocket(...)`. | Runner/catalog support explicitly must not downgrade WebSocket routes; broader runtime selection is not complete. |
|
||||
| OpenAI-compatible Chat | `src/protocols/openai-compatible-chat.ts`, `src/providers/openai-compatible.ts` | Usable for generic Chat and several profiles: Baseten, Cerebras, DeepInfra, DeepSeek, Fireworks, Groq, TogetherAI. | Family quirks are mostly endpoint defaults, not full typed behavior. |
|
||||
| OpenAI-compatible Responses | `src/protocols/openai-compatible-responses.ts`, `src/providers/openai-compatible-responses.ts` | Usable for deployments that implement the OpenAI Responses wire protocol. | No named family profiles or recorded deployment coverage yet. |
|
||||
| Anthropic-compatible Messages | `src/protocols/anthropic-messages.ts`, `src/providers/anthropic-compatible.ts` | Usable for deployments that implement the Anthropic Messages wire protocol. Named Anthropic composes this base. | No named compatible family profiles or recorded deployment coverage yet. |
|
||||
| Anthropic Messages | `src/protocols/anthropic-messages.ts`, `src/providers/anthropic.ts` | Usable. Supports tools, thinking, cache control, images, server-hosted tool events, and usage. | Provider option surface is small. Beta/header handling, metadata, and newer Messages fields need a typed parity pass. |
|
||||
| Gemini Developer API | `src/protocols/gemini.ts`, `src/providers/google.ts` | Usable for Google API key flow. Supports text, images, tools, thinking signatures, and cache usage. | This is not Vertex. Typed provider options are narrow; many Gemini request fields currently require raw `http.body` overlays. |
|
||||
| Vertex Gemini | `src/protocols/google-vertex-gemini.ts`, `src/providers/google-vertex.ts` | Usable through API-key express mode, explicit OAuth tokens, or ADC with project/location endpoint derivation, including tuned `endpoints/...` deployments. | Core runner/catalog mapping and recorded provider coverage are missing. |
|
||||
| Vertex Anthropic Messages | `src/protocols/google-vertex-anthropic.ts`, `src/providers/google-vertex-anthropic.ts` | Usable through explicit OAuth tokens or ADC, including global, regional, and `eu`/`us` multi-region endpoints. | Core runner/catalog mapping and recorded provider coverage are missing; Vertex-specific hosted-tool parity needs review. |
|
||||
| Bedrock Converse | `src/protocols/bedrock-converse.ts`, `src/providers/amazon-bedrock.ts` | Partial but real. Supports AWS event-stream framing, SigV4 with supplied credentials, bearer auth, tools, reasoning signatures, media, cache points, and recorded tests. | Native facade does not mirror the AI SDK plugin's default AWS credential chain/profile behavior. Runner/catalog mapping is missing. Guardrails, inference profiles, region-specific model ID fixes, and model-specific request fields need a parity pass. |
|
||||
| Azure OpenAI | `src/providers/azure.ts` using OpenAI Chat/Responses protocols | Partial. Supports resource/base URL setup, API key auth, API version query, Chat, and Responses selectors. | Core runner does not map `@ai-sdk/azure` to this native facade. AAD/token auth and Azure-specific endpoint variants need review. |
|
||||
| Cloudflare AI Gateway / Workers AI | `src/providers/cloudflare.ts` | Present via OpenAI-compatible Chat routes. | Useful but not part of the critical AI SDK replacement set yet. Needs per-product recorded coverage before relying on it broadly. |
|
||||
|
|
@ -39,7 +42,7 @@ This file tracks the gap between the native `@opencode-ai/llm` package and the A
|
|||
| `aisdk:@ai-sdk/anthropic` | `AnthropicMessages.route` |
|
||||
| `aisdk:@ai-sdk/openai-compatible` with explicit URL | `OpenAICompatibleChat.route` |
|
||||
|
||||
Everything else currently fails with `SessionRunnerModel.UnsupportedApiError` when the V2 native runner tries to resolve it. This includes `@ai-sdk/google`, `@ai-sdk/google-vertex`, `@ai-sdk/google-vertex/anthropic`, `@ai-sdk/azure`, `@ai-sdk/amazon-bedrock`, and `@ai-sdk/amazon-bedrock/mantle`.
|
||||
Other `aisdk:` packages, including Google Vertex, Azure, and Bedrock, currently fall back through the AI SDK loader in the production runner. The dependency-free resolver seam rejects them with `SessionRunnerModel.UnsupportedPackageError`; they are not native route mappings yet.
|
||||
|
||||
## AI SDK Package Parity Matrix
|
||||
|
||||
|
|
@ -49,8 +52,10 @@ Everything else currently fails with `SessionRunnerModel.UnsupportedApiError` wh
|
|||
| `@ai-sdk/openai-compatible` | Generic OpenAI-compatible Chat and Responses | Partial / usable | Decide per-family namespace/profile behavior and runner API selection for providers that support Responses versus Chat only. |
|
||||
| `@ai-sdk/anthropic` | `AnthropicMessages` | Partial / usable | Finish Messages API parity for headers/betas/metadata/newer fields and document hosted-tool continuation expectations. |
|
||||
| `@ai-sdk/google` | Gemini Developer API | Partial / usable | Add typed options for safety, response schema/modalities, cached content, grounding/search/code execution, and non-text output modes where supported. |
|
||||
| `@ai-sdk/google-vertex` | Vertex Gemini namespace/facade | Missing | Implement Vertex endpoint derivation, ADC/OAuth auth, project/location/env resolution, OpenAI-compatible Vertex endpoint handling, and runner/catalog mapping. |
|
||||
| `@ai-sdk/google-vertex/anthropic` | Anthropic Messages over Vertex namespace/facade | Missing | Implement Vertex Anthropic endpoint/auth selection, regional endpoint behavior, and compatibility with Anthropic Messages lowering/parsing. |
|
||||
| `@ai-sdk/google-vertex` | Vertex Gemini namespace/facade | Partial / usable | Add runner/catalog mapping, recorded coverage, and broader provider-option parity. |
|
||||
| `@ai-sdk/google-vertex/anthropic` | Anthropic Messages over Vertex namespace/facade | Partial / usable | Add runner/catalog mapping, recorded coverage, and Vertex-specific hosted-tool parity. |
|
||||
| `@ai-sdk/google-vertex/maas` | Vertex MaaS OpenAI-compatible namespace/facade | Missing | Decide native Chat/Responses selection, endpoint derivation, auth, and catalog mapping. |
|
||||
| `@ai-sdk/google-vertex/xai` | Vertex xAI OpenAI-compatible namespace/facade | Missing | Decide whether this composes the generic compatible bases or the xAI facade, then add endpoint/auth mapping and tests. |
|
||||
| `@ai-sdk/azure` | Azure OpenAI Chat/Responses facade | Partial | Map runner/catalog metadata to native Azure, handle resourceName/baseURL/apiVersion variants, add AAD/token auth story, and verify Chat vs Responses deployment selection. |
|
||||
| `@ai-sdk/amazon-bedrock` | Bedrock Converse | Partial | Add default AWS credential chain/profile support, region/inference-profile model ID handling, provider option parity via `additionalModelRequestFields`, guardrails/performance config, and runner/catalog mapping. |
|
||||
| `@ai-sdk/amazon-bedrock/mantle` | Bedrock Mantle OpenAI-compatible Chat/Responses namespace | Missing | Decide native Mantle shape, likely separate from Converse because it uses OpenAI-compatible Chat/Responses semantics over Bedrock. Add package mapping and tests. |
|
||||
|
|
@ -60,38 +65,42 @@ Everything else currently fails with `SessionRunnerModel.UnsupportedApiError` wh
|
|||
1. Runner support is narrower than the LLM package. The package has native provider facades for Google, Azure, and Bedrock, but the V2 Session runner only maps OpenAI, Anthropic, and explicit OpenAI-compatible Chat from `aisdk` catalog metadata.
|
||||
2. OpenAI-compatible Responses is available as a separate package entrypoint, but the V2 runner still maps `@ai-sdk/openai-compatible` to Chat only. Catalog selection must become API-aware before Responses deployments can use it.
|
||||
3. Bedrock native auth is not AI SDK parity. The AI SDK plugin uses the default AWS provider chain, profile, container credentials, and Bedrock bearer token env behavior. Native Bedrock currently expects explicit credentials or bearer auth on the facade.
|
||||
4. Vertex is not implemented natively. Google Gemini Developer API exists, but Vertex Gemini and Vertex Anthropic are separate auth/endpoint products and should be separate namespaces/facades.
|
||||
4. Vertex Gemini and Vertex Anthropic now have native package entrypoints, but the core runner does not map catalog metadata to them yet and recorded provider coverage is still missing.
|
||||
5. Azure is only a provider facade, not a full runtime replacement. Native Azure exists, but the catalog runner does not select it, and token auth/resource variants need review.
|
||||
6. Provider option typing is uneven. OpenAI, Anthropic, Gemini, Bedrock, and OpenRouter each expose a small typed subset plus raw HTTP overlays; this is useful but not equivalent to AI SDK provider option coverage.
|
||||
7. Structured output is not provider-native yet. `LLM.generateObject` still uses a synthetic tool strategy, while the future design expects native structured output where reliable and tool fallback where needed.
|
||||
8. Package/namespace boundaries for the current native loading set are explicit in docs and exports. Other exported provider facades are not catalog package entrypoints until they implement the contract. Missing native API boundaries remain for Vertex Gemini, Vertex Anthropic Messages, and Bedrock Mantle.
|
||||
8. Package/namespace boundaries for the current native loading set are explicit in docs and exports. Other exported provider facades are not catalog package entrypoints until they implement the contract. Missing native boundaries remain for Vertex MaaS, Vertex xAI, and Bedrock Mantle.
|
||||
9. Recorded coverage is uneven. OpenAI, Anthropic, Gemini, Bedrock Converse, Cloudflare, OpenRouter, and several OpenAI-compatible Chat providers have cassettes. Azure, Vertex, and Mantle need first-class recorded scenarios before switching defaults.
|
||||
|
||||
## Native Namespace Shape
|
||||
|
||||
These are implementation/API slices, not separate npm packages.
|
||||
|
||||
| API slice | Package-like entrypoint | Purpose |
|
||||
| --------------------------- | -------------------------------------------------------- | ---------------------------------------------------------------------------- |
|
||||
| OpenAI Chat | `@opencode-ai/llm/providers/openai/chat` | OpenAI `/chat/completions` semantics. |
|
||||
| OpenAI Responses | `@opencode-ai/llm/providers/openai/responses` | OpenAI `/responses` semantics with HTTP/WebSocket selected through settings. |
|
||||
| OpenAI-compatible Chat | `@opencode-ai/llm/providers/openai-compatible` | Generic OpenAI-compatible `/chat/completions`. |
|
||||
| OpenAI-compatible Responses | `@opencode-ai/llm/providers/openai-compatible/responses` | Generic OpenAI-compatible `/responses`. |
|
||||
| Anthropic Messages | `@opencode-ai/llm/providers/anthropic` | Anthropic Messages API. |
|
||||
| Gemini Developer API | `@opencode-ai/llm/providers/google` | Google AI Studio Gemini API. |
|
||||
| Vertex Gemini | Missing | Vertex Gemini API. |
|
||||
| Vertex Anthropic Messages | Missing | Vertex-hosted Anthropic Messages API. |
|
||||
| Bedrock Converse | `@opencode-ai/llm/providers/amazon-bedrock` | AWS Bedrock Converse API. |
|
||||
| Bedrock Mantle | Missing | AWS Bedrock Mantle OpenAI-compatible APIs. |
|
||||
| Azure OpenAI Chat | `@opencode-ai/llm/providers/azure/chat` | Azure specialization of OpenAI Chat. |
|
||||
| Azure OpenAI Responses | `@opencode-ai/llm/providers/azure/responses` | Azure specialization of OpenAI Responses. |
|
||||
| API slice | Package-like entrypoint | Purpose |
|
||||
| ----------------------------- | -------------------------------------------------------- | ---------------------------------------------------------------------------- |
|
||||
| OpenAI Chat | `@opencode-ai/llm/providers/openai/chat` | OpenAI `/chat/completions` semantics. |
|
||||
| OpenAI Responses | `@opencode-ai/llm/providers/openai/responses` | OpenAI `/responses` semantics with HTTP/WebSocket selected through settings. |
|
||||
| OpenAI-compatible Chat | `@opencode-ai/llm/providers/openai-compatible` | Generic OpenAI-compatible `/chat/completions`. |
|
||||
| OpenAI-compatible Responses | `@opencode-ai/llm/providers/openai-compatible/responses` | Generic OpenAI-compatible `/responses`. |
|
||||
| Anthropic-compatible Messages | `@opencode-ai/llm/providers/anthropic-compatible` | Generic Anthropic-compatible `/messages`. |
|
||||
| Anthropic Messages | `@opencode-ai/llm/providers/anthropic` | Anthropic Messages API. |
|
||||
| Gemini Developer API | `@opencode-ai/llm/providers/google` | Google AI Studio Gemini API. |
|
||||
| Vertex Gemini | `@opencode-ai/llm/providers/google-vertex` | Vertex Gemini API. |
|
||||
| Vertex Anthropic Messages | `@opencode-ai/llm/providers/google-vertex/anthropic` | Vertex-hosted Anthropic Messages API. |
|
||||
| Vertex MaaS | Missing | Vertex OpenAI-compatible MaaS APIs. |
|
||||
| Vertex xAI | Missing | Vertex-hosted xAI APIs. |
|
||||
| Bedrock Converse | `@opencode-ai/llm/providers/amazon-bedrock` | AWS Bedrock Converse API. |
|
||||
| Bedrock Mantle | Missing | AWS Bedrock Mantle OpenAI-compatible APIs. |
|
||||
| Azure OpenAI Chat | `@opencode-ai/llm/providers/azure/chat` | Azure specialization of OpenAI Chat. |
|
||||
| Azure OpenAI Responses | `@opencode-ai/llm/providers/azure/responses` | Azure specialization of OpenAI Responses. |
|
||||
|
||||
## Suggested Next Work Slices
|
||||
|
||||
1. Add native runner/catalog mappings for `@ai-sdk/azure`, `@ai-sdk/google`, and `@ai-sdk/amazon-bedrock` where the existing native facades are already close.
|
||||
2. Add API-aware runner/catalog selection between OpenAI-compatible Chat and Responses.
|
||||
3. Bring Bedrock native auth/config to AI SDK parity: region, profile, default AWS credential chain, bearer token env, endpoint override, and cross-region inference profile handling.
|
||||
4. Add Vertex Gemini and Vertex Anthropic native facades with ADC/OAuth auth and project/location endpoint derivation.
|
||||
5. Add Bedrock Mantle as a separate OpenAI-compatible Bedrock namespace after deciding whether it uses Chat, Responses, or both by model.
|
||||
6. Expand typed provider options from the existing V1 lowerer knowledge in `packages/core/src/v1/config/provider-options.ts` before adding more raw overlay examples.
|
||||
7. Add recorded provider tests for Azure, Vertex Gemini, Vertex Anthropic, Bedrock credential-chain behavior, and Mantle before making native runtime the default for those packages.
|
||||
4. Add runner/catalog mappings and recorded scenarios for the native Vertex Gemini and Vertex Anthropic entrypoints.
|
||||
5. Add native Vertex MaaS and Vertex xAI entrypoints by composing the compatible bases and shared Vertex auth/endpoint setup.
|
||||
6. Add Bedrock Mantle as a separate OpenAI-compatible Bedrock namespace after deciding whether it uses Chat, Responses, or both by model.
|
||||
7. Expand typed provider options from the existing V1 lowerer knowledge in `packages/core/src/v1/config/provider-options.ts` before adding more raw overlay examples.
|
||||
8. Add recorded provider tests for Azure, Vertex Gemini, Vertex Anthropic, Bedrock credential-chain behavior, and Mantle before making native runtime the default for those packages.
|
||||
|
|
|
|||
|
|
@ -360,6 +360,21 @@ import { model } from "@opencode-ai/llm/providers/openai/responses"
|
|||
model("gpt-4o", { apiKey, transport: "websocket" })
|
||||
```
|
||||
|
||||
Vertex keeps Gemini and Anthropic Messages as separate package-like entrypoints,
|
||||
while sharing project/location resolution and ADC authentication internally:
|
||||
|
||||
```ts
|
||||
import { model } from "@opencode-ai/llm/providers/google-vertex"
|
||||
|
||||
model("gemini-3.5-flash", { project, location: "global" })
|
||||
```
|
||||
|
||||
```ts
|
||||
import { model } from "@opencode-ai/llm/providers/google-vertex/anthropic"
|
||||
|
||||
model("claude-sonnet-4-6", { project, location: "global" })
|
||||
```
|
||||
|
||||
The client should not require a different public layer just because a selected
|
||||
route uses WebSocket. Use one `LLMClient.layer` with HTTP and WebSocket runtime
|
||||
capabilities available; routes that do not need WebSocket simply never touch it.
|
||||
|
|
|
|||
|
|
@ -18,12 +18,15 @@
|
|||
"./provider-package": "./src/provider-package.ts",
|
||||
"./providers/amazon-bedrock": "./src/providers/amazon-bedrock.ts",
|
||||
"./providers/anthropic": "./src/providers/anthropic.ts",
|
||||
"./providers/anthropic-compatible": "./src/providers/anthropic-compatible.ts",
|
||||
"./providers/azure": "./src/providers/azure.ts",
|
||||
"./providers/azure/responses": "./src/providers/azure/responses.ts",
|
||||
"./providers/azure/chat": "./src/providers/azure/chat.ts",
|
||||
"./providers/cloudflare": "./src/providers/cloudflare.ts",
|
||||
"./providers/github-copilot": "./src/providers/github-copilot.ts",
|
||||
"./providers/google": "./src/providers/google.ts",
|
||||
"./providers/google-vertex": "./src/providers/google-vertex.ts",
|
||||
"./providers/google-vertex/anthropic": "./src/providers/google-vertex/anthropic.ts",
|
||||
"./providers/openai": "./src/providers/openai.ts",
|
||||
"./providers/openai/responses": "./src/providers/openai/responses.ts",
|
||||
"./providers/openai/chat": "./src/providers/openai/chat.ts",
|
||||
|
|
@ -36,6 +39,8 @@
|
|||
"./protocols/anthropic-messages": "./src/protocols/anthropic-messages.ts",
|
||||
"./protocols/bedrock-converse": "./src/protocols/bedrock-converse.ts",
|
||||
"./protocols/gemini": "./src/protocols/gemini.ts",
|
||||
"./protocols/google-vertex-anthropic": "./src/protocols/google-vertex-anthropic.ts",
|
||||
"./protocols/google-vertex-gemini": "./src/protocols/google-vertex-gemini.ts",
|
||||
"./protocols/openai-chat": "./src/protocols/openai-chat.ts",
|
||||
"./protocols/openai-compatible-chat": "./src/protocols/openai-compatible-chat.ts",
|
||||
"./protocols/openai-compatible-responses": "./src/protocols/openai-compatible-responses.ts",
|
||||
|
|
@ -54,6 +59,7 @@
|
|||
"@smithy/util-utf8": "4.2.2",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"aws4fetch": "1.0.20",
|
||||
"effect": "catalog:"
|
||||
"effect": "catalog:",
|
||||
"google-auth-library": "10.5.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -182,7 +182,7 @@ const AnthropicBodyFields = {
|
|||
thinking: Schema.optional(AnthropicThinking),
|
||||
output_config: Schema.optional(AnthropicOutputConfig),
|
||||
}
|
||||
const AnthropicMessagesBody = Schema.Struct(AnthropicBodyFields)
|
||||
export const AnthropicMessagesBody = Schema.Struct(AnthropicBodyFields)
|
||||
export type AnthropicMessagesBody = Schema.Schema.Type<typeof AnthropicMessagesBody>
|
||||
|
||||
const AnthropicUsage = Schema.Struct({
|
||||
|
|
|
|||
42
packages/llm/src/protocols/google-vertex-anthropic.ts
Normal file
42
packages/llm/src/protocols/google-vertex-anthropic.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import { Effect, Schema, Struct } from "effect"
|
||||
import { AnthropicMessages } from "./anthropic-messages"
|
||||
import { Auth } from "../route/auth"
|
||||
import { Route } from "../route/client"
|
||||
import { Endpoint } from "../route/endpoint"
|
||||
import { Framing } from "../route/framing"
|
||||
import { Protocol } from "../route/protocol"
|
||||
|
||||
const VERSION = "vertex-2023-10-16" as const
|
||||
|
||||
export const GoogleVertexAnthropicBody = Schema.Struct({
|
||||
...Struct.omit(AnthropicMessages.AnthropicMessagesBody.fields, ["model"]),
|
||||
anthropic_version: Schema.Literal(VERSION),
|
||||
})
|
||||
export type GoogleVertexAnthropicBody = Schema.Schema.Type<typeof GoogleVertexAnthropicBody>
|
||||
|
||||
export const protocol = Protocol.make({
|
||||
id: "google-vertex-anthropic",
|
||||
body: {
|
||||
schema: GoogleVertexAnthropicBody,
|
||||
from: (request) =>
|
||||
AnthropicMessages.protocol.body.from(request).pipe(
|
||||
Effect.map((body) => ({
|
||||
...Struct.omit(body, ["model"]),
|
||||
anthropic_version: VERSION,
|
||||
})),
|
||||
),
|
||||
},
|
||||
stream: AnthropicMessages.protocol.stream,
|
||||
})
|
||||
|
||||
export const route = Route.make({
|
||||
id: "google-vertex-anthropic",
|
||||
provider: "google-vertex-anthropic",
|
||||
providerMetadataKey: "anthropic",
|
||||
protocol,
|
||||
endpoint: Endpoint.path(({ request }) => `/${request.model.id}:streamRawPredict`),
|
||||
auth: Auth.none,
|
||||
framing: Framing.sse,
|
||||
})
|
||||
|
||||
export * as GoogleVertexAnthropic from "./google-vertex-anthropic"
|
||||
20
packages/llm/src/protocols/google-vertex-gemini.ts
Normal file
20
packages/llm/src/protocols/google-vertex-gemini.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { Gemini } from "./gemini"
|
||||
import { Auth } from "../route/auth"
|
||||
import { Route } from "../route/client"
|
||||
import { Endpoint } from "../route/endpoint"
|
||||
import { Framing } from "../route/framing"
|
||||
|
||||
export const route = Route.make({
|
||||
id: "google-vertex-gemini",
|
||||
provider: "google-vertex",
|
||||
providerMetadataKey: "google",
|
||||
protocol: Gemini.protocol,
|
||||
endpoint: Endpoint.path(({ request }) => {
|
||||
const model = String(request.model.id)
|
||||
return `/${model.startsWith("endpoints/") ? model : `models/${model}`}:streamGenerateContent?alt=sse`
|
||||
}),
|
||||
auth: Auth.none,
|
||||
framing: Framing.sse,
|
||||
})
|
||||
|
||||
export * as GoogleVertexGemini from "./google-vertex-gemini"
|
||||
|
|
@ -1,4 +1,6 @@
|
|||
export * as AnthropicMessages from "./anthropic-messages"
|
||||
export * as GoogleVertexAnthropic from "./google-vertex-anthropic"
|
||||
export * as GoogleVertexGemini from "./google-vertex-gemini"
|
||||
export * as BedrockConverse from "./bedrock-converse"
|
||||
export * as Gemini from "./gemini"
|
||||
export * as OpenAIChat from "./openai-chat"
|
||||
|
|
|
|||
67
packages/llm/src/providers/anthropic-compatible.ts
Normal file
67
packages/llm/src/providers/anthropic-compatible.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import type { ProviderPackage } from "../provider-package"
|
||||
import { AnthropicMessages } from "../protocols/anthropic-messages"
|
||||
import { Auth } from "../route/auth"
|
||||
import type { ProviderAuthOption } from "../route/auth-options"
|
||||
import type { RouteDefaultsInput } from "../route/client"
|
||||
import { ProviderID, type ModelID } from "../schema"
|
||||
|
||||
export const id = ProviderID.make("anthropic-compatible")
|
||||
|
||||
export type Config = RouteDefaultsInput &
|
||||
ProviderAuthOption<"optional"> & {
|
||||
readonly provider?: string
|
||||
readonly baseURL: string
|
||||
}
|
||||
|
||||
export type Settings = ProviderPackage.Settings &
|
||||
(
|
||||
| { readonly apiKey?: string; readonly authToken?: never }
|
||||
| { readonly apiKey?: never; readonly authToken?: string }
|
||||
) & {
|
||||
readonly baseURL: string
|
||||
readonly provider?: string
|
||||
}
|
||||
|
||||
export const routes = [AnthropicMessages.route]
|
||||
|
||||
const auth = (input: ProviderAuthOption<"optional">) => {
|
||||
if ("auth" in input && input.auth) return input.auth
|
||||
return Auth.optional("apiKey" in input ? input.apiKey : undefined, "apiKey").pipe(Auth.header("x-api-key"))
|
||||
}
|
||||
|
||||
export const configure = (input: Config) => {
|
||||
if (!input.baseURL) throw new Error("Anthropic-compatible providers require a baseURL")
|
||||
const provider = input.provider ?? "anthropic-compatible"
|
||||
const { provider: _, baseURL, apiKey: _apiKey, auth: _auth, ...rest } = input
|
||||
const route = AnthropicMessages.route.with({
|
||||
...rest,
|
||||
provider,
|
||||
endpoint: { baseURL },
|
||||
auth: auth(input),
|
||||
})
|
||||
return {
|
||||
id: ProviderID.make(provider),
|
||||
model: (modelID: string | ModelID) => route.model({ id: modelID }),
|
||||
configure,
|
||||
}
|
||||
}
|
||||
|
||||
export const provider = {
|
||||
id,
|
||||
configure,
|
||||
}
|
||||
|
||||
export const model: ProviderPackage.Definition<Settings>["model"] = (modelID, settings) => {
|
||||
if (settings.apiKey !== undefined && settings.authToken !== undefined)
|
||||
throw new Error("Anthropic-compatible apiKey cannot be combined with authToken")
|
||||
return configure({
|
||||
...(settings.authToken === undefined ? { apiKey: settings.apiKey } : { auth: Auth.bearer(settings.authToken) }),
|
||||
baseURL: settings.baseURL,
|
||||
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
limits: settings.limits,
|
||||
provider: settings.provider,
|
||||
}).model(modelID)
|
||||
}
|
||||
|
||||
export * as AnthropicCompatible from "./anthropic-compatible"
|
||||
|
|
@ -3,7 +3,8 @@ import { Auth } from "../route/auth"
|
|||
import type { ProviderAuthOption } from "../route/auth-options"
|
||||
import type { ProviderPackage } from "../provider-package"
|
||||
import { ProviderID, type ModelID } from "../schema"
|
||||
import * as AnthropicMessages from "../protocols/anthropic-messages"
|
||||
import { AnthropicMessages } from "../protocols/anthropic-messages"
|
||||
import { AnthropicCompatible } from "./anthropic-compatible"
|
||||
|
||||
export const id = ProviderID.make("anthropic")
|
||||
|
||||
|
|
@ -11,11 +12,13 @@ export const routes = [AnthropicMessages.route]
|
|||
|
||||
export type Config = RouteDefaultsInput & ProviderAuthOption<"optional"> & { readonly baseURL?: string }
|
||||
|
||||
export interface Settings extends ProviderPackage.Settings {
|
||||
readonly apiKey?: string
|
||||
readonly authToken?: string
|
||||
readonly baseURL?: string
|
||||
}
|
||||
export type Settings = ProviderPackage.Settings &
|
||||
(
|
||||
| { readonly apiKey?: string; readonly authToken?: never }
|
||||
| { readonly apiKey?: never; readonly authToken?: string }
|
||||
) & {
|
||||
readonly baseURL?: string
|
||||
}
|
||||
|
||||
const auth = (options: ProviderAuthOption<"optional">) => {
|
||||
if ("auth" in options && options.auth) return options.auth
|
||||
|
|
@ -24,26 +27,30 @@ const auth = (options: ProviderAuthOption<"optional">) => {
|
|||
.pipe(Auth.header("x-api-key"))
|
||||
}
|
||||
|
||||
const configuredRoute = (input: Config) => {
|
||||
const { apiKey: _, auth: _auth, baseURL, ...rest } = input
|
||||
return AnthropicMessages.route.with({ ...rest, endpoint: { baseURL }, auth: auth(input) })
|
||||
}
|
||||
|
||||
export const configure = (input: Config = {}) => {
|
||||
const route = configuredRoute(input)
|
||||
const { apiKey: _, auth: _auth, baseURL, ...rest } = input
|
||||
const compatible = AnthropicCompatible.configure({
|
||||
...rest,
|
||||
auth: auth(input),
|
||||
baseURL: baseURL ?? AnthropicMessages.DEFAULT_BASE_URL,
|
||||
provider: id,
|
||||
})
|
||||
return {
|
||||
id,
|
||||
model: (modelID: string | ModelID) => route.model({ id: modelID }),
|
||||
model: (modelID: string | ModelID) => compatible.model(modelID),
|
||||
configure,
|
||||
}
|
||||
}
|
||||
|
||||
export const provider = configure()
|
||||
export const model: ProviderPackage.Definition<Settings>["model"] = (modelID, settings) =>
|
||||
configure({
|
||||
export const model: ProviderPackage.Definition<Settings>["model"] = (modelID, settings) => {
|
||||
if (settings.apiKey !== undefined && settings.authToken !== undefined)
|
||||
throw new Error("Anthropic apiKey cannot be combined with authToken")
|
||||
return configure({
|
||||
...(settings.authToken === undefined ? { apiKey: settings.apiKey } : { auth: Auth.bearer(settings.authToken) }),
|
||||
baseURL: settings.baseURL,
|
||||
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
limits: settings.limits,
|
||||
}).model(modelID)
|
||||
}
|
||||
|
|
|
|||
77
packages/llm/src/providers/google-vertex-anthropic.ts
Normal file
77
packages/llm/src/providers/google-vertex-anthropic.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import type { ProviderPackage } from "../provider-package"
|
||||
import { GoogleVertexAnthropic } from "../protocols/google-vertex-anthropic"
|
||||
import type { RouteDefaultsInput } from "../route/client"
|
||||
import { ProviderID, type ModelID, type ProviderOptions } from "../schema"
|
||||
import { GoogleVertexShared } from "./google-vertex-shared"
|
||||
|
||||
export const id = ProviderID.make("google-vertex-anthropic")
|
||||
|
||||
export type Config = RouteDefaultsInput &
|
||||
GoogleVertexShared.OAuthOptions & {
|
||||
readonly baseURL?: string
|
||||
readonly location?: string
|
||||
readonly project?: string
|
||||
}
|
||||
|
||||
export interface Settings extends ProviderPackage.Settings {
|
||||
readonly accessToken?: string
|
||||
readonly apiKey?: never
|
||||
readonly baseURL?: string
|
||||
readonly location?: string
|
||||
readonly project?: string
|
||||
readonly providerOptions?: ProviderOptions
|
||||
}
|
||||
|
||||
export const routes = [GoogleVertexAnthropic.route]
|
||||
|
||||
const configuredRoute = (input: Config) => {
|
||||
if ("apiKey" in input && input.apiKey !== undefined)
|
||||
throw new Error("Google Vertex Anthropic does not support API keys")
|
||||
const {
|
||||
accessToken: _accessToken,
|
||||
auth: _auth,
|
||||
baseURL,
|
||||
location: inputLocation,
|
||||
project: inputProject,
|
||||
...rest
|
||||
} = input
|
||||
const location = GoogleVertexShared.location(inputLocation, "global")
|
||||
const project = GoogleVertexShared.project(inputProject)
|
||||
return GoogleVertexAnthropic.route.with({
|
||||
...rest,
|
||||
endpoint: {
|
||||
baseURL:
|
||||
baseURL ??
|
||||
`https://${GoogleVertexShared.host(location)}/v1/projects/${GoogleVertexShared.requireProject(project)}/locations/${location}/publishers/anthropic/models`,
|
||||
},
|
||||
auth: GoogleVertexShared.oauth(input, project),
|
||||
})
|
||||
}
|
||||
|
||||
export const configure = (input: Config = {}) => {
|
||||
const route = configuredRoute(input)
|
||||
return {
|
||||
id,
|
||||
model: (modelID: string | ModelID) => route.model({ id: modelID }),
|
||||
configure,
|
||||
}
|
||||
}
|
||||
|
||||
export const provider = {
|
||||
id,
|
||||
configure,
|
||||
}
|
||||
|
||||
export const model: ProviderPackage.Definition<Settings>["model"] = (modelID, settings) => {
|
||||
if (settings.apiKey !== undefined) throw new Error("Google Vertex Anthropic does not support API keys")
|
||||
return configure({
|
||||
accessToken: settings.accessToken,
|
||||
baseURL: settings.baseURL,
|
||||
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
limits: settings.limits,
|
||||
location: settings.location,
|
||||
project: settings.project,
|
||||
providerOptions: settings.providerOptions,
|
||||
}).model(modelID)
|
||||
}
|
||||
77
packages/llm/src/providers/google-vertex-shared.ts
Normal file
77
packages/llm/src/providers/google-vertex-shared.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import type { AnyAuthClient } from "google-auth-library"
|
||||
import { Effect, Redacted } from "effect"
|
||||
import { Auth, MissingCredentialError } from "../route/auth"
|
||||
|
||||
const SCOPE = "https://www.googleapis.com/auth/cloud-platform"
|
||||
|
||||
export type OAuthOptions =
|
||||
| { readonly accessToken?: string; readonly auth?: never }
|
||||
| { readonly accessToken?: never; readonly auth?: Auth }
|
||||
|
||||
export type ApiKeyOptions =
|
||||
| (OAuthOptions & { readonly apiKey?: never })
|
||||
| { readonly accessToken?: never; readonly apiKey?: string; readonly auth?: never }
|
||||
|
||||
export const project = (value?: string) =>
|
||||
value ??
|
||||
process.env.GOOGLE_VERTEX_PROJECT ??
|
||||
process.env.GOOGLE_CLOUD_PROJECT ??
|
||||
process.env.GCP_PROJECT ??
|
||||
process.env.GCLOUD_PROJECT
|
||||
|
||||
export const location = (value: string | undefined, fallback: string) =>
|
||||
value ??
|
||||
process.env.GOOGLE_VERTEX_LOCATION ??
|
||||
process.env.GOOGLE_CLOUD_LOCATION ??
|
||||
process.env.VERTEX_LOCATION ??
|
||||
fallback
|
||||
|
||||
export const host = (location: string) => {
|
||||
if (location === "global") return "aiplatform.googleapis.com"
|
||||
// Jurisdictional multi-regions use Regional Endpoint Platform domains.
|
||||
if (location === "eu" || location === "us") return `aiplatform.${location}.rep.googleapis.com`
|
||||
return `${location}-aiplatform.googleapis.com`
|
||||
}
|
||||
|
||||
export const requireProject = (value: string | undefined) => {
|
||||
if (value) return value
|
||||
throw new Error("Google Vertex requires a project when baseURL is not configured")
|
||||
}
|
||||
|
||||
export const apiKey = (input: ApiKeyOptions) => {
|
||||
if (input.apiKey !== undefined && (input.accessToken !== undefined || input.auth !== undefined))
|
||||
throw new Error("Google Vertex apiKey cannot be combined with accessToken or auth")
|
||||
if (input.accessToken !== undefined || input.auth !== undefined) return undefined
|
||||
return input.apiKey ?? process.env.GOOGLE_VERTEX_API_KEY
|
||||
}
|
||||
|
||||
const adc = (project?: string) => {
|
||||
let client: Promise<AnyAuthClient> | undefined
|
||||
const loadClient = () => {
|
||||
if (client) return client
|
||||
client = import("google-auth-library").then(({ GoogleAuth }) =>
|
||||
new GoogleAuth({ projectId: project, scopes: [SCOPE] }).getClient(),
|
||||
)
|
||||
return client
|
||||
}
|
||||
return Auth.effect(
|
||||
Effect.tryPromise({
|
||||
try: async () => {
|
||||
const token = await (await loadClient()).getAccessToken()
|
||||
if (!token.token) throw new Error("Google ADC returned an empty access token")
|
||||
return Redacted.make(token.token)
|
||||
},
|
||||
catch: () => new MissingCredentialError("Google Application Default Credentials"),
|
||||
}),
|
||||
).bearer()
|
||||
}
|
||||
|
||||
export const oauth = (input: OAuthOptions, project?: string) => {
|
||||
if (input.accessToken !== undefined && input.auth !== undefined)
|
||||
throw new Error("Google Vertex accessToken cannot be combined with auth")
|
||||
if (input.auth) return input.auth
|
||||
if (input.accessToken !== undefined) return Auth.bearer(input.accessToken)
|
||||
return adc(project)
|
||||
}
|
||||
|
||||
export * as GoogleVertexShared from "./google-vertex-shared"
|
||||
83
packages/llm/src/providers/google-vertex.ts
Normal file
83
packages/llm/src/providers/google-vertex.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import type { ProviderPackage } from "../provider-package"
|
||||
import { GoogleVertexGemini } from "../protocols/google-vertex-gemini"
|
||||
import { Auth } from "../route/auth"
|
||||
import type { RouteDefaultsInput } from "../route/client"
|
||||
import { ProviderID, type ModelID, type ProviderOptions } from "../schema"
|
||||
import { GoogleVertexShared } from "./google-vertex-shared"
|
||||
|
||||
export const id = ProviderID.make("google-vertex")
|
||||
|
||||
export type Config = RouteDefaultsInput &
|
||||
GoogleVertexShared.ApiKeyOptions & {
|
||||
readonly baseURL?: string
|
||||
readonly location?: string
|
||||
readonly project?: string
|
||||
}
|
||||
|
||||
export type Settings = ProviderPackage.Settings &
|
||||
(
|
||||
| { readonly accessToken?: string; readonly apiKey?: never }
|
||||
| { readonly accessToken?: never; readonly apiKey?: string }
|
||||
) & {
|
||||
readonly baseURL?: string
|
||||
readonly location?: string
|
||||
readonly project?: string
|
||||
readonly providerOptions?: ProviderOptions
|
||||
}
|
||||
|
||||
export const routes = [GoogleVertexGemini.route]
|
||||
|
||||
const configuredRoute = (input: Config, modelID: string | ModelID) => {
|
||||
const {
|
||||
accessToken: _accessToken,
|
||||
apiKey: _apiKey,
|
||||
auth: _auth,
|
||||
baseURL,
|
||||
location: inputLocation,
|
||||
project: inputProject,
|
||||
...rest
|
||||
} = input
|
||||
const apiKey = GoogleVertexShared.apiKey(input)
|
||||
const endpointModel = String(modelID).startsWith("endpoints/")
|
||||
if (apiKey !== undefined && endpointModel)
|
||||
throw new Error("Google Vertex tuned models do not support Express Mode API keys")
|
||||
const location = GoogleVertexShared.location(inputLocation, "us-central1")
|
||||
const project = GoogleVertexShared.project(inputProject)
|
||||
const endpoint =
|
||||
baseURL ??
|
||||
(apiKey
|
||||
? "https://aiplatform.googleapis.com/v1/publishers/google"
|
||||
: `https://${GoogleVertexShared.host(location)}/v1beta1/projects/${GoogleVertexShared.requireProject(project)}/locations/${location}${endpointModel ? "" : "/publishers/google"}`)
|
||||
return GoogleVertexGemini.route.with({
|
||||
...rest,
|
||||
endpoint: { baseURL: endpoint },
|
||||
auth: apiKey === undefined ? GoogleVertexShared.oauth(input, project) : Auth.header("x-goog-api-key", apiKey),
|
||||
})
|
||||
}
|
||||
|
||||
export const configure = (input: Config = {}) => {
|
||||
return {
|
||||
id,
|
||||
model: (modelID: string | ModelID) => configuredRoute(input, modelID).model({ id: modelID }),
|
||||
configure,
|
||||
}
|
||||
}
|
||||
|
||||
export const provider = {
|
||||
id,
|
||||
configure,
|
||||
}
|
||||
export const model: ProviderPackage.Definition<Settings>["model"] = (modelID, settings) => {
|
||||
if (settings.apiKey !== undefined && settings.accessToken !== undefined)
|
||||
throw new Error("Google Vertex apiKey cannot be combined with accessToken or auth")
|
||||
return configure({
|
||||
...(settings.apiKey === undefined ? { accessToken: settings.accessToken } : { apiKey: settings.apiKey }),
|
||||
baseURL: settings.baseURL,
|
||||
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
limits: settings.limits,
|
||||
location: settings.location,
|
||||
project: settings.project,
|
||||
providerOptions: settings.providerOptions,
|
||||
}).model(modelID)
|
||||
}
|
||||
2
packages/llm/src/providers/google-vertex/anthropic.ts
Normal file
2
packages/llm/src/providers/google-vertex/anthropic.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { model } from "../google-vertex-anthropic"
|
||||
export type { Settings } from "../google-vertex-anthropic"
|
||||
|
|
@ -1,10 +1,13 @@
|
|||
export * as Anthropic from "./anthropic"
|
||||
export * as AnthropicCompatible from "./anthropic-compatible"
|
||||
export * as AmazonBedrock from "./amazon-bedrock"
|
||||
export * as Azure from "./azure"
|
||||
export * as Cloudflare from "./cloudflare"
|
||||
export { CloudflareAIGateway, CloudflareWorkersAI } from "./cloudflare"
|
||||
export * as GitHubCopilot from "./github-copilot"
|
||||
export * as Google from "./google"
|
||||
export * as GoogleVertex from "./google-vertex"
|
||||
export * as GoogleVertexAnthropic from "./google-vertex-anthropic"
|
||||
export * as OpenAI from "./openai"
|
||||
export * as OpenAICompatible from "./openai-compatible"
|
||||
export * as OpenAICompatibleResponses from "./openai-compatible-responses"
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ const applyQuery = (url: string, query: Record<string, string> | undefined) => {
|
|||
}
|
||||
|
||||
const PROTOCOL_BODY_OVERLAY_DENYLIST = new Set([
|
||||
"anthropic_version",
|
||||
"content",
|
||||
"contents",
|
||||
"frequencyPenalty",
|
||||
|
|
|
|||
|
|
@ -5,10 +5,13 @@ import { Auth as RuntimeAuth } from "../src/route/auth"
|
|||
import * as OpenAIChat from "../src/protocols/openai-chat"
|
||||
import * as AmazonBedrock from "../src/providers/amazon-bedrock"
|
||||
import * as Anthropic from "../src/providers/anthropic"
|
||||
import * as AnthropicCompatible from "../src/providers/anthropic-compatible"
|
||||
import * as Azure from "../src/providers/azure"
|
||||
import * as Cloudflare from "../src/providers/cloudflare"
|
||||
import * as GitHubCopilot from "../src/providers/github-copilot"
|
||||
import * as Google from "../src/providers/google"
|
||||
import * as GoogleVertex from "../src/providers/google-vertex"
|
||||
import * as GoogleVertexAnthropic from "../src/providers/google-vertex-anthropic"
|
||||
import * as OpenAI from "../src/providers/openai"
|
||||
import * as OpenAICompatible from "../src/providers/openai-compatible"
|
||||
import * as OpenRouter from "../src/providers/openrouter"
|
||||
|
|
@ -135,11 +138,57 @@ Azure.configure({ resourceName: "resource", apiKey: "azure-key", auth: RuntimeAu
|
|||
Anthropic.configure({ apiKey: "anthropic-key" }).model("claude-haiku")
|
||||
// @ts-expect-error Anthropic model selectors only accept model ids.
|
||||
Anthropic.configure({ apiKey: "anthropic-key" }).model("claude-haiku", {})
|
||||
// @ts-expect-error Anthropic package settings accept only one auth source.
|
||||
Anthropic.model("claude-sonnet-4-6", { apiKey: "anthropic-key", authToken: "anthropic-token" })
|
||||
|
||||
AnthropicCompatible.configure({
|
||||
apiKey: "messages-key",
|
||||
baseURL: "https://messages.example.com/v1",
|
||||
provider: "example",
|
||||
}).model("compatible-model")
|
||||
// @ts-expect-error Anthropic-compatible providers require a base URL.
|
||||
AnthropicCompatible.configure({ apiKey: "messages-key" })
|
||||
// @ts-expect-error Anthropic-compatible model selectors only accept model ids.
|
||||
AnthropicCompatible.configure({ baseURL: "https://messages.example.com/v1" }).model("compatible-model", {})
|
||||
// @ts-expect-error Anthropic-compatible package settings accept only one auth source.
|
||||
AnthropicCompatible.model("compatible-model", {
|
||||
apiKey: "messages-key",
|
||||
authToken: "messages-token",
|
||||
baseURL: "https://messages.example.com/v1",
|
||||
})
|
||||
|
||||
Google.configure({ apiKey: "google-key" }).model("gemini-2.5-flash")
|
||||
// @ts-expect-error Google model selectors only accept model ids.
|
||||
Google.configure({ apiKey: "google-key" }).model("gemini-2.5-flash", {})
|
||||
|
||||
GoogleVertex.configure({ apiKey: "vertex-key" }).model("gemini-3.5-flash")
|
||||
GoogleVertex.configure({ accessToken: "vertex-token", project: "project" }).model("gemini-3.5-flash")
|
||||
GoogleVertex.configure({ auth: RuntimeAuth.bearer("vertex-token"), project: "project" }).model("gemini-3.5-flash")
|
||||
// @ts-expect-error Vertex Gemini model selectors only accept model ids.
|
||||
GoogleVertex.configure({ apiKey: "vertex-key" }).model("gemini-3.5-flash", {})
|
||||
// @ts-expect-error Vertex Gemini config accepts only one auth source.
|
||||
GoogleVertex.configure({ accessToken: "vertex-token", apiKey: "vertex-key", project: "project" })
|
||||
// @ts-expect-error Vertex Gemini package settings accept only one auth source.
|
||||
GoogleVertex.model("gemini-3.5-flash", { accessToken: "vertex-token", apiKey: "vertex-key", project: "project" })
|
||||
|
||||
GoogleVertexAnthropic.configure({ accessToken: "vertex-token", project: "project" }).model("claude-sonnet-4-6")
|
||||
// @ts-expect-error Vertex Anthropic package settings do not accept API keys.
|
||||
GoogleVertexAnthropic.model("claude-sonnet-4-6", { apiKey: "vertex-key", project: "project" })
|
||||
GoogleVertexAnthropic.configure({ auth: RuntimeAuth.bearer("vertex-token"), project: "project" }).model(
|
||||
"claude-sonnet-4-6",
|
||||
)
|
||||
GoogleVertexAnthropic.configure({ accessToken: "vertex-token", project: "project" }).model(
|
||||
"claude-sonnet-4-6",
|
||||
// @ts-expect-error Vertex Anthropic model selectors only accept model ids.
|
||||
{},
|
||||
)
|
||||
GoogleVertexAnthropic.configure({
|
||||
accessToken: "vertex-token",
|
||||
// @ts-expect-error Vertex Anthropic config accepts only one auth source.
|
||||
auth: RuntimeAuth.bearer("vertex-token"),
|
||||
project: "project",
|
||||
})
|
||||
|
||||
AmazonBedrock.configure({ apiKey: "bedrock-key" }).model("anthropic.claude")
|
||||
// @ts-expect-error Bedrock model selectors only accept model ids.
|
||||
AmazonBedrock.configure({ apiKey: "bedrock-key" }).model("anthropic.claude", {})
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ describe("provider package entrypoints", () => {
|
|||
import("@opencode-ai/llm/providers/openai/responses"),
|
||||
import("@opencode-ai/llm/providers/openai/chat"),
|
||||
import("@opencode-ai/llm/providers/anthropic"),
|
||||
import("@opencode-ai/llm/providers/anthropic-compatible"),
|
||||
import("@opencode-ai/llm/providers/openai-compatible"),
|
||||
import("@opencode-ai/llm/providers/openai-compatible/responses"),
|
||||
import("@opencode-ai/llm/providers/amazon-bedrock"),
|
||||
|
|
@ -15,11 +16,13 @@ describe("provider package entrypoints", () => {
|
|||
import("@opencode-ai/llm/providers/azure/responses"),
|
||||
import("@opencode-ai/llm/providers/azure/chat"),
|
||||
import("@opencode-ai/llm/providers/google"),
|
||||
import("@opencode-ai/llm/providers/google-vertex"),
|
||||
import("@opencode-ai/llm/providers/google-vertex/anthropic"),
|
||||
])
|
||||
|
||||
for (const module of modules) expect(module.model).toBeFunction()
|
||||
expect(modules[0].model).toBe(modules[1].model)
|
||||
expect(modules[7].model).toBe(modules[8].model)
|
||||
expect(modules[8].model).toBe(modules[9].model)
|
||||
})
|
||||
|
||||
test("maps package settings onto the executable model", () => {
|
||||
|
|
@ -69,6 +72,53 @@ describe("provider package entrypoints", () => {
|
|||
})
|
||||
})
|
||||
|
||||
test("maps Anthropic-compatible settings onto the executable model", async () => {
|
||||
const AnthropicCompatible = await import("@opencode-ai/llm/providers/anthropic-compatible")
|
||||
const selected = AnthropicCompatible.model("compatible-model", {
|
||||
apiKey: "fixture",
|
||||
baseURL: "https://messages.example.test/v1",
|
||||
provider: "example",
|
||||
headers: { "x-application": "opencode" },
|
||||
body: { metadata: { user_id: "user_1" } },
|
||||
limits: { context: 200_000, output: 64_000 },
|
||||
})
|
||||
|
||||
expect(String(selected.provider)).toBe("example")
|
||||
expect(selected.route.id).toBe("anthropic-messages")
|
||||
expect(selected.route.endpoint).toMatchObject({
|
||||
baseURL: "https://messages.example.test/v1",
|
||||
path: "/messages",
|
||||
})
|
||||
expect(selected.route.defaults.headers).toEqual({ "x-application": "opencode" })
|
||||
expect(selected.route.defaults.http?.body).toEqual({ metadata: { user_id: "user_1" } })
|
||||
expect(selected.route.defaults.limits).toEqual({ context: 200_000, output: 64_000 })
|
||||
})
|
||||
|
||||
test("requires an Anthropic-compatible base URL at runtime", async () => {
|
||||
const AnthropicCompatible = await import("@opencode-ai/llm/providers/anthropic-compatible")
|
||||
expect(() =>
|
||||
Reflect.apply(AnthropicCompatible.model, undefined, ["compatible-model", { apiKey: "fixture" }]),
|
||||
).toThrow("Anthropic-compatible providers require a baseURL")
|
||||
})
|
||||
|
||||
test("rejects conflicting Anthropic-compatible auth settings at runtime", async () => {
|
||||
const Anthropic = await import("@opencode-ai/llm/providers/anthropic")
|
||||
const AnthropicCompatible = await import("@opencode-ai/llm/providers/anthropic-compatible")
|
||||
expect(() =>
|
||||
Reflect.apply(AnthropicCompatible.model, undefined, [
|
||||
"compatible-model",
|
||||
{
|
||||
apiKey: "fixture",
|
||||
authToken: "token",
|
||||
baseURL: "https://messages.example.test/v1",
|
||||
},
|
||||
]),
|
||||
).toThrow("Anthropic-compatible apiKey cannot be combined with authToken")
|
||||
expect(() =>
|
||||
Reflect.apply(Anthropic.model, undefined, ["claude-sonnet-4-6", { apiKey: "fixture", authToken: "token" }]),
|
||||
).toThrow("Anthropic apiKey cannot be combined with authToken")
|
||||
})
|
||||
|
||||
test("maps legacy OpenAI organization and project settings to headers", () => {
|
||||
const selected = model("gpt-5", {
|
||||
apiKey: "fixture",
|
||||
|
|
@ -126,4 +176,64 @@ describe("provider package entrypoints", () => {
|
|||
gemini: { thinkingConfig: { thinkingBudget: 1_024 } },
|
||||
})
|
||||
})
|
||||
|
||||
test("selects Vertex entrypoints with the same model contract", async () => {
|
||||
const GoogleVertex = await import("@opencode-ai/llm/providers/google-vertex")
|
||||
const GoogleVertexAnthropic = await import("@opencode-ai/llm/providers/google-vertex/anthropic")
|
||||
const gemini = GoogleVertex.model("gemini-3.5-flash", {
|
||||
apiKey: "fixture",
|
||||
headers: { "x-application": "opencode" },
|
||||
body: { safetySettings: [] },
|
||||
limits: { context: 1_000_000, output: 65_536 },
|
||||
})
|
||||
const anthropic = GoogleVertexAnthropic.model("claude-sonnet-4-6", {
|
||||
accessToken: "fixture",
|
||||
location: "global",
|
||||
project: "vertex-project",
|
||||
})
|
||||
|
||||
expect(gemini.route.id).toBe("google-vertex-gemini")
|
||||
expect(gemini.route.endpoint.baseURL).toBe("https://aiplatform.googleapis.com/v1/publishers/google")
|
||||
expect(gemini.route.defaults.headers).toEqual({ "x-application": "opencode" })
|
||||
expect(gemini.route.defaults.http?.body).toEqual({ safetySettings: [] })
|
||||
expect(gemini.route.defaults.limits).toEqual({ context: 1_000_000, output: 65_536 })
|
||||
expect(
|
||||
GoogleVertex.model("gemini-3.5-flash", {
|
||||
accessToken: "fixture",
|
||||
location: "eu",
|
||||
project: "vertex-project",
|
||||
}).route.endpoint.baseURL,
|
||||
).toBe("https://aiplatform.eu.rep.googleapis.com/v1beta1/projects/vertex-project/locations/eu/publishers/google")
|
||||
expect(anthropic.route.id).toBe("google-vertex-anthropic")
|
||||
expect(anthropic.route.endpoint.baseURL).toBe(
|
||||
"https://aiplatform.googleapis.com/v1/projects/vertex-project/locations/global/publishers/anthropic/models",
|
||||
)
|
||||
})
|
||||
|
||||
test("rejects conflicting Vertex auth settings at runtime", async () => {
|
||||
const GoogleVertex = await import("@opencode-ai/llm/providers/google-vertex")
|
||||
const GoogleVertexAnthropic = await import("@opencode-ai/llm/providers/google-vertex/anthropic")
|
||||
const Providers = await import("@opencode-ai/llm/providers")
|
||||
expect(() =>
|
||||
Reflect.apply(GoogleVertex.model, undefined, [
|
||||
"gemini-3.5-flash",
|
||||
{ accessToken: "token", apiKey: "fixture", project: "vertex-project" },
|
||||
]),
|
||||
).toThrow("Google Vertex apiKey cannot be combined with accessToken or auth")
|
||||
const configured = Reflect.apply(GoogleVertex.configure, undefined, [
|
||||
{ accessToken: "token", auth: {}, project: "vertex-project" },
|
||||
])
|
||||
expect(() => configured.model("gemini-3.5-flash")).toThrow("Google Vertex accessToken cannot be combined with auth")
|
||||
expect(() =>
|
||||
Reflect.apply(GoogleVertexAnthropic.model, undefined, [
|
||||
"claude-sonnet-4-6",
|
||||
{ apiKey: "fixture", project: "vertex-project" },
|
||||
]),
|
||||
).toThrow("Google Vertex Anthropic does not support API keys")
|
||||
expect(() =>
|
||||
Reflect.apply(Providers.GoogleVertexAnthropic.configure, undefined, [
|
||||
{ apiKey: "fixture", project: "vertex-project" },
|
||||
]),
|
||||
).toThrow("Google Vertex Anthropic does not support API keys")
|
||||
})
|
||||
})
|
||||
|
|
|
|||
165
packages/llm/test/provider/google-vertex.test.ts
Normal file
165
packages/llm/test/provider/google-vertex.test.ts
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { HttpClientRequest } from "effect/unstable/http"
|
||||
import { LLM } from "../../src"
|
||||
import { GoogleVertex, GoogleVertexAnthropic } from "../../src/providers"
|
||||
import { LLMClient } from "../../src/route"
|
||||
import { it } from "../lib/effect"
|
||||
import { dynamicResponse } from "../lib/http"
|
||||
import { sseEvents } from "../lib/sse"
|
||||
|
||||
describe("Google Vertex providers", () => {
|
||||
it.effect("sends Gemini requests to the global Vertex endpoint", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(
|
||||
LLM.request({
|
||||
model: GoogleVertex.configure({
|
||||
accessToken: "vertex-token",
|
||||
location: "global",
|
||||
project: "vertex-project",
|
||||
}).model("gemini-3.5-flash"),
|
||||
prompt: "Say hello.",
|
||||
}),
|
||||
).pipe(
|
||||
Effect.provide(
|
||||
dynamicResponse((input) =>
|
||||
Effect.gen(function* () {
|
||||
const request = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
|
||||
expect(request.url).toBe(
|
||||
"https://aiplatform.googleapis.com/v1beta1/projects/vertex-project/locations/global/publishers/google/models/gemini-3.5-flash:streamGenerateContent?alt=sse",
|
||||
)
|
||||
expect(request.headers.get("authorization")).toBe("Bearer vertex-token")
|
||||
expect(yield* Effect.promise(() => request.json())).toMatchObject({
|
||||
contents: [{ role: "user", parts: [{ text: "Say hello." }] }],
|
||||
})
|
||||
return input.respond(
|
||||
sseEvents({
|
||||
candidates: [
|
||||
{
|
||||
content: { role: "model", parts: [{ text: "Hello." }] },
|
||||
finishReason: "STOP",
|
||||
},
|
||||
],
|
||||
}),
|
||||
{ headers: { "content-type": "text/event-stream" } },
|
||||
)
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.text).toBe("Hello.")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("projects Anthropic Messages onto the Vertex raw-predict API", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(
|
||||
LLM.request({
|
||||
model: GoogleVertexAnthropic.configure({
|
||||
accessToken: "vertex-token",
|
||||
location: "eu",
|
||||
project: "vertex-project",
|
||||
}).model("claude-sonnet-4-6"),
|
||||
prompt: "Say hello.",
|
||||
}),
|
||||
).pipe(
|
||||
Effect.provide(
|
||||
dynamicResponse((input) =>
|
||||
Effect.gen(function* () {
|
||||
const request = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
|
||||
expect(request.url).toBe(
|
||||
"https://aiplatform.eu.rep.googleapis.com/v1/projects/vertex-project/locations/eu/publishers/anthropic/models/claude-sonnet-4-6:streamRawPredict",
|
||||
)
|
||||
expect(request.headers.get("authorization")).toBe("Bearer vertex-token")
|
||||
expect(request.headers.get("anthropic-version")).toBeNull()
|
||||
const body = yield* Effect.promise(() => request.json())
|
||||
expect(body).toMatchObject({
|
||||
anthropic_version: "vertex-2023-10-16",
|
||||
messages: [{ role: "user", content: [{ type: "text", text: "Say hello." }] }],
|
||||
stream: true,
|
||||
})
|
||||
expect(body).not.toHaveProperty("model")
|
||||
return input.respond(
|
||||
sseEvents(
|
||||
{ type: "content_block_start", index: 0, content_block: { type: "text", text: "" } },
|
||||
{ type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "Hello." } },
|
||||
{ type: "content_block_stop", index: 0 },
|
||||
{ type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 2 } },
|
||||
{ type: "message_stop" },
|
||||
),
|
||||
{ headers: { "content-type": "text/event-stream" } },
|
||||
)
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.text).toBe("Hello.")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("protects the Vertex Anthropic API version from body overlays", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.prepare(
|
||||
LLM.request({
|
||||
model: GoogleVertexAnthropic.configure({
|
||||
accessToken: "vertex-token",
|
||||
http: { body: { anthropic_version: "wrong" } },
|
||||
project: "vertex-project",
|
||||
}).model("claude-sonnet-4-6"),
|
||||
prompt: "Say hello.",
|
||||
}),
|
||||
).pipe(Effect.flip)
|
||||
|
||||
expect(error.message).toContain("http.body cannot overlay protocol-owned field(s): anthropic_version")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("routes tuned Gemini models through their deployed endpoint", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(
|
||||
LLM.request({
|
||||
model: GoogleVertex.configure({
|
||||
accessToken: "vertex-token",
|
||||
location: "us-central1",
|
||||
project: "vertex-project",
|
||||
}).model("endpoints/1234567890"),
|
||||
prompt: "Say hello.",
|
||||
}),
|
||||
).pipe(
|
||||
Effect.provide(
|
||||
dynamicResponse((input) =>
|
||||
Effect.gen(function* () {
|
||||
const request = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
|
||||
expect(request.url).toBe(
|
||||
"https://us-central1-aiplatform.googleapis.com/v1beta1/projects/vertex-project/locations/us-central1/endpoints/1234567890:streamGenerateContent?alt=sse",
|
||||
)
|
||||
return input.respond(
|
||||
sseEvents({
|
||||
candidates: [
|
||||
{
|
||||
content: { role: "model", parts: [{ text: "Hello." }] },
|
||||
finishReason: "STOP",
|
||||
},
|
||||
],
|
||||
}),
|
||||
{ headers: { "content-type": "text/event-stream" } },
|
||||
)
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.text).toBe("Hello.")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects tuned Gemini models in express mode", () =>
|
||||
Effect.sync(() => {
|
||||
expect(() => GoogleVertex.configure({ apiKey: "fixture" }).model("endpoints/1234567890")).toThrow(
|
||||
"Google Vertex tuned models do not support Express Mode API keys",
|
||||
)
|
||||
}),
|
||||
)
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue