Compare commits

..

2 Commits

Author SHA1 Message Date
Aiden Cline 44a898f3ee fix(core): preserve model capability semantics 2026-07-07 23:33:15 -05:00
Aiden Cline 77fce8b24c chore(core): checkpoint model capability defaults 2026-07-07 22:37:19 -05:00
28 changed files with 294 additions and 230 deletions
@@ -46,7 +46,7 @@ const layer = Layer.effect(
.flatMap((item) => item.info.watcher?.ignore ?? [])
const home = path.resolve(location.directory) === path.resolve(os.homedir())
if (!home && location.vcs) {
if (!home) {
yield* watcher
.subscribe({
path: location.directory,
+1 -1
View File
@@ -65,7 +65,7 @@ export const Model = Schema.Struct({
name: Schema.String,
family: Schema.optional(Schema.String),
release_date: Schema.String,
attachment: Schema.Boolean,
attachment: Schema.optional(Schema.Boolean),
reasoning: Schema.Boolean,
reasoning_options: Schema.optional(Schema.Array(ReasoningOption)),
temperature: Schema.optional(Schema.Boolean),
+5 -4
View File
@@ -185,11 +185,12 @@ function applyModel(
draft.family = model.family ? ModelV2.Family.make(model.family) : undefined
draft.package = model.provider?.npm ? ProviderV2.aisdk(model.provider.npm) : undefined
draft.settings = model.provider?.api ? { ...draft.settings, baseURL: model.provider.api } : draft.settings
draft.capabilities = {
const capabilities = ModelV2.Capabilities.defaults({
tools: model.tool_call,
input: [...(model.modalities?.input ?? [])],
output: [...(model.modalities?.output ?? [])],
}
input: model.modalities?.input ?? (model.attachment === false ? ["text"] : undefined),
output: model.modalities?.output,
})
draft.capabilities = { ...capabilities, input: [...capabilities.input], output: [...capabilities.output] }
mergeVariants(draft, input.variants ?? [])
draft.time.released = released(model.release_date)
draft.cost = (input.cost ?? cost(model.cost)).map((item) => ({
+5 -4
View File
@@ -237,9 +237,10 @@ const layer = Layer.effect(
const entries = yield* SessionHistory.entriesForRunner(db, session.id, checkpoint.baselineSeq)
const context = entries.map((entry) => entry.message)
const isLastStep = agent.info?.steps !== undefined && currentStep >= agent.info.steps
const toolMaterialization = isLastStep
? undefined
: yield* tools.materialize({ permissions: agent.info?.permissions, model })
const toolMaterialization =
isLastStep || !resolved.capabilities.tools
? undefined
: yield* tools.materialize({ permissions: agent.info?.permissions, model })
const promptCacheKey = /^ses_[0-9a-f]{64}$/.test(session.id) ? session.id.slice(4) : session.id
const request = LLM.request({
model,
@@ -251,7 +252,7 @@ const layer = Layer.effect(
.filter((part): part is string => part !== undefined && part.length > 0)
.map(SystemPart.make),
messages: [
...toLLMMessages(context, resolved.ref, providerMetadataKey),
...toLLMMessages(context, resolved.ref, providerMetadataKey, resolved.capabilities),
...(isLastStep ? [Message.assistant(MAX_STEPS_PROMPT)] : []),
],
tools: toolMaterialization?.definitions ?? [],
+10 -1
View File
@@ -82,6 +82,8 @@ export interface Resolved {
readonly model: Model
/** Selected catalog identity. Durable records and displays must use this, never the API model id. */
readonly ref: ModelV2.Ref
/** Capabilities of the selected catalog model. */
readonly capabilities: ModelV2.Capabilities
/** Catalog pricing in dollars per million tokens. */
readonly cost: ModelV2.Info["cost"]
}
@@ -96,13 +98,19 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
export const layerWith = (resolve: Interface["resolve"]) => Layer.succeed(Service, Service.of({ resolve }))
/** Builds a Resolved whose catalog identity mirrors the route model. Test or embedding seam. */
export const resolved = (model: Model, variant?: ModelV2.VariantID, cost: ModelV2.Info["cost"] = []): Resolved => ({
export const resolved = (
model: Model,
variant?: ModelV2.VariantID,
cost: ModelV2.Info["cost"] = [],
capabilities = ModelV2.Capabilities.defaults(),
): Resolved => ({
model,
ref: ModelV2.Ref.make({
id: ModelV2.ID.make(model.id),
providerID: ProviderV2.ID.make(model.provider),
...(variant === undefined ? {} : { variant }),
}),
capabilities,
cost,
})
@@ -344,6 +352,7 @@ const layer = Layer.effect(
providerID: selected.providerID,
...(session.model?.variant === undefined ? {} : { variant: session.model.variant }),
}),
capabilities: selected.capabilities,
cost: selected.cost,
}
}),
@@ -11,8 +11,6 @@ import type { ModelV2 } from "../../model"
import { SessionMessage } from "../message"
import type { FileAttachment } from "@opencode-ai/schema/prompt"
const imageMimes = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"])
const media = (file: FileAttachment): ContentPart => ({
type: "media",
mediaType: file.mime,
@@ -21,6 +19,23 @@ const media = (file: FileAttachment): ContentPart => ({
metadata: file.description === undefined ? undefined : { description: file.description },
})
const modality = (mime: string) => {
if (mime.startsWith("image/")) return "image"
if (mime.startsWith("audio/")) return "audio"
if (mime.startsWith("video/")) return "video"
if (mime === "application/pdf") return "pdf"
return undefined
}
const attachment = (file: FileAttachment, capabilities?: ModelV2.Capabilities): ContentPart => {
const type = modality(file.mime)
if (!type || (capabilities?.input ?? ["text", "image"]).includes(type)) return media(file)
return {
type: "text",
text: `ERROR: Cannot read ${file.name ? `"${file.name}"` : type} (this model does not support ${type} input). Inform the user.`,
}
}
const textAttachment = (file: FileAttachment) =>
Message.make({
role: "user",
@@ -168,7 +183,12 @@ const assistant = (message: SessionMessage.Assistant, model: ModelV2.Ref, provid
]
}
function toLLMMessage(message: SessionMessage.Info, model: ModelV2.Ref, providerMetadataKey: string): Message[] {
function toLLMMessage(
message: SessionMessage.Info,
model: ModelV2.Ref,
providerMetadataKey: string,
capabilities?: ModelV2.Capabilities,
): Message[] {
switch (message.type) {
case "agent-switched":
case "model-switched":
@@ -183,7 +203,9 @@ function toLLMMessage(message: SessionMessage.Info, model: ModelV2.Ref, provider
role: "user",
content: [
{ type: "text", text: message.text },
...files.filter((file) => imageMimes.has(file.mime)).map(media),
...files
.filter((file) => file.mime !== "text/plain" && file.mime !== "application/x-directory")
.map((file) => attachment(file, capabilities)),
],
metadata: {
...message.metadata,
@@ -236,4 +258,5 @@ export const toLLMMessages = (
messages: readonly SessionMessage.Info[],
model: ModelV2.Ref,
providerMetadataKey: string = model.providerID,
) => messages.flatMap((message) => toLLMMessage(message, model, providerMetadataKey))
capabilities?: ModelV2.Capabilities,
) => messages.flatMap((message) => toLLMMessage(message, model, providerMetadataKey, capabilities))
+10 -2
View File
@@ -7,6 +7,7 @@ import { ConfigMCPV1 } from "./mcp"
import { ConfigPermissionV1 } from "./permission"
import { ConfigProviderV1 } from "./provider"
import { ConfigProviderOptionsV1 } from "./provider-options"
import { ModelV2 } from "../../model"
import { ProviderV2 } from "../../provider"
const keys = new Set([
@@ -245,8 +246,15 @@ function migrateModel(info: typeof ConfigProviderV1.Model.Type) {
: []),
]
const capabilities =
info.tool_call !== undefined || info.modalities?.input !== undefined || info.modalities?.output !== undefined
? { tools: info.tool_call ?? false, input: info.modalities?.input ?? [], output: info.modalities?.output ?? [] }
info.tool_call !== undefined ||
info.attachment !== undefined ||
info.modalities?.input !== undefined ||
info.modalities?.output !== undefined
? ModelV2.Capabilities.defaults({
tools: info.tool_call,
input: info.modalities?.input ?? (info.attachment === false ? ["text"] : undefined),
output: info.modalities?.output,
})
: undefined
return {
modelID: info.id,
+15
View File
@@ -680,9 +680,17 @@ describe("Config", () => {
options: { apiKey: "secret" },
models: {
model: {
attachment: true,
options: { reasoningEffort: "high" },
variants: { fast: { temperature: 0.2 } },
},
text: {
attachment: false,
},
audio: {
attachment: true,
modalities: { input: ["audio"], output: ["audio"] },
},
},
},
openai: {
@@ -758,9 +766,16 @@ describe("Config", () => {
settings: { apiKey: "secret" },
models: {
model: {
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
settings: { reasoningEffort: "high" },
variants: [{ id: "fast", settings: { temperature: 0.2 } }],
},
text: {
capabilities: { tools: true, input: ["text"], output: ["text"] },
},
audio: {
capabilities: { tools: true, input: ["audio"], output: ["audio"] },
},
},
})
expect(documents[0]?.info.providers?.openai).toMatchObject({
@@ -237,6 +237,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
const provider = required(yield* catalog.provider.get(providerID))
const model = required(yield* catalog.model.get(providerID, modelID))
const defaultModel = required(yield* catalog.model.get(providerID, ModelV2.ID.make("default")))
expect((yield* catalog.model.default())?.id).toBe(ModelV2.ID.make("default"))
expect(provider.name).toBe("Renamed")
expect((yield* integrations.get(Integration.ID.make("custom")))?.methods).toContainEqual({
@@ -252,6 +253,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
expect(model.modelID).toBe(ModelV2.ID.make("api-chat"))
expect(model.name).toBe("Last")
expect(model.capabilities).toEqual({ tools: true, input: ["text"], output: ["text"] })
expect(defaultModel.capabilities).toEqual({ tools: true, input: ["text", "image"], output: ["text"] })
expect(model.enabled).toBe(false)
expect(model.limit).toEqual({ context: 100, output: 75 })
expect(model.cost).toEqual([
+15
View File
@@ -165,6 +165,21 @@ describe("ModelsDev Service", () => {
}),
)
it.effect("allows models.dev entries without legacy attachment metadata", () =>
Effect.sync(() => {
const result = Schema.decodeUnknownSync(ModelsDev.Model)({
id: "no-attachment-model",
name: "No Attachment Model",
release_date: "2026-01-01",
reasoning: false,
tool_call: true,
limit: { context: 128000, output: 8192 },
})
expect(result.attachment).toBeUndefined()
}),
)
it.live("get() returns providers from disk when cache file exists", () =>
Effect.gen(function* () {
yield* writeCache(fixture)
@@ -85,6 +85,24 @@ describe("ModelsDevPlugin", () => {
},
},
},
default: {
id: "default",
name: "Default",
release_date: "2026-01-01",
reasoning: false,
tool_call: false,
limit: { context: 128_000, output: 8_192 },
},
explicit: {
id: "explicit",
name: "Explicit",
release_date: "2026-01-01",
attachment: true,
reasoning: false,
tool_call: false,
modalities: { input: ["audio"], output: ["audio"] },
limit: { context: 128_000, output: 8_192 },
},
},
},
} satisfies Record<string, ModelsDev.Provider>),
@@ -101,9 +119,14 @@ describe("ModelsDevPlugin", () => {
const providerID = ProviderV2.ID.make("acme")
const base = yield* catalog.model.get(providerID, ModelV2.ID.make("gpt-5.4"))
const fast = yield* catalog.model.get(providerID, ModelV2.ID.make("gpt-5.4-fast"))
const defaults = yield* catalog.model.get(providerID, ModelV2.ID.make("default"))
const explicit = yield* catalog.model.get(providerID, ModelV2.ID.make("explicit"))
expect(base?.variants).toEqual([])
expect(base?.body).toEqual({})
expect(base?.capabilities).toEqual({ tools: true, input: ["text"], output: ["text"] })
expect(defaults?.capabilities).toEqual({ tools: false, input: ["text", "image"], output: ["text"] })
expect(explicit?.capabilities).toEqual({ tools: false, input: ["audio"], output: ["audio"] })
expect(fast).toMatchObject({
id: "gpt-5.4-fast",
modelID: "gpt-5.4",
@@ -181,6 +204,9 @@ describe("ModelsDevPlugin", () => {
connections: [],
}),
])
expect(yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("gpt-5.5"))).toMatchObject({
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
})
}).pipe(Effect.provide(AppNodeBuilder.build(ModelsDev.node))),
(previous) =>
Effect.sync(() => {
@@ -240,7 +240,7 @@ Recent work
expect(messages[1]?.content).toEqual([{ type: "text", text: "Review this directory" }])
})
test("uses materialized image data as provider media and drops unsupported attachments", () => {
test("defaults missing model capabilities to text and image input", () => {
const data = Base64.make("AAECAw==")
const messages = toLLMMessages(
[
@@ -266,6 +266,113 @@ Recent work
expect(messages[0]?.content).toEqual([
{ type: "text", text: "Inspect this image" },
{ type: "media", mediaType: "image/png", data, filename: "image.png" },
{
type: "text",
text: 'ERROR: Cannot read "document.pdf" (this model does not support pdf input). Inform the user.',
},
])
})
test("uses explicit model input capabilities instead of attachment defaults", () => {
const data = Base64.make("AAECAw==")
const messages = toLLMMessages(
[
SessionMessage.User.make({
id: id("user-unsupported-pdf"),
type: "user",
text: "Inspect these files",
files: [
FileAttachment.make({ data, mime: "image/png", source: { type: "inline" }, name: "image.png" }),
FileAttachment.make({
data: Base64.make("JVBERg=="),
mime: "application/pdf",
source: { type: "inline" },
name: "document.pdf",
}),
],
time: { created },
}),
],
model,
model.providerID,
{ tools: true, input: ["text", "pdf"], output: ["text"] },
)
expect(messages[0]?.content).toEqual([
{ type: "text", text: "Inspect these files" },
{
type: "text",
text: 'ERROR: Cannot read "image.png" (this model does not support image input). Inform the user.',
},
{ type: "media", mediaType: "application/pdf", data: "JVBERg==", filename: "document.pdf" },
])
})
test("treats explicit empty input capabilities as authoritative", () => {
const messages = toLLMMessages(
[
SessionMessage.User.make({
id: id("user-empty-capabilities"),
type: "user",
text: "Inspect this image",
files: [
FileAttachment.make({
data: Base64.make("AAECAw=="),
mime: "image/png",
source: { type: "inline" },
name: "image.png",
}),
],
time: { created },
}),
],
model,
model.providerID,
{ tools: false, input: [], output: [] },
)
expect(messages[0]?.content).toEqual([
{ type: "text", text: "Inspect this image" },
{
type: "text",
text: 'ERROR: Cannot read "image.png" (this model does not support image input). Inform the user.',
},
])
})
test("classifies audio and video MIME families through explicit capabilities", () => {
const messages = toLLMMessages(
[
SessionMessage.User.make({
id: id("user-media-capabilities"),
type: "user",
text: "Inspect this media",
files: [
FileAttachment.make({
data: Base64.make("AAECAw=="),
mime: "audio/mpeg",
source: { type: "inline" },
name: "audio.mp3",
}),
FileAttachment.make({
data: Base64.make("AAECAw=="),
mime: "video/webm",
source: { type: "inline" },
name: "video.webm",
}),
],
time: { created },
}),
],
model,
model.providerID,
{ tools: false, input: ["text", "audio", "video"], output: ["text"] },
)
expect(messages[0]?.content).toEqual([
{ type: "text", text: "Inspect this media" },
{ type: "media", mediaType: "audio/mpeg", data: "AAECAw==", filename: "audio.mp3" },
{ type: "media", mediaType: "video/webm", data: "AAECAw==", filename: "video.webm" },
])
})
+20
View File
@@ -235,12 +235,15 @@ const echo = Layer.effectDiscard(
const echoNode = makeLocationNode({ name: "test/session-runner-tools", layer: echo, deps: [ToolRegistry.node] })
let modelResolveHook = Effect.void
let currentModel = model
let modelCapabilities = ModelV2.Capabilities.defaults()
const models = SessionRunnerModel.layerWith((session) =>
modelResolveHook.pipe(
Effect.as(
SessionRunnerModel.resolved(
session.model?.id === "replacement" ? replacementModel : currentModel,
session.model?.variant,
[],
modelCapabilities,
),
),
),
@@ -412,6 +415,7 @@ const setup = Effect.gen(function* () {
systemLoadHook = Effect.void
modelResolveHook = Effect.void
currentModel = model
modelCapabilities = ModelV2.Capabilities.defaults()
skillBaselines.clear()
responses = undefined
streamFailure = undefined
@@ -787,6 +791,22 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("does not advertise tools to a model without tool capability", () =>
Effect.gen(function* () {
yield* setup
modelCapabilities = ModelV2.Capabilities.defaults({ tools: false })
const session = yield* SessionV2.Service
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "No tools" }), resume: false })
requests.length = 0
response = []
yield* session.resume(sessionID)
expect(requests).toHaveLength(1)
expect(requests[0]?.tools).toEqual([])
}),
)
it.effect("retries the first provider turn after system context becomes available", () =>
Effect.gen(function* () {
const session = yield* setup
-17
View File
@@ -113,23 +113,6 @@ Keep provider facades small and explicit:
`Provider.make(...)` remains available for simple static provider definitions, but new built-in providers should prefer plain configured facades unless a helper removes real duplication without adding runtime behavior.
### Provider Package Entrypoints
Catalog-selected native providers use package-like export paths from `@opencode-ai/llm`. They are internal entrypoints in one npm package, not separately published provider packages. Every entrypoint implements `ProviderPackage.Definition` and exposes `model(modelID, settings)`, where settings are serializable provider configuration plus common `headers`, `body`, and `limits` overlays.
```ts
import { model } from "@opencode-ai/llm/providers/openai/responses"
const selected = model("gpt-5", {
apiKey,
transport: "websocket",
})
```
Keep semantic APIs as separate entrypoints, such as OpenAI `chat` and `responses`. Keep transport choices inside the semantic entrypoint settings, so OpenAI Responses HTTP and WebSocket share one entrypoint. Provider facades may still expose named selectors such as `responsesWebSocket` for direct typed call sites; the package-like contract maps its settings to those selectors before returning an executable `Model`.
Do not expose `Route` in provider package settings. Route composition stays an implementation detail behind `model(...)`.
### Folder layout
```
-26
View File
@@ -106,32 +106,6 @@ const gateway = CloudflareAIGateway.configure({
Included providers: OpenAI, Anthropic, Google (Gemini), Amazon Bedrock, Azure OpenAI, Cloudflare AI Gateway, Cloudflare Workers AI, GitHub Copilot, OpenRouter, xAI, plus generic OpenAI-compatible helpers for DeepSeek, Cerebras, Groq, Fireworks, Together, etc.
### Package-like entrypoints
Native catalog integrations load provider behavior through package-like entrypoints. These are export paths from the same `@opencode-ai/llm` npm package, not independently published packages. Each entrypoint exports the same `model(modelID, settings)` contract, and `settings` contains serializable provider configuration plus common `headers`, `body`, and `limits` overlays.
```ts
import { model } from "@opencode-ai/llm/providers/openai/responses"
const selected = model("gpt-5", {
apiKey: process.env.OPENAI_API_KEY,
transport: "websocket",
headers: { "x-application": "opencode" },
limits: { context: 200_000, output: 64_000 },
})
```
OpenAI Chat and OpenAI Responses are separate semantic entrypoints:
- `@opencode-ai/llm/providers/openai/chat`
- `@opencode-ai/llm/providers/openai/responses`
Responses HTTP versus WebSocket is a scoped `transport` setting on the Responses entrypoint, not another entrypoint. Azure follows the same Chat/Responses split at `providers/azure/chat` and `providers/azure/responses`. Anthropic, OpenAI-compatible Chat, Google Gemini, and Amazon Bedrock expose their single native API through their existing provider paths.
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.
Other provider exports listed above remain direct facades until they explicitly implement the package-like contract. Exporting a provider facade does not implicitly make it a catalog-loadable provider package.
## Provider options & HTTP overlays
Three escape hatches in order of stability:
+16 -17
View File
@@ -1,6 +1,6 @@
# LLM Provider Parity Status
Last reviewed: 2026-07-08
Last reviewed: 2026-07-02
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.
@@ -64,27 +64,26 @@ Everything else currently fails with `SessionRunnerModel.UnsupportedApiError` wh
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 OpenAI-compatible Responses, Vertex Gemini, Vertex Anthropic Messages, and Bedrock Mantle.
8. Package/namespace boundaries need to be made explicit in docs and exports. Protocol namespaces exist, but planned public groupings should call out OpenAI Chat, OpenAI Responses, OpenAI-compatible Chat, OpenAI-compatible Responses, Anthropic Messages, Gemini, Vertex Gemini, Vertex Anthropic Messages, Bedrock Converse, and Bedrock Mantle as separate API slices.
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
## Proposed 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 | Missing | 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. |
| Namespace | Purpose |
| --- | --- |
| `OpenAI.Chat` or `OpenAIChat` | OpenAI `/chat/completions` semantics. |
| `OpenAI.Responses` or `OpenAIResponses` | OpenAI `/responses` HTTP and WebSocket semantics. |
| `OpenAICompatible.Chat` or `OpenAICompatibleChat` | Generic OpenAI-compatible `/chat/completions`. |
| `OpenAICompatible.Responses` or `OpenAICompatibleResponses` | Generic OpenAI-compatible `/responses`. Missing today. |
| `Anthropic.Messages` or `AnthropicMessages` | Anthropic Messages API. |
| `Google.Gemini` or `Gemini` | Gemini Developer API. |
| `GoogleVertex.Gemini` | Vertex Gemini API. Missing today. |
| `GoogleVertex.AnthropicMessages` | Vertex-hosted Anthropic Messages API. Missing today. |
| `Bedrock.Converse` or `BedrockConverse` | AWS Bedrock Converse API. |
| `Bedrock.Mantle` | AWS Bedrock Mantle OpenAI-compatible APIs. Missing today. |
| `Azure.OpenAIChat` / `Azure.OpenAIResponses` | Azure deployment specializations over OpenAI protocols. |
## Suggested Next Work Slices
+8 -19
View File
@@ -342,24 +342,14 @@ const response =
)
```
For direct provider-facade calls, HTTP versus WebSocket is represented as named
route selectors, not as model or request overrides. Same protocol, different
transport, different route:
HTTP versus WebSocket is represented as named route selectors, not as model or
request overrides. Same protocol, different transport, different route:
```ts
OpenAI.responses("gpt-4o")
OpenAI.responsesWebSocket("gpt-4o")
```
The package-like OpenAI Responses entrypoint instead keeps transport scoped to
Responses settings while preserving the same `model(...)` contract:
```ts
import { model } from "@opencode-ai/llm/providers/openai/responses"
model("gpt-4o", { apiKey, transport: "websocket" })
```
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.
@@ -478,10 +468,10 @@ const model =
```
That boundary can branch on durable config/catalog metadata and call typed
provider APIs directly. A direct provider-facade boundary maps metadata like
`endpoint.websocket` to `OpenAI.responsesWebSocket(apiModelID)`. A package-loading
boundary passes `transport: "websocket"` to the OpenAI Responses entrypoint.
The client runtime only executes the route carried by the resulting model.
provider APIs directly. Transport selection belongs there too: map metadata like
`endpoint.websocket` to `OpenAI.responsesWebSocket(apiModelID)`; otherwise use
the normal `OpenAI.responses(apiModelID)` route. The client runtime only executes
the route carried by the model.
## Competitive Shape
@@ -517,9 +507,8 @@ App boundary = explicit durable-config -> typed-provider call
id.
- No `model(id, overrides)` escape hatch. Model selection takes the model id;
endpoint/auth/deployment customization happens by configuring the route first.
- No transport override on an executable model or request. Direct provider
facades use `responses` versus `responsesWebSocket`; the package-like Responses
entrypoint maps its scoped `transport` setting before constructing the model.
- No transport override on model/request. HTTP SSE versus WebSocket is a named
route selector such as `responses` versus `responsesWebSocket`.
- No separate public `LLMClient.layerWithWebSocket`. The runtime should expose one
client layer with the available transport capabilities.
- No executable `ModelRef`. The executable handle is `Model`; durable model
-2
View File
@@ -19,8 +19,6 @@
"./providers/amazon-bedrock": "./src/providers/amazon-bedrock.ts",
"./providers/anthropic": "./src/providers/anthropic.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",
-30
View File
@@ -1,7 +1,6 @@
import { Auth } from "../route/auth"
import { type AtLeastOne, type ProviderAuthOption } from "../route/auth-options"
import type { Route as RouteDef, RouteDefaultsInput } from "../route/client"
import type { ProviderPackage } from "../provider-package"
import { ProviderID, type ModelID } from "../schema"
import * as OpenAIChat from "../protocols/openai-chat"
import * as OpenAIResponses from "../protocols/openai-responses"
@@ -24,14 +23,6 @@ export type ModelOptions = AzureURL &
}
export type Config = ModelOptions
export type Settings = ProviderPackage.Settings &
AzureURL & {
readonly apiKey?: string
readonly apiVersion?: string
readonly queryParams?: Readonly<Record<string, string>>
readonly providerOptions?: OpenAIProviderOptionsInput
}
const resourceBaseURL = (resourceName: string) => `https://${resourceName.trim()}.openai.azure.com/openai/v1`
const responsesRoute = OpenAIResponses.route.with({
@@ -117,24 +108,3 @@ export const provider = {
id,
configure,
}
const config = (settings: Settings): Config => {
const common = {
apiKey: settings.apiKey,
apiVersion: settings.apiVersion,
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
limits: settings.limits,
providerOptions: settings.providerOptions,
queryParams: settings.queryParams === undefined ? undefined : { ...settings.queryParams },
}
if (settings.baseURL !== undefined) return { ...common, baseURL: settings.baseURL }
if (settings.resourceName !== undefined) return { ...common, resourceName: settings.resourceName }
throw new Error("Azure requires resourceName or baseURL")
}
export const responsesModel: ProviderPackage.Definition<Settings>["model"] = (modelID, settings) =>
configure(config(settings)).responses(modelID)
export const chatModel: ProviderPackage.Definition<Settings>["model"] = (modelID, settings) =>
configure(config(settings)).chat(modelID)
export const model = responsesModel
-2
View File
@@ -1,2 +0,0 @@
export { chatModel as model } from "../azure"
export type { Settings } from "../azure"
@@ -1,2 +0,0 @@
export { responsesModel as model } from "../azure"
export type { Settings } from "../azure"
+2 -17
View File
@@ -1,8 +1,7 @@
import type { RouteDefaultsInput } from "../route/client"
import { Auth } from "../route/auth"
import type { ProviderAuthOption } from "../route/auth-options"
import type { ProviderPackage } from "../provider-package"
import { ProviderID, type ModelID, type ProviderOptions } from "../schema"
import { ProviderID, type ModelID } from "../schema"
import * as Gemini from "../protocols/gemini"
export const id = ProviderID.make("google")
@@ -11,12 +10,6 @@ export const routes = [Gemini.route]
export type Config = RouteDefaultsInput & ProviderAuthOption<"optional"> & { readonly baseURL?: string }
export interface Settings extends ProviderPackage.Settings {
readonly apiKey?: string
readonly baseURL?: string
readonly providerOptions?: ProviderOptions
}
const auth = (options: ProviderAuthOption<"optional">) => {
if ("auth" in options && options.auth) return options.auth
return Auth.optional("apiKey" in options ? options.apiKey : undefined, "apiKey")
@@ -39,12 +32,4 @@ export const configure = (input: Config = {}) => {
}
export const provider = configure()
export const model: ProviderPackage.Definition<Settings>["model"] = (modelID, settings) =>
configure({
apiKey: settings.apiKey,
baseURL: settings.baseURL,
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
limits: settings.limits,
providerOptions: settings.providerOptions,
}).model(modelID)
export const model = provider.model
@@ -10,15 +10,10 @@ describe("provider package entrypoints", () => {
import("@opencode-ai/llm/providers/anthropic"),
import("@opencode-ai/llm/providers/openai-compatible"),
import("@opencode-ai/llm/providers/amazon-bedrock"),
import("@opencode-ai/llm/providers/azure"),
import("@opencode-ai/llm/providers/azure/responses"),
import("@opencode-ai/llm/providers/azure/chat"),
import("@opencode-ai/llm/providers/google"),
])
for (const module of modules) expect(module.model).toBeFunction()
expect(modules[0].model).toBe(modules[1].model)
expect(modules[6].model).toBe(modules[7].model)
})
test("maps package settings onto the executable model", () => {
@@ -54,49 +49,4 @@ describe("provider package entrypoints", () => {
"OpenAI-Project": "proj_123",
})
})
test("selects Azure API entrypoints with the same model contract", async () => {
const Azure = await import("@opencode-ai/llm/providers/azure")
const AzureChat = await import("@opencode-ai/llm/providers/azure/chat")
const AzureResponses = await import("@opencode-ai/llm/providers/azure/responses")
const settings = {
apiKey: "fixture",
resourceName: "opencode-test",
headers: { "x-application": "opencode" },
body: { service_tier: "priority" },
limits: { context: 200_000, output: 64_000 },
}
const responses = AzureResponses.model("deployment", settings)
const chat = AzureChat.model("deployment", settings)
expect(Azure.model("deployment", settings).route.id).toBe("azure-openai-responses")
expect(responses.route.id).toBe("azure-openai-responses")
expect(responses.route.endpoint.baseURL).toBe("https://opencode-test.openai.azure.com/openai/v1")
expect(responses.route.defaults.headers).toEqual({ "x-application": "opencode" })
expect(responses.route.defaults.http?.body).toEqual({ service_tier: "priority" })
expect(responses.route.defaults.limits).toEqual({ context: 200_000, output: 64_000 })
expect(chat.route.id).toBe("azure-openai-chat")
})
test("maps Google package settings onto the Gemini model", async () => {
const Google = await import("@opencode-ai/llm/providers/google")
const selected = Google.model("gemini-2.5-flash", {
apiKey: "fixture",
baseURL: "https://generativelanguage.test/v1beta",
headers: { "x-application": "opencode" },
body: { safetySettings: [] },
limits: { context: 1_000_000, output: 65_536 },
providerOptions: { gemini: { thinkingConfig: { thinkingBudget: 1_024 } } },
})
expect(selected.route.id).toBe("gemini")
expect(selected.route.endpoint.baseURL).toBe("https://generativelanguage.test/v1beta")
expect(selected.route.defaults.headers).toEqual({ "x-application": "opencode" })
expect(selected.route.defaults.http?.body).toEqual({ safetySettings: [] })
expect(selected.route.defaults.limits).toEqual({ context: 1_000_000, output: 65_536 })
expect(selected.route.defaults.providerOptions).toEqual({
gemini: { thinkingConfig: { thinkingBudget: 1_024 } },
})
})
})
+19 -2
View File
@@ -26,7 +26,24 @@ export const Capabilities = Schema.Struct({
tools: Schema.Boolean,
input: Schema.Array(Schema.String),
output: Schema.Array(Schema.String),
}).annotate({ identifier: "Model.Capabilities" })
})
.annotate({ identifier: "Model.Capabilities" })
.pipe(
statics((schema) => ({
defaults: (
input: {
readonly tools?: boolean
readonly input?: ReadonlyArray<string>
readonly output?: ReadonlyArray<string>
} = {},
) =>
schema.make({
tools: input.tools ?? true,
input: input.input === undefined ? ["text", "image"] : [...input.input],
output: input.output === undefined ? ["text"] : [...input.output],
}),
})),
)
export interface Cost extends Schema.Schema.Type<typeof Cost> {}
export const Cost = Schema.Struct({
@@ -80,7 +97,7 @@ export const Info = Schema.Struct({
modelID: id,
providerID,
name: id,
capabilities: { tools: false, input: [], output: [] },
capabilities: Capabilities.defaults(),
variants: [],
time: { released: 0 },
cost: [],
@@ -18,13 +18,7 @@ import { Effect, Queue } from "effect"
export type Item =
| { readonly type: "textDelta"; readonly text: string }
| { readonly type: "reasoningDelta"; readonly text: string }
| {
readonly type: "toolCall"
readonly index: number
readonly id: string
readonly name: string
readonly input: unknown
}
| { readonly type: "toolCall"; readonly id: string; readonly name: string; readonly input: unknown }
| { readonly type: "raw"; readonly chunk: unknown }
export type FinishReason = "stop" | "tool-calls" | "length" | "content-filter"
+1 -5
View File
@@ -30,11 +30,7 @@ function chunkOf(item: SimulationLLMExchange.Item): OpenAIChatEvent | unknown {
{
delta: {
tool_calls: [
{
index: item.index,
id: item.id,
function: { name: item.name, arguments: JSON.stringify(item.input) },
},
{ index: 0, id: item.id, function: { name: item.name, arguments: JSON.stringify(item.input) } },
],
},
},
+1 -7
View File
@@ -141,13 +141,7 @@ export namespace Backend {
export const Item = Schema.Union([
Schema.Struct({ type: Schema.Literal("textDelta"), text: Schema.String }),
Schema.Struct({ type: Schema.Literal("reasoningDelta"), text: Schema.String }),
Schema.Struct({
type: Schema.Literal("toolCall"),
index: Schema.Number,
id: Schema.String,
name: Schema.String,
input: Schema.Json,
}),
Schema.Struct({ type: Schema.Literal("toolCall"), id: Schema.String, name: Schema.String, input: Schema.Json }),
Schema.Struct({ type: Schema.Literal("raw"), chunk: Schema.Json }),
])
export type Item = Schema.Schema.Type<typeof Item>
-8
View File
@@ -1770,14 +1770,6 @@ test("settles pending tools when a live failure arrives", async () => {
name: "bash",
},
})
await wait(() => {
const assistant = sync.session.message.get("session-1", "msg_explicit_assistant_9")
return (
assistant?.type === "assistant" &&
assistant.content[0]?.type === "tool" &&
assistant.content[0].state.status === "streaming"
)
})
emitEvent(events, {
id: "evt_called_1",
created: 0,