feat(ai): Responses routes for OpenRouter, DigitalOcean and Venice (#59991)

This commit is contained in:
Aiden Cline 2026-10-28 14:52:22 -05:00 • committed by GitHub
parent da702a85a6
commit dd35fbc2f4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 623 additions and 47 deletions

View file

@ -206,7 +206,7 @@ const everything = AIClient.layerWith(logged) // or AI.make({ layer: logged })
## Venice AI
`Venice` provides native Chat Completions with streaming tools and reasoning. `model` and `chat`
select the same API; credentials default to `VENICE_API_KEY`.
select the same API, and `responses` selects its Responses API; credentials default to `VENICE_API_KEY`.
```ts
import { LLM } from "@opencode/ai/effect"
@ -235,8 +235,8 @@ signatures, including per-tool signatures. Venice's encrypted scalar trailers ar
reasoning but retained in provider metadata for replay. Cache affinity uses `promptCacheKey`, and cache-write
usage reads Venice's `cache_creation_input_tokens` field.
The native package entrypoint is `@opencode/ai/providers/venice`. Image generation, embeddings, Responses, and
client-side E2EE are not implemented by this provider.
The native package entrypoints are `@opencode/ai/providers/venice` and `@opencode/ai/providers/venice/responses`.
Image generation, embeddings, and client-side E2EE are not implemented by this provider.
## Decisions

View file

@ -57,6 +57,7 @@
"./providers/digitalocean": "./src/providers/digitalocean.ts",
"./providers/digitalocean/decisions": "./src/providers/digitalocean/decisions.ts",
"./providers/digitalocean/images": "./src/providers/digitalocean/images.ts",
"./providers/digitalocean/responses": "./src/providers/digitalocean/responses.ts",
"./providers/elevenlabs": "./src/providers/elevenlabs.ts",
"./providers/fal": "./src/providers/fal.ts",
"./providers/fireworks": "./src/providers/fireworks.ts",
@ -100,6 +101,7 @@
"./providers/openrouter": "./src/providers/openrouter.ts",
"./providers/openrouter/decisions": "./src/providers/openrouter/decisions.ts",
"./providers/openrouter/images": "./src/providers/openrouter/images.ts",
"./providers/openrouter/responses": "./src/providers/openrouter/responses.ts",
"./providers/replicate": "./src/providers/replicate.ts",
"./providers/runway": "./src/providers/runway.ts",
"./providers/stability": "./src/providers/stability.ts",
@ -107,6 +109,7 @@
"./providers/typesafe-ai": "./src/providers/typesafe-ai.ts",
"./providers/typesafe-ai/decisions": "./src/providers/typesafe-ai/decisions.ts",
"./providers/venice": "./src/providers/venice.ts",
"./providers/venice/responses": "./src/providers/venice/responses.ts",
"./providers/vercel-ai-gateway": "./src/providers/vercel-ai-gateway.ts",
"./providers/vercel-ai-gateway/chat": "./src/providers/vercel-ai-gateway/chat.ts",
"./providers/vercel-ai-gateway/decisions": "./src/providers/vercel-ai-gateway/decisions.ts",

View file

@ -1,6 +1,7 @@
import type { ProviderPackage } from "../provider-package.js"
import { OpenAIChat } from "../protocols/openai-chat.js"
import { OpenAIImages } from "../protocols/openai-images.js"
import { OpenResponses } from "../protocols/open-responses.js"
import { SystemOne } from "../protocols/system-one.js"
import { cacheControl } from "../protocols/utils/cache.js"
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
@ -10,6 +11,7 @@ import { MediaRoute } from "../route/media.js"
import { Protocol } from "../route/protocol.js"
import { ProviderID, type ModelID, type ProviderOptions } from "../schema/index.js"
import type { OpenAIProviderOptionsInput } from "./openai-options.js"
import type { OpenResponsesProviderOptionsInput } from "./open-responses-options.js"
export const id = ProviderID.make("digitalocean")
const baseURL = "https://inference.do-ai.run/v1"
@ -17,10 +19,11 @@ const baseURL = "https://inference.do-ai.run/v1"
export type Config = Omit<RouteDefaultsInput, "providerOptions"> &
ProviderAuthOption<"optional"> & {
readonly baseURL?: string
readonly providerOptions?: OpenAIProviderOptionsInput
readonly providerOptions?: OpenAIProviderOptionsInput | OpenResponsesProviderOptionsInput
}
export type Settings = ProviderPackage.Settings & OpenAIProviderOptionsInput & { readonly apiKey?: string }
export type Settings<Options = OpenAIProviderOptionsInput> = ProviderPackage.Settings &
Options & { readonly apiKey?: string }
export const protocol = Protocol.make({
id: "digitalocean-chat",
@ -40,16 +43,24 @@ export const route = Route.make({
framing: OpenAIChat.framing,
})
export const routes = [route]
const responsesRoute = Route.make({
id: "digitalocean-responses",
provider: id,
providerMetadataKey: "digitalocean",
protocol: OpenResponses.protocol,
endpoint: Endpoint.path("/responses", { baseURL }),
transport: OpenResponses.httpTransport,
defaults: { providerOptions: { store: false, include: ["reasoning.encrypted_content"] } },
})
export const routes = [route, responsesRoute]
export const configure = (input: Config = {}) => {
const { apiKey: _apiKey, auth: _auth, baseURL: endpoint, ...defaults } = input
const auth = AuthOptions.bearer(input, ["DIGITALOCEAN_ACCESS_TOKEN", "DIGITALOCEAN_API_KEY", "DO_INFERENCE_API_KEY"])
const configured = route.with({
...defaults,
endpoint: { baseURL: endpoint ?? baseURL },
auth,
})
const options = { ...defaults, endpoint: { baseURL: endpoint ?? baseURL }, auth }
const configured = route.with(options)
const configuredResponses = responsesRoute.with(options)
const decisionRoute = SystemOne.route.with({
provider: id,
providerMetadataKey: "digitalocean",
@ -73,6 +84,8 @@ export const configure = (input: Config = {}) => {
supportsPromptCacheKey: true,
},
}),
responses: (modelID: string | ModelID) =>
configuredResponses.model<OpenResponsesProviderOptionsInput>({ id: modelID }),
image,
decision,
configure,
@ -82,9 +95,14 @@ export const configure = (input: Config = {}) => {
export const provider = configure()
export const decision = provider.decision
export const image = provider.image
export const responses = provider.responses
export const model: ProviderPackage.Model<Settings, OpenAIProviderOptionsInput> = (modelID, settings) =>
configure(config(settings)).model(modelID)
export const responsesModel: ProviderPackage.Model<
Settings<OpenResponsesProviderOptionsInput>,
OpenResponsesProviderOptionsInput
> = (modelID, settings) => configure(config(settings)).responses(modelID)
export const imageModel = (modelID: string, settings?: Settings) => {
const input = config(settings)
return configure(input).image(modelID, input.providerOptions)
@ -94,7 +112,7 @@ export const decisionModel = (modelID: string, settings?: Settings) => {
return configure(input).decision(modelID, input.providerOptions)
}
function config({ apiKey, baseURL, body, headers, ...providerOptions }: Settings = {}) {
function config({ apiKey, baseURL, body, headers, ...providerOptions }: Settings<Config["providerOptions"]> = {}) {
return {
apiKey,
baseURL,

View file

@ -0,0 +1,4 @@
import type { OpenResponsesProviderOptionsInput } from "../open-responses-options.js"
import type { DigitalOcean } from "../digitalocean.js"
export { responsesModel as model } from "../digitalocean.js"
export type Settings = DigitalOcean.Settings<OpenResponsesProviderOptionsInput>

View file

@ -9,8 +9,10 @@ import { SystemOne } from "../protocols/system-one.js"
import { OpenAIImages } from "../protocols/openai-images.js"
import { MediaRoute } from "../route/media.js"
import { OpenAIChat } from "../protocols/openai-chat.js"
import { OpenResponses } from "../protocols/open-responses.js"
import { cacheControl } from "../protocols/utils/cache.js"
import { isRecord, JsonObject, lenient, ProviderShared } from "../protocols/shared.js"
import type { OpenResponsesProviderOptionsInput } from "./open-responses-options.js"
export const id = ProviderID.make("openrouter")
const baseURL = "https://openrouter.ai/api/v1"
@ -83,11 +85,11 @@ export interface OpenRouterDecisionOptions {
export type Config = Omit<RouteDefaultsInput, "providerOptions"> &
ProviderAuthOption<"optional"> & {
readonly baseURL?: string
readonly providerOptions?: OpenRouterProviderOptionsInput
readonly providerOptions?: OpenRouterProviderOptionsInput | OpenResponsesProviderOptionsInput
}
export type Settings = ProviderPackage.Settings &
OpenRouterProviderOptionsInput & {
export type Settings<Options = OpenRouterProviderOptionsInput> = ProviderPackage.Settings &
Options & {
readonly apiKey?: string
readonly baseURL?: string
}
@ -183,20 +185,24 @@ export const route = Route.make({
framing: OpenAIChat.framing,
})
export const routes = [route]
const responsesRoute = Route.make({
id: "openrouter-responses",
provider: id,
providerMetadataKey: "openrouter",
protocol: OpenResponses.protocol,
endpoint: Endpoint.path("/responses", { baseURL }),
transport: OpenResponses.httpTransport,
defaults: { providerOptions: { store: false, include: ["reasoning.encrypted_content"] } },
})
const configuredRoute = (input: Config) => {
const { apiKey: _, auth: _auth, baseURL: endpoint, ...rest } = input
return route.with({
...rest,
endpoint: { baseURL: endpoint ?? baseURL },
auth: AuthOptions.bearer(input, "OPENROUTER_API_KEY"),
})
}
export const routes = [route, responsesRoute]
export const configure = (input: Config = {}) => {
const route = configuredRoute(input)
const { apiKey: _apiKey, auth: _auth, baseURL: endpoint, ...defaults } = input
const auth = AuthOptions.bearer(input, "OPENROUTER_API_KEY")
const options = { ...defaults, endpoint: { baseURL: endpoint ?? baseURL }, auth }
const chatRoute = route.with(options)
const configuredResponses = responsesRoute.with(options)
const decisionRoute = SystemOne.route.with({
provider: id,
providerMetadataKey: "openrouter",
@ -217,7 +223,9 @@ export const configure = (input: Config = {}) => {
return {
id,
model: (modelID: string | ModelID) =>
route.model<OpenRouterProviderOptionsInput>({ id: modelID, compatibility: { supportsPromptCacheKey: true } }),
chatRoute.model<OpenRouterProviderOptionsInput>({ id: modelID, compatibility: { supportsPromptCacheKey: true } }),
responses: (modelID: string | ModelID) =>
configuredResponses.model<OpenResponsesProviderOptionsInput>({ id: modelID }),
image,
decision,
configure,
@ -227,8 +235,13 @@ export const configure = (input: Config = {}) => {
export const provider = configure()
export const decision = provider.decision
export const image = provider.image
export const responses = provider.responses
export const model: ProviderPackage.Model<Settings, OpenRouterProviderOptionsInput> = (modelID, settings) =>
configure(config(settings)).model(modelID)
export const responsesModel: ProviderPackage.Model<
Settings<OpenResponsesProviderOptionsInput>,
OpenResponsesProviderOptionsInput
> = (modelID, settings) => configure(config(settings)).responses(modelID)
export const imageModel = (modelID: string, settings?: Settings) => {
const input = config(settings)
return configure(input).image(modelID, input.providerOptions)
@ -238,7 +251,7 @@ export const decisionModel = (modelID: string, settings?: Settings) => {
return configure(input).decision(modelID, input.providerOptions)
}
function config({ apiKey, baseURL, body, headers, ...providerOptions }: Settings = {}) {
function config({ apiKey, baseURL, body, headers, ...providerOptions }: Settings<Config["providerOptions"]> = {}) {
return {
apiKey,
baseURL,
@ -247,3 +260,5 @@ function config({ apiKey, baseURL, body, headers, ...providerOptions }: Settings
providerOptions,
}
}
export * as OpenRouter from "./openrouter.js"

View file

@ -0,0 +1,4 @@
import type { OpenResponsesProviderOptionsInput } from "../open-responses-options.js"
import type { OpenRouter } from "../openrouter.js"
export { responsesModel as model } from "../openrouter.js"
export type Settings = OpenRouter.Settings<OpenResponsesProviderOptionsInput>

View file

@ -1,10 +1,12 @@
import type { ProviderPackage } from "../provider-package.js"
import { VeniceChat } from "../protocols/venice-chat.js"
import { OpenAIChat } from "../protocols/openai-chat.js"
import { OpenResponses } from "../protocols/open-responses.js"
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
import { Route, type RouteDefaultsInput } from "../route/client.js"
import { Endpoint } from "../route/endpoint.js"
import { ProviderID, type ModelID } from "../schema/index.js"
import type { OpenResponsesProviderOptionsInput } from "./open-responses-options.js"
export const id = ProviderID.make("venice")
export type ChatOptionsInput = VeniceChat.OptionsInput
@ -12,51 +14,76 @@ export type Config = Omit<RouteDefaultsInput, "providerOptions"> &
ProviderAuthOption<"optional"> & {
readonly baseURL?: string
readonly queryParams?: Readonly<Record<string, string>>
readonly providerOptions?: ChatOptionsInput
readonly providerOptions?: ChatOptionsInput | OpenResponsesProviderOptionsInput
}
export type Settings = ProviderPackage.Settings &
ChatOptionsInput & {
export type Settings<Options = ChatOptionsInput> = ProviderPackage.Settings &
Options & {
readonly apiKey?: string
readonly baseURL?: string
readonly queryParams?: Readonly<Record<string, string>>
}
const baseURL = "https://api.venice.ai/api/v1"
const route = Route.make({
id: "venice-chat",
provider: id,
providerMetadataKey: "venice",
protocol: VeniceChat.protocol,
endpoint: Endpoint.path("/chat/completions", { baseURL: "https://api.venice.ai/api/v1" }),
endpoint: Endpoint.path("/chat/completions", { baseURL }),
framing: OpenAIChat.framing,
})
export const routes = [route]
const responsesRoute = Route.make({
id: "venice-responses",
provider: id,
providerMetadataKey: "venice",
protocol: OpenResponses.protocol,
endpoint: Endpoint.path("/responses", { baseURL }),
transport: OpenResponses.httpTransport,
defaults: { providerOptions: { store: false, include: ["reasoning.encrypted_content"] } },
})
export const routes = [route, responsesRoute]
export const configure = (input: Config = {}) => {
const { apiKey: _apiKey, auth: _auth, baseURL, queryParams, ...rest } = input
const { apiKey: _apiKey, auth: _auth, baseURL: endpoint, queryParams, ...rest } = input
const options = {
...rest,
endpoint: { baseURL: endpoint ?? baseURL, query: queryParams },
auth: AuthOptions.bearer(input, "VENICE_API_KEY"),
}
const chat = (modelID: string | ModelID) =>
route
.with({
...rest,
endpoint: { baseURL: baseURL ?? route.endpoint.baseURL, query: queryParams },
auth: AuthOptions.bearer(input, "VENICE_API_KEY"),
})
.model<ChatOptionsInput>({ id: modelID, compatibility: VeniceChat.compatibility })
return { id, model: chat, chat, configure }
route.with(options).model<ChatOptionsInput>({ id: modelID, compatibility: VeniceChat.compatibility })
const responses = (modelID: string | ModelID) =>
responsesRoute.with(options).model<OpenResponsesProviderOptionsInput>({ id: modelID })
return { id, model: chat, chat, responses, configure }
}
export const provider = configure()
export const chat = provider.chat
export const model: ProviderPackage.Model<Settings, ChatOptionsInput> = (
modelID,
{ apiKey, baseURL, queryParams, body, headers, ...providerOptions }: Settings = {},
) =>
configure({
export const responses = provider.responses
export const model: ProviderPackage.Model<Settings, ChatOptionsInput> = (modelID, settings) =>
configure(config(settings)).model(modelID)
export const responsesModel: ProviderPackage.Model<
Settings<OpenResponsesProviderOptionsInput>,
OpenResponsesProviderOptionsInput
> = (modelID, settings) => configure(config(settings)).responses(modelID)
function config({
apiKey,
baseURL,
queryParams,
body,
headers,
...providerOptions
}: Settings<Config["providerOptions"]> = {}) {
return {
apiKey,
baseURL,
queryParams,
headers,
http: body === undefined ? undefined : { body },
providerOptions,
}).model(modelID)
}
}
export * as Venice from "./venice.js"

View file

@ -0,0 +1,4 @@
import type { OpenResponsesProviderOptionsInput } from "../open-responses-options.js"
import type { Venice } from "../venice.js"
export { responsesModel as model } from "../venice.js"
export type Settings = Venice.Settings<OpenResponsesProviderOptionsInput>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,35 @@
{
"version": 1,
"metadata": {
"model": "openai/gpt-5.4-mini",
"tags": [
"prefix:openrouter-responses",
"provider:openrouter",
"protocol:open-responses",
"tool",
"freeform"
],
"name": "openrouter-responses/calls-a-freeform-text-tool",
"recordedAt": "2026-10-28T19:12:31.652Z"
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://openrouter.ai/api/v1/responses",
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"openai/gpt-5.4-mini\",\"input\":[{\"type\":\"message\",\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Use the run tool to execute exactly this code: print(1)\"}]}],\"stream\":true,\"max_output_tokens\":256,\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"tools\":[{\"type\":\"custom\",\"name\":\"run\",\"description\":\"Run a Python snippet.\",\"format\":{\"type\":\"text\"}}],\"tool_choice\":\"required\"}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream"
},
"body": "data: {\"type\":\"response.created\",\"response\":{\"id\":\"gen-1793214750-mKxSP1x7cfTqz0InQBR1\",\"object\":\"response\",\"created_at\":1793214750,\"model\":\"openai/gpt-5.4-mini\",\"status\":\"in_progress\",\"completed_at\":null,\"output\":[],\"error\":null,\"incomplete_details\":null,\"tools\":[{\"type\":\"custom\",\"name\":\"run\",\"description\":\"Run a Python snippet.\",\"format\":{\"type\":\"text\"}}],\"tool_choice\":\"required\",\"parallel_tool_calls\":true,\"max_output_tokens\":256,\"temperature\":1,\"top_p\":1,\"presence_penalty\":0,\"frequency_penalty\":0,\"top_logprobs\":0,\"max_tool_calls\":null,\"metadata\":{},\"background\":false,\"previous_response_id\":null,\"service_tier\":\"auto\",\"truncation\":\"disabled\",\"store\":false,\"instructions\":null,\"text\":{\"format\":{\"type\":\"text\"}},\"reasoning\":null,\"safety_identifier\":null,\"prompt_cache_key\":null,\"prompt_cache_options\":null,\"usage\":null},\"sequence_number\":0}\n\ndata: {\"type\":\"response.in_progress\",\"response\":{\"id\":\"gen-1793214750-mKxSP1x7cfTqz0InQBR1\",\"object\":\"response\",\"created_at\":1793214750,\"model\":\"openai/gpt-5.4-mini\",\"status\":\"in_progress\",\"completed_at\":null,\"output\":[],\"error\":null,\"incomplete_details\":null,\"tools\":[{\"type\":\"custom\",\"name\":\"run\",\"description\":\"Run a Python snippet.\",\"format\":{\"type\":\"text\"}}],\"tool_choice\":\"required\",\"parallel_tool_calls\":true,\"max_output_tokens\":256,\"temperature\":1,\"top_p\":1,\"presence_penalty\":0,\"frequency_penalty\":0,\"top_logprobs\":0,\"max_tool_calls\":null,\"metadata\":{},\"background\":false,\"previous_response_id\":null,\"service_tier\":\"auto\",\"truncation\":\"disabled\",\"store\":false,\"instructions\":null,\"text\":{\"format\":{\"type\":\"text\"}},\"reasoning\":null,\"safety_identifier\":null,\"prompt_cache_key\":null,\"prompt_cache_options\":null,\"usage\":null},\"sequence_number\":1}\n\ndata: {\"type\":\"response.output_item.added\",\"output_index\":0,\"item\":{\"id\":\"ctc_tmp_aasptvj0fik\",\"type\":\"custom_tool_call\",\"status\":\"in_progress\",\"call_id\":\"call_jlLLytaby5F4w22U0yMR8kvU\",\"name\":\"run\",\"input\":\"\"},\"sequence_number\":2}\n\ndata: {\"type\":\"response.custom_tool_call_input.delta\",\"output_index\":0,\"item_id\":\"ctc_tmp_aasptvj0fik\",\"delta\":\"print\",\"sequence_number\":3}\n\ndata: {\"type\":\"response.custom_tool_call_input.delta\",\"output_index\":0,\"item_id\":\"ctc_tmp_aasptvj0fik\",\"delta\":\"(\",\"sequence_number\":4}\n\ndata: {\"type\":\"response.custom_tool_call_input.delta\",\"output_index\":0,\"item_id\":\"ctc_tmp_aasptvj0fik\",\"delta\":\"1\",\"sequence_number\":5}\n\ndata: {\"type\":\"response.custom_tool_call_input.delta\",\"output_index\":0,\"item_id\":\"ctc_tmp_aasptvj0fik\",\"delta\":\")\",\"sequence_number\":6}\n\ndata: {\"type\":\"response.custom_tool_call_input.done\",\"output_index\":0,\"item_id\":\"ctc_tmp_aasptvj0fik\",\"input\":\"print(1)\",\"sequence_number\":7}\n\ndata: {\"type\":\"response.output_item.done\",\"output_index\":0,\"item\":{\"id\":\"ctc_tmp_aasptvj0fik\",\"type\":\"custom_tool_call\",\"status\":\"completed\",\"call_id\":\"call_jlLLytaby5F4w22U0yMR8kvU\",\"name\":\"run\",\"input\":\"print(1)\"},\"sequence_number\":8}\n\ndata: {\"type\":\"response.completed\",\"response\":{\"id\":\"gen-1793214750-mKxSP1x7cfTqz0InQBR1\",\"object\":\"response\",\"created_at\":1793214750,\"model\":\"openai/gpt-5.4-mini\",\"status\":\"completed\",\"completed_at\":1793214751,\"output\":[{\"id\":\"ctc_tmp_aasptvj0fik\",\"type\":\"custom_tool_call\",\"status\":\"completed\",\"call_id\":\"call_jlLLytaby5F4w22U0yMR8kvU\",\"name\":\"run\",\"input\":\"print(1)\"}],\"error\":null,\"incomplete_details\":null,\"tools\":[{\"type\":\"custom\",\"name\":\"run\",\"description\":\"Run a Python snippet.\",\"format\":{\"type\":\"text\"}}],\"tool_choice\":\"required\",\"parallel_tool_calls\":true,\"max_output_tokens\":256,\"temperature\":1,\"top_p\":1,\"presence_penalty\":0,\"frequency_penalty\":0,\"top_logprobs\":0,\"max_tool_calls\":null,\"metadata\":{},\"background\":false,\"previous_response_id\":null,\"service_tier\":\"default\",\"truncation\":\"disabled\",\"store\":false,\"instructions\":null,\"text\":{\"format\":{\"type\":\"text\"}},\"reasoning\":{\"context\":\"current_turn\",\"effort\":\"none\",\"mode\":\"standard\",\"summary\":\"detailed\"},\"safety_identifier\":null,\"prompt_cache_key\":null,\"prompt_cache_options\":null,\"usage\":{\"input_tokens\":64,\"input_tokens_details\":{\"cached_tokens\":0},\"output_tokens\":14,\"output_tokens_details\":{\"reasoning_tokens\":0},\"total_tokens\":78,\"cost\":0.000111,\"is_byok\":false,\"cost_details\":{\"upstream_inference_cost\":0.000111,\"upstream_inference_input_cost\":0.000048,\"upstream_inference_output_cost\":0.000063}}},\"sequence_number\":9}\n\ndata: [DONE]\n\n"
}
}
]
}

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,35 @@
{
"version": 1,
"metadata": {
"model": "openai/gpt-5.4-mini",
"tags": [
"prefix:openrouter-responses",
"provider:openrouter",
"protocol:open-responses",
"text",
"usage"
],
"name": "openrouter-responses/streams-text",
"recordedAt": "2026-10-28T19:11:59.562Z"
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://openrouter.ai/api/v1/responses",
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"openai/gpt-5.4-mini\",\"input\":[{\"type\":\"message\",\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: OK\"}]}],\"stream\":true,\"max_output_tokens\":64,\"store\":false,\"include\":[\"reasoning.encrypted_content\"]}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream"
},
"body": "data: {\"type\":\"response.created\",\"response\":{\"id\":\"gen-1793214718-hQ61zZmJsMM3exScOj2w\",\"object\":\"response\",\"created_at\":1793214718,\"model\":\"openai/gpt-5.4-mini\",\"status\":\"in_progress\",\"completed_at\":null,\"output\":[],\"error\":null,\"incomplete_details\":null,\"tools\":[],\"tool_choice\":\"auto\",\"parallel_tool_calls\":true,\"max_output_tokens\":64,\"temperature\":1,\"top_p\":1,\"presence_penalty\":0,\"frequency_penalty\":0,\"top_logprobs\":0,\"max_tool_calls\":null,\"metadata\":{},\"background\":false,\"previous_response_id\":null,\"service_tier\":\"auto\",\"truncation\":\"disabled\",\"store\":false,\"instructions\":null,\"text\":{\"format\":{\"type\":\"text\"}},\"reasoning\":null,\"safety_identifier\":null,\"prompt_cache_key\":null,\"prompt_cache_options\":null,\"usage\":null},\"sequence_number\":0}\n\ndata: {\"type\":\"response.in_progress\",\"response\":{\"id\":\"gen-1793214718-hQ61zZmJsMM3exScOj2w\",\"object\":\"response\",\"created_at\":1793214718,\"model\":\"openai/gpt-5.4-mini\",\"status\":\"in_progress\",\"completed_at\":null,\"output\":[],\"error\":null,\"incomplete_details\":null,\"tools\":[],\"tool_choice\":\"auto\",\"parallel_tool_calls\":true,\"max_output_tokens\":64,\"temperature\":1,\"top_p\":1,\"presence_penalty\":0,\"frequency_penalty\":0,\"top_logprobs\":0,\"max_tool_calls\":null,\"metadata\":{},\"background\":false,\"previous_response_id\":null,\"service_tier\":\"auto\",\"truncation\":\"disabled\",\"store\":false,\"instructions\":null,\"text\":{\"format\":{\"type\":\"text\"}},\"reasoning\":null,\"safety_identifier\":null,\"prompt_cache_key\":null,\"prompt_cache_options\":null,\"usage\":null},\"sequence_number\":1}\n\ndata: {\"type\":\"response.output_item.added\",\"output_index\":0,\"item\":{\"id\":\"msg_tmp_luc1vnm9au\",\"type\":\"message\",\"status\":\"in_progress\",\"role\":\"assistant\",\"content\":[],\"phase\":\"final_answer\"},\"sequence_number\":2}\n\ndata: {\"type\":\"response.content_part.added\",\"output_index\":0,\"item_id\":\"msg_tmp_luc1vnm9au\",\"content_index\":0,\"part\":{\"type\":\"output_text\",\"text\":\"\",\"annotations\":[],\"logprobs\":[]},\"sequence_number\":3}\n\ndata: {\"type\":\"response.output_text.delta\",\"output_index\":0,\"item_id\":\"msg_tmp_luc1vnm9au\",\"content_index\":0,\"delta\":\"OK\",\"logprobs\":[],\"sequence_number\":4}\n\ndata: {\"type\":\"response.output_text.done\",\"output_index\":0,\"item_id\":\"msg_tmp_luc1vnm9au\",\"content_index\":0,\"text\":\"OK\",\"logprobs\":[],\"sequence_number\":5}\n\ndata: {\"type\":\"response.content_part.done\",\"output_index\":0,\"item_id\":\"msg_tmp_luc1vnm9au\",\"content_index\":0,\"part\":{\"type\":\"output_text\",\"text\":\"OK\",\"annotations\":[],\"logprobs\":[]},\"sequence_number\":6}\n\ndata: {\"type\":\"response.output_item.done\",\"output_index\":0,\"item\":{\"id\":\"msg_tmp_luc1vnm9au\",\"type\":\"message\",\"status\":\"completed\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"OK\",\"annotations\":[],\"logprobs\":[]}],\"phase\":\"final_answer\"},\"sequence_number\":7}\n\ndata: {\"type\":\"response.completed\",\"response\":{\"id\":\"gen-1793214718-hQ61zZmJsMM3exScOj2w\",\"object\":\"response\",\"created_at\":1793214718,\"model\":\"openai/gpt-5.4-mini\",\"status\":\"completed\",\"completed_at\":1793214719,\"output\":[{\"id\":\"msg_tmp_luc1vnm9au\",\"type\":\"message\",\"role\":\"assistant\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"text\":\"OK\",\"annotations\":[],\"logprobs\":[]}],\"phase\":\"final_answer\"}],\"error\":null,\"incomplete_details\":null,\"tools\":[],\"tool_choice\":\"auto\",\"parallel_tool_calls\":true,\"max_output_tokens\":64,\"temperature\":1,\"top_p\":1,\"presence_penalty\":0,\"frequency_penalty\":0,\"top_logprobs\":0,\"max_tool_calls\":null,\"metadata\":{},\"background\":false,\"previous_response_id\":null,\"service_tier\":\"default\",\"truncation\":\"disabled\",\"store\":false,\"instructions\":null,\"text\":{\"format\":{\"type\":\"text\"}},\"reasoning\":{\"context\":\"current_turn\",\"effort\":\"none\",\"mode\":\"standard\",\"summary\":\"detailed\"},\"safety_identifier\":null,\"prompt_cache_key\":null,\"prompt_cache_options\":null,\"usage\":{\"input_tokens\":10,\"input_tokens_details\":{\"cached_tokens\":0},\"output_tokens\":5,\"output_tokens_details\":{\"reasoning_tokens\":0},\"total_tokens\":15,\"cost\":0.00003,\"is_byok\":false,\"cost_details\":{\"upstream_inference_cost\":0.00003,\"upstream_inference_input_cost\":0.0000075,\"upstream_inference_output_cost\":0.0000225}}},\"sequence_number\":8}\n\ndata: [DONE]\n\n"
}
}
]
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -58,6 +58,37 @@ describe("provider package entrypoints", () => {
})
})
test("maps OpenRouter, DigitalOcean and Venice Responses entrypoints onto provider-owned routes", async () => {
const providers = [
["openrouter", "https://openrouter.ai/api/v1", await import("@opencode/ai/providers/openrouter/responses")],
["digitalocean", "https://inference.do-ai.run/v1", await import("@opencode/ai/providers/digitalocean/responses")],
["venice", "https://api.venice.ai/api/v1", await import("@opencode/ai/providers/venice/responses")],
] as const
const settings = {
apiKey: "fixture",
headers: { "x-test": "fixture" },
body: { future_option: true },
reasoningEffort: "low",
}
for (const [provider, baseURL, module] of providers) {
const selected = module.model("gpt-5.4", settings)
expect(selected.provider).toBe(provider)
expect(selected.route.id).toBe(`${provider}-responses`)
expect(selected.route.protocol).toBe("open-responses")
expect(selected.route.endpoint).toMatchObject({ baseURL, path: "/responses" })
expect(selected.route.defaults.headers).toEqual(settings.headers)
expect(selected.route.defaults.http?.body).toEqual(settings.body)
expect(selected.route.defaults.providerOptions).toEqual({
reasoningEffort: "low",
store: false,
include: ["reasoning.encrypted_content"],
})
expect(module.model("gpt-5.4", { ...settings, baseURL: "https://gateway.test/v1" }).route.endpoint.baseURL).toBe(
"https://gateway.test/v1",
)
}
})
test("maps Cohere entrypoints onto native and compatibility routes", async () => {
const modules = await Promise.all([
import("@opencode/ai/providers/cohere"),

View file

@ -12,10 +12,12 @@ import {
import * as AmazonBedrockMantle from "../../src/providers/amazon-bedrock-mantle.js"
import * as Anthropic from "../../src/providers/anthropic.js"
import * as Azure from "../../src/providers/azure.js"
import * as DigitalOcean from "../../src/providers/digitalocean.js"
import * as OpenAI from "../../src/providers/openai.js"
import * as OpenAICompatible from "../../src/providers/openai-compatible.js"
import * as OpenAICompatibleResponses from "../../src/providers/openai-compatible-responses.js"
import * as OpenRouter from "../../src/providers/openrouter.js"
import * as Venice from "../../src/providers/venice.js"
import * as VercelAIGateway from "../../src/providers/vercel-ai-gateway.js"
import { LLMClient } from "../../src/route.js"
import { compileRequest } from "../../src/route/client.js"
@ -112,6 +114,9 @@ describe("freeform tools", () => {
AmazonBedrockMantle.responsesModel("openai.gpt-5.4", { ...settings, region: "us-east-1" }),
VercelAIGateway.responsesModel("openai/gpt-5.4", settings),
OpenAICompatibleResponses.model("gpt-5.4", { ...settings, baseURL: "https://responses.test/v1" }),
OpenRouter.responsesModel("openai/gpt-5.4", settings),
DigitalOcean.responsesModel("openai-gpt-5.4", settings),
Venice.responsesModel("openai-gpt-54", settings),
]
const chatModels = [
Azure.chatModel("gpt-5.4", { ...settings, resourceName: "fixture" }),

View file

@ -0,0 +1,90 @@
import { expect } from "bun:test"
import { Effect, Schema } from "effect"
import { LLM, LLMEvent, toDefinitions } from "../../src/effect.js"
import { DigitalOcean, OpenRouter, Venice } from "../../src/providers.js"
import { LLMClient } from "../../src/route.js"
import { Tool } from "../../src/tool.js"
import { expectWeatherToolLoop, runWeatherToolLoop, weatherToolLoopRequest } from "../recorded-scenarios.js"
import { recordedTests } from "../recorded-test.js"
const run = toDefinitions({
run: Tool.make({
description: "Run a Python snippet.",
parameters: Schema.Struct({ code: Schema.String }),
success: Schema.String,
freeform: { type: "text" },
}),
})
for (const item of [
{
provider: "openrouter",
env: "OPENROUTER_API_KEY",
model: OpenRouter.configure({ apiKey: process.env.OPENROUTER_API_KEY ?? "fixture" }).responses(
"openai/gpt-5.4-mini",
),
},
{
provider: "digitalocean",
env: "DIGITAL_OCEAN_OFFICIAL_API_KEY",
model: DigitalOcean.configure({ apiKey: process.env.DIGITAL_OCEAN_OFFICIAL_API_KEY ?? "fixture" }).responses(
"openai-gpt-5.4-mini",
),
},
{
provider: "venice",
env: "VENICE_API_KEY",
model: Venice.configure({ apiKey: process.env.VENICE_API_KEY ?? "fixture" }).responses("openai-gpt-54-mini"),
},
]) {
const recorded = recordedTests({
prefix: `${item.provider}-responses`,
provider: item.provider,
protocol: "open-responses",
requires: [item.env],
metadata: { model: item.model.id },
})
recorded.effect.with("streams text", { tags: ["text", "usage"] }, () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
LLM.request({ model: item.model, prompt: "Reply exactly: OK", generation: { maxTokens: 64 } }),
)
expect(response.text.trim()).toMatch(/^OK\.?$/)
expect(response.events.some(LLMEvent.is.textDelta)).toBe(true)
expect(response.finishReason.normalized).toBe("stop")
expect(response.usage.outputTokens).toBeGreaterThan(0)
}),
)
recorded.effect.with("continues a tool call", { tags: ["tool", "tool-loop"] }, () =>
Effect.gen(function* () {
const events = yield* runWeatherToolLoop(
weatherToolLoopRequest({
id: `${item.provider}-responses-tool-loop`,
model: item.model,
maxTokens: 256,
temperature: false,
}),
)
expectWeatherToolLoop(events)
}),
)
recorded.effect.with("calls a freeform text tool", { tags: ["tool", "freeform"] }, () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
LLM.request({
model: item.model,
prompt: "Use the run tool to execute exactly this code: print(1)",
tools: run,
toolChoice: "required",
generation: { maxTokens: 256 },
}),
)
expect(response.toolCalls).toMatchObject([{ name: "run", input: { code: expect.stringContaining("print(1)") } }])
expect(response.toolCalls[0]?.providerMetadata).toMatchObject({ [item.provider]: { custom: true } })
expect(response.finishReason.normalized).toBe("tool-calls")
}),
)
}

View file

@ -95,6 +95,7 @@ const builtins = new Map<string, () => Promise<unknown>>([
["@opencode/ai/providers/digitalocean", () => import("@opencode/ai/providers/digitalocean")],
["@opencode/ai/providers/digitalocean/decisions", () => import("@opencode/ai/providers/digitalocean/decisions")],
["@opencode/ai/providers/digitalocean/images", () => import("@opencode/ai/providers/digitalocean/images")],
["@opencode/ai/providers/digitalocean/responses", () => import("@opencode/ai/providers/digitalocean/responses")],
["@opencode/ai/providers/fireworks", () => import("@opencode/ai/providers/fireworks")],
["@opencode/ai/providers/google", () => import("@opencode/ai/providers/google")],
["@opencode/ai/providers/google/interactions", () => import("@opencode/ai/providers/google/interactions")],
@ -144,9 +145,11 @@ const builtins = new Map<string, () => Promise<unknown>>([
["@opencode/ai/providers/openrouter", () => import("@opencode/ai/providers/openrouter")],
["@opencode/ai/providers/openrouter/decisions", () => import("@opencode/ai/providers/openrouter/decisions")],
["@opencode/ai/providers/openrouter/images", () => import("@opencode/ai/providers/openrouter/images")],
["@opencode/ai/providers/openrouter/responses", () => import("@opencode/ai/providers/openrouter/responses")],
["@opencode/ai/providers/togetherai", () => import("@opencode/ai/providers/togetherai")],
["@opencode/ai/providers/typesafe-ai/decisions", () => import("@opencode/ai/providers/typesafe-ai/decisions")],
["@opencode/ai/providers/venice", () => import("@opencode/ai/providers/venice")],
["@opencode/ai/providers/venice/responses", () => import("@opencode/ai/providers/venice/responses")],
["@opencode/ai/providers/vercel-ai-gateway", () => import("@opencode/ai/providers/vercel-ai-gateway")],
["@opencode/ai/providers/vercel-ai-gateway/chat", () => import("@opencode/ai/providers/vercel-ai-gateway/chat")],
[

View file

@ -621,6 +621,9 @@ const PROTOCOLS: Readonly<Record<string, Protocol>> = {
"@opencode/ai/providers/openai-compatible-responses": openResponses,
"@opencode/ai/providers/google-vertex/responses": openResponses,
"@opencode/ai/providers/xai": openResponses,
"@opencode/ai/providers/digitalocean/responses": openResponses,
"@opencode/ai/providers/openrouter/responses": openResponses,
"@opencode/ai/providers/venice/responses": openResponses,
"@opencode/ai/providers/openai-compatible": openaiCompatible,
"@opencode/ai/providers/openai/chat": openaiChat,