mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-26 03:26:12 +00:00
Compare commits
35
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f6ea3e8ef | ||
|
|
f74d7972eb | ||
|
|
0395985b58 | ||
|
|
7a0944cdd6 | ||
|
|
fe2583cd26 | ||
|
|
b63167558c | ||
|
|
88ad378d83 | ||
|
|
f19ad6fa31 | ||
|
|
1b7332467e | ||
|
|
e6b6f30914 | ||
|
|
4b71ae6a0d | ||
|
|
e211b6f30e | ||
|
|
ab4621e437 | ||
|
|
97ba700fac | ||
|
|
03fb5c6c67 | ||
|
|
5a54eb4afc | ||
|
|
c4dcf72e13 | ||
|
|
27e0de6b23 | ||
|
|
73d7b1d4c1 | ||
|
|
690ad8e8bd | ||
|
|
6c97be6974 | ||
|
|
6cd1ffac50 | ||
|
|
f08c234890 | ||
|
|
7f2b052db6 | ||
|
|
297a3328c6 | ||
|
|
7f9e5e91ab | ||
|
|
3726e3254d | ||
|
|
24605d048f | ||
|
|
0a84625618 | ||
|
|
2e7f06a155 | ||
|
|
c2a3b813a0 | ||
|
|
b79cad5ec8 | ||
|
|
61dd296161 | ||
|
|
66a790c624 | ||
|
|
0ae3aac317 |
@@ -71,6 +71,7 @@ jobs:
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
@@ -110,9 +111,16 @@ jobs:
|
||||
|
||||
- name: Run unit tests
|
||||
timeout-minutes: 20
|
||||
run: GITHUB_ACTIONS=false bun turbo test
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
GITHUB_ACTIONS=false bun turbo test
|
||||
exit 0
|
||||
fi
|
||||
GITHUB_ACTIONS=false bun turbo test --affected
|
||||
env:
|
||||
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }}
|
||||
TURBO_SCM_BASE: ${{ github.event.pull_request.base.sha || github.event.before }}
|
||||
TURBO_SCM_HEAD: ${{ github.sha }}
|
||||
|
||||
- name: Verify published codemode package
|
||||
if: runner.os == 'Linux'
|
||||
@@ -122,8 +130,15 @@ jobs:
|
||||
- name: Verify packed workerd SDK
|
||||
if: runner.os == 'Linux'
|
||||
timeout-minutes: 15
|
||||
working-directory: packages/sdk
|
||||
run: bun run verify:package
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
bun turbo verify:package --filter=@opencode-ai/sdk
|
||||
exit 0
|
||||
fi
|
||||
bun turbo verify:package --affected --filter=@opencode-ai/sdk
|
||||
env:
|
||||
TURBO_SCM_BASE: ${{ github.event.pull_request.base.sha || github.event.before }}
|
||||
TURBO_SCM_HEAD: ${{ github.sha }}
|
||||
|
||||
- name: Verify compiled service lifecycle
|
||||
if: always()
|
||||
|
||||
@@ -346,7 +346,6 @@
|
||||
"@ai-sdk/amazon-bedrock": "4.0.112",
|
||||
"@ai-sdk/anthropic": "3.0.82",
|
||||
"@ai-sdk/azure": "3.0.88",
|
||||
"@ai-sdk/cerebras": "2.0.41",
|
||||
"@ai-sdk/cohere": "3.0.27",
|
||||
"@ai-sdk/deepinfra": "2.0.41",
|
||||
"@ai-sdk/gateway": "3.0.104",
|
||||
@@ -357,7 +356,6 @@
|
||||
"@ai-sdk/perplexity": "3.0.26",
|
||||
"@ai-sdk/provider": "3.0.8",
|
||||
"@ai-sdk/provider-utils": "4.0.23",
|
||||
"@ai-sdk/togetherai": "2.0.41",
|
||||
"@ai-sdk/vercel": "2.0.39",
|
||||
"@aws-sdk/credential-providers": "3.1057.0",
|
||||
"@ff-labs/fff-bun": "0.10.5",
|
||||
@@ -667,6 +665,7 @@
|
||||
"dependencies": {
|
||||
"@opencode-ai/client": "workspace:*",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@opencode-ai/server": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
@@ -973,6 +972,7 @@
|
||||
"dependencies": {
|
||||
"@effect/opentelemetry": "catalog:",
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@effect/platform-node-shared": "catalog:",
|
||||
"@npmcli/arborist": "catalog:",
|
||||
"@npmcli/config": "10.8.1",
|
||||
"@opentelemetry/api": "1.9.0",
|
||||
@@ -986,6 +986,7 @@
|
||||
"mime-types": "3.0.2",
|
||||
"minimatch": "10.2.5",
|
||||
"npm-package-arg": "13.0.2",
|
||||
"pacote": "21.5.1",
|
||||
"resolve.exports": "catalog:",
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -995,6 +996,7 @@
|
||||
"@types/node": "catalog:",
|
||||
"@types/npm-package-arg": "6.1.4",
|
||||
"@types/npmcli__arborist": "6.3.3",
|
||||
"@types/pacote": "11.1.8",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
},
|
||||
},
|
||||
@@ -1206,8 +1208,6 @@
|
||||
|
||||
"@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.23", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-z8GlDaCmRSDlqkMF2f4/RFgWxdarvIbyuk+m6WXT1LYgsnGiXRJGTD2Z1+SDl3LqtFuRtGX1aghYvQLoHL/9pg=="],
|
||||
|
||||
"@ai-sdk/togetherai": ["@ai-sdk/togetherai@2.0.41", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.37", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-k3p9e3k0/gpDDyTtvafsK4HYR4D/aUQW/kzCwWo1+CzdBU84i4L14gWISC/mv6tgSicMXHcEUd521fPufQwNlg=="],
|
||||
|
||||
"@ai-sdk/vercel": ["@ai-sdk/vercel@2.0.39", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.37", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-8eu3ljJpkCTP4ppcyYB+NcBrkcBoSOFthCSgk5VnjaxnDaOJFaxnPwfddM7wx3RwMk2CiK1O61Px/LlqNc7QkQ=="],
|
||||
|
||||
"@ai-sdk/xai": ["@ai-sdk/xai@3.0.123", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.69", "@ai-sdk/provider": "3.0.15", "@ai-sdk/provider-utils": "4.0.46" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-WNASvd1C516oh2qYIj9EvAVPdU+Abads8DQWU6p9lQtvFFeGh8QW+3LDOARZd1GCINUFfw5yadEK845SMQKLsA=="],
|
||||
@@ -5954,10 +5954,6 @@
|
||||
|
||||
"@ai-sdk/perplexity/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
|
||||
|
||||
"@ai-sdk/togetherai/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.37", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-+POSFVcgiu47BK64dhsI6OpcDC0/VAE2ZSaXdXGNNhpC/ava++uSRJYks0k2bpfY0wwCTgpAWZsXn/dG2Yppiw=="],
|
||||
|
||||
"@ai-sdk/togetherai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
|
||||
|
||||
"@ai-sdk/vercel/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.37", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-+POSFVcgiu47BK64dhsI6OpcDC0/VAE2ZSaXdXGNNhpC/ava++uSRJYks0k2bpfY0wwCTgpAWZsXn/dG2Yppiw=="],
|
||||
|
||||
"@ai-sdk/vercel/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-3Jx1Q7hl+Y0Log/k2vd5y6dzBpzFKWlhShPESxn1Rm4=",
|
||||
"aarch64-linux": "sha256-EiiI6g01oBIrExCMAUgT3w82P0fvu4FAJhI32C+ze0I=",
|
||||
"aarch64-darwin": "sha256-s+w49HRp1+ewtiTaU65tPWjUiO1NQw3kzfemMEEQZb0=",
|
||||
"x86_64-darwin": "sha256-/Ee5V7pnL/qm3c4ZHeWEjH7FhGVXArXryOugbG5vsz8="
|
||||
"x86_64-linux": "sha256-fOM/kGJJ1cipCHQIxioDZEB7NZykpSiqgwm7gIS6THI=",
|
||||
"aarch64-linux": "sha256-XTY2C33HjsBMWO7VIiWc2MynjJrxbTLrOJ+6pM+afI0=",
|
||||
"aarch64-darwin": "sha256-wX6+bC18djtPZ7A9ch+wryM7tDFfrAlT0xx0QTk6EJQ=",
|
||||
"x86_64-darwin": "sha256-dcRRX4bYq5AmG4GcVmYq/M+06dlf4KJHn+clT2JY48g="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,9 +157,9 @@ const PROVIDERS: ReadonlyArray<Provider> = [
|
||||
id: "togetherai",
|
||||
label: "TogetherAI",
|
||||
tier: "compatible",
|
||||
note: "Existing OpenAI-compatible text/tool recorded tests",
|
||||
vars: [{ name: "TOGETHER_AI_API_KEY" }],
|
||||
validate: (env) => validateBearer("https://api.together.xyz/v1/models", Redacted.make(env.TOGETHER_AI_API_KEY)),
|
||||
note: "Native Together AI text/tool recorded tests",
|
||||
vars: [{ name: "TOGETHER_API_KEY" }],
|
||||
validate: (env) => validateBearer("https://api.together.xyz/v1/models", Redacted.make(env.TOGETHER_API_KEY)),
|
||||
},
|
||||
{
|
||||
id: "minimax",
|
||||
@@ -200,8 +200,8 @@ const PROVIDERS: ReadonlyArray<Provider> = [
|
||||
{
|
||||
id: "cerebras",
|
||||
label: "Cerebras",
|
||||
tier: "optional",
|
||||
note: "OpenAI-compatible bridge",
|
||||
tier: "compatible",
|
||||
note: "Native Cerebras text/tool/tool-loop recorded tests",
|
||||
vars: [{ name: "CEREBRAS_API_KEY" }],
|
||||
validate: (env) => validateBearer("https://api.cerebras.ai/v1/models", Redacted.make(env.CEREBRAS_API_KEY)),
|
||||
},
|
||||
|
||||
@@ -36,7 +36,7 @@ const resolve = (policy: CachePolicy | undefined): CachePolicyObject => {
|
||||
// Protocols whose wire format ignores inline cache markers (OpenAI's implicit
|
||||
// prefix caching, Gemini's implicit + out-of-band CachedContent). Skip the
|
||||
// whole policy pass for these — emitting hints would be harmless but pointless.
|
||||
const RESPECTS_INLINE_HINTS = new Set(["anthropic-messages", "bedrock-converse", "openrouter"])
|
||||
const RESPECTS_INLINE_HINTS = new Set(["anthropic-messages", "google-vertex-messages", "bedrock-converse", "openrouter"])
|
||||
|
||||
const makeHint = (ttlSeconds: number | undefined): CacheHint =>
|
||||
ttlSeconds !== undefined ? new CacheHint({ type: "ephemeral", ttlSeconds }) : new CacheHint({ type: "ephemeral" })
|
||||
|
||||
@@ -744,9 +744,12 @@ const endsInServerToolUse = (message: LLMRequest["messages"][number]) => {
|
||||
return message.role === "assistant" && last?.type === "tool-call" && last.providerExecuted === true
|
||||
}
|
||||
|
||||
const canUseNativeSystemUpdate = (messages: LLMRequest["messages"], index: number) => {
|
||||
const previous = messages[index - 1]
|
||||
const next = messages[index + 1]
|
||||
const canUseNativeSystemUpdate = (request: LLMRequest, index: number) => {
|
||||
const previous = request.messages[index - 1]
|
||||
const next = request.messages[index + 1]
|
||||
// Vertex currently rejects/404s for a system message after local tool results,
|
||||
// so fold it into the user tool-result turn across continuations and history.
|
||||
if (request.model.route.id === "google-vertex-messages" && previous?.role === "tool") return false
|
||||
return (
|
||||
previous !== undefined &&
|
||||
previous.role !== "system" &&
|
||||
@@ -793,7 +796,7 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
|
||||
if (message.role === "system") {
|
||||
if (splitsLocalToolResults(request.messages, index))
|
||||
return yield* invalid("Anthropic Messages system updates cannot split a local tool call from its tool result")
|
||||
if (supportsNativeSystemUpdates(request) && canUseNativeSystemUpdate(request.messages, index)) {
|
||||
if (supportsNativeSystemUpdates(request) && canUseNativeSystemUpdate(request, index)) {
|
||||
messages.push(yield* lowerNativeSystemUpdate(message, breakpoints))
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -79,10 +79,60 @@ const OpenResponsesReasoningItem = Schema.Struct({
|
||||
encrypted_content: optionalNull(Schema.String),
|
||||
})
|
||||
|
||||
const OpenResponsesItemReference = Schema.Struct({
|
||||
type: Schema.tag("item_reference"),
|
||||
id: Schema.String,
|
||||
})
|
||||
const OpenResponsesWebSearchCall = Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
type: Schema.tag("web_search_call"),
|
||||
id: Schema.String,
|
||||
status: Schema.optional(Schema.String),
|
||||
action: optionalNull(JsonObject),
|
||||
}),
|
||||
[JsonObject],
|
||||
)
|
||||
|
||||
const OpenResponsesFileSearchCall = Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
type: Schema.tag("file_search_call"),
|
||||
id: Schema.String,
|
||||
status: Schema.optional(Schema.String),
|
||||
queries: Schema.optional(Schema.Array(Schema.String)),
|
||||
results: optionalNull(Schema.Array(JsonObject)),
|
||||
}),
|
||||
[JsonObject],
|
||||
)
|
||||
|
||||
const OpenResponsesCodeInterpreterCall = Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
type: Schema.tag("code_interpreter_call"),
|
||||
id: Schema.String,
|
||||
status: Schema.optional(Schema.String),
|
||||
code: optionalNull(Schema.String),
|
||||
container_id: optionalNull(Schema.String),
|
||||
outputs: optionalNull(Schema.Array(JsonObject)),
|
||||
}),
|
||||
[JsonObject],
|
||||
)
|
||||
|
||||
const OpenResponsesMCPCall = Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
type: Schema.tag("mcp_call"),
|
||||
id: Schema.String,
|
||||
status: Schema.optional(Schema.String),
|
||||
server_label: Schema.optional(Schema.String),
|
||||
name: Schema.optional(Schema.String),
|
||||
arguments: Schema.optional(Schema.String),
|
||||
output: optionalNull(Schema.String),
|
||||
error: Schema.optional(Schema.Unknown),
|
||||
}),
|
||||
[JsonObject],
|
||||
)
|
||||
|
||||
export const HostedToolItem = Schema.Union([
|
||||
OpenResponsesWebSearchCall,
|
||||
OpenResponsesFileSearchCall,
|
||||
OpenResponsesCodeInterpreterCall,
|
||||
OpenResponsesMCPCall,
|
||||
])
|
||||
export type HostedToolItem = Schema.Schema.Type<typeof HostedToolItem>
|
||||
|
||||
// `function_call_output.output` accepts either a plain string or an ordered
|
||||
// array of content items so tools can return images and files in addition to text.
|
||||
@@ -111,7 +161,6 @@ export const InputItem = Schema.Union([
|
||||
phase: Schema.optionalKey(MessagePhase),
|
||||
}),
|
||||
OpenResponsesReasoningItem,
|
||||
OpenResponsesItemReference,
|
||||
Schema.Struct({
|
||||
type: Schema.tag("function_call"),
|
||||
id: Schema.optionalKey(Schema.String),
|
||||
@@ -124,10 +173,17 @@ export const InputItem = Schema.Union([
|
||||
call_id: Schema.String,
|
||||
output: OpenResponsesFunctionCallOutput,
|
||||
}),
|
||||
HostedToolItem,
|
||||
])
|
||||
type OpenResponsesInputItem = Schema.Schema.Type<typeof InputItem>
|
||||
export type ExtendedHostedToolItem = {
|
||||
readonly type: string
|
||||
readonly id: string
|
||||
readonly [key: string]: unknown
|
||||
}
|
||||
type LoweredInputItem =
|
||||
| OpenResponsesInputItem
|
||||
| ExtendedHostedToolItem
|
||||
| {
|
||||
readonly type: "message"
|
||||
readonly id?: string
|
||||
@@ -317,7 +373,7 @@ export const Event = Schema.StructWithRest(
|
||||
export type Event = Schema.Schema.Type<typeof Event>
|
||||
|
||||
// Which lowered input item a persisted item id is about to be attached to.
|
||||
export type ItemKind = "message" | "reasoning" | "function-call" | "reference"
|
||||
export type ItemKind = "message" | "reasoning" | "function-call" | "hosted-tool"
|
||||
|
||||
export interface Extension {
|
||||
readonly id: string
|
||||
@@ -327,6 +383,7 @@ export interface Extension {
|
||||
readonly media: ProviderShared.NormalizedMedia
|
||||
readonly request: LLMRequest
|
||||
}) => MediaInput | undefined
|
||||
readonly lowerHostedToolItem?: (item: unknown) => ExtendedHostedToolItem | undefined
|
||||
// Optional grammar check applied before a persisted item id is resent as
|
||||
// part of replayed history. Returning false drops the id; every lowered
|
||||
// item treats a dropped id the same as an absent one.
|
||||
@@ -346,7 +403,6 @@ export interface ParserState {
|
||||
readonly messageItems: ReadonlySet<string>
|
||||
readonly messagePhases: Readonly<Record<string, MessagePhase | null>>
|
||||
readonly reasoningItems: Readonly<Record<string, ReasoningStreamItem>>
|
||||
readonly store: boolean | undefined
|
||||
}
|
||||
|
||||
type ReasoningSummaryStatus = "active" | "can-conclude" | "concluded"
|
||||
@@ -528,10 +584,7 @@ const lowerToolResultOutput = Effect.fnUntraced(function* (
|
||||
})
|
||||
|
||||
const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (request: LLMRequest, extension: Extension) {
|
||||
const system: LoweredInputItem[] =
|
||||
request.system.length === 0 ? [] : [{ role: "system", content: ProviderShared.joinText(request.system) }]
|
||||
const input: LoweredInputItem[] = [...system]
|
||||
const store = OpenResponsesOptions.resolve(request).store
|
||||
const input: LoweredInputItem[] = []
|
||||
const providerMetadataKey = request.model.route.providerMetadataKey ?? "openresponses"
|
||||
|
||||
for (const message of request.messages) {
|
||||
@@ -554,8 +607,7 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
|
||||
if (message.role === "assistant") {
|
||||
const content: TextPart[] = []
|
||||
const reasoningItems: Record<string, OpenResponsesReasoningInput> = {}
|
||||
const reasoningReferences = new Set<string>()
|
||||
const hostedToolReferences = new Set<string>()
|
||||
const hostedToolItems = new Set<string>()
|
||||
const flushText = () => {
|
||||
if (content.length === 0) return
|
||||
const groups = content.reduce<
|
||||
@@ -590,11 +642,6 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
|
||||
flushText()
|
||||
const reasoning = lowerReasoning(part, providerMetadataKey, extension)
|
||||
if (!reasoning) continue
|
||||
if (store !== false) {
|
||||
if (!reasoningReferences.has(reasoning.id)) input.push({ type: "item_reference", id: reasoning.id })
|
||||
reasoningReferences.add(reasoning.id)
|
||||
continue
|
||||
}
|
||||
const existing = reasoningItems[reasoning.id]
|
||||
if (existing) {
|
||||
existing.summary.push(...reasoning.summary)
|
||||
@@ -615,24 +662,29 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
|
||||
if (part.type === "tool-result" && part.providerExecuted === true) {
|
||||
flushText()
|
||||
const id = itemID(part.providerMetadata, providerMetadataKey)
|
||||
const reference = acceptsItemID(extension, "reference", id) ? id : undefined
|
||||
if (store !== false && reference && !hostedToolReferences.has(reference))
|
||||
input.push({ type: "item_reference", id: reference })
|
||||
if (store === false) {
|
||||
// The server is not storing this exchange, so the tool outcome has to
|
||||
// travel in the input. Non-content results degrade to their text form.
|
||||
const content: ReadonlyArray<Content> =
|
||||
part.result.type === "content"
|
||||
const hosted =
|
||||
part.result.type !== "json"
|
||||
? undefined
|
||||
: Schema.is(HostedToolItem)(part.result.value)
|
||||
? part.result.value
|
||||
: [{ type: "text", text: ProviderShared.toolResultText(part) }]
|
||||
input.push({
|
||||
role: "user",
|
||||
content: yield* Effect.forEach(content, (item) =>
|
||||
lowerHostedToolResultContentItem(item, request, extension),
|
||||
),
|
||||
})
|
||||
: extension.lowerHostedToolItem?.(part.result.value)
|
||||
if (acceptsItemID(extension, "hosted-tool", id) && hosted?.id === id) {
|
||||
if (!hostedToolItems.has(id)) {
|
||||
input.push(hosted)
|
||||
hostedToolItems.add(id)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (reference) hostedToolReferences.add(reference)
|
||||
const content: ReadonlyArray<Content> =
|
||||
part.result.type === "content"
|
||||
? part.result.value
|
||||
: [{ type: "text", text: ProviderShared.toolResultText(part) }]
|
||||
input.push({
|
||||
role: "user",
|
||||
content: yield* Effect.forEach(content, (item) =>
|
||||
lowerHostedToolResultContentItem(item, request, extension),
|
||||
),
|
||||
})
|
||||
continue
|
||||
}
|
||||
return yield* ProviderShared.unsupportedContent(extension.name, "assistant", [
|
||||
@@ -662,10 +714,11 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
|
||||
|
||||
const lowerOptions = (request: LLMRequest) => {
|
||||
const options = OpenResponsesOptions.resolve(request)
|
||||
const instructions = ProviderShared.joinText(request.system)
|
||||
const cacheKey = ProviderShared.promptCacheKey(request)
|
||||
const parallelToolCalls = resolveParallelToolCalls(request)
|
||||
return {
|
||||
...(options.instructions ? { instructions: options.instructions } : {}),
|
||||
...(instructions ? { instructions } : {}),
|
||||
...(options.store !== undefined ? { store: options.store } : {}),
|
||||
...(options.metadata ? { metadata: options.metadata } : {}),
|
||||
...(options.safetyIdentifier ? { safety_identifier: options.safetyIdentifier } : {}),
|
||||
@@ -891,25 +944,23 @@ const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
|
||||
events,
|
||||
]
|
||||
}
|
||||
if (item?.type !== "function_call" || !item.id) return [state, NO_EVENTS]
|
||||
const metadata = providerMetadata(state, { itemId: item.id })
|
||||
if (item?.type !== "function_call" || !item.call_id) return [state, NO_EVENTS]
|
||||
const id = item.id ?? item.call_id
|
||||
const metadata = item.id ? providerMetadata(state, { itemId: item.id }) : undefined
|
||||
const events: LLMEvent[] = []
|
||||
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
|
||||
return [
|
||||
{
|
||||
...state,
|
||||
lifecycle,
|
||||
tools: ToolStream.start(state.tools, item.id, {
|
||||
id: item.call_id ?? item.id,
|
||||
tools: ToolStream.start(state.tools, id, {
|
||||
id: item.call_id,
|
||||
name: item.name ?? "",
|
||||
input: item.arguments ?? "",
|
||||
providerMetadata: metadata,
|
||||
}),
|
||||
},
|
||||
[
|
||||
...events,
|
||||
LLMEvent.toolInputStart({ id: item.call_id ?? item.id, name: item.name ?? "", providerMetadata: metadata }),
|
||||
],
|
||||
[...events, LLMEvent.toolInputStart({ id: item.call_id, name: item.name ?? "", providerMetadata: metadata })],
|
||||
]
|
||||
}
|
||||
|
||||
@@ -964,31 +1015,21 @@ const onReasoningSummaryPartDone = (state: ParserState, event: Event): StepResul
|
||||
if (!event.item_id || event.summary_index === undefined) return [state, NO_EVENTS]
|
||||
const item = state.reasoningItems[event.item_id]
|
||||
if (!item) return [state, NO_EVENTS]
|
||||
const events: LLMEvent[] = []
|
||||
return [
|
||||
{
|
||||
...state,
|
||||
lifecycle:
|
||||
state.store !== false
|
||||
? Lifecycle.reasoningEnd(
|
||||
state.lifecycle,
|
||||
events,
|
||||
`${event.item_id}:${event.summary_index}`,
|
||||
providerMetadata(state, { itemId: event.item_id }),
|
||||
)
|
||||
: state.lifecycle,
|
||||
reasoningItems: {
|
||||
...state.reasoningItems,
|
||||
[event.item_id]: {
|
||||
...item,
|
||||
summaryParts: {
|
||||
...item.summaryParts,
|
||||
[event.summary_index]: state.store !== false ? "concluded" : "can-conclude",
|
||||
[event.summary_index]: "can-conclude",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
events,
|
||||
NO_EVENTS,
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1051,18 +1092,19 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
|
||||
}
|
||||
|
||||
if (item.type === "function_call") {
|
||||
if (!item.id || !item.call_id || !item.name) return [state, NO_EVENTS] satisfies StepResult
|
||||
const tools = state.tools[item.id]
|
||||
if (!item.call_id || !item.name) return [state, NO_EVENTS] satisfies StepResult
|
||||
const id = item.id ?? item.call_id
|
||||
const tools = state.tools[id]
|
||||
? state.tools
|
||||
: ToolStream.start(state.tools, item.id, {
|
||||
: ToolStream.start(state.tools, id, {
|
||||
id: item.call_id,
|
||||
name: item.name,
|
||||
providerMetadata: providerMetadata(state, { itemId: item.id }),
|
||||
providerMetadata: item.id ? providerMetadata(state, { itemId: item.id }) : undefined,
|
||||
})
|
||||
const result =
|
||||
item.arguments === undefined
|
||||
? yield* ToolStream.finish(state.id, tools, item.id)
|
||||
: yield* ToolStream.finishWithInput(state.id, tools, item.id, item.arguments)
|
||||
? yield* ToolStream.finish(state.id, tools, id)
|
||||
: yield* ToolStream.finishWithInput(state.id, tools, id, item.arguments)
|
||||
const events: LLMEvent[] = []
|
||||
const resultEvents = result.events ?? []
|
||||
const lifecycle = resultEvents.length ? Lifecycle.stepStart(state.lifecycle, events) : state.lifecycle
|
||||
@@ -1116,10 +1158,11 @@ const onResponseFinish = Effect.fn("OpenResponses.onResponseFinish")(function* (
|
||||
event.response?.output ?? [],
|
||||
() => [state, NO_EVENTS] satisfies StepResult,
|
||||
([current, events], item) => {
|
||||
const id = item.id ?? (item.type === "function_call" ? item.call_id : undefined)
|
||||
if (
|
||||
!item.id ||
|
||||
((item.type !== "function_call" || !current.tools[item.id]) &&
|
||||
(item.type !== "reasoning" || !current.reasoningItems[item.id]))
|
||||
!id ||
|
||||
((item.type !== "function_call" || !current.tools[id]) &&
|
||||
(item.type !== "reasoning" || !current.reasoningItems[id]))
|
||||
)
|
||||
return Effect.succeed([current, events] satisfies StepResult)
|
||||
return onOutputItemDone(current, { type: "response.output_item.done", item }).pipe(
|
||||
@@ -1245,10 +1288,11 @@ export const step = (state: ParserState, input: Event) => {
|
||||
if (event.type === "response.output_item.added") {
|
||||
if (event.item?.type === "message" && !event.item.id)
|
||||
return ProviderShared.eventError(state.id, `${event.type} message is missing id`)
|
||||
const id = event.item?.id ?? (event.item?.type === "function_call" ? event.item.call_id : undefined)
|
||||
return Effect.succeed(
|
||||
onOutputItemAdded(
|
||||
event.output_index !== undefined && event.item?.id
|
||||
? { ...state, outputItems: { ...state.outputItems, [event.output_index]: event.item.id } }
|
||||
event.output_index !== undefined && id
|
||||
? { ...state, outputItems: { ...state.outputItems, [event.output_index]: id } }
|
||||
: state,
|
||||
event,
|
||||
),
|
||||
@@ -1291,7 +1335,6 @@ export const initial = (request: LLMRequest, extension: Extension = BASE): Parse
|
||||
messageItems: new Set<string>(),
|
||||
messagePhases: {},
|
||||
reasoningItems: {},
|
||||
store: OpenResponsesOptions.resolve(request).store,
|
||||
})
|
||||
|
||||
export const protocol = Protocol.make({
|
||||
|
||||
@@ -364,8 +364,9 @@ const lowerUserMessage = Effect.fn("OpenAIChat.lowerUserMessage")(function* (
|
||||
|
||||
const lowerAssistantMessage = Effect.fn("OpenAIChat.lowerAssistantMessage")(function* (
|
||||
message: OpenAIChatRequestMessage,
|
||||
configuredField?: string,
|
||||
options: LoweringOptions = {},
|
||||
configuredField: string | undefined,
|
||||
requireReasoning: boolean,
|
||||
options: LoweringOptions,
|
||||
) {
|
||||
const content: TextPart[] = []
|
||||
const reasoning: ReasoningPart[] = []
|
||||
@@ -392,15 +393,17 @@ const lowerAssistantMessage = Effect.fn("OpenAIChat.lowerAssistantMessage")(func
|
||||
const nativeReasoning = openAICompatibleReasoningContent(message.native?.openaiCompatible)
|
||||
const fullyStructured = reasoning.every((part) => Array.isArray(part.providerMetadata?.openai?.reasoningDetails))
|
||||
const field = (() => {
|
||||
if (configuredField !== undefined) return configuredField
|
||||
if (reasoning.length === 0) return undefined
|
||||
if (configuredField !== undefined && (requireReasoning || reasoning.length > 0 || nativeReasoning !== undefined))
|
||||
return configuredField
|
||||
if (reasoning.length === 0) return requireReasoning ? "reasoning_content" : undefined
|
||||
if (observedField !== undefined) return observedField
|
||||
if (nativeReasoning !== undefined) return "reasoning_content"
|
||||
if (!fullyStructured) return "reasoning_content"
|
||||
if (!fullyStructured || requireReasoning) return "reasoning_content"
|
||||
})()
|
||||
const reasoningText = (() => {
|
||||
if (configuredField !== undefined) return reasoning.length === 0 ? (nativeReasoning ?? "") : text
|
||||
if (reasoning.length === 0) return nativeReasoning
|
||||
if (configuredField !== undefined)
|
||||
return reasoning.length === 0 ? (nativeReasoning ?? (requireReasoning ? "" : undefined)) : text
|
||||
if (reasoning.length === 0) return nativeReasoning ?? (requireReasoning ? "" : undefined)
|
||||
return text
|
||||
})()
|
||||
const cached = message.content.findLast((part) => "cache" in part && part.cache !== undefined)
|
||||
@@ -454,11 +457,13 @@ const lowerToolMessages = Effect.fn("OpenAIChat.lowerToolMessages")(function* (
|
||||
|
||||
const lowerMessage = Effect.fn("OpenAIChat.lowerMessage")(function* (
|
||||
message: OpenAIChatRequestMessage,
|
||||
reasoningField?: string,
|
||||
options: LoweringOptions = {},
|
||||
reasoningField: string | undefined,
|
||||
requireReasoning: boolean,
|
||||
options: LoweringOptions,
|
||||
) {
|
||||
if (message.role === "user") return [yield* lowerUserMessage(message, options)]
|
||||
if (message.role === "assistant") return [yield* lowerAssistantMessage(message, reasoningField, options)]
|
||||
if (message.role === "assistant")
|
||||
return [yield* lowerAssistantMessage(message, reasoningField, requireReasoning, options)]
|
||||
return (yield* lowerToolMessages(message, options)).messages
|
||||
})
|
||||
|
||||
@@ -480,6 +485,13 @@ const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request:
|
||||
: [{ role: "system", content: ProviderShared.joinText(request.system) }]
|
||||
const messages = [...system]
|
||||
const modelID = request.model.id.toLowerCase()
|
||||
const requireReasoning =
|
||||
request.model.compatibility?.requireReasoning ??
|
||||
(request.model.compatibility?.reasoningField !== undefined ||
|
||||
request.model.provider === "deepseek" ||
|
||||
request.model.route.endpoint.baseURL?.toLowerCase().includes("deepseek.com") ||
|
||||
modelID.includes("deepseek"))
|
||||
const reasoningField = request.model.compatibility?.reasoningField
|
||||
const mistral = ["mistral", "devstral", "codestral", "pixtral", "mixtral"].some((family) => modelID.includes(family))
|
||||
const lowering = {
|
||||
...options,
|
||||
@@ -554,7 +566,7 @@ const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request:
|
||||
continue
|
||||
}
|
||||
flushImages()
|
||||
messages.push(...(yield* lowerMessage(message, request.model.compatibility?.reasoningField, lowering)))
|
||||
messages.push(...(yield* lowerMessage(message, reasoningField, requireReasoning, lowering)))
|
||||
}
|
||||
flushImages()
|
||||
return messages
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Protocol } from "../route/protocol.js"
|
||||
import { HttpTransport } from "../route/transport/index.js"
|
||||
import { LLMRequest, type JsonSchema, type ToolDefinition } from "../schema/index.js"
|
||||
import { OpenResponses } from "./open-responses.js"
|
||||
import { optionalArray, ProviderShared } from "./shared.js"
|
||||
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared.js"
|
||||
import { OpenAIImage } from "./utils/openai-image.js"
|
||||
import { ResponsesHostedTools } from "./utils/responses-hosted-tools.js"
|
||||
import { ToolSchemaProjection } from "./utils/tool-schema.js"
|
||||
@@ -32,6 +32,40 @@ const OpenAIResponsesImageGenerationTool = Schema.Struct({
|
||||
size: Schema.optional(OpenAIImage.Size),
|
||||
})
|
||||
|
||||
const OpenAIResponsesHostedToolItem = Schema.Union([
|
||||
Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
type: Schema.tag("computer_call"),
|
||||
id: Schema.String,
|
||||
status: Schema.optional(Schema.String),
|
||||
call_id: Schema.optional(Schema.String),
|
||||
action: optionalNull(JsonObject),
|
||||
pending_safety_checks: Schema.optional(Schema.Array(JsonObject)),
|
||||
}),
|
||||
[JsonObject],
|
||||
),
|
||||
Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
type: Schema.tag("web_search_preview_call"),
|
||||
id: Schema.String,
|
||||
status: Schema.optional(Schema.String),
|
||||
action: optionalNull(JsonObject),
|
||||
}),
|
||||
[JsonObject],
|
||||
),
|
||||
Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
type: Schema.tag("image_generation_call"),
|
||||
id: Schema.String,
|
||||
status: Schema.optional(Schema.String),
|
||||
result: optionalNull(Schema.String),
|
||||
output_format: Schema.optional(Schema.Literals(["png", "jpeg", "webp"])),
|
||||
revised_prompt: optionalNull(Schema.String),
|
||||
}),
|
||||
[JsonObject],
|
||||
),
|
||||
])
|
||||
|
||||
const OpenAIResponsesTools = Schema.Union([OpenResponses.Tool, OpenAIResponsesImageGenerationTool])
|
||||
|
||||
const OpenAIResponsesToolChoice = Schema.Union([
|
||||
@@ -41,6 +75,7 @@ const OpenAIResponsesToolChoice = Schema.Union([
|
||||
|
||||
const OpenAIResponsesCoreFields = {
|
||||
...OpenResponses.coreFields,
|
||||
input: Schema.Array(Schema.Union([OpenResponses.InputItem, OpenAIResponsesHostedToolItem])),
|
||||
tools: optionalArray(OpenAIResponsesTools),
|
||||
tool_choice: Schema.optional(OpenAIResponsesToolChoice),
|
||||
}
|
||||
@@ -54,21 +89,21 @@ export type OpenAIResponsesBody = Schema.Schema.Type<typeof OpenAIResponsesBody>
|
||||
// Replayed items are paired with stored server state by id, so a foreign or
|
||||
// synthetic token can fail request validation even when `call_id` pairing is
|
||||
// intact. Only resend ids in each item kind's own grammar; hosted tool
|
||||
// references keep generic validation because every hosted tool mints its own
|
||||
// items keep generic validation because every hosted tool mints its own
|
||||
// prefix. The same allowlist approach codex uses before resending history
|
||||
// (codex-rs core/src/client.rs, `prepare_response_items_for_request`).
|
||||
const ITEM_ID_PREFIXES: Record<OpenResponses.ItemKind, ReadonlyArray<string>> = {
|
||||
message: ["msg_"],
|
||||
reasoning: ["rs_"],
|
||||
"function-call": ["fc_"],
|
||||
// Every hosted tool mints its own id prefix, so references keep generic
|
||||
// validation only.
|
||||
reference: [],
|
||||
// Every hosted tool mints its own id prefix, so items keep generic validation.
|
||||
"hosted-tool": [],
|
||||
}
|
||||
|
||||
const extension = {
|
||||
id: ADAPTER,
|
||||
name: NAME,
|
||||
lowerHostedToolItem: (item: unknown) => (Schema.is(OpenAIResponsesHostedToolItem)(item) ? item : undefined),
|
||||
acceptsItemID: (kind: OpenResponses.ItemKind, id: string) => {
|
||||
const prefixes = ITEM_ID_PREFIXES[kind]
|
||||
return prefixes.length === 0 || prefixes.some((prefix) => id.startsWith(prefix))
|
||||
@@ -105,6 +140,8 @@ const lowerToolChoice = (toolChoice: NonNullable<LLMRequest["toolChoice"]>, tool
|
||||
: { type: "function" as const, name },
|
||||
})
|
||||
|
||||
const decodeBody = ProviderShared.validateWith(Schema.decodeUnknownEffect(OpenAIResponsesBody))
|
||||
|
||||
const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request: LLMRequest) {
|
||||
const body = yield* OpenResponses.fromRequestWithExtension(
|
||||
LLMRequest.update(request, { tools: [], toolChoice: undefined }),
|
||||
@@ -112,7 +149,7 @@ const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request:
|
||||
)
|
||||
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
|
||||
const parallelToolCalls = OpenResponses.resolveParallelToolCalls(request)
|
||||
return {
|
||||
return yield* decodeBody({
|
||||
...body,
|
||||
...(parallelToolCalls === undefined ? {} : { parallel_tool_calls: parallelToolCalls }),
|
||||
tools:
|
||||
@@ -123,7 +160,7 @@ const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request:
|
||||
),
|
||||
tool_choice:
|
||||
body.tool_choice ?? (request.toolChoice ? yield* lowerToolChoice(request.toolChoice, request.tools) : undefined),
|
||||
} satisfies OpenAIResponsesBody
|
||||
})
|
||||
})
|
||||
|
||||
const hostedToolResult = Effect.fn("OpenAIResponses.hostedToolResult")(function* (item: ResponsesHostedTools.Item) {
|
||||
|
||||
@@ -210,10 +210,9 @@ export const errorText = (error: unknown) => {
|
||||
* `framing` step for Server-Sent Events. Decodes UTF-8, runs the SSE channel
|
||||
* decoder, optionally filters named events, and drops empty / `[DONE]`
|
||||
* keep-alive events so the protocol event schema sees one JSON string per
|
||||
* element. The SSE channel emits a
|
||||
* `Retry` control event on its error channel; we drop it here (we don't
|
||||
* implement client-driven retries). Decoder failures become provider output
|
||||
* errors so the public error channel stays `AIError`.
|
||||
* element. Retry control events are ignored without interrupting the stream.
|
||||
* Decoder failures become provider output errors so the public error channel
|
||||
* stays `AIError`.
|
||||
*/
|
||||
export const sseFraming = (
|
||||
bytes: Stream.Stream<Uint8Array, AIError>,
|
||||
@@ -221,9 +220,23 @@ export const sseFraming = (
|
||||
): Stream.Stream<string, AIError> =>
|
||||
bytes.pipe(
|
||||
Stream.decodeText(),
|
||||
Stream.pipeThroughChannel(Sse.decode()),
|
||||
Stream.catchTag("Retry", () => Stream.empty),
|
||||
Stream.catchTag("SseError", (error) => Stream.fail(eventError("sse", error.message))),
|
||||
Stream.mapAccumEffect(
|
||||
() => {
|
||||
const output: Sse.Event[] = []
|
||||
return {
|
||||
output,
|
||||
parser: Sse.makeParser((event) => {
|
||||
if (event._tag === "Event") output.push(event)
|
||||
}),
|
||||
}
|
||||
},
|
||||
(state, chunk) =>
|
||||
Effect.gen(function* () {
|
||||
const error = state.parser.feed(chunk)
|
||||
if (error) return yield* eventError("sse", error.message)
|
||||
return [state, state.output.splice(0)] as const
|
||||
}),
|
||||
),
|
||||
Stream.filter(
|
||||
(event) =>
|
||||
(events === undefined || events.has(event.event)) &&
|
||||
|
||||
@@ -56,7 +56,6 @@ export const StreamOptions = Schema.Struct({
|
||||
})
|
||||
|
||||
export const Options = Schema.Struct({
|
||||
instructions: Schema.optional(Schema.String),
|
||||
store: Schema.optional(Schema.Boolean),
|
||||
metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)),
|
||||
safetyIdentifier: Schema.optional(Schema.String),
|
||||
|
||||
@@ -1,15 +1,52 @@
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Protocol } from "../route/protocol.js"
|
||||
import type { LLMRequest } from "../schema/index.js"
|
||||
import { OpenResponses } from "./open-responses.js"
|
||||
import { JsonObject, optionalNull, ProviderShared } from "./shared.js"
|
||||
import { ResponsesHostedTools } from "./utils/responses-hosted-tools.js"
|
||||
|
||||
const ADAPTER = "xai-responses"
|
||||
const NAME = "xAI Responses"
|
||||
|
||||
const XAIResponsesHostedToolItem = Schema.Union([
|
||||
Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
type: Schema.tag("x_search_call"),
|
||||
id: Schema.String,
|
||||
status: Schema.optional(Schema.String),
|
||||
action: optionalNull(JsonObject),
|
||||
}),
|
||||
[JsonObject],
|
||||
),
|
||||
Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
type: Schema.tag("image_generation_call"),
|
||||
id: Schema.String,
|
||||
status: Schema.optional(Schema.String),
|
||||
result: Schema.optional(Schema.Unknown),
|
||||
error: Schema.optional(Schema.Unknown),
|
||||
}),
|
||||
[JsonObject],
|
||||
),
|
||||
])
|
||||
|
||||
const XAIResponsesBody = Schema.Struct({
|
||||
...OpenResponses.coreFields,
|
||||
input: Schema.Array(Schema.Union([OpenResponses.InputItem, XAIResponsesHostedToolItem])),
|
||||
stream: Schema.Literal(true),
|
||||
})
|
||||
|
||||
const extension = {
|
||||
id: ADAPTER,
|
||||
name: NAME,
|
||||
lowerHostedToolItem: (item: unknown) => (Schema.is(XAIResponsesHostedToolItem)(item) ? item : undefined),
|
||||
} satisfies OpenResponses.Extension
|
||||
|
||||
const decodeBody = ProviderShared.validateWith(Schema.decodeUnknownEffect(XAIResponsesBody))
|
||||
const fromRequest = Effect.fn("XAIResponses.fromRequest")(function* (request: LLMRequest) {
|
||||
return yield* decodeBody(yield* OpenResponses.fromRequestWithExtension(request, extension))
|
||||
})
|
||||
|
||||
const HOSTED_TOOLS = {
|
||||
web_search_call: { name: "web_search", input: (item) => item.action ?? {} },
|
||||
x_search_call: { name: "x_search", input: (item) => item.action ?? {} },
|
||||
@@ -35,7 +72,10 @@ const step = (state: OpenResponses.ParserState, event: OpenResponses.Event) => {
|
||||
|
||||
export const protocol = Protocol.make({
|
||||
id: ADAPTER,
|
||||
body: OpenResponses.protocol.body,
|
||||
body: {
|
||||
schema: XAIResponsesBody,
|
||||
from: fromRequest,
|
||||
},
|
||||
stream: {
|
||||
event: OpenResponses.protocol.stream.event,
|
||||
initial: (request) => OpenResponses.initial(request, extension),
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { ProviderPackage } from "../provider-package.js"
|
||||
import { OpenAICompatibleChat } from "../protocols/openai-compatible-chat.js"
|
||||
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
|
||||
import type { RouteDefaultsInput } from "../route/client.js"
|
||||
import { ProviderID, type ModelID } from "../schema/index.js"
|
||||
import { profiles } from "./openai-compatible-profile.js"
|
||||
import type { OpenAIProviderOptionsInput } from "./openai-options.js"
|
||||
|
||||
export const id = ProviderID.make("cerebras")
|
||||
|
||||
export type LanguageModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
|
||||
ProviderAuthOption<"optional"> & {
|
||||
readonly baseURL?: string
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
}
|
||||
|
||||
export interface Settings extends ProviderPackage.Settings {
|
||||
readonly apiKey?: string
|
||||
readonly baseURL?: string
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
}
|
||||
|
||||
export const route = OpenAICompatibleChat.route.with({
|
||||
id: "cerebras-chat",
|
||||
provider: id,
|
||||
endpoint: { baseURL: profiles.cerebras.baseURL },
|
||||
})
|
||||
|
||||
export const routes = [route]
|
||||
|
||||
export const configure = (input: LanguageModelOptions = {}) => {
|
||||
const { apiKey: _apiKey, auth: _auth, baseURL, ...defaults } = input
|
||||
const configured = route.with({
|
||||
...defaults,
|
||||
endpoint: { baseURL: baseURL ?? profiles.cerebras.baseURL },
|
||||
auth: AuthOptions.bearer(input, "CEREBRAS_API_KEY"),
|
||||
})
|
||||
return {
|
||||
id,
|
||||
model: (modelID: string | ModelID) =>
|
||||
configured.model<OpenAIProviderOptionsInput>({
|
||||
id: modelID,
|
||||
compatibility: { maxTokensField: "max_tokens", reasoningField: "reasoning", supportsStore: false },
|
||||
}),
|
||||
configure,
|
||||
}
|
||||
}
|
||||
|
||||
export const provider = configure()
|
||||
|
||||
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["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 } },
|
||||
providerOptions: settings.providerOptions,
|
||||
}).model(modelID)
|
||||
@@ -3,6 +3,7 @@ export * as AnthropicCompatible from "./anthropic-compatible.js"
|
||||
export * as AmazonBedrock from "./amazon-bedrock.js"
|
||||
export * as AmazonBedrockMantle from "./amazon-bedrock-mantle.js"
|
||||
export * as Azure from "./azure.js"
|
||||
export * as Cerebras from "./cerebras.js"
|
||||
export * as Cloudflare from "./cloudflare.js"
|
||||
export { CloudflareAIGateway, CloudflareWorkersAI } from "./cloudflare.js"
|
||||
export * as Google from "./google.js"
|
||||
@@ -14,5 +15,6 @@ export * as OpenAI from "./openai.js"
|
||||
export * as OpenAICompatible from "./openai-compatible.js"
|
||||
export * as OpenAICompatibleResponses from "./openai-compatible-responses.js"
|
||||
export * as OpenRouter from "./openrouter.js"
|
||||
export * as TogetherAI from "./togetherai.js"
|
||||
export * as XAI from "./xai.js"
|
||||
export * as ZAI from "./zai.js"
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { ProviderPackage } from "../provider-package.js"
|
||||
import { OpenAICompatibleChat } from "../protocols/openai-compatible-chat.js"
|
||||
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
|
||||
import type { RouteDefaultsInput } from "../route/client.js"
|
||||
import { ProviderID, type ModelID } from "../schema/index.js"
|
||||
import { profiles } from "./openai-compatible-profile.js"
|
||||
import type { OpenAIProviderOptionsInput } from "./openai-options.js"
|
||||
|
||||
export const id = ProviderID.make("togetherai")
|
||||
|
||||
export type LanguageModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
|
||||
ProviderAuthOption<"optional"> & {
|
||||
readonly baseURL?: string
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
}
|
||||
|
||||
export interface Settings extends ProviderPackage.Settings {
|
||||
readonly apiKey?: string
|
||||
readonly baseURL?: string
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
}
|
||||
|
||||
export const route = OpenAICompatibleChat.route.with({
|
||||
id: "togetherai-chat",
|
||||
provider: id,
|
||||
endpoint: { baseURL: profiles.togetherai.baseURL },
|
||||
})
|
||||
|
||||
export const routes = [route]
|
||||
|
||||
export const configure = (input: LanguageModelOptions = {}) => {
|
||||
const { apiKey: _apiKey, auth: _auth, baseURL, ...defaults } = input
|
||||
const configured = route.with({
|
||||
...defaults,
|
||||
endpoint: { baseURL: baseURL ?? profiles.togetherai.baseURL },
|
||||
auth: AuthOptions.bearer(input, ["TOGETHER_API_KEY", "TOGETHER_AI_API_KEY"]),
|
||||
})
|
||||
return {
|
||||
id,
|
||||
model: (modelID: string | ModelID) =>
|
||||
configured.model<OpenAIProviderOptionsInput>({
|
||||
id: modelID,
|
||||
compatibility: { maxTokensField: "max_tokens", supportsStore: false, supportsStrictMode: false },
|
||||
}),
|
||||
configure,
|
||||
}
|
||||
}
|
||||
|
||||
export const provider = configure()
|
||||
|
||||
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["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 } },
|
||||
providerOptions: settings.providerOptions,
|
||||
}).model(modelID)
|
||||
@@ -153,6 +153,8 @@ export class LanguageModelCompatibility extends Schema.Class<LanguageModelCompat
|
||||
)({
|
||||
toolSchema: Schema.optional(LanguageModelToolSchemaCompatibility),
|
||||
reasoningField: Schema.optional(Schema.String),
|
||||
/** Require every assistant message to include its reasoning field, even when empty. */
|
||||
requireReasoning: Schema.optional(Schema.Boolean),
|
||||
maxTokensField: Schema.optional(LanguageModelMaxTokensFieldCompatibility),
|
||||
requireFinishReason: Schema.optional(Schema.Boolean),
|
||||
requireAssistantAfterTool: Schema.optional(Schema.Boolean),
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Effect } from "effect"
|
||||
import { CacheHint, LLM, Message } from "../src/index.js"
|
||||
import { Auth } from "../src/route.js"
|
||||
import { compileRequest } from "../src/route/client.js"
|
||||
import { AmazonBedrock } from "../src/providers.js"
|
||||
import { AmazonBedrock, GoogleVertexMessages } from "../src/providers.js"
|
||||
import * as AnthropicMessages from "../src/protocols/anthropic-messages.js"
|
||||
import * as Gemini from "../src/protocols/gemini.js"
|
||||
import * as OpenAIChat from "../src/protocols/openai-chat.js"
|
||||
@@ -86,6 +86,27 @@ describe("applyCachePolicy", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("'auto' emits Anthropic cache markers on Vertex", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: GoogleVertexMessages.configure({ accessToken: "test", location: "global", project: "test" }).model(
|
||||
"claude-opus-4-8",
|
||||
),
|
||||
system: "You are concise.",
|
||||
tools: [{ name: "lookup", description: "Look up a value", inputSchema: { type: "object", properties: {} } }],
|
||||
prompt: "hi",
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body).toMatchObject({
|
||||
tools: [{ name: "lookup", cache_control: { type: "ephemeral" } }],
|
||||
system: [{ type: "text", text: "You are concise.", cache_control: { type: "ephemeral" } }],
|
||||
messages: [{ role: "user", content: [{ type: "text", text: "hi", cache_control: { type: "ephemeral" } }] }],
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("'auto' is a no-op on OpenAI (implicit caching protocol)", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"provider": "cerebras",
|
||||
"route": "cerebras-chat",
|
||||
"transport": "http",
|
||||
"model": "gpt-oss-120b",
|
||||
"tags": ["prefix:cerebras-chat", "provider:cerebras", "text", "golden"],
|
||||
"name": "cerebras-chat/cerebras-gpt-oss-120b-text",
|
||||
"recordedAt": "2026-08-25T23:55:27.619Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://api.cerebras.ai/v1/chat/completions",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"gpt-oss-120b\",\"messages\":[{\"role\":\"system\",\"content\":\"You are concise.\"},{\"role\":\"user\",\"content\":\"Reply exactly with: Hello!\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":256}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream; charset=utf-8"
|
||||
},
|
||||
"body": "data: {\"id\":\"chatcmpl-cd45cd6e-886a-433e-8ba9-caca78c1f13a\",\"choices\":[{\"delta\":{\"role\":\"assistant\"},\"index\":0}],\"created\":1787702127,\"model\":\"gpt-oss-120b\",\"system_fingerprint\":\"fp_e2cabf4999eb0aead3d1\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"id\":\"chatcmpl-cd45cd6e-886a-433e-8ba9-caca78c1f13a\",\"choices\":[{\"delta\":{\"reasoning\":\"The\"},\"index\":0}],\"created\":1787702127,\"model\":\"gpt-oss-120b\",\"system_fingerprint\":\"fp_e2cabf4999eb0aead3d1\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"id\":\"chatcmpl-cd45cd6e-886a-433e-8ba9-caca78c1f13a\",\"choices\":[{\"delta\":{\"reasoning\":\" user says: \\\"Reply exactly with: Hello!\\\" So we must output exactly\"},\"index\":0}],\"created\":1787702127,\"model\":\"gpt-oss-120b\",\"system_fingerprint\":\"fp_e2cabf4999eb0aead3d1\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"id\":\"chatcmpl-cd45cd6e-886a-433e-8ba9-caca78c1f13a\",\"choices\":[{\"delta\":{\"reasoning\":\" \\\"Hello!\\\" with no extra characters, no formatting\"},\"index\":0}],\"created\":1787702127,\"model\":\"gpt-oss-120b\",\"system_fingerprint\":\"fp_e2cabf4999eb0aead3d1\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"id\":\"chatcmpl-cd45cd6e-886a-433e-8ba9-caca78c1f13a\",\"choices\":[{\"delta\":{\"reasoning\":\". Ensure no extra spaces or new\"},\"index\":0}],\"created\":1787702127,\"model\":\"gpt-oss-120b\",\"system_fingerprint\":\"fp_e2cabf4999eb0aead3d1\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"id\":\"chatcmpl-cd45cd6e-886a-433e-8ba9-caca78c1f13a\",\"choices\":[{\"delta\":{\"reasoning\":\"lines? Probably just \\\"Hello!\\\".\"},\"index\":0}],\"created\":1787702127,\"model\":\"gpt-oss-120b\",\"system_fingerprint\":\"fp_e2cabf4999eb0aead3d1\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"id\":\"chatcmpl-cd45cd6e-886a-433e-8ba9-caca78c1f13a\",\"choices\":[{\"delta\":{\"reasoning\":\" Usually we output exactly that.\"},\"index\":0}],\"created\":1787702127,\"model\":\"gpt-oss-120b\",\"system_fingerprint\":\"fp_e2cabf4999eb0aead3d1\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"id\":\"chatcmpl-cd45cd6e-886a-433e-8ba9-caca78c1f13a\",\"choices\":[{\"delta\":{\"content\":\"Hello!\"},\"index\":0}],\"created\":1787702127,\"model\":\"gpt-oss-120b\",\"system_fingerprint\":\"fp_e2cabf4999eb0aead3d1\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"id\":\"chatcmpl-cd45cd6e-886a-433e-8ba9-caca78c1f13a\",\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\",\"index\":0}],\"created\":1787702127,\"model\":\"gpt-oss-120b\",\"system_fingerprint\":\"fp_e2cabf4999eb0aead3d1\",\"object\":\"chat.completion.chunk\",\"usage\":{\"total_tokens\":142,\"completion_tokens\":58,\"completion_tokens_details\":{\"accepted_prediction_tokens\":0,\"rejected_prediction_tokens\":0,\"reasoning_tokens\":46},\"prompt_tokens\":84,\"prompt_tokens_details\":{\"cached_tokens\":0}},\"time_info\":{\"created\":1787702127.645281,\"queue_time\":0.003817115,\"prompt_time\":0.001587193,\"completion_time\":0.029805929,\"total_time\":0.036823272705078125}}\n\ndata: [DONE]\n\n"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"provider": "cerebras",
|
||||
"route": "cerebras-chat",
|
||||
"transport": "http",
|
||||
"model": "gpt-oss-120b",
|
||||
"tags": ["prefix:cerebras-chat", "provider:cerebras", "tool", "tool-call", "golden"],
|
||||
"name": "cerebras-chat/cerebras-gpt-oss-120b-tool-call",
|
||||
"recordedAt": "2026-08-25T23:55:28.454Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://api.cerebras.ai/v1/chat/completions",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"gpt-oss-120b\",\"messages\":[{\"role\":\"system\",\"content\":\"Call tools exactly as requested.\"},{\"role\":\"user\",\"content\":\"Call get_weather with city exactly Paris.\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}}],\"tool_choice\":{\"type\":\"function\",\"function\":{\"name\":\"get_weather\"}},\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":512}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream; charset=utf-8"
|
||||
},
|
||||
"body": "data: {\"id\":\"chatcmpl-402a09cc-8668-446d-8f9c-3e4121f5fa51\",\"choices\":[{\"delta\":{\"role\":\"assistant\"},\"index\":0}],\"created\":1787702127,\"model\":\"gpt-oss-120b\",\"system_fingerprint\":\"fp_4cfabdd6620dc0120785\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"id\":\"chatcmpl-402a09cc-8668-446d-8f9c-3e4121f5fa51\",\"choices\":[{\"delta\":{\"reasoning\":\"We\"},\"index\":0}],\"created\":1787702127,\"model\":\"gpt-oss-120b\",\"system_fingerprint\":\"fp_4cfabdd6620dc0120785\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"id\":\"chatcmpl-402a09cc-8668-446d-8f9c-3e4121f5fa51\",\"choices\":[{\"delta\":{\"reasoning\":\" need to call the function get_weather with city \\\"\"},\"index\":0}],\"created\":1787702127,\"model\":\"gpt-oss-120b\",\"system_fingerprint\":\"fp_4cfabdd6620dc0120785\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"id\":\"chatcmpl-402a09cc-8668-446d-8f9c-3e4121f5fa51\",\"choices\":[{\"delta\":{\"reasoning\":\"Paris\\\".\"},\"index\":0}],\"created\":1787702127,\"model\":\"gpt-oss-120b\",\"system_fingerprint\":\"fp_4cfabdd6620dc0120785\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"id\":\"chatcmpl-402a09cc-8668-446d-8f9c-3e4121f5fa51\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"function\":{\"name\":\"get_weather\",\"arguments\":\"\"},\"type\":\"function\",\"id\":\"3d860cefe\",\"index\":0}]},\"index\":0}],\"created\":1787702127,\"model\":\"gpt-oss-120b\",\"system_fingerprint\":\"fp_4cfabdd6620dc0120785\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"id\":\"chatcmpl-402a09cc-8668-446d-8f9c-3e4121f5fa51\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"function\":{\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\"},\"type\":\"function\",\"index\":0}]},\"index\":0}],\"created\":1787702127,\"model\":\"gpt-oss-120b\",\"system_fingerprint\":\"fp_4cfabdd6620dc0120785\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"id\":\"chatcmpl-402a09cc-8668-446d-8f9c-3e4121f5fa51\",\"choices\":[{\"delta\":{},\"finish_reason\":\"tool_calls\",\"index\":0}],\"created\":1787702127,\"model\":\"gpt-oss-120b\",\"system_fingerprint\":\"fp_4cfabdd6620dc0120785\",\"object\":\"chat.completion.chunk\",\"usage\":{\"total_tokens\":174,\"completion_tokens\":37,\"completion_tokens_details\":{\"accepted_prediction_tokens\":0,\"rejected_prediction_tokens\":0,\"reasoning_tokens\":13},\"prompt_tokens\":137,\"prompt_tokens_details\":{\"cached_tokens\":0}},\"time_info\":{\"created\":1787702127.8019717,\"queue_time\":0.31196235,\"prompt_time\":0.005234764,\"completion_time\":0.020198402,\"total_time\":0.702225923538208}}\n\ndata: [DONE]\n\n"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+50
File diff suppressed because one or more lines are too long
+2
-2
File diff suppressed because one or more lines are too long
+5
-9
File diff suppressed because one or more lines are too long
+2
-2
@@ -32,7 +32,7 @@
|
||||
{
|
||||
"direction": "client",
|
||||
"kind": "text",
|
||||
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"system\",\"content\":\"Call get_weather once, then reply exactly: Paris is sunny.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]}],\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"max_output_tokens\":50,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"}}"
|
||||
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]}],\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"max_output_tokens\":50,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"instructions\":\"Call get_weather once, then reply exactly: Paris is sunny.\"}"
|
||||
},
|
||||
{
|
||||
"direction": "server",
|
||||
@@ -62,7 +62,7 @@
|
||||
{
|
||||
"direction": "client",
|
||||
"kind": "text",
|
||||
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"type\":\"function_call_output\",\"call_id\":\"call_ws_weather\",\"output\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}],\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"max_output_tokens\":50,\"previous_response_id\":\"resp_ws_tool_1\",\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"}}"
|
||||
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"type\":\"function_call_output\",\"call_id\":\"call_ws_weather\",\"output\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}],\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"max_output_tokens\":50,\"previous_response_id\":\"resp_ws_tool_1\",\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"instructions\":\"Call get_weather once, then reply exactly: Paris is sunny.\"}"
|
||||
},
|
||||
{
|
||||
"direction": "server",
|
||||
|
||||
+2
-2
@@ -32,7 +32,7 @@
|
||||
{
|
||||
"direction": "client",
|
||||
"kind": "text",
|
||||
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"system\",\"content\":\"Follow the user's exact reply instruction.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Alpha.\"}]}],\"store\":false,\"max_output_tokens\":30,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"}}"
|
||||
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Alpha.\"}]}],\"store\":false,\"max_output_tokens\":30,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"instructions\":\"Follow the user's exact reply instruction.\"}"
|
||||
},
|
||||
{
|
||||
"direction": "server",
|
||||
@@ -81,7 +81,7 @@
|
||||
{
|
||||
"direction": "client",
|
||||
"kind": "text",
|
||||
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"system\",\"content\":\"Follow the user's exact reply instruction.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Alpha.\"}]},{\"type\":\"message\",\"id\":\"msg_ws_reconnect_1\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Alpha.\"}]},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Beta.\"}]}],\"store\":false,\"max_output_tokens\":30,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"}}"
|
||||
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Alpha.\"}]},{\"type\":\"message\",\"id\":\"msg_ws_reconnect_1\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Alpha.\"}]},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Beta.\"}]}],\"store\":false,\"max_output_tokens\":30,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"instructions\":\"Follow the user's exact reply instruction.\"}"
|
||||
},
|
||||
{
|
||||
"direction": "server",
|
||||
|
||||
+3
-3
@@ -32,7 +32,7 @@
|
||||
{
|
||||
"direction": "client",
|
||||
"kind": "text",
|
||||
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"system\",\"content\":\"Follow the user's exact reply instruction.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Ready.\"}]}],\"store\":false,\"max_output_tokens\":30,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"}}"
|
||||
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Ready.\"}]}],\"store\":false,\"max_output_tokens\":30,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"instructions\":\"Follow the user's exact reply instruction.\"}"
|
||||
},
|
||||
{
|
||||
"direction": "server",
|
||||
@@ -81,7 +81,7 @@
|
||||
{
|
||||
"direction": "client",
|
||||
"kind": "text",
|
||||
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Recovered.\"}]}],\"store\":false,\"max_output_tokens\":30,\"previous_response_id\":\"resp_ws_rejection_1\",\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"}}"
|
||||
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Recovered.\"}]}],\"store\":false,\"max_output_tokens\":30,\"previous_response_id\":\"resp_ws_rejection_1\",\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"instructions\":\"Follow the user's exact reply instruction.\"}"
|
||||
},
|
||||
{
|
||||
"direction": "server",
|
||||
@@ -91,7 +91,7 @@
|
||||
{
|
||||
"direction": "client",
|
||||
"kind": "text",
|
||||
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"system\",\"content\":\"Follow the user's exact reply instruction.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Ready.\"}]},{\"type\":\"message\",\"id\":\"msg_ws_rejection_1\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Ready.\"}]},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Recovered.\"}]}],\"store\":false,\"max_output_tokens\":30,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"}}"
|
||||
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Ready.\"}]},{\"type\":\"message\",\"id\":\"msg_ws_rejection_1\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Ready.\"}]},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Recovered.\"}]}],\"store\":false,\"max_output_tokens\":30,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"instructions\":\"Follow the user's exact reply instruction.\"}"
|
||||
},
|
||||
{
|
||||
"direction": "server",
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
@@ -26,7 +26,7 @@
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"system\",\"content\":\"Show concise reasoning when the provider supports visible reasoning summaries.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Think briefly, then reply exactly with: Hello!\"}]}],\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"low\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"max_output_tokens\":120,\"stream\":true}"
|
||||
"body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Think briefly, then reply exactly with: Hello!\"}]}],\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"low\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"max_output_tokens\":120,\"stream\":true,\"instructions\":\"Show concise reasoning when the provider supports visible reasoning summaries.\"}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
|
||||
Vendored
+1
-1
@@ -18,7 +18,7 @@
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"system\",\"content\":\"Show concise reasoning when the provider supports visible reasoning summaries.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Think briefly, then reply exactly with: Hello!\"}]}],\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"low\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"max_output_tokens\":120,\"stream\":true}"
|
||||
"body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Think briefly, then reply exactly with: Hello!\"}]}],\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"low\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"max_output_tokens\":120,\"stream\":true,\"instructions\":\"Show concise reasoning when the provider supports visible reasoning summaries.\"}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
|
||||
Vendored
+2
-2
@@ -25,7 +25,7 @@
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"system\",\"content\":\"Use the get_weather tool exactly once. After the tool result, reply exactly: Paris is sunny.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]}],\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"max_output_tokens\":80,\"stream\":true}"
|
||||
"body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]}],\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"max_output_tokens\":80,\"stream\":true,\"instructions\":\"Use the get_weather tool exactly once. After the tool result, reply exactly: Paris is sunny.\"}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
@@ -43,7 +43,7 @@
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"system\",\"content\":\"Use the get_weather tool exactly once. After the tool result, reply exactly: Paris is sunny.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]},{\"type\":\"reasoning\",\"id\":\"rs_0ad67c31d9ddad95016a869efbd02487d1a51eed850e6f87f5\",\"summary\":[],\"encrypted_content\":\"gAAAAABqhp79U9UPKmTdmo9tmdil0C2KXpkFqUc4MNkYHT53Lzos9omncFPg76QzUmmSOdBcajisWBEo-xiTCvhp135uACUq8TJcdw4DluieYq6dWszijy28PFFfeO-6MmHwi7zeln1Z202zErJUEyuf1bML68VAeam5PqlMLG-a4-pmnWiH2ExWKibTUX37QoMQoArrkccJOCmxwDflV_kWDPMFxQVDfeMg9fd1gVv2u-x1Mjk0b9mJDOq0Fe5Gh-IkpWzfXgZTdptFmCM75cksvs61Rqsx6P33czal-LSixEF0WMizCvbMQmqKGs7MKGMeoa6j6vWOnB3ICIbv6FShnSaTpZWJFwejvOurkfuxa-2q6xVDZsBoQCgMWPHsqLxwAo1JKdfBk0pMvSuvpw2BRxykUZ1ULCYJ-BypST65292-EuSZFIuXPMPir-_raSCTsgsZNMscDG6ll3qksDTDS6_o5NutD7Ra-WZzaUe_HQlSLKLACTc4qv2EK1QoC4aYv4goxkTSx17WhS2D86lILgkUd-TIHjJ6iR3uxSNx7YeBNxiJgddIAEjAaSrdF-WDouSNT9k3efd5HhT3zahIOMKgb3XIQzFOYWfWgea5-SbaIdKwne9hU0QyhcBQs6yoifSg-fJZtahbPb-GCDYnOLlH-bV94vldoccb-2P1JdB3jaLj5tJUecfr2H4qiu8MgkPj0TkwYNbJynYmJo9H5Lm-XJ9gfzIXzJh0arKwsS4gwDLf4J3LOEF3WEW3mknOjjb9PrLmHRYXQQh9tTiX9ILPZpbufkyCurTUMQgWiSCitXBC6FoLXRHilSmb-6_avBnlUMziMfey-FkKvRfiPox6BaJrnOq6SGlOv11y7EKvzrn29la7HKPygYenDAkyq2mq0Zk2nLWNmJcv9sQTBrkBdFMmJYPi2J2im8XD5MmAjEL8R4FCBHoPIIZ6pENQykvH8PhpWKuzF5gJlY3Vwz4iJ0Qb9TrNI0hzBoI1U0LeB5FJ2HgjZQwCFF5x3ubh72xrUsFpuyYyYPa8GDT0Bo-LW_IlJ_mN4EwI5Nk9n-8Bt015yxsfpa5YaDeCeQFcdj8SD0UAd7QWtGACpzKcIj1-vJJU7OiwscV_v1dLvoiEe1ehI9jcvPn28TgHlo_dippe0iMN4FAm1Bf8vtWVMFDvfV1rPv1pAFFnSa9XqFszD5Exo_xzcQEoKXvQv3OnUtoiM4Db4uadClazLjoep2TQgHcJBVbTbLySTVPmok4ROFQZsU_mq4vu2M__d8HOjADfIIYz5VLVQKNpo0Hv_QkT2bn56Q==\"},{\"type\":\"function_call\",\"id\":\"fc_0ad67c31d9ddad95016a869efd126887d1b7e2f17f155cb5dc\",\"call_id\":\"call_qrzOfKDfzaq8fqbSNNVHlNsV\",\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\"},{\"type\":\"function_call_output\",\"call_id\":\"call_qrzOfKDfzaq8fqbSNNVHlNsV\",\"output\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}],\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"max_output_tokens\":80,\"stream\":true}"
|
||||
"body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]},{\"type\":\"reasoning\",\"id\":\"rs_0ad67c31d9ddad95016a869efbd02487d1a51eed850e6f87f5\",\"summary\":[],\"encrypted_content\":\"gAAAAABqhp79U9UPKmTdmo9tmdil0C2KXpkFqUc4MNkYHT53Lzos9omncFPg76QzUmmSOdBcajisWBEo-xiTCvhp135uACUq8TJcdw4DluieYq6dWszijy28PFFfeO-6MmHwi7zeln1Z202zErJUEyuf1bML68VAeam5PqlMLG-a4-pmnWiH2ExWKibTUX37QoMQoArrkccJOCmxwDflV_kWDPMFxQVDfeMg9fd1gVv2u-x1Mjk0b9mJDOq0Fe5Gh-IkpWzfXgZTdptFmCM75cksvs61Rqsx6P33czal-LSixEF0WMizCvbMQmqKGs7MKGMeoa6j6vWOnB3ICIbv6FShnSaTpZWJFwejvOurkfuxa-2q6xVDZsBoQCgMWPHsqLxwAo1JKdfBk0pMvSuvpw2BRxykUZ1ULCYJ-BypST65292-EuSZFIuXPMPir-_raSCTsgsZNMscDG6ll3qksDTDS6_o5NutD7Ra-WZzaUe_HQlSLKLACTc4qv2EK1QoC4aYv4goxkTSx17WhS2D86lILgkUd-TIHjJ6iR3uxSNx7YeBNxiJgddIAEjAaSrdF-WDouSNT9k3efd5HhT3zahIOMKgb3XIQzFOYWfWgea5-SbaIdKwne9hU0QyhcBQs6yoifSg-fJZtahbPb-GCDYnOLlH-bV94vldoccb-2P1JdB3jaLj5tJUecfr2H4qiu8MgkPj0TkwYNbJynYmJo9H5Lm-XJ9gfzIXzJh0arKwsS4gwDLf4J3LOEF3WEW3mknOjjb9PrLmHRYXQQh9tTiX9ILPZpbufkyCurTUMQgWiSCitXBC6FoLXRHilSmb-6_avBnlUMziMfey-FkKvRfiPox6BaJrnOq6SGlOv11y7EKvzrn29la7HKPygYenDAkyq2mq0Zk2nLWNmJcv9sQTBrkBdFMmJYPi2J2im8XD5MmAjEL8R4FCBHoPIIZ6pENQykvH8PhpWKuzF5gJlY3Vwz4iJ0Qb9TrNI0hzBoI1U0LeB5FJ2HgjZQwCFF5x3ubh72xrUsFpuyYyYPa8GDT0Bo-LW_IlJ_mN4EwI5Nk9n-8Bt015yxsfpa5YaDeCeQFcdj8SD0UAd7QWtGACpzKcIj1-vJJU7OiwscV_v1dLvoiEe1ehI9jcvPn28TgHlo_dippe0iMN4FAm1Bf8vtWVMFDvfV1rPv1pAFFnSa9XqFszD5Exo_xzcQEoKXvQv3OnUtoiM4Db4uadClazLjoep2TQgHcJBVbTbLySTVPmok4ROFQZsU_mq4vu2M__d8HOjADfIIYz5VLVQKNpo0Hv_QkT2bn56Q==\"},{\"type\":\"function_call\",\"id\":\"fc_0ad67c31d9ddad95016a869efd126887d1b7e2f17f155cb5dc\",\"call_id\":\"call_qrzOfKDfzaq8fqbSNNVHlNsV\",\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\"},{\"type\":\"function_call_output\",\"call_id\":\"call_qrzOfKDfzaq8fqbSNNVHlNsV\",\"output\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}],\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"max_output_tokens\":80,\"stream\":true,\"instructions\":\"Use the get_weather tool exactly once. After the tool result, reply exactly: Paris is sunny.\"}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"gpt-4o-mini\",\"input\":[{\"role\":\"system\",\"content\":\"Read the PDF returned by the tool and follow the user's response format exactly.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Return only the verification code from the PDF.\"}]},{\"type\":\"function_call\",\"call_id\":\"call_pdf_1\",\"name\":\"read_pdf\",\"arguments\":\"{}\"},{\"type\":\"function_call_output\",\"call_id\":\"call_pdf_1\",\"output\":[{\"type\":\"input_text\",\"text\":\"PDF read successfully\"},{\"type\":\"input_file\",\"filename\":\"verification.pdf\",\"file_data\":\"data:application/pdf;base64,JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvTWVkaWFCb3ggWzAgMCA2MTIgNzkyXSAvUmVzb3VyY2VzIDw8IC9Gb250IDw8IC9GMSA1IDAgUiA+PiA+PiAvQ29udGVudHMgNCAwIFIgPj4KZW5kb2JqCjQgMCBvYmoKPDwgL0xlbmd0aCA3NSA+PgpzdHJlYW0KQlQKL0YxIDE4IFRmCjcyIDcyMCBUZAooUERGIGNhc3NldHRlIHZlcmlmaWNhdGlvbiBjb2RlOiBPUkNISUQtNzM5MSkgVGoKRVQKZW5kc3RyZWFtCmVuZG9iago1IDAgb2JqCjw8IC9UeXBlIC9Gb250IC9TdWJ0eXBlIC9UeXBlMSAvQmFzZUZvbnQgL0hlbHZldGljYSA+PgplbmRvYmoKeHJlZgowIDYKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDA5IDAwMDAwIG4gCjAwMDAwMDAwNTggMDAwMDAgbiAKMDAwMDAwMDExNSAwMDAwMCBuIAowMDAwMDAwMjQxIDAwMDAwIG4gCjAwMDAwMDAzNjUgMDAwMDAgbiAKdHJhaWxlcgo8PCAvU2l6ZSA2IC9Sb290IDEgMCBSID4+CnN0YXJ0eHJlZgo0MzUKJSVFT0YK\"}]}],\"tools\":[{\"type\":\"function\",\"name\":\"read_pdf\",\"description\":\"Read the attached PDF.\",\"parameters\":{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"max_output_tokens\":40,\"temperature\":0,\"stream\":true}"
|
||||
"body": "{\"model\":\"gpt-4o-mini\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Return only the verification code from the PDF.\"}]},{\"type\":\"function_call\",\"call_id\":\"call_pdf_1\",\"name\":\"read_pdf\",\"arguments\":\"{}\"},{\"type\":\"function_call_output\",\"call_id\":\"call_pdf_1\",\"output\":[{\"type\":\"input_text\",\"text\":\"PDF read successfully\"},{\"type\":\"input_file\",\"filename\":\"verification.pdf\",\"file_data\":\"data:application/pdf;base64,JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvTWVkaWFCb3ggWzAgMCA2MTIgNzkyXSAvUmVzb3VyY2VzIDw8IC9Gb250IDw8IC9GMSA1IDAgUiA+PiA+PiAvQ29udGVudHMgNCAwIFIgPj4KZW5kb2JqCjQgMCBvYmoKPDwgL0xlbmd0aCA3NSA+PgpzdHJlYW0KQlQKL0YxIDE4IFRmCjcyIDcyMCBUZAooUERGIGNhc3NldHRlIHZlcmlmaWNhdGlvbiBjb2RlOiBPUkNISUQtNzM5MSkgVGoKRVQKZW5kc3RyZWFtCmVuZG9iago1IDAgb2JqCjw8IC9UeXBlIC9Gb250IC9TdWJ0eXBlIC9UeXBlMSAvQmFzZUZvbnQgL0hlbHZldGljYSA+PgplbmRvYmoKeHJlZgowIDYKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDA5IDAwMDAwIG4gCjAwMDAwMDAwNTggMDAwMDAgbiAKMDAwMDAwMDExNSAwMDAwMCBuIAowMDAwMDAwMjQxIDAwMDAwIG4gCjAwMDAwMDAzNjUgMDAwMDAgbiAKdHJhaWxlcgo8PCAvU2l6ZSA2IC9Sb290IDEgMCBSID4+CnN0YXJ0eHJlZgo0MzUKJSVFT0YK\"}]}],\"tools\":[{\"type\":\"function\",\"name\":\"read_pdf\",\"description\":\"Read the attached PDF.\",\"parameters\":{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"max_output_tokens\":40,\"temperature\":0,\"stream\":true,\"instructions\":\"Read the PDF returned by the tool and follow the user's response format exactly.\"}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"grok-4.5\",\"input\":[{\"role\":\"system\",\"content\":\"Read the PDF returned by the tool and follow the user's response format exactly.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Return only the verification code from the PDF.\"}]},{\"type\":\"function_call\",\"call_id\":\"call_pdf_1\",\"name\":\"read_pdf\",\"arguments\":\"{}\"},{\"type\":\"function_call_output\",\"call_id\":\"call_pdf_1\",\"output\":[{\"type\":\"input_text\",\"text\":\"PDF read successfully\"},{\"type\":\"input_file\",\"filename\":\"verification.pdf\",\"file_data\":\"data:application/pdf;base64,JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvTWVkaWFCb3ggWzAgMCA2MTIgNzkyXSAvUmVzb3VyY2VzIDw8IC9Gb250IDw8IC9GMSA1IDAgUiA+PiA+PiAvQ29udGVudHMgNCAwIFIgPj4KZW5kb2JqCjQgMCBvYmoKPDwgL0xlbmd0aCA3NSA+PgpzdHJlYW0KQlQKL0YxIDE4IFRmCjcyIDcyMCBUZAooUERGIGNhc3NldHRlIHZlcmlmaWNhdGlvbiBjb2RlOiBPUkNISUQtNzM5MSkgVGoKRVQKZW5kc3RyZWFtCmVuZG9iago1IDAgb2JqCjw8IC9UeXBlIC9Gb250IC9TdWJ0eXBlIC9UeXBlMSAvQmFzZUZvbnQgL0hlbHZldGljYSA+PgplbmRvYmoKeHJlZgowIDYKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDA5IDAwMDAwIG4gCjAwMDAwMDAwNTggMDAwMDAgbiAKMDAwMDAwMDExNSAwMDAwMCBuIAowMDAwMDAwMjQxIDAwMDAwIG4gCjAwMDAwMDAzNjUgMDAwMDAgbiAKdHJhaWxlcgo8PCAvU2l6ZSA2IC9Sb290IDEgMCBSID4+CnN0YXJ0eHJlZgo0MzUKJSVFT0YK\"}]}],\"tools\":[{\"type\":\"function\",\"name\":\"read_pdf\",\"description\":\"Read the attached PDF.\",\"parameters\":{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"max_output_tokens\":40,\"temperature\":0,\"stream\":true}"
|
||||
"body": "{\"model\":\"grok-4.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Return only the verification code from the PDF.\"}]},{\"type\":\"function_call\",\"call_id\":\"call_pdf_1\",\"name\":\"read_pdf\",\"arguments\":\"{}\"},{\"type\":\"function_call_output\",\"call_id\":\"call_pdf_1\",\"output\":[{\"type\":\"input_text\",\"text\":\"PDF read successfully\"},{\"type\":\"input_file\",\"filename\":\"verification.pdf\",\"file_data\":\"data:application/pdf;base64,JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvTWVkaWFCb3ggWzAgMCA2MTIgNzkyXSAvUmVzb3VyY2VzIDw8IC9Gb250IDw8IC9GMSA1IDAgUiA+PiA+PiAvQ29udGVudHMgNCAwIFIgPj4KZW5kb2JqCjQgMCBvYmoKPDwgL0xlbmd0aCA3NSA+PgpzdHJlYW0KQlQKL0YxIDE4IFRmCjcyIDcyMCBUZAooUERGIGNhc3NldHRlIHZlcmlmaWNhdGlvbiBjb2RlOiBPUkNISUQtNzM5MSkgVGoKRVQKZW5kc3RyZWFtCmVuZG9iago1IDAgb2JqCjw8IC9UeXBlIC9Gb250IC9TdWJ0eXBlIC9UeXBlMSAvQmFzZUZvbnQgL0hlbHZldGljYSA+PgplbmRvYmoKeHJlZgowIDYKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDA5IDAwMDAwIG4gCjAwMDAwMDAwNTggMDAwMDAgbiAKMDAwMDAwMDExNSAwMDAwMCBuIAowMDAwMDAwMjQxIDAwMDAwIG4gCjAwMDAwMDAzNjUgMDAwMDAgbiAKdHJhaWxlcgo8PCAvU2l6ZSA2IC9Sb290IDEgMCBSID4+CnN0YXJ0eHJlZgo0MzUKJSVFT0YK\"}]}],\"tools\":[{\"type\":\"function\",\"name\":\"read_pdf\",\"description\":\"Read the attached PDF.\",\"parameters\":{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"max_output_tokens\":40,\"temperature\":0,\"stream\":true,\"instructions\":\"Read the PDF returned by the tool and follow the user's response format exactly.\"}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
|
||||
@@ -26,6 +26,8 @@ describe("provider package entrypoints", () => {
|
||||
import("@opencode-ai/ai/providers/amazon-bedrock/mantle"),
|
||||
import("@opencode-ai/ai/providers/amazon-bedrock/mantle/chat"),
|
||||
import("@opencode-ai/ai/providers/amazon-bedrock/mantle/responses"),
|
||||
import("@opencode-ai/ai/providers/togetherai"),
|
||||
import("@opencode-ai/ai/providers/cerebras"),
|
||||
])
|
||||
|
||||
for (const module of modules) expect(module.model).toBeFunction()
|
||||
|
||||
@@ -5,6 +5,7 @@ import { CacheHint, LLM, AIError, LLMRequest, Message, ToolCallPart, ToolDefinit
|
||||
import { Auth, LLMClient } from "../../src/route.js"
|
||||
import { compileRequest } from "../../src/route/client.js"
|
||||
import * as AnthropicMessages from "../../src/protocols/anthropic-messages.js"
|
||||
import { GoogleVertexMessages } from "../../src/providers.js"
|
||||
import { continuationRequest, nativeAnthropicMessagesContinuation } from "../continuation-scenarios.js"
|
||||
import { it } from "../lib/effect.js"
|
||||
import { dynamicResponse, fixedResponse } from "../lib/http.js"
|
||||
@@ -27,6 +28,12 @@ const compileUnsignedReasoning = (model: LLMRequest["model"]) =>
|
||||
}),
|
||||
)
|
||||
|
||||
const vertexOpus48 = GoogleVertexMessages.configure({
|
||||
accessToken: "test",
|
||||
location: "global",
|
||||
project: "test",
|
||||
}).model("claude-opus-4-8")
|
||||
|
||||
const request = LLM.request({
|
||||
id: "req_1",
|
||||
model,
|
||||
@@ -286,6 +293,149 @@ describe("Anthropic Messages route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps a terminal Vertex system update in the tool-result turn", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: vertexOpus48,
|
||||
messages: [
|
||||
Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: {} })]),
|
||||
Message.tool({ id: "call_1", name: "lookup", result: "Done." }),
|
||||
Message.system("Operator update."),
|
||||
],
|
||||
cache: "none",
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.messages).toEqual([
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "tool_use", id: "call_1", name: "lookup", input: {} }],
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "call_1",
|
||||
content: '"Done."',
|
||||
is_error: undefined,
|
||||
cache_control: undefined,
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
text: "<system-update>\nOperator update.\n</system-update>",
|
||||
cache_control: undefined,
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves folded tool-result system updates across multi-turn Vertex history", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: vertexOpus48,
|
||||
messages: [
|
||||
Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: {} })]),
|
||||
Message.tool({ id: "call_1", name: "lookup", result: "Done." }),
|
||||
Message.system("Operator update."),
|
||||
Message.assistant("Acknowledged."),
|
||||
Message.user("Next step."),
|
||||
],
|
||||
cache: "none",
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.messages).toEqual([
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "tool_use", id: "call_1", name: "lookup", input: {} }],
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "call_1",
|
||||
content: '"Done."',
|
||||
is_error: undefined,
|
||||
cache_control: undefined,
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
text: "<system-update>\nOperator update.\n</system-update>",
|
||||
cache_control: undefined,
|
||||
},
|
||||
],
|
||||
},
|
||||
{ role: "assistant", content: [{ type: "text", text: "Acknowledged." }] },
|
||||
{ role: "user", content: [{ type: "text", text: "Next step." }] },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps a terminal direct Anthropic system update native", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: opus48,
|
||||
messages: [
|
||||
Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: {} })]),
|
||||
Message.tool({ id: "call_1", name: "lookup", result: "Done." }),
|
||||
Message.system("Operator update."),
|
||||
],
|
||||
cache: "none",
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.messages).toEqual([
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "tool_use", id: "call_1", name: "lookup", input: {} }],
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "call_1",
|
||||
content: '"Done."',
|
||||
is_error: undefined,
|
||||
cache_control: undefined,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "system",
|
||||
content: [{ type: "text", text: "Operator update.", cache_control: undefined }],
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps an ordinary terminal Vertex system update native", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: vertexOpus48,
|
||||
messages: [Message.user("Before."), Message.system("Operator update.")],
|
||||
cache: "none",
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.messages).toEqual([
|
||||
{ role: "user", content: [{ type: "text", text: "Before." }] },
|
||||
{
|
||||
role: "system",
|
||||
content: [{ type: "text", text: "Operator update.", cache_control: undefined }],
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects a system update between a local tool call and its result", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* compileRequest(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as Anthropic from "../../src/providers/anthropic.js"
|
||||
import * as AnthropicCompatible from "../../src/providers/anthropic-compatible.js"
|
||||
import { Cerebras, TogetherAI } from "../../src/providers/index.js"
|
||||
import { CloudflareAIGateway, CloudflareWorkersAI } from "../../src/providers/cloudflare.js"
|
||||
import * as Google from "../../src/providers/google.js"
|
||||
import * as OpenAI from "../../src/providers/openai.js"
|
||||
@@ -47,11 +48,10 @@ const cloudflareWorkersAITools = cloudflareWorkers.model("@cf/openai/gpt-oss-20b
|
||||
const deepseek = OpenAICompatible.deepseek
|
||||
.configure({ apiKey: process.env.DEEPSEEK_API_KEY ?? "fixture" })
|
||||
.model("deepseek-chat")
|
||||
const together = OpenAICompatible.togetherai
|
||||
.configure({
|
||||
apiKey: process.env.TOGETHER_AI_API_KEY ?? "fixture",
|
||||
})
|
||||
.model("meta-llama/Llama-3.3-70B-Instruct-Turbo")
|
||||
const together = TogetherAI.configure({
|
||||
apiKey: process.env.TOGETHER_API_KEY ?? process.env.TOGETHER_AI_API_KEY ?? "fixture",
|
||||
}).model("meta-llama/Llama-3.3-70B-Instruct-Turbo")
|
||||
const cerebras = Cerebras.configure({ apiKey: process.env.CEREBRAS_API_KEY ?? "fixture" }).model("gpt-oss-120b")
|
||||
const groq = OpenAICompatible.groq
|
||||
.configure({ apiKey: process.env.GROQ_API_KEY ?? "fixture" })
|
||||
.model("llama-3.3-70b-versatile")
|
||||
@@ -193,8 +193,27 @@ describeRecordedGoldenScenarios([
|
||||
name: "TogetherAI Llama 3.3 70B",
|
||||
prefix: "openai-compatible-chat",
|
||||
model: together,
|
||||
requires: ["TOGETHER_AI_API_KEY"],
|
||||
scenarios: ["text", "tool-call"],
|
||||
requires: ["TOGETHER_API_KEY"],
|
||||
scenarios: [
|
||||
{
|
||||
id: "text",
|
||||
cassette: "openai-compatible-chat/togetherai-streams-text",
|
||||
prompt: "Reply with exactly: Hello!",
|
||||
maxTokens: 20,
|
||||
},
|
||||
{ id: "tool-call", cassette: "openai-compatible-chat/togetherai-streams-tool-call" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Cerebras GPT OSS 120B",
|
||||
prefix: "cerebras-chat",
|
||||
model: cerebras,
|
||||
requires: ["CEREBRAS_API_KEY"],
|
||||
scenarios: [
|
||||
{ id: "text", maxTokens: 256, temperature: false },
|
||||
{ id: "tool-call", maxTokens: 512, temperature: false },
|
||||
{ id: "tool-loop", maxTokens: 512, temperature: false, timeout: 30_000 },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Groq Llama 3.3 70B",
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { ConfigProvider, Effect } from "effect"
|
||||
import { HttpClientRequest } from "effect/unstable/http"
|
||||
import { LLM, Message, ToolDefinition } from "../../src/index.js"
|
||||
import { Cerebras, TogetherAI } from "../../src/providers/index.js"
|
||||
import { compileRequest } from "../../src/route/client.js"
|
||||
import { it } from "../lib/effect.js"
|
||||
import { dynamicResponse } from "../lib/http.js"
|
||||
import { sseEvents } from "../lib/sse.js"
|
||||
|
||||
describe("native OpenAI-compatible providers", () => {
|
||||
it.effect("preserves native Together AI and Cerebras provider and route identities", () =>
|
||||
Effect.gen(function* () {
|
||||
const together = TogetherAI.configure({ apiKey: "fixture" }).model("meta-llama/Llama-3.3-70B")
|
||||
const cerebras = Cerebras.configure({ apiKey: "fixture" }).model("qwen-3-235b-a22b")
|
||||
|
||||
expect(together).toMatchObject({
|
||||
provider: "togetherai",
|
||||
compatibility: { maxTokensField: "max_tokens", supportsStore: false, supportsStrictMode: false },
|
||||
route: { id: "togetherai-chat", protocol: "openai-chat" },
|
||||
})
|
||||
expect(together.route.endpoint.baseURL).toBe("https://api.together.xyz/v1")
|
||||
expect(cerebras).toMatchObject({
|
||||
provider: "cerebras",
|
||||
compatibility: { maxTokensField: "max_tokens", reasoningField: "reasoning", supportsStore: false },
|
||||
route: { id: "cerebras-chat", protocol: "openai-chat" },
|
||||
})
|
||||
expect(cerebras.route.endpoint.baseURL).toBe("https://api.cerebras.ai/v1")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("applies native provider request defaults even with a custom gateway URL", () =>
|
||||
Effect.gen(function* () {
|
||||
const together = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: TogetherAI.configure({ apiKey: "fixture", baseURL: "https://gateway.example/v1" }).model("llama"),
|
||||
prompt: "Use a tool.",
|
||||
generation: { maxTokens: 32 },
|
||||
tools: [
|
||||
ToolDefinition.make({ name: "lookup", description: "Look up data", inputSchema: { type: "object" } }),
|
||||
],
|
||||
providerOptions: { store: true },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(together.body).toMatchObject({
|
||||
max_tokens: 32,
|
||||
stream_options: { include_usage: true },
|
||||
tools: [{ function: { name: "lookup" } }],
|
||||
})
|
||||
expect(together.body).not.toHaveProperty("max_completion_tokens")
|
||||
expect(together.body).not.toHaveProperty("store")
|
||||
expect(together.body.tools?.[0]?.function).not.toHaveProperty("strict")
|
||||
|
||||
const cerebras = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: Cerebras.configure({ apiKey: "fixture", baseURL: "https://gateway.example/v1" }).model("qwen"),
|
||||
generation: { maxTokens: 48 },
|
||||
messages: [
|
||||
Message.user("Think first."),
|
||||
Message.assistant([
|
||||
{ type: "reasoning", text: "A deliberate thought." },
|
||||
{ type: "text", text: "An answer." },
|
||||
]),
|
||||
Message.user("Continue."),
|
||||
],
|
||||
providerOptions: { store: true },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(cerebras.body).toMatchObject({
|
||||
max_tokens: 48,
|
||||
messages: [
|
||||
{ role: "user", content: "Think first." },
|
||||
{ role: "assistant", content: "An answer.", reasoning: "A deliberate thought." },
|
||||
{ role: "user", content: "Continue." },
|
||||
],
|
||||
})
|
||||
expect(cerebras.body).not.toHaveProperty("max_completion_tokens")
|
||||
expect(cerebras.body).not.toHaveProperty("store")
|
||||
expect(cerebras.body.messages[1]).not.toHaveProperty("reasoning_content")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("maps package settings onto native executable models", () =>
|
||||
Effect.gen(function* () {
|
||||
for (const native of [TogetherAI, Cerebras]) {
|
||||
const selected = native.model("provider-model", {
|
||||
apiKey: "fixture",
|
||||
baseURL: "https://gateway.example/v1",
|
||||
headers: { "x-application": "opencode" },
|
||||
body: { service_tier: "priority" },
|
||||
providerOptions: { reasoningEffort: "high" },
|
||||
})
|
||||
|
||||
expect(selected.route.endpoint.baseURL).toBe("https://gateway.example/v1")
|
||||
expect(selected.route.defaults.headers).toEqual({ "x-application": "opencode" })
|
||||
expect(selected.route.defaults.http?.body).toEqual({ service_tier: "priority" })
|
||||
expect(selected.route.defaults.providerOptions).toEqual({ reasoningEffort: "high" })
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("resolves provider environment credentials and preserves deprecated Together credentials", () =>
|
||||
Effect.gen(function* () {
|
||||
const scenarios = [
|
||||
{
|
||||
model: TogetherAI.configure().model("llama"),
|
||||
env: { TOGETHER_API_KEY: "together-primary", TOGETHER_AI_API_KEY: "together-legacy" },
|
||||
token: "together-primary",
|
||||
url: "https://api.together.xyz/v1/chat/completions",
|
||||
},
|
||||
{
|
||||
model: TogetherAI.configure().model("llama"),
|
||||
env: { TOGETHER_AI_API_KEY: "together-legacy" },
|
||||
token: "together-legacy",
|
||||
url: "https://api.together.xyz/v1/chat/completions",
|
||||
},
|
||||
{
|
||||
model: Cerebras.configure().model("qwen"),
|
||||
env: { CEREBRAS_API_KEY: "cerebras-secret" },
|
||||
token: "cerebras-secret",
|
||||
url: "https://api.cerebras.ai/v1/chat/completions",
|
||||
},
|
||||
]
|
||||
|
||||
yield* Effect.forEach(scenarios, (scenario) =>
|
||||
LLM.generate(LLM.request({ model: scenario.model, 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(scenario.url)
|
||||
expect(request.headers.get("authorization")).toBe(`Bearer ${scenario.token}`)
|
||||
return input.respond(
|
||||
sseEvents(
|
||||
{ id: "chatcmpl_fixture", choices: [{ delta: { content: "Hello" }, finish_reason: null }] },
|
||||
{ id: "chatcmpl_fixture", choices: [{ delta: {}, finish_reason: "stop" }] },
|
||||
),
|
||||
{ headers: { "content-type": "text/event-stream" } },
|
||||
)
|
||||
}),
|
||||
),
|
||||
),
|
||||
Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env: scenario.env }))),
|
||||
Effect.tap((response) => Effect.sync(() => expect(response.text).toBe("Hello"))),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -169,6 +169,56 @@ describe("OpenAI Chat route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves observed reasoning fields when reasoning is required", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: LanguageModel.update(model, { compatibility: { requireReasoning: true } }),
|
||||
messages: [
|
||||
Message.assistant([
|
||||
{
|
||||
type: "reasoning",
|
||||
text: "thinking",
|
||||
providerMetadata: { openai: { reasoningField: "reasoning_text" } },
|
||||
},
|
||||
{ type: "text", text: "Hello" },
|
||||
]),
|
||||
Message.assistant("Done"),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.messages).toEqual([
|
||||
{ role: "assistant", content: "Hello", reasoning_text: "thinking" },
|
||||
{ role: "assistant", content: "Done", reasoning_content: "" },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("omits empty configured reasoning fields when reasoning is explicitly optional", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: LanguageModel.update(model, {
|
||||
compatibility: { reasoningField: "reasoning_text", requireReasoning: false },
|
||||
}),
|
||||
messages: [
|
||||
Message.assistant([
|
||||
{ type: "reasoning", text: "thinking" },
|
||||
{ type: "text", text: "Hello" },
|
||||
]),
|
||||
Message.assistant("Done"),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.messages).toEqual([
|
||||
{ role: "assistant", content: "Hello", reasoning_text: "thinking" },
|
||||
{ role: "assistant", content: "Done" },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects reasoning fields that conflict with assistant message fields", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* compileRequest(
|
||||
|
||||
@@ -317,6 +317,56 @@ describe("OpenAI-compatible Chat route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("requires reasoning for DeepSeek models, providers, and endpoints unless explicitly overridden", () =>
|
||||
Effect.gen(function* () {
|
||||
const cases = [
|
||||
{ id: "DeepSeek-V3", provider: "custom", baseURL: "https://api.custom.test/v1", required: true },
|
||||
{ id: "custom-model", provider: "deepseek", baseURL: "https://api.custom.test/v1", required: true },
|
||||
{ id: "custom-model", provider: "custom", baseURL: "https://API.DeepSeek.COM/v1", required: true },
|
||||
{ id: "ordinary-model", provider: "custom", baseURL: "https://api.custom.test/v1", required: false },
|
||||
{
|
||||
id: "ordinary-model",
|
||||
provider: "custom",
|
||||
baseURL: "https://api.custom.test/v1",
|
||||
compatibility: { requireReasoning: true, reasoningField: "reasoning" },
|
||||
required: true,
|
||||
field: "reasoning",
|
||||
},
|
||||
{
|
||||
id: "deepseek-chat",
|
||||
provider: "deepseek",
|
||||
baseURL: "https://api.deepseek.com/v1",
|
||||
compatibility: { requireReasoning: false },
|
||||
required: false,
|
||||
},
|
||||
] as const
|
||||
|
||||
yield* Effect.forEach(cases, (item) =>
|
||||
Effect.gen(function* () {
|
||||
const selected = OpenAICompatibleChat.route
|
||||
.with({ provider: item.provider, endpoint: { baseURL: item.baseURL } })
|
||||
.model({ id: item.id, compatibility: "compatibility" in item ? item.compatibility : undefined })
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: selected,
|
||||
messages: [
|
||||
Message.assistant("Hello"),
|
||||
Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: {} })]),
|
||||
Message.tool({ id: "call_1", name: "lookup", result: "Sunny" }),
|
||||
],
|
||||
}),
|
||||
)
|
||||
const field = "field" in item ? item.field : "reasoning_content"
|
||||
|
||||
for (const message of prepared.body.messages.filter((message) => message.role === "assistant")) {
|
||||
if (item.required) expect(message).toHaveProperty(field, "")
|
||||
else expect(message).not.toHaveProperty(field)
|
||||
}
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("posts to the configured compatible endpoint and parses text usage", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
|
||||
@@ -47,10 +47,8 @@ describe("Open Responses-compatible route", () => {
|
||||
})
|
||||
expect(prepared.body).toEqual({
|
||||
model: "example-model",
|
||||
input: [
|
||||
{ role: "system", content: "You are concise." },
|
||||
{ role: "user", content: [{ type: "input_text", text: "Say hello." }] },
|
||||
],
|
||||
input: [{ role: "user", content: [{ type: "input_text", text: "Say hello." }] }],
|
||||
instructions: "You are concise.",
|
||||
stream: true,
|
||||
store: false,
|
||||
include: ["reasoning.encrypted_content"],
|
||||
@@ -84,10 +82,12 @@ describe("Open Responses-compatible route", () => {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
system: "Initial instructions.",
|
||||
messages: [Message.user("Before."), Message.system("Operator update."), Message.assistant("After.")],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.instructions).toBe("Initial instructions.")
|
||||
expect(prepared.body.input).toEqual([
|
||||
{ role: "user", content: [{ type: "input_text", text: "Before." }] },
|
||||
{ role: "developer", content: "Operator update." },
|
||||
@@ -225,6 +225,44 @@ describe("Open Responses-compatible route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("replays only shared hosted tool items", () =>
|
||||
Effect.gen(function* () {
|
||||
const model = configure({
|
||||
apiKey: "test-key",
|
||||
baseURL: "https://responses.example.test/v1",
|
||||
provider: "example",
|
||||
}).model("example-model")
|
||||
const items = [
|
||||
{ type: "web_search_call", id: "ws_1", status: "completed" },
|
||||
{ type: "x_search_call", id: "x_search_1", status: "completed" },
|
||||
{ type: "future_call", id: "future_1", status: "completed" },
|
||||
{ type: "file_search_call", id: "fs_1", queries: "not-an-array" },
|
||||
]
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: items.map((item) =>
|
||||
Message.assistant({
|
||||
type: "tool-result",
|
||||
id: item.id,
|
||||
name: item.type,
|
||||
result: { type: "json", value: item },
|
||||
providerExecuted: true,
|
||||
providerMetadata: { openresponses: { itemId: item.id } },
|
||||
}),
|
||||
),
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.input).toEqual([
|
||||
items[0],
|
||||
{ role: "user", content: [{ type: "input_text", text: JSON.stringify(items[1]) }] },
|
||||
{ role: "user", content: [{ type: "input_text", text: JSON.stringify(items[2]) }] },
|
||||
{ role: "user", content: [{ type: "input_text", text: JSON.stringify(items[3]) }] },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("routes response deltas by output index", () =>
|
||||
Effect.gen(function* () {
|
||||
const model = configure({
|
||||
@@ -250,6 +288,43 @@ describe("Open Responses-compatible route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("streams function calls without optional item ids through the shared baseline", () =>
|
||||
Effect.gen(function* () {
|
||||
const model = configure({
|
||||
apiKey: "test-key",
|
||||
baseURL: "https://responses.example.test/v1",
|
||||
provider: "example",
|
||||
}).model("example-model")
|
||||
const item = { type: "function_call", call_id: "call_1", name: "lookup", arguments: "" }
|
||||
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Look it up." })).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ type: "response.output_item.added", output_index: 1, item },
|
||||
{
|
||||
type: "response.function_call_arguments.delta",
|
||||
output_index: 1,
|
||||
item_id: "opaque_item",
|
||||
delta: '{"query":"shared"}',
|
||||
},
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
output_index: 1,
|
||||
item: { ...item, arguments: '{"query":"complete"}' },
|
||||
},
|
||||
{ type: "response.completed", response: { id: "resp_1" } },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.events.filter(LLMEvent.is.toolCall)).toEqual([
|
||||
expect.objectContaining({ id: "call_1", name: "lookup", input: { query: "complete" } }),
|
||||
])
|
||||
expect(response.events.find(LLMEvent.is.toolCall)?.providerMetadata).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("finalizes pending function calls from completed response output", () =>
|
||||
Effect.gen(function* () {
|
||||
const model = configure({
|
||||
|
||||
@@ -136,6 +136,7 @@ describe("OpenAI Responses WebSocket recorded", () => {
|
||||
expect(channel.opens()).toBe(1)
|
||||
expect(channel.sent).toHaveLength(2)
|
||||
expect(channel.sent[1]).toMatchObject({
|
||||
instructions: "Call get_weather once, then reply exactly: Paris is sunny.",
|
||||
previous_response_id: expect.any(String),
|
||||
input: [{ type: "function_call_output", call_id: call.id, output: expect.any(String) }],
|
||||
})
|
||||
@@ -167,8 +168,8 @@ describe("OpenAI Responses WebSocket recorded", () => {
|
||||
expect(channel.opens()).toBe(2)
|
||||
expect(channel.sent[1]).not.toHaveProperty("previous_response_id")
|
||||
expect(channel.sent[1]).toMatchObject({
|
||||
instructions: "Follow the user's exact reply instruction.",
|
||||
input: [
|
||||
{ role: "system", content: "Follow the user's exact reply instruction." },
|
||||
{ role: "user", content: [{ type: "input_text", text: "Reply exactly: Alpha." }] },
|
||||
{ role: "assistant", content: [{ type: "output_text", text: "Alpha." }] },
|
||||
{ role: "user", content: [{ type: "input_text", text: "Reply exactly: Beta." }] },
|
||||
@@ -204,8 +205,8 @@ describe("OpenAI Responses WebSocket recorded", () => {
|
||||
expect(channel.sent[1]).toHaveProperty("previous_response_id", expect.any(String))
|
||||
expect(channel.sent[2]).not.toHaveProperty("previous_response_id")
|
||||
expect(channel.sent[2]).toMatchObject({
|
||||
instructions: "Follow the user's exact reply instruction.",
|
||||
input: [
|
||||
{ role: "system", content: "Follow the user's exact reply instruction." },
|
||||
{ role: "user", content: [{ type: "input_text", text: "Reply exactly: Ready." }] },
|
||||
{ role: "assistant", content: [{ type: "output_text", text: "Ready." }] },
|
||||
{ role: "user", content: [{ type: "input_text", text: "Reply exactly: Recovered." }] },
|
||||
|
||||
@@ -112,10 +112,8 @@ describe("OpenAI Responses route", () => {
|
||||
|
||||
expect(prepared.body).toEqual({
|
||||
model: "gpt-4.1-mini",
|
||||
input: [
|
||||
{ role: "system", content: "You are concise." },
|
||||
{ role: "user", content: [{ type: "input_text", text: "Say hello." }] },
|
||||
],
|
||||
input: [{ role: "user", content: [{ type: "input_text", text: "Say hello." }] }],
|
||||
instructions: "You are concise.",
|
||||
store: false,
|
||||
include: ["reasoning.encrypted_content"],
|
||||
stream: true,
|
||||
@@ -469,7 +467,7 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("continues a tool call with only the new tool output", () =>
|
||||
it.effect("continues an item-id-less tool call with only the new tool output", () =>
|
||||
Effect.gen(function* () {
|
||||
const firstRequest = {
|
||||
type: "response.create",
|
||||
@@ -485,7 +483,6 @@ describe("OpenAI Responses route", () => {
|
||||
type: "response.output_item.done",
|
||||
item: {
|
||||
type: "function_call",
|
||||
id: "fc_1",
|
||||
status: "completed",
|
||||
call_id: "call_1",
|
||||
name: "weather",
|
||||
@@ -1597,8 +1594,8 @@ describe("OpenAI Responses route", () => {
|
||||
)
|
||||
|
||||
expect(prepared.body).toMatchObject({
|
||||
instructions: "You are concise. Continue from the provided history.",
|
||||
input: [
|
||||
{ role: "system", content: "You are concise. Continue from the provided history." },
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
@@ -2120,6 +2117,47 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("routes item-id-less function arguments by output index and prefers item completion", () =>
|
||||
Effect.gen(function* () {
|
||||
const item = { type: "function_call", call_id: "call_1", name: "lookup", arguments: "" }
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ type: "response.output_item.added", output_index: 2, item },
|
||||
{
|
||||
type: "response.function_call_arguments.delta",
|
||||
output_index: 2,
|
||||
item_id: "opaque_delta",
|
||||
delta: '{"query":"streamed"}',
|
||||
},
|
||||
{
|
||||
type: "response.function_call_arguments.done",
|
||||
output_index: 2,
|
||||
item_id: "opaque_done",
|
||||
arguments: '{"query":"arguments-done"}',
|
||||
},
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
output_index: 2,
|
||||
item: { ...item, arguments: '{"query":"output-item-done"}' },
|
||||
},
|
||||
{ type: "response.completed", response: { id: "resp_1" } },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.events.filter((event) => event.type === "tool-input-delta")).toMatchObject([
|
||||
{ id: "call_1", text: '{"query":"streamed"}' },
|
||||
])
|
||||
expect(response.events.filter(LLMEvent.is.toolCall)).toEqual([
|
||||
expect.objectContaining({ id: "call_1", name: "lookup", input: { query: "output-item-done" } }),
|
||||
])
|
||||
expect(response.events.find(LLMEvent.is.toolCall)?.providerMetadata).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("routes reasoning summary events by output index", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
@@ -2298,13 +2336,25 @@ describe("OpenAI Responses route", () => {
|
||||
it.effect("rejects function argument events without the spec-required item id", () =>
|
||||
Effect.gen(function* () {
|
||||
const events = [
|
||||
{ type: "response.function_call_arguments.delta", delta: "{}" },
|
||||
{ type: "response.function_call_arguments.done", arguments: "{}" },
|
||||
{ type: "response.function_call_arguments.delta", output_index: 0, delta: "{}" },
|
||||
{ type: "response.function_call_arguments.done", output_index: 0, arguments: "{}" },
|
||||
]
|
||||
|
||||
for (const event of events) {
|
||||
const error = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(fixedResponse(sseEvents(event, { type: "response.completed", response: { id: "resp_1" } }))),
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{
|
||||
type: "response.output_item.added",
|
||||
output_index: 0,
|
||||
item: { type: "function_call", call_id: "call_1", name: "lookup", arguments: "" },
|
||||
},
|
||||
event,
|
||||
{ type: "response.completed", response: { id: "resp_1" } },
|
||||
),
|
||||
),
|
||||
),
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
@@ -2758,7 +2808,7 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("closes reasoning summary parts when storage is not disabled", () =>
|
||||
it.effect("preserves final reasoning metadata when storage is enabled", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(LLMRequest.update(request, { providerOptions: { store: true } })).pipe(
|
||||
Effect.provide(
|
||||
@@ -2776,7 +2826,7 @@ describe("OpenAI Responses route", () => {
|
||||
{ type: "response.reasoning_summary_part.done", item_id: "rs_1", summary_index: 1 },
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: { type: "reasoning", id: "rs_1", encrypted_content: null },
|
||||
item: { type: "reasoning", id: "rs_1", encrypted_content: "encrypted-state" },
|
||||
},
|
||||
{ type: "response.completed", response: { id: "resp_1" } },
|
||||
),
|
||||
@@ -2786,7 +2836,11 @@ describe("OpenAI Responses route", () => {
|
||||
|
||||
expect(response.events.filter((event) => event.type === "reasoning-end")).toEqual([
|
||||
{ type: "reasoning-end", id: "rs_1:0", providerMetadata: { openai: { itemId: "rs_1" } } },
|
||||
{ type: "reasoning-end", id: "rs_1:1", providerMetadata: { openai: { itemId: "rs_1" } } },
|
||||
{
|
||||
type: "reasoning-end",
|
||||
id: "rs_1:1",
|
||||
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
@@ -2891,7 +2945,7 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("references stored reasoning items by id", () =>
|
||||
it.effect("replays complete reasoning items when storage is enabled", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
@@ -2901,7 +2955,7 @@ describe("OpenAI Responses route", () => {
|
||||
{
|
||||
type: "reasoning",
|
||||
text: "Checked the previous diff.",
|
||||
providerMetadata: { openai: { itemId: "rs_1" } },
|
||||
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
|
||||
},
|
||||
]),
|
||||
],
|
||||
@@ -2909,12 +2963,20 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.input).toEqual([{ type: "item_reference", id: "rs_1" }])
|
||||
expect(prepared.body.input).toEqual([
|
||||
{
|
||||
type: "reasoning",
|
||||
id: "rs_1",
|
||||
summary: [{ type: "summary_text", text: "Checked the previous diff." }],
|
||||
encrypted_content: "encrypted-state",
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("references stored provider-executed hosted tool results by id", () =>
|
||||
it.effect("replays complete hosted tool items when storage is enabled", () =>
|
||||
Effect.gen(function* () {
|
||||
const item = { type: "web_search_call", id: "ws_1", status: "completed" }
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
@@ -2931,7 +2993,7 @@ describe("OpenAI Responses route", () => {
|
||||
type: "tool-result",
|
||||
id: "ws_1",
|
||||
name: "web_search",
|
||||
result: { type: "json", value: { type: "web_search_call", id: "ws_1", status: "completed" } },
|
||||
result: { type: "json", value: item },
|
||||
providerExecuted: true,
|
||||
providerMetadata: { openai: { itemId: "ws_1" } },
|
||||
},
|
||||
@@ -2943,14 +3005,15 @@ describe("OpenAI Responses route", () => {
|
||||
)
|
||||
|
||||
expect(prepared.body.input).toEqual([
|
||||
{ type: "item_reference", id: "ws_1" },
|
||||
item,
|
||||
{ role: "user", content: [{ type: "input_text", text: "Continue." }] },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("continues stateless hosted tool results with their text form", () =>
|
||||
it.effect("replays stateless hosted tool results as native provider items", () =>
|
||||
Effect.gen(function* () {
|
||||
const item = { type: "web_search_call", id: "ws_1", status: "completed" }
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
@@ -2968,7 +3031,7 @@ describe("OpenAI Responses route", () => {
|
||||
type: "tool-result",
|
||||
id: "ws_1",
|
||||
name: "web_search",
|
||||
result: { type: "json", value: { type: "web_search_call", id: "ws_1", status: "completed" } },
|
||||
result: { type: "json", value: item },
|
||||
providerExecuted: true,
|
||||
providerMetadata: { openai: { itemId: "ws_1" } },
|
||||
},
|
||||
@@ -2981,6 +3044,74 @@ describe("OpenAI Responses route", () => {
|
||||
|
||||
expect(prepared.body.input).toEqual([
|
||||
{ role: "user", content: [{ type: "input_text", text: "Search." }] },
|
||||
{ type: "web_search_call", id: "ws_1", status: "completed" },
|
||||
{ role: "user", content: [{ type: "input_text", text: "Continue." }] },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("replays OpenAI hosted tool extensions but rejects foreign and unknown items", () =>
|
||||
Effect.gen(function* () {
|
||||
const items = [
|
||||
{ type: "computer_call", id: "computer_1", status: "completed", action: { type: "click", x: 1, y: 2 } },
|
||||
{ type: "x_search_call", id: "x_search_1", status: "completed" },
|
||||
{ type: "future_call", id: "future_1", status: "completed" },
|
||||
]
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: items.map((item) =>
|
||||
Message.assistant({
|
||||
type: "tool-result",
|
||||
id: item.id,
|
||||
name: item.type,
|
||||
result: { type: "json", value: item },
|
||||
providerExecuted: true,
|
||||
providerMetadata: { openai: { itemId: item.id } },
|
||||
}),
|
||||
),
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.input).toEqual([
|
||||
items[0],
|
||||
{ role: "user", content: [{ type: "input_text", text: JSON.stringify(items[1]) }] },
|
||||
{ role: "user", content: [{ type: "input_text", text: JSON.stringify(items[2]) }] },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves foreign hosted tool results as portable message content when storage is enabled", () =>
|
||||
Effect.gen(function* () {
|
||||
const item = { type: "web_search_call", id: "ws_1", status: "completed" }
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: xaiModel,
|
||||
messages: [
|
||||
Message.assistant([
|
||||
ToolCallPart.make({
|
||||
id: "ws_1",
|
||||
name: "web_search",
|
||||
input: { query: "effect 4" },
|
||||
providerExecuted: true,
|
||||
providerMetadata: { openai: { itemId: "ws_1" } },
|
||||
}),
|
||||
{
|
||||
type: "tool-result",
|
||||
id: "ws_1",
|
||||
name: "web_search",
|
||||
result: { type: "json", value: item },
|
||||
providerExecuted: true,
|
||||
providerMetadata: { openai: { itemId: "ws_1" } },
|
||||
},
|
||||
]),
|
||||
Message.user("Continue."),
|
||||
],
|
||||
providerOptions: { store: true },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.input).toEqual([
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "input_text", text: '{"type":"web_search_call","id":"ws_1","status":"completed"}' }],
|
||||
@@ -2990,6 +3121,35 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not replay hosted tool items whose result id differs from provider metadata", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [
|
||||
Message.assistant([
|
||||
{
|
||||
type: "tool-result",
|
||||
id: "ws_1",
|
||||
name: "web_search",
|
||||
result: { type: "json", value: { type: "web_search_call", id: "ws_other", status: "completed" } },
|
||||
providerExecuted: true,
|
||||
providerMetadata: { openai: { itemId: "ws_1" } },
|
||||
},
|
||||
]),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.input).toEqual([
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "input_text", text: '{"type":"web_search_call","id":"ws_other","status":"completed"}' }],
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("drops replayed item ids outside the server's grammar", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
@@ -3046,25 +3206,28 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps well-formed hosted references and drops malformed ones under storage", () =>
|
||||
it.effect("falls back to portable hosted results when stored item metadata is malformed", () =>
|
||||
Effect.gen(function* () {
|
||||
const hostedResult = (itemId: string) => [
|
||||
ToolCallPart.make({
|
||||
id: itemId,
|
||||
name: "web_search",
|
||||
input: { query: "effect 4" },
|
||||
providerExecuted: true,
|
||||
providerMetadata: { openai: { itemId } },
|
||||
}),
|
||||
{
|
||||
type: "tool-result" as const,
|
||||
id: itemId,
|
||||
name: "web_search",
|
||||
result: { type: "json" as const, value: { status: "completed" } },
|
||||
providerExecuted: true as const,
|
||||
providerMetadata: { openai: { itemId } },
|
||||
},
|
||||
]
|
||||
const hostedResult = (itemId: string) => {
|
||||
const item = { type: "web_search_call", id: itemId, status: "completed" }
|
||||
return [
|
||||
ToolCallPart.make({
|
||||
id: itemId,
|
||||
name: "web_search",
|
||||
input: { query: "effect 4" },
|
||||
providerExecuted: true,
|
||||
providerMetadata: { openai: { itemId } },
|
||||
}),
|
||||
{
|
||||
type: "tool-result" as const,
|
||||
id: itemId,
|
||||
name: "web_search",
|
||||
result: { type: "json" as const, value: item },
|
||||
providerExecuted: true as const,
|
||||
providerMetadata: { openai: { itemId } },
|
||||
},
|
||||
]
|
||||
}
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
@@ -3073,7 +3236,13 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.input).toEqual([{ type: "item_reference", id: "ws_1" }])
|
||||
expect(prepared.body.input).toEqual([
|
||||
{ type: "web_search_call", id: "ws_1", status: "completed" },
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "input_text", text: '{"type":"web_search_call","id":"bad ref","status":"completed"}' }],
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -3119,6 +3288,43 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves foreign hosted images as portable image content when storage is enabled", () =>
|
||||
Effect.gen(function* () {
|
||||
const item = { type: "image_generation_call", id: "ig_1", status: "completed", result: "AQID" }
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: xaiModel,
|
||||
messages: [
|
||||
Message.assistant([
|
||||
ToolCallPart.make({
|
||||
id: "ig_1",
|
||||
name: "image_generation",
|
||||
input: {},
|
||||
providerExecuted: true,
|
||||
providerMetadata: { openai: { itemId: "ig_1" } },
|
||||
}),
|
||||
ToolResultPart.make({
|
||||
id: "ig_1",
|
||||
name: "image_generation",
|
||||
result: {
|
||||
type: "content",
|
||||
value: [{ type: "file", uri: "data:image/png;base64,AQID", mime: "image/png" }],
|
||||
},
|
||||
providerExecuted: true,
|
||||
providerMetadata: { openai: { itemId: "ig_1" } },
|
||||
}),
|
||||
]),
|
||||
],
|
||||
providerOptions: { store: true },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.input).toEqual([
|
||||
{ role: "user", content: [{ type: "input_image", image_url: "data:image/png;base64,AQID" }] },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("joins streamed summary blocks into one continuation reasoning item", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
@@ -3301,6 +3507,43 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("finalizes and replays a completed function call without an optional item id", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: { type: "function_call", call_id: "call_1", name: "lookup", arguments: '{"query":"weather"}' },
|
||||
},
|
||||
{ type: "response.completed", response: { id: "resp_1" } },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.events.filter(LLMEvent.is.toolCall)).toEqual([
|
||||
expect.objectContaining({ id: "call_1", name: "lookup", input: { query: "weather" } }),
|
||||
])
|
||||
expect(response.events.find(LLMEvent.is.toolCall)?.providerMetadata).toBeUndefined()
|
||||
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [
|
||||
response.message,
|
||||
Message.tool({ id: "call_1", name: "lookup", resultType: "json", result: { forecast: "sunny" } }),
|
||||
],
|
||||
}),
|
||||
)
|
||||
expect(prepared.body.input).toEqual([
|
||||
{ type: "function_call", call_id: "call_1", name: "lookup", arguments: '{"query":"weather"}' },
|
||||
{ type: "function_call_output", call_id: "call_1", output: '{"forecast":"sunny"}' },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("emits only missing function arguments from the arguments done event", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = sseEvents(
|
||||
@@ -3525,6 +3768,37 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("reconciles an item-id-less pending function call from completed response output", () =>
|
||||
Effect.gen(function* () {
|
||||
const item = { type: "function_call", call_id: "call_1", name: "lookup", arguments: "" }
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ type: "response.output_item.added", output_index: 0, item },
|
||||
{
|
||||
type: "response.function_call_arguments.delta",
|
||||
output_index: 0,
|
||||
item_id: "opaque_delta",
|
||||
delta: '{"query":"partial',
|
||||
},
|
||||
{
|
||||
type: "response.completed",
|
||||
response: { id: "resp_1", output: [{ ...item, arguments: '{"query":"complete"}' }] },
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.events.filter(LLMEvent.is.toolCall)).toEqual([
|
||||
expect.objectContaining({ id: "call_1", name: "lookup", input: { query: "complete" } }),
|
||||
])
|
||||
expect(response.events.find(LLMEvent.is.toolCall)?.providerMetadata).toBeUndefined()
|
||||
expect(response.events.filter(LLMEvent.is.toolInputEnd)).toHaveLength(1)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("lets completed response output override arguments done", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = sseEvents(
|
||||
@@ -3828,13 +4102,15 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("decodes image generation output as image content", () =>
|
||||
it.effect("replays hosted image results as portable content regardless of storage", () =>
|
||||
Effect.gen(function* () {
|
||||
const item = {
|
||||
type: "image_generation_call",
|
||||
id: "ig_1",
|
||||
status: "completed",
|
||||
result: "AQID",
|
||||
action: "generate",
|
||||
output_format: "png",
|
||||
}
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
@@ -3847,6 +4123,9 @@ describe("OpenAI Responses route", () => {
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.events.find(LLMEvent.is.toolCall)).toMatchObject({
|
||||
providerMetadata: { openai: { itemId: "ig_1" } },
|
||||
})
|
||||
expect(response.events.find(LLMEvent.is.toolResult)).toMatchObject({
|
||||
id: "ig_1",
|
||||
name: "image_generation",
|
||||
@@ -3855,7 +4134,52 @@ describe("OpenAI Responses route", () => {
|
||||
type: "content",
|
||||
value: [{ type: "file", uri: "data:image/png;base64,AQID", mime: "image/png" }],
|
||||
},
|
||||
providerMetadata: { openai: { itemId: "ig_1" } },
|
||||
})
|
||||
|
||||
const prepared = yield* Effect.forEach([false, true], (store) =>
|
||||
compileRequest(LLM.request({ model, messages: [response.message], providerOptions: { store } })),
|
||||
)
|
||||
expect(prepared.map((request) => request.body.input)).toEqual([
|
||||
[{ role: "user", content: [{ type: "input_image", image_url: "data:image/png;base64,AQID" }] }],
|
||||
[{ role: "user", content: [{ type: "input_image", image_url: "data:image/png;base64,AQID" }] }],
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves failed hosted tool results as portable error content", () =>
|
||||
Effect.gen(function* () {
|
||||
const item = {
|
||||
type: "web_search_call",
|
||||
id: "ws_failed",
|
||||
status: "failed",
|
||||
error: { code: "search_failed", message: "Search unavailable" },
|
||||
}
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ type: "response.output_item.done", item },
|
||||
{ type: "response.completed", response: { id: "resp_1" } },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.events.find(LLMEvent.is.toolResult)).toMatchObject({
|
||||
result: { type: "error", value: item.error },
|
||||
providerMetadata: { openai: { itemId: "ws_failed" } },
|
||||
})
|
||||
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({ model, messages: [response.message], providerOptions: { store: true } }),
|
||||
)
|
||||
expect(prepared.body.input).toEqual([
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "input_text", text: '{"code":"search_failed","message":"Search unavailable"}' }],
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { LLM, LLMEvent } from "../../src/index.js"
|
||||
import { LLM, LLMEvent, Message } from "../../src/index.js"
|
||||
import { XAI } from "../../src/providers.js"
|
||||
import { OpenResponses } from "../../src/protocols/open-responses.js"
|
||||
import { OpenAIResponses } from "../../src/protocols/openai-responses.js"
|
||||
@@ -14,9 +14,9 @@ import { sseEvents } from "../lib/sse.js"
|
||||
const model = XAI.configure({ apiKey: "test", baseURL: "https://api.x.ai/v1" }).responses("grok-4.6")
|
||||
|
||||
describe("xAI Responses route", () => {
|
||||
it.effect("extends the Open Responses baseline directly", () =>
|
||||
it.effect("composes the Open Responses baseline with xAI extensions", () =>
|
||||
Effect.gen(function* () {
|
||||
expect(XAIResponses.protocol.body).toBe(OpenResponses.protocol.body)
|
||||
expect(XAIResponses.protocol.body).not.toBe(OpenResponses.protocol.body)
|
||||
expect(XAIResponses.protocol.body).not.toBe(OpenAIResponses.protocol.body)
|
||||
|
||||
const prepared = yield* compileRequest(LLM.request({ model, prompt: "Hello" }))
|
||||
@@ -106,16 +106,70 @@ describe("xAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("replays xAI hosted tool items when continuing with the same provider", () =>
|
||||
Effect.gen(function* () {
|
||||
const item = { type: "x_search_call", id: "x_search_1", status: "completed", action: { query: "news" } }
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [
|
||||
Message.assistant([
|
||||
{
|
||||
type: "tool-result",
|
||||
id: "x_search_1",
|
||||
name: "x_search",
|
||||
result: { type: "json", value: item },
|
||||
providerExecuted: true,
|
||||
providerMetadata: { xai: { itemId: "x_search_1" } },
|
||||
},
|
||||
]),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.input).toEqual([item])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("replays shared and xAI hosted tool items but rejects OpenAI extensions", () =>
|
||||
Effect.gen(function* () {
|
||||
const items = [
|
||||
{ type: "web_search_call", id: "ws_1", status: "completed" },
|
||||
{ type: "image_generation_call", id: "ig_1", status: "completed", result: "AQID" },
|
||||
{ type: "computer_call", id: "computer_1", status: "completed" },
|
||||
]
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: items.map((item) =>
|
||||
Message.assistant({
|
||||
type: "tool-result",
|
||||
id: item.id,
|
||||
name: item.type,
|
||||
result: { type: "json", value: item },
|
||||
providerExecuted: true,
|
||||
providerMetadata: { xai: { itemId: item.id } },
|
||||
}),
|
||||
),
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.input).toEqual([
|
||||
items[0],
|
||||
items[1],
|
||||
{ role: "user", content: [{ type: "input_text", text: JSON.stringify(items[2]) }] },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("parses xAI hosted tool items", () =>
|
||||
Effect.gen(function* () {
|
||||
const item = { type: "x_search_call", id: "x_search_1", status: "completed", action: { query: "news" } }
|
||||
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Search X" })).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: { type: "x_search_call", id: "x_search_1", status: "completed", action: { query: "news" } },
|
||||
},
|
||||
{ type: "response.output_item.done", item },
|
||||
{ type: "response.completed", response: { id: "response_1" } },
|
||||
),
|
||||
),
|
||||
@@ -127,6 +181,11 @@ describe("xAI Responses route", () => {
|
||||
name: "x_search",
|
||||
input: { query: "news" },
|
||||
providerExecuted: true,
|
||||
providerMetadata: { xai: { itemId: "x_search_1" } },
|
||||
})
|
||||
expect(response.events.find(LLMEvent.is.toolResult)).toMatchObject({
|
||||
result: { type: "json", value: item },
|
||||
providerMetadata: { xai: { itemId: "x_search_1" } },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -20,6 +20,7 @@ type ScenarioInput =
|
||||
readonly name?: string
|
||||
readonly cassette?: string
|
||||
readonly tags?: ReadonlyArray<string>
|
||||
readonly prompt?: string
|
||||
readonly maxTokens?: number
|
||||
readonly temperature?: number | false
|
||||
readonly timeout?: number
|
||||
@@ -87,6 +88,7 @@ const runTarget = (target: TargetInput) => {
|
||||
yield* runGoldenScenario(input.id, {
|
||||
id: `recorded_${kebab(target.name).replaceAll("-", "_")}_${input.id.replaceAll("-", "_")}`,
|
||||
model: target.model,
|
||||
prompt: input.prompt,
|
||||
maxTokens: input.maxTokens,
|
||||
temperature: input.temperature,
|
||||
})
|
||||
|
||||
@@ -164,6 +164,7 @@ export const expectGoldenWeatherToolLoop = (events: ReadonlyArray<LLMEvent>) =>
|
||||
export interface GoldenScenarioContext {
|
||||
readonly id: string
|
||||
readonly model: LanguageModel
|
||||
readonly prompt?: string
|
||||
readonly maxTokens?: number
|
||||
readonly temperature?: number | false
|
||||
}
|
||||
@@ -298,7 +299,7 @@ const runGeneratedConversation = (context: GoldenScenarioContext, steps: Readonl
|
||||
|
||||
const runTextScenario = (context: GoldenScenarioContext) =>
|
||||
runGeneratedConversation(context, [
|
||||
user("Reply exactly with: Hello!"),
|
||||
user(context.prompt ?? "Reply exactly with: Hello!"),
|
||||
assistant.expectText(/^Hello!?$/, {
|
||||
system: "You are concise.",
|
||||
maxTokens: context.maxTokens ?? 40,
|
||||
|
||||
@@ -102,6 +102,38 @@ describe("AI.Usage", () => {
|
||||
expect(error.reason._tag).toBe("InvalidProviderOutput")
|
||||
})
|
||||
|
||||
test("sseFraming ignores retry directives without ending the stream", async () => {
|
||||
const encoder = new TextEncoder()
|
||||
const frames = await Effect.runPromise(
|
||||
ProviderShared.sseFraming(
|
||||
Stream.make(
|
||||
encoder.encode("retry: 1000\n\n"),
|
||||
encoder.encode('data: {"first":true}\n\n'),
|
||||
encoder.encode("retry: 2000\n\n"),
|
||||
encoder.encode('data: {"second":true}\n\n'),
|
||||
).pipe(Stream.rechunk(1)),
|
||||
).pipe(Stream.runCollect),
|
||||
)
|
||||
|
||||
expect(Array.from(frames)).toEqual(['{"first":true}', '{"second":true}'])
|
||||
})
|
||||
|
||||
test("sseFraming preserves event data around retry directives", async () => {
|
||||
const encoder = new TextEncoder()
|
||||
const frames = await Effect.runPromise(
|
||||
ProviderShared.sseFraming(
|
||||
Stream.make(
|
||||
encoder.encode("event: update\ndata: first\n"),
|
||||
encoder.encode("retry: 1000\n"),
|
||||
encoder.encode("data: second\n\n"),
|
||||
).pipe(Stream.rechunk(1)),
|
||||
new Set(["update"]),
|
||||
).pipe(Stream.runCollect),
|
||||
)
|
||||
|
||||
expect(Array.from(frames)).toEqual(["first\nsecond"])
|
||||
})
|
||||
|
||||
test("visibleOutputTokens clamps reasoning > output to zero", () => {
|
||||
expect(new Usage({ outputTokens: 10, reasoningTokens: 4 }).visibleOutputTokens).toBe(6)
|
||||
expect(new Usage({ outputTokens: 10 }).visibleOutputTokens).toBe(10)
|
||||
|
||||
@@ -92,6 +92,15 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
initialOffset: () => (input.pinned() ? Number.MAX_SAFE_INTEGER : 0),
|
||||
initialMeasurementsCache: initialMeasurements,
|
||||
estimateSize: () => fallbackItemSize,
|
||||
// Do not replace this with TanStack's default measurer: without a ResizeObserver entry,
|
||||
// it returns the cached height instead of reading the element (TanStack/virtual#1183).
|
||||
// Restored sessions, deferred tools, and rewrapped content can then keep stale heights;
|
||||
// our fixed-height, overflow-clipped rows will hide their content. Keep observer entries
|
||||
// on the cheap precomputed path, but make explicit measurements read the real height.
|
||||
measureElement: (element, entry) => {
|
||||
const box = entry?.borderBoxSize[0]
|
||||
return box ? Math.round(box.blockSize) : element.offsetHeight
|
||||
},
|
||||
scrollToFn: (offset, options, instance) => {
|
||||
if (virtualContent) virtualContent.style.height = `${instance.getTotalSize()}px`
|
||||
elementScroll(offset, options, instance)
|
||||
|
||||
@@ -6,6 +6,7 @@ import { LocalProvider } from "@/providers/models/selection"
|
||||
import type { ServerConnection } from "@/runtime/server/registry"
|
||||
import { sessionHref } from "@/shell/routes/session"
|
||||
import { useData } from "@/runtime/server/current"
|
||||
import { useServerSDK } from "@/runtime/server/client"
|
||||
import { useTabs } from "@/shell/tabs/tabs"
|
||||
|
||||
export function SessionUIProvider(
|
||||
@@ -17,6 +18,7 @@ export function SessionUIProvider(
|
||||
const navigate = useNavigate()
|
||||
const params = useParams()
|
||||
const data = useData()
|
||||
const serverSDK = useServerSDK()
|
||||
const tabs = useTabs()
|
||||
const directory = () => props.directory
|
||||
const href = (sessionID: string) => sessionHref(props.server, sessionID)
|
||||
@@ -53,6 +55,7 @@ export function SessionUIProvider(
|
||||
data={sessionUIData()}
|
||||
directory={directory()}
|
||||
sessionID={params.id}
|
||||
shellOutput={(input) => serverSDK.api.shell.output(input)}
|
||||
onNavigateToSession={navigateToSession}
|
||||
onSessionHref={href}
|
||||
>
|
||||
|
||||
@@ -113,6 +113,32 @@ test("reactive count updates preserve measured row sizes", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("explicit measurement refreshes a cached row size with a custom measurer", () => {
|
||||
const root = document.createElement("div")
|
||||
const element = document.createElement("div")
|
||||
element.dataset.index = "0"
|
||||
Object.defineProperty(element, "offsetHeight", { value: 120 })
|
||||
|
||||
const virtualizer = new Virtualizer<HTMLDivElement, HTMLDivElement>({
|
||||
count: 1,
|
||||
estimateSize: () => 60,
|
||||
initialRect: { width: 400, height: 200 },
|
||||
getScrollElement: () => root,
|
||||
scrollToFn: () => {},
|
||||
observeElementRect: () => {},
|
||||
observeElementOffset: () => {},
|
||||
measureElement: (node) => node.offsetHeight,
|
||||
})
|
||||
|
||||
virtualizer.getTotalSize()
|
||||
virtualizer.resizeItem(0, 60)
|
||||
virtualizer._willUpdate()
|
||||
virtualizer.measureElement(element)
|
||||
|
||||
expect(virtualizer.itemSizeCache.get(0)).toBe(120)
|
||||
expect(virtualizer.getTotalSize()).toBe(120)
|
||||
})
|
||||
|
||||
test("initial rect projects rows before a scroll element connects", () => {
|
||||
createRoot((dispose) => {
|
||||
const virtualizer = createVirtualizer<HTMLDivElement, HTMLDivElement>({
|
||||
|
||||
@@ -181,7 +181,7 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
|
||||
Spec.make("add", {
|
||||
description: "Install a plugin and add it to the global configuration",
|
||||
params: {
|
||||
package: Argument.string("package").pipe(Argument.withDescription("npm registry package specifier")),
|
||||
package: Argument.string("package").pipe(Argument.withDescription("npm registry or Git package specifier")),
|
||||
},
|
||||
}),
|
||||
Spec.make("remove", {
|
||||
|
||||
@@ -13,10 +13,8 @@ import { Config } from "../../../config"
|
||||
export default Runtime.handler(
|
||||
Commands.commands.plugin.commands.add,
|
||||
Effect.fn("cli.plugin.add")(function* (input) {
|
||||
if (!(yield* Effect.promise(() => Npm.isRegistryPackage(input.package))))
|
||||
return yield* Effect.fail(
|
||||
new Error("Plugin target must be an npm registry package name, version, tag, or semver range"),
|
||||
)
|
||||
if (!(yield* Effect.promise(() => Npm.isInstallablePackage(input.package))))
|
||||
return yield* Effect.fail(new Error("Plugin target must be an npm registry package or Git package specifier"))
|
||||
const npm = yield* Npm.Service
|
||||
const installed = yield* npm.add(input.package, { subpaths: ["server", ""] })
|
||||
const tui = yield* npm.resolve(input.package, { subpaths: ["tui"] })
|
||||
|
||||
@@ -88,8 +88,35 @@ export type PluginListInput = {
|
||||
export type PluginListOutput = { readonly location: Location.Info; readonly data: ReadonlyArray<Plugin.Info> }
|
||||
export type PluginListOperation<E = never> = (input?: PluginListInput) => Effect.Effect<PluginListOutput, E>
|
||||
|
||||
export type PluginCheckInput = {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}
|
||||
export type PluginCheckOutput = { readonly location: Location.Info; readonly data: ReadonlyArray<Plugin.UpdateInfo> }
|
||||
export type PluginCheckOperation<E = never> = (input?: PluginCheckInput) => Effect.Effect<PluginCheckOutput, E>
|
||||
|
||||
export type PluginUpdateInput = {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
readonly name: string
|
||||
}
|
||||
export type PluginUpdateOutput = { readonly location: Location.Info; readonly data: Plugin.UpdateResult }
|
||||
export type PluginUpdateOperation<E = never> = (input: PluginUpdateInput) => Effect.Effect<PluginUpdateOutput, E>
|
||||
|
||||
export type PluginUpdateAllInput = {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}
|
||||
export type PluginUpdateAllOutput = {
|
||||
readonly location: Location.Info
|
||||
readonly data: ReadonlyArray<Plugin.UpdateResult>
|
||||
}
|
||||
export type PluginUpdateAllOperation<E = never> = (
|
||||
input?: PluginUpdateAllInput,
|
||||
) => Effect.Effect<PluginUpdateAllOutput, E>
|
||||
|
||||
export interface PluginApi<E = never> {
|
||||
readonly list: PluginListOperation<E>
|
||||
readonly check: PluginCheckOperation<E>
|
||||
readonly update: PluginUpdateOperation<E>
|
||||
readonly updateAll: PluginUpdateAllOperation<E>
|
||||
}
|
||||
|
||||
export type SessionListInput = {
|
||||
|
||||
@@ -15,6 +15,12 @@ import type {
|
||||
AgentGetOutput,
|
||||
PluginListInput,
|
||||
PluginListOutput,
|
||||
PluginCheckInput,
|
||||
PluginCheckOutput,
|
||||
PluginUpdateInput,
|
||||
PluginUpdateOutput,
|
||||
PluginUpdateAllInput,
|
||||
PluginUpdateAllOutput,
|
||||
SessionListInput,
|
||||
SessionListOutput,
|
||||
SessionStatsInput,
|
||||
@@ -313,7 +319,29 @@ const EndpointPluginList = (raw: RawClient["server.plugin"]) => (input?: PluginL
|
||||
raw["plugin.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const adaptGroupPlugin = (raw: RawClient["server.plugin"]) => ({ list: EndpointPluginList(raw) })
|
||||
const EndpointPluginCheck = (raw: RawClient["server.plugin"]) => (input?: PluginCheckInput) =>
|
||||
preserveEffect<PluginCheckOutput>()(
|
||||
raw["plugin.check"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const EndpointPluginUpdate = (raw: RawClient["server.plugin"]) => (input: PluginUpdateInput) =>
|
||||
preserveEffect<PluginUpdateOutput>()(
|
||||
raw["plugin.update"]({ query: { location: input["location"] }, payload: { name: input["name"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
),
|
||||
)
|
||||
|
||||
const EndpointPluginUpdateAll = (raw: RawClient["server.plugin"]) => (input?: PluginUpdateAllInput) =>
|
||||
preserveEffect<PluginUpdateAllOutput>()(
|
||||
raw["plugin.updateAll"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const adaptGroupPlugin = (raw: RawClient["server.plugin"]) => ({
|
||||
list: EndpointPluginList(raw),
|
||||
check: EndpointPluginCheck(raw),
|
||||
update: EndpointPluginUpdate(raw),
|
||||
updateAll: EndpointPluginUpdateAll(raw),
|
||||
})
|
||||
|
||||
const EndpointSessionList = (raw: RawClient["server.session"]) => (input?: SessionListInput) =>
|
||||
preserveEffect<SessionListOutput>()(
|
||||
|
||||
@@ -9,6 +9,12 @@ import type {
|
||||
AgentGetOutput,
|
||||
PluginListInput,
|
||||
PluginListOutput,
|
||||
PluginCheckInput,
|
||||
PluginCheckOutput,
|
||||
PluginUpdateInput,
|
||||
PluginUpdateOutput,
|
||||
PluginUpdateAllInput,
|
||||
PluginUpdateAllOutput,
|
||||
SessionListInput,
|
||||
SessionListOutput,
|
||||
SessionStatsInput,
|
||||
@@ -457,6 +463,43 @@ export function make(options: ClientOptions) {
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
check: (input?: PluginCheckInput, requestOptions?: RequestOptions) =>
|
||||
request<PluginCheckOutput>(
|
||||
{
|
||||
method: "GET",
|
||||
path: `/api/plugin/update`,
|
||||
query: { location: input?.["location"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [401, 400],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
update: (input: PluginUpdateInput, requestOptions?: RequestOptions) =>
|
||||
request<PluginUpdateOutput>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/plugin/update`,
|
||||
query: { location: input["location"] },
|
||||
body: { name: input["name"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [401, 400],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
updateAll: (input?: PluginUpdateAllInput, requestOptions?: RequestOptions) =>
|
||||
request<PluginUpdateAllOutput>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/plugin/update-all`,
|
||||
query: { location: input?.["location"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [401, 400],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
},
|
||||
session: {
|
||||
list: (input?: SessionListInput, requestOptions?: RequestOptions) =>
|
||||
|
||||
@@ -426,6 +426,24 @@ export type PluginInfo =
|
||||
| { id: string; source: PluginSource; status: "active"; tui: boolean }
|
||||
| { id?: string; source: PluginSource; status: "failed"; error: string; tui: boolean }
|
||||
|
||||
export type PluginUpdateInfo = {
|
||||
name: string
|
||||
source: PluginSource
|
||||
status: "not-updateable" | "pinned" | "up-to-date" | "available" | "failed"
|
||||
currentVersion?: string
|
||||
latestVersion?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
export type PluginUpdateResult = {
|
||||
name: string
|
||||
source: PluginSource
|
||||
status: "not-updateable" | "pinned" | "up-to-date" | "updated" | "failed"
|
||||
previousVersion?: string
|
||||
version?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
export type SessionMessageLocationSwitched = {
|
||||
id: string
|
||||
metadata?: { [x: string]: JsonValue }
|
||||
@@ -1319,6 +1337,7 @@ export type ToolContent1 = ToolTextContent | ToolFileContent1
|
||||
|
||||
export type ModelCompatibility = {
|
||||
reasoningField?: ModelReasoningField
|
||||
requireReasoning?: boolean
|
||||
maxTokensField?: ModelMaxTokensField
|
||||
requireFinishReason?: boolean
|
||||
requireAssistantAfterTool?: boolean
|
||||
@@ -2132,6 +2151,7 @@ export type SessionMessagesResponse = {
|
||||
export type IntegrationInfo = {
|
||||
id: string
|
||||
name: string
|
||||
metadata?: { [x: string]: any }
|
||||
methods: Array<IntegrationMethod>
|
||||
connections: Array<ConnectionInfo>
|
||||
}
|
||||
@@ -2458,6 +2478,40 @@ export type PluginListOutput = {
|
||||
data: Array<PluginInfo>
|
||||
}
|
||||
|
||||
export type PluginCheckInput = {
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}["location"]
|
||||
}
|
||||
|
||||
export type PluginCheckOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||
data: Array<PluginUpdateInfo>
|
||||
}
|
||||
|
||||
export type PluginUpdateInput = {
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}["location"]
|
||||
readonly name: { readonly name: string }["name"]
|
||||
}
|
||||
|
||||
export type PluginUpdateOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||
data: PluginUpdateResult
|
||||
}
|
||||
|
||||
export type PluginUpdateAllInput = {
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}["location"]
|
||||
}
|
||||
|
||||
export type PluginUpdateAllOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||
data: Array<PluginUpdateResult>
|
||||
}
|
||||
|
||||
export type SessionListInput = {
|
||||
readonly workspace?: {
|
||||
readonly workspace?: string | undefined
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
"benchmark:location": "bun run script/benchmark-location.ts",
|
||||
"build": "bun run script/build.ts",
|
||||
"update-models-snapshot": "bun run script/update-models-snapshot.ts",
|
||||
"test": "bun test --only-failures",
|
||||
"test": "bun run script/test.ts",
|
||||
"typecheck": "tsgo -b tsconfig.json tsconfig.tests.json"
|
||||
},
|
||||
"exports": {
|
||||
@@ -102,7 +102,6 @@
|
||||
"@ai-sdk/amazon-bedrock": "4.0.112",
|
||||
"@ai-sdk/anthropic": "3.0.82",
|
||||
"@ai-sdk/azure": "3.0.88",
|
||||
"@ai-sdk/cerebras": "2.0.41",
|
||||
"@ai-sdk/cohere": "3.0.27",
|
||||
"@ai-sdk/deepinfra": "2.0.41",
|
||||
"@ai-sdk/gateway": "3.0.104",
|
||||
@@ -113,7 +112,6 @@
|
||||
"@ai-sdk/perplexity": "3.0.26",
|
||||
"@ai-sdk/provider": "3.0.8",
|
||||
"@ai-sdk/provider-utils": "4.0.23",
|
||||
"@ai-sdk/togetherai": "2.0.41",
|
||||
"@ai-sdk/vercel": "2.0.39",
|
||||
"@aws-sdk/credential-providers": "3.1057.0",
|
||||
"@lydell/node-pty": "catalog:",
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { tmpdir } from "../test/fixture/tmpdir"
|
||||
|
||||
await using directory = await tmpdir("oc-")
|
||||
const home = directory.path
|
||||
const temporary = path.join(home, "tmp")
|
||||
await fs.mkdir(temporary)
|
||||
|
||||
const environment = {
|
||||
...Object.fromEntries(
|
||||
Object.entries(process.env).filter(([name]) => {
|
||||
if (process.env.RECORD === "true" && name === "OPENAI_API_KEY") return true
|
||||
if (
|
||||
/^(?:AWS|AZURE|GOOGLE|GCP|GCLOUD|VERTEX|OPENAI|ANTHROPIC|GEMINI|XAI|CLOUDFLARE|CF_AIG|SNOWFLAKE|AICORE|GITLAB|NPM_CONFIG)_/i.test(
|
||||
name,
|
||||
)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
return !/(?:^|_)(?:API_KEY|AUTHORIZATION|TOKEN|SECRET|PASSWORD|CREDENTIALS?)$/i.test(name)
|
||||
}),
|
||||
),
|
||||
HOME: home,
|
||||
OPENCODE_TEST_HOME: home,
|
||||
XDG_CONFIG_HOME: path.join(home, ".config"),
|
||||
XDG_DATA_HOME: path.join(home, ".local", "share"),
|
||||
XDG_CACHE_HOME: path.join(home, ".cache"),
|
||||
XDG_STATE_HOME: path.join(home, ".local", "state"),
|
||||
OPENCODE_CONFIG_DIR: path.join(home, ".config", "opencode"),
|
||||
OPENCODE_CONFIG: undefined,
|
||||
OPENCODE_CONFIG_CONTENT: undefined,
|
||||
TMPDIR: temporary,
|
||||
...(process.platform === "win32" ? { USERPROFILE: home, TMP: temporary, TEMP: temporary } : {}),
|
||||
}
|
||||
|
||||
const child = Bun.spawn({
|
||||
cmd: [process.execPath, "test", "--only-failures", ...process.argv.slice(2)],
|
||||
env: environment,
|
||||
stdin: "inherit",
|
||||
stdout: "inherit",
|
||||
stderr: "inherit",
|
||||
})
|
||||
const interrupt = () => child.kill("SIGINT")
|
||||
const terminate = () => child.kill("SIGTERM")
|
||||
process.once("SIGINT", interrupt)
|
||||
process.once("SIGTERM", terminate)
|
||||
const result = await child.exited
|
||||
process.off("SIGINT", interrupt)
|
||||
process.off("SIGTERM", terminate)
|
||||
|
||||
process.exitCode = result
|
||||
@@ -53,6 +53,17 @@ export function map(input: MapInput): Mapping | undefined {
|
||||
...mapOpenAIOptions(input.settings),
|
||||
},
|
||||
}
|
||||
case "@ai-sdk/cerebras":
|
||||
case "@ai-sdk/togetherai":
|
||||
return {
|
||||
package: `@opencode-ai/ai/providers/${input.packageName === "@ai-sdk/cerebras" ? "cerebras" : "togetherai"}`,
|
||||
settings: {
|
||||
...baseSettings,
|
||||
...mapAPIKey(input.settings),
|
||||
...mapProviderOptions(input.settings, ["apiKey", "baseURL", "fetch", "headers", "name"]),
|
||||
},
|
||||
...(isStringRecord(input.settings.headers) ? { headers: input.settings.headers } : {}),
|
||||
}
|
||||
case "@ai-sdk/google":
|
||||
return {
|
||||
package: "@opencode-ai/ai/providers/google",
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
/* oxlint-disable */
|
||||
import type { MigrationConfig } from "drizzle-orm/migrator"
|
||||
import { readMigrationFiles } from "drizzle-orm/migrator"
|
||||
import type { AnyRelations } from "drizzle-orm/relations"
|
||||
import { migrate as coreMigrate } from "../sqlite-core/effect/session.js"
|
||||
import type { EffectSQLiteDatabase } from "./driver.js"
|
||||
|
||||
export function migrate<TRelations extends AnyRelations>(
|
||||
db: EffectSQLiteDatabase<TRelations>,
|
||||
config: MigrationConfig,
|
||||
) {
|
||||
const migrations = readMigrationFiles(config)
|
||||
return coreMigrate(migrations, db.session, config)
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
export { EffectLogger } from "drizzle-orm/effect-core"
|
||||
export * from "./effect-sqlite/driver.js"
|
||||
export * from "./effect-sqlite/session.js"
|
||||
export { migrate } from "./effect-sqlite/migrator.js"
|
||||
|
||||
export * as EffectDrizzleSqlite from "./index.js"
|
||||
|
||||
@@ -5,13 +5,10 @@ import type { SqlError } from "effect/unstable/sql/SqlError"
|
||||
import type { EffectCacheShape } from "drizzle-orm/cache/core/cache-effect"
|
||||
import { NoopCache, strategyFor } from "drizzle-orm/cache/core/cache"
|
||||
import type { WithCacheConfig } from "drizzle-orm/cache/core/types"
|
||||
import { MigratorInitError } from "drizzle-orm/effect-core/errors"
|
||||
import { EffectDrizzleQueryError, EffectTransactionRollbackError } from "drizzle-orm/effect-core/errors"
|
||||
import type { EffectLoggerShape } from "drizzle-orm/effect-core/logger"
|
||||
import type { QueryEffectHKTBase, QueryEffectKind } from "drizzle-orm/effect-core/query-effect"
|
||||
import { entityKind, is } from "drizzle-orm/entity"
|
||||
import type { MigrationConfig, MigrationMeta } from "drizzle-orm/migrator"
|
||||
import { getMigrationsToRun } from "drizzle-orm/migrator.utils"
|
||||
import type {
|
||||
AnyRelations,
|
||||
EmptyRelations,
|
||||
@@ -20,17 +17,14 @@ import type {
|
||||
} from "drizzle-orm/relations"
|
||||
import { makeJitRqbMapper } from "drizzle-orm/relations"
|
||||
import type { PreparedQuery } from "drizzle-orm/session"
|
||||
import { fillPlaceholders, type Query, type SQL, sql } from "drizzle-orm/sql/sql"
|
||||
import { fillPlaceholders, type Query, type SQL } from "drizzle-orm/sql/sql"
|
||||
import type { SQLiteDialect } from "drizzle-orm/sqlite-core/dialect"
|
||||
import type { SelectedFieldsOrdered } from "drizzle-orm/sqlite-core/query-builders/select.types"
|
||||
import type { PreparedQueryConfig, SQLiteExecuteMethod, SQLiteTransactionConfig } from "drizzle-orm/sqlite-core/session"
|
||||
import { upgradeIfNeeded } from "../../up-migrations/effect-sqlite.js"
|
||||
import { assertUnreachable, makeJitQueryMapper, type RowsMapper } from "drizzle-orm/utils"
|
||||
import { mapResultRow, resolveNullableObjectPaths } from "../../internal/drizzle-utils.js"
|
||||
import { SQLiteEffectDatabase } from "./db.js"
|
||||
|
||||
type MigrationConfigWithInit = MigrationConfig & { init?: boolean }
|
||||
|
||||
type SQLiteEffectExecuteMethod = SQLiteExecuteMethod | "values"
|
||||
|
||||
export class SQLiteEffectPreparedQuery<
|
||||
@@ -429,69 +423,3 @@ export abstract class SQLiteEffectTransaction<
|
||||
return new EffectTransactionRollbackError()
|
||||
}
|
||||
}
|
||||
|
||||
export const migrate = Effect.fn("migrate")(function* <TEffectHKT extends QueryEffectHKTBase>(
|
||||
migrations: MigrationMeta[],
|
||||
session: SQLiteEffectSession<TEffectHKT>,
|
||||
config: string | MigrationConfigWithInit,
|
||||
) {
|
||||
const migrationsTable =
|
||||
typeof config === "string" ? "__drizzle_migrations" : (config.migrationsTable ?? "__drizzle_migrations")
|
||||
|
||||
const { newDb } = yield* upgradeIfNeeded(migrationsTable, session, migrations)
|
||||
|
||||
if (newDb) {
|
||||
yield* session.run(sql`
|
||||
CREATE TABLE IF NOT EXISTS ${sql.identifier(migrationsTable)} (
|
||||
id INTEGER PRIMARY KEY,
|
||||
hash text NOT NULL,
|
||||
created_at numeric,
|
||||
name text,
|
||||
applied_at TEXT
|
||||
)
|
||||
`)
|
||||
}
|
||||
|
||||
const dbMigrations = yield* session.all<{ id: number; hash: string; created_at: string; name: string | null }>(
|
||||
sql`SELECT id, hash, created_at, name FROM ${sql.identifier(migrationsTable)}`,
|
||||
)
|
||||
|
||||
if (typeof config === "object" && config.init) {
|
||||
if (dbMigrations.length) {
|
||||
return yield* new MigratorInitError({ exitCode: "databaseMigrations" })
|
||||
}
|
||||
|
||||
if (migrations.length > 1) {
|
||||
return yield* new MigratorInitError({ exitCode: "localMigrations" })
|
||||
}
|
||||
|
||||
const [migration] = migrations
|
||||
if (!migration) return
|
||||
|
||||
yield* session.run(
|
||||
sql`insert into ${sql.identifier(
|
||||
migrationsTable,
|
||||
)} ("hash", "created_at", "name", "applied_at") values(${migration.hash}, ${migration.folderMillis}, ${migration.name}, ${new Date().toISOString()})`,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const migrationsToRun = getMigrationsToRun({ localMigrations: migrations, dbMigrations })
|
||||
if (migrationsToRun.length === 0) return
|
||||
|
||||
yield* session.transaction((tx) =>
|
||||
Effect.gen(function* () {
|
||||
for (const migration of migrationsToRun) {
|
||||
for (const stmt of migration.sql) {
|
||||
yield* tx.run(sql.raw(stmt))
|
||||
}
|
||||
yield* tx.run(
|
||||
sql`insert into ${sql.identifier(
|
||||
migrationsTable,
|
||||
)} ("hash", "created_at", "name", "applied_at") values(${migration.hash}, ${migration.folderMillis}, ${migration.name}, ${new Date().toISOString()})`,
|
||||
)
|
||||
}
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
/* oxlint-disable */
|
||||
import * as Effect from "effect/Effect"
|
||||
import type { SqlError } from "effect/unstable/sql/SqlError"
|
||||
import { EffectDrizzleError } from "drizzle-orm/effect-core/errors"
|
||||
import type { QueryEffectHKTBase } from "drizzle-orm/effect-core/query-effect"
|
||||
import type { MigrationMeta } from "drizzle-orm/migrator"
|
||||
import { sql } from "drizzle-orm/sql/sql"
|
||||
import type { SQLiteEffectSession } from "../sqlite-core/effect/session.js"
|
||||
import {
|
||||
buildSQLiteMigrationBackfillStatements,
|
||||
prepareSQLiteMigrationBackfill,
|
||||
type SQLiteMigrationTableRow,
|
||||
} from "./sqlite.js"
|
||||
import { GET_VERSION_FOR, MIGRATIONS_TABLE_VERSIONS, type UpgradeResult } from "./utils.js"
|
||||
|
||||
const migrationUpgradeError = (cause: unknown) =>
|
||||
new EffectDrizzleError({
|
||||
message:
|
||||
typeof cause === "object" && cause !== null && "message" in cause && typeof cause.message === "string"
|
||||
? cause.message
|
||||
: String(cause),
|
||||
cause,
|
||||
})
|
||||
|
||||
export const upgradeIfNeeded: <TEffectHKT extends QueryEffectHKTBase>(
|
||||
migrationsTable: string,
|
||||
session: SQLiteEffectSession<TEffectHKT>,
|
||||
localMigrations: MigrationMeta[],
|
||||
) => Effect.Effect<UpgradeResult, EffectDrizzleError | TEffectHKT["error"] | SqlError, TEffectHKT["context"]> =
|
||||
Effect.fn("upgradeIfNeeded")(function* <TEffectHKT extends QueryEffectHKTBase>(
|
||||
migrationsTable: string,
|
||||
session: SQLiteEffectSession<TEffectHKT>,
|
||||
localMigrations: MigrationMeta[],
|
||||
) {
|
||||
const tableExists = yield* session.all(
|
||||
sql`SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ${migrationsTable}`,
|
||||
)
|
||||
|
||||
if (tableExists.length === 0) {
|
||||
return { newDb: true }
|
||||
}
|
||||
|
||||
const rows = yield* session.all<{ column_name: string }>(
|
||||
sql`SELECT name as column_name FROM pragma_table_info(${migrationsTable})`,
|
||||
)
|
||||
|
||||
const version = GET_VERSION_FOR.sqlite(rows.map((r) => r.column_name))
|
||||
|
||||
for (let v = version; v < MIGRATIONS_TABLE_VERSIONS.sqlite; v++) {
|
||||
const upgradeFn = upgradeFunctions[v]
|
||||
if (!upgradeFn) {
|
||||
return yield* new EffectDrizzleError({
|
||||
message: `No upgrade path from migration table version ${v} to ${v + 1}`,
|
||||
cause: { version: v },
|
||||
})
|
||||
}
|
||||
yield* upgradeFn(migrationsTable, session, localMigrations)
|
||||
}
|
||||
|
||||
return { newDb: false }
|
||||
})
|
||||
|
||||
const upgradeFunctions: Record<
|
||||
number,
|
||||
<TEffectHKT extends QueryEffectHKTBase>(
|
||||
migrationsTable: string,
|
||||
session: SQLiteEffectSession<TEffectHKT>,
|
||||
localMigrations: MigrationMeta[],
|
||||
) => Effect.Effect<void, EffectDrizzleError | TEffectHKT["error"] | SqlError, TEffectHKT["context"]>
|
||||
> = {
|
||||
0: upgradeFromV0,
|
||||
}
|
||||
|
||||
function upgradeFromV0<TEffectHKT extends QueryEffectHKTBase>(
|
||||
migrationsTable: string,
|
||||
session: SQLiteEffectSession<TEffectHKT>,
|
||||
localMigrations: MigrationMeta[],
|
||||
): Effect.Effect<void, EffectDrizzleError | TEffectHKT["error"] | SqlError, TEffectHKT["context"]> {
|
||||
return Effect.gen(function* () {
|
||||
const table = sql`${sql.identifier(migrationsTable)}`
|
||||
|
||||
const dbRows = yield* session.all<SQLiteMigrationTableRow>(
|
||||
sql`SELECT id, hash, created_at FROM ${table} ORDER BY id ASC`,
|
||||
)
|
||||
const statements = yield* Effect.try({
|
||||
try: () =>
|
||||
buildSQLiteMigrationBackfillStatements(
|
||||
migrationsTable,
|
||||
prepareSQLiteMigrationBackfill(dbRows, localMigrations),
|
||||
),
|
||||
catch: migrationUpgradeError,
|
||||
})
|
||||
|
||||
yield* session.transaction((tx) =>
|
||||
Effect.gen(function* () {
|
||||
for (const statement of statements) {
|
||||
yield* tx.run(statement)
|
||||
}
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
/* oxlint-disable */
|
||||
import type { MigrationMeta } from "drizzle-orm/migrator"
|
||||
import { type SQL, sql } from "drizzle-orm/sql/sql"
|
||||
|
||||
/** @internal */
|
||||
export type SQLiteMigrationTableRow = { id: number | null; hash: string; created_at: number }
|
||||
|
||||
type SQLiteMigrationBackfillEntry = {
|
||||
name: string
|
||||
selector:
|
||||
| { column: "id"; value: number }
|
||||
| { column: "created_at"; value: number }
|
||||
| { column: "hash"; value: string }
|
||||
}
|
||||
|
||||
function unmatchedMigrationError(unmatched: SQLiteMigrationTableRow[]) {
|
||||
return new Error(
|
||||
`While upgrading your database migrations table we found ${unmatched.length} (${unmatched
|
||||
.map((it) => `[id: ${it.id}, created_at: ${it.created_at}]`)
|
||||
.join(
|
||||
", ",
|
||||
)}) migrations in the database that do not match any local migration. This means that some migrations were applied to the database but are missing from the local environment`,
|
||||
)
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export function prepareSQLiteMigrationBackfill(
|
||||
dbRows: SQLiteMigrationTableRow[],
|
||||
localMigrations: MigrationMeta[],
|
||||
): SQLiteMigrationBackfillEntry[] {
|
||||
const sortedLocalMigrations = [...localMigrations].sort((a, b) =>
|
||||
a.folderMillis !== b.folderMillis ? a.folderMillis - b.folderMillis : (a.name ?? "").localeCompare(b.name ?? ""),
|
||||
)
|
||||
const byMillis = new Map<number, MigrationMeta[]>()
|
||||
const byHash = new Map<string, MigrationMeta>()
|
||||
for (const migration of sortedLocalMigrations) {
|
||||
if (!byMillis.has(migration.folderMillis)) {
|
||||
byMillis.set(migration.folderMillis, [])
|
||||
}
|
||||
byMillis.get(migration.folderMillis)!.push(migration)
|
||||
byHash.set(migration.hash, migration)
|
||||
}
|
||||
|
||||
const toApply: SQLiteMigrationBackfillEntry[] = []
|
||||
const unmatched: SQLiteMigrationTableRow[] = []
|
||||
|
||||
for (const dbRow of dbRows) {
|
||||
const stringified = String(dbRow.created_at)
|
||||
const millis = Number(stringified.substring(0, stringified.length - 3) + "000")
|
||||
const candidates = byMillis.get(millis)
|
||||
|
||||
const matchedByMillis = candidates?.length === 1 ? candidates[0] : undefined
|
||||
const matchedByCandidateHash =
|
||||
candidates && candidates.length > 1
|
||||
? candidates.find((candidate) => candidate.hash && dbRow.hash && candidate.hash === dbRow.hash)
|
||||
: undefined
|
||||
const matchedByHash = matchedByMillis || matchedByCandidateHash ? undefined : byHash.get(dbRow.hash)
|
||||
const matched = matchedByMillis ?? matchedByCandidateHash ?? matchedByHash
|
||||
|
||||
if (matched) {
|
||||
toApply.push({
|
||||
name: matched.name,
|
||||
selector:
|
||||
dbRow.id !== null
|
||||
? { column: "id", value: dbRow.id }
|
||||
: matchedByMillis
|
||||
? { column: "created_at", value: dbRow.created_at }
|
||||
: { column: "hash", value: dbRow.hash },
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
unmatched.push(dbRow)
|
||||
}
|
||||
|
||||
if (unmatched.length > 0) {
|
||||
throw unmatchedMigrationError(unmatched)
|
||||
}
|
||||
|
||||
return toApply
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export function buildSQLiteMigrationBackfillStatements(
|
||||
migrationsTable: string,
|
||||
backfillEntries: SQLiteMigrationBackfillEntry[],
|
||||
) {
|
||||
const table = sql`${sql.identifier(migrationsTable)}`
|
||||
const statements: SQL[] = [
|
||||
sql`ALTER TABLE ${table} ADD COLUMN ${sql.identifier("name")} text`,
|
||||
sql`ALTER TABLE ${table} ADD COLUMN ${sql.identifier("applied_at")} TEXT`,
|
||||
]
|
||||
|
||||
for (const backfillEntry of backfillEntries) {
|
||||
const updateQuery = sql`UPDATE ${table} SET ${sql.identifier("name")} = ${backfillEntry.name}, ${sql.identifier(
|
||||
"applied_at",
|
||||
)} = NULL WHERE`
|
||||
|
||||
updateQuery.append(sql` ${sql.identifier(backfillEntry.selector.column)} = ${backfillEntry.selector.value}`)
|
||||
|
||||
statements.push(updateQuery)
|
||||
}
|
||||
|
||||
return statements
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
/* oxlint-disable */
|
||||
export interface UpgradeResult {
|
||||
newDb: boolean
|
||||
}
|
||||
|
||||
export const MIGRATIONS_TABLE_VERSIONS = {
|
||||
sqlite: 1,
|
||||
pg: 1,
|
||||
effect: 1,
|
||||
mysql: 1,
|
||||
mssql: 1,
|
||||
cockroach: 1,
|
||||
singlestore: 1,
|
||||
} as const
|
||||
|
||||
export const GET_VERSION_FOR = {
|
||||
mysql: (columns: string[]): number => {
|
||||
if (columns.includes("name")) return 1
|
||||
return 0
|
||||
},
|
||||
pg: (columns: string[]): number => {
|
||||
if (columns.includes("name")) return 1
|
||||
return 0
|
||||
},
|
||||
effect: (columns: string[]): number => {
|
||||
if (columns.includes("name")) return 1
|
||||
return 0
|
||||
},
|
||||
mssql: (columns: string[]): number => {
|
||||
if (columns.includes("name")) return 1
|
||||
return 0
|
||||
},
|
||||
cockroach: (columns: string[]): number => {
|
||||
if (columns.includes("name")) return 1
|
||||
return 0
|
||||
},
|
||||
singlestore: (columns: string[]): number => {
|
||||
if (columns.includes("name")) return 1
|
||||
return 0
|
||||
},
|
||||
sqlite: (columns: string[]): number => {
|
||||
if (columns.includes("name")) return 1
|
||||
return 0
|
||||
},
|
||||
} as const
|
||||
@@ -361,6 +361,7 @@ const layer = Layer.effect(
|
||||
Info.make({
|
||||
id: entry.ref.id,
|
||||
name: entry.ref.name,
|
||||
...(entry.ref.metadata === undefined ? {} : { metadata: entry.ref.metadata }),
|
||||
methods: entry.methods,
|
||||
connections,
|
||||
})
|
||||
|
||||
@@ -228,6 +228,7 @@ export const layer = (options?: Options) =>
|
||||
.transform((draft) => {
|
||||
draft.update(integrationID, (ref) => {
|
||||
ref.name = name
|
||||
ref.metadata = { source: "mcp" }
|
||||
})
|
||||
draft.method.update({
|
||||
integrationID,
|
||||
|
||||
@@ -333,8 +333,10 @@ function usesAPIKeyAuth(packageName: string | undefined) {
|
||||
return (
|
||||
name === "@ai-sdk/openai" ||
|
||||
name === "@ai-sdk/anthropic" ||
|
||||
name === "@ai-sdk/cerebras" ||
|
||||
name === "@ai-sdk/openai-compatible" ||
|
||||
name === "@ai-sdk/google" ||
|
||||
name === "@ai-sdk/togetherai" ||
|
||||
name === "@ai-sdk/xai" ||
|
||||
name === "@openrouter/ai-sdk-provider" ||
|
||||
name === "@ai-sdk/azure" ||
|
||||
@@ -342,8 +344,10 @@ function usesAPIKeyAuth(packageName: string | undefined) {
|
||||
name?.startsWith("@opencode-ai/ai/providers/openai/") === true ||
|
||||
name === "@opencode-ai/ai/providers/anthropic" ||
|
||||
name === "@opencode-ai/ai/providers/anthropic-compatible" ||
|
||||
name === "@opencode-ai/ai/providers/cerebras" ||
|
||||
name === "@opencode-ai/ai/providers/openai-compatible" ||
|
||||
name === "@opencode-ai/ai/providers/google" ||
|
||||
name === "@opencode-ai/ai/providers/togetherai" ||
|
||||
name === "@opencode-ai/ai/providers/xai" ||
|
||||
name === "@opencode-ai/ai/providers/openrouter" ||
|
||||
name === "@opencode-ai/ai/providers/azure" ||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export * as Plugin from "./plugin.js"
|
||||
export { Event, ID, Info, Source } from "@opencode-ai/schema/plugin"
|
||||
export { Event, ID, Info, Source, UpdateInfo, UpdateResult } from "@opencode-ai/schema/plugin"
|
||||
|
||||
import { Plugin } from "@opencode-ai/schema/plugin"
|
||||
import type { Plugin as PluginDefinition } from "@opencode-ai/plugin/effect/plugin"
|
||||
|
||||
@@ -26,7 +26,6 @@ import { OpencodePlugin } from "./provider/opencode.js"
|
||||
import { OpenRouterPlugin } from "./provider/openrouter.js"
|
||||
import { PerplexityPlugin } from "./provider/perplexity.js"
|
||||
import { SapAICorePlugin } from "./provider/sap-ai-core.js"
|
||||
import { TogetherAIPlugin } from "./provider/togetherai.js"
|
||||
import { VercelPlugin } from "./provider/vercel.js"
|
||||
import { VenicePlugin } from "./provider/venice.js"
|
||||
import { VLLMPlugin } from "./provider/vllm.js"
|
||||
@@ -62,7 +61,6 @@ export const ProviderPlugins: PluginInternal.InternalPlugin[] = [
|
||||
OpenRouterPlugin,
|
||||
PerplexityPlugin,
|
||||
SapAICorePlugin,
|
||||
TogetherAIPlugin,
|
||||
VercelPlugin,
|
||||
VenicePlugin,
|
||||
VLLMPlugin,
|
||||
|
||||
@@ -7,20 +7,12 @@ export const CerebrasPlugin = define({
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.catalog.transform((evt) => {
|
||||
for (const item of evt.provider.list()) {
|
||||
if (!Provider.isAISDK(item.provider.package)) continue
|
||||
if (Provider.packageName(item.provider.package) !== "@ai-sdk/cerebras") continue
|
||||
const name = Provider.packageName(item.provider.package)
|
||||
if (name !== "@ai-sdk/cerebras" && name !== "@opencode-ai/ai/providers/cerebras") continue
|
||||
evt.provider.update(item.provider.id, (provider) => {
|
||||
provider.headers = { ...provider.headers, "X-Cerebras-3rd-Party-Integration": "opencode" }
|
||||
})
|
||||
}
|
||||
})
|
||||
yield* ctx.aisdk.hook(
|
||||
"sdk",
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.package !== "@ai-sdk/cerebras") return
|
||||
const mod = yield* Effect.promise(() => import("@ai-sdk/cerebras"))
|
||||
evt.sdk = mod.createCerebras(evt.options)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
import { createProviderPlugin } from "./factory.js"
|
||||
|
||||
export const TogetherAIPlugin = createProviderPlugin({
|
||||
id: "opencode.provider.togetherai",
|
||||
package: "@ai-sdk/togetherai",
|
||||
load: async (options) => {
|
||||
const { createTogetherAI } = await import("@ai-sdk/togetherai")
|
||||
return createTogetherAI(options)
|
||||
},
|
||||
})
|
||||
@@ -1,6 +1,7 @@
|
||||
export * as PluginSupervisor from "./supervisor-service.js"
|
||||
|
||||
import { Context, Effect } from "effect"
|
||||
import { Plugin } from "@opencode-ai/schema/plugin"
|
||||
|
||||
/**
|
||||
* Dependency-only supervisor seam. Keep this module free of implementation
|
||||
@@ -9,6 +10,15 @@ import { Context, Effect } from "effect"
|
||||
export interface Interface {
|
||||
/** Wait for the initial plugin generation and startup updates to settle. */
|
||||
readonly flush: Effect.Effect<void>
|
||||
readonly check: () => Effect.Effect<Plugin.UpdateInfo[]>
|
||||
readonly update: (name: string) => Effect.Effect<Plugin.UpdateResult>
|
||||
readonly updateAll: () => Effect.Effect<Plugin.UpdateResult[]>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/PluginSupervisor") {}
|
||||
|
||||
export const noUpdates = {
|
||||
check: () => Effect.succeed([]),
|
||||
update: () => Effect.die("Plugin updates unavailable"),
|
||||
updateAll: () => Effect.succeed([]),
|
||||
} satisfies Omit<Interface, "flush">
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
export * as PluginSupervisor from "./supervisor.js"
|
||||
export { Service, type Interface } from "./supervisor-service.js"
|
||||
export { Service, type Interface, noUpdates } from "./supervisor-service.js"
|
||||
|
||||
import type { Plugin as PluginDefinition } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Event } from "@opencode-ai/schema/config"
|
||||
import { Cause, Effect, Latch, Layer, Schema, Stream } from "effect"
|
||||
import { Cause, Effect, Latch, Layer, Schema, Semaphore, Stream } from "effect"
|
||||
import path from "path"
|
||||
import { pathToFileURL } from "url"
|
||||
import { ConfigPluginSource } from "../config/plugin/source.js"
|
||||
@@ -106,25 +106,30 @@ const load = Effect.fn("PluginSupervisor.load")(function* (
|
||||
operation: Extract<ConfigPluginSource.Operation, { type: "add" }>,
|
||||
) {
|
||||
const npm = yield* Npm.Service
|
||||
const entrypoint = path.isAbsolute(operation.target)
|
||||
? pathToFileURL(operation.target).href
|
||||
: (yield* npm.add(operation.target, { subpaths: ["server", ""] })).entrypoint
|
||||
const local = path.isAbsolute(operation.target)
|
||||
const installed = local
|
||||
? { entrypoint: pathToFileURL(operation.target).href, revision: operation.mtime?.toString() }
|
||||
: yield* npm.add(operation.target, { subpaths: ["server", ""] })
|
||||
const entrypoint = installed.entrypoint
|
||||
if (!entrypoint) return yield* Effect.fail(new Error(`Plugin entrypoint not found: ${operation.target}`))
|
||||
// Bun currently ignores query parameters when caching file:// imports.
|
||||
const source =
|
||||
operation.mtime === undefined
|
||||
const source = local
|
||||
? operation.mtime === undefined
|
||||
? entrypoint
|
||||
: typeof Bun !== "undefined"
|
||||
? `${operation.target.replaceAll("\\", "/")}?mtime=${operation.mtime}`
|
||||
: `${entrypoint}?mtime=${operation.mtime}`
|
||||
yield* Effect.log({ msg: "loading plugin", id: operation.target, entrypoint: source })
|
||||
: installed.revision
|
||||
? `${entrypoint}?revision=${encodeURIComponent(installed.revision)}`
|
||||
: entrypoint
|
||||
yield* Effect.log({ msg: "loading plugin", local })
|
||||
const mod = yield* Effect.promise(() => importModule(source))
|
||||
const value = (yield* Schema.decodeUnknownEffect(PluginModule)(mod)).default
|
||||
const plugin = "effect" in value ? value : PluginPromise.fromPromise(value)
|
||||
return {
|
||||
id: plugin.id,
|
||||
tui: plugin.tui,
|
||||
version: JSON.stringify(operation),
|
||||
version: `${JSON.stringify(operation)}:${installed.revision ?? ""}`,
|
||||
source: pluginSource(operation.target),
|
||||
effect: (host) => plugin.effect({ ...host, options: operation.options }),
|
||||
} satisfies Plugin.Versioned
|
||||
@@ -134,15 +139,16 @@ export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* Plugin.Service
|
||||
const npm = yield* Npm.Service
|
||||
const sdk = yield* SdkPlugins.Service
|
||||
const sources = yield* ConfigPluginSource.Service
|
||||
const bus = yield* Bus.Service
|
||||
const ready = yield* Latch.make()
|
||||
const activationLock = Semaphore.makeUnsafe(1)
|
||||
const internal = yield* PluginInternal.list()
|
||||
let observed = 0
|
||||
|
||||
const activate = Effect.fn("PluginSupervisor.activate")(function* () {
|
||||
// Resolve OpenCode's internal plugins with their privileged Location services.
|
||||
const internal = yield* PluginInternal.list()
|
||||
// Combine internal plugins with host-contributed SDK plugins in boot order.
|
||||
const pre = [
|
||||
...internal.pre.map((plugin) => ({ ...plugin, version: "internal", source: { type: "builtin" as const } })),
|
||||
@@ -159,6 +165,73 @@ export const layer = Layer.effect(
|
||||
// Replace the active generation in one scoped, batched activation.
|
||||
yield* registry.activate(resolved.plugins, resolved.failures)
|
||||
})
|
||||
const checkOne = Effect.fn("PluginSupervisor.checkOne")(function* (info: Plugin.Info) {
|
||||
const name = pluginName(info)
|
||||
if (info.source.type !== "package") {
|
||||
return { name, source: info.source, status: "not-updateable" } satisfies Plugin.UpdateInfo
|
||||
}
|
||||
const source = info.source
|
||||
return yield* npm.check(source.package).pipe(
|
||||
Effect.map(
|
||||
(update): Plugin.UpdateInfo => ({
|
||||
name,
|
||||
source: info.source,
|
||||
status: update.pinned
|
||||
? "pinned"
|
||||
: !update.updateable
|
||||
? "not-updateable"
|
||||
: update.updateAvailable
|
||||
? "available"
|
||||
: "up-to-date",
|
||||
currentVersion: update.currentVersion,
|
||||
latestVersion: update.latestVersion,
|
||||
}),
|
||||
),
|
||||
Effect.catchCause(() =>
|
||||
Effect.succeed({
|
||||
name,
|
||||
source: info.source,
|
||||
status: "failed",
|
||||
error: "Failed to check plugin update",
|
||||
} satisfies Plugin.UpdateInfo),
|
||||
),
|
||||
)
|
||||
})
|
||||
const check = Effect.fn("PluginSupervisor.check")(function* () {
|
||||
return yield* Effect.forEach(yield* registry.list(), checkOne, { concurrency: "unbounded" })
|
||||
})
|
||||
const updateOne = Effect.fn("PluginSupervisor.updateOne")(function* (info: Plugin.Info) {
|
||||
const name = pluginName(info)
|
||||
if (info.source.type !== "package") {
|
||||
return { name, source: info.source, status: "not-updateable" } satisfies Plugin.UpdateResult
|
||||
}
|
||||
const source = info.source
|
||||
return yield* npm.update(source.package).pipe(
|
||||
Effect.map(
|
||||
(update): Plugin.UpdateResult => ({
|
||||
name,
|
||||
source: info.source,
|
||||
status: update.pinned
|
||||
? "pinned"
|
||||
: !update.updateable
|
||||
? "not-updateable"
|
||||
: update.updated
|
||||
? "updated"
|
||||
: "up-to-date",
|
||||
previousVersion: update.previousVersion,
|
||||
version: update.latestVersion ?? update.currentVersion,
|
||||
}),
|
||||
),
|
||||
Effect.catchCause(() =>
|
||||
Effect.succeed({
|
||||
name,
|
||||
source: info.source,
|
||||
status: "failed",
|
||||
error: "Failed to update plugin",
|
||||
} satisfies Plugin.UpdateResult),
|
||||
),
|
||||
)
|
||||
})
|
||||
const updates = Stream.merge(sources.changes(), bus.subscribe([Event.Updated, SdkPlugins.Updated])).pipe(
|
||||
// Make accepted work visible to flush before coalescing the burst.
|
||||
Stream.mapEffect(() =>
|
||||
@@ -169,19 +242,73 @@ export const layer = Layer.effect(
|
||||
}),
|
||||
),
|
||||
)
|
||||
const reload = (packages: readonly string[]) =>
|
||||
activationLock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
yield* activate().pipe(Effect.scoped, Effect.provideService(Npm.Service, npm))
|
||||
const failed = (yield* registry.list()).flatMap((info) =>
|
||||
info.status === "failed" && info.source.type === "package" && packages.includes(info.source.package)
|
||||
? [info.source.package]
|
||||
: [],
|
||||
)
|
||||
if (failed.length === 0) return failed
|
||||
yield* Effect.forEach(failed, (pkg) =>
|
||||
npm
|
||||
.rollback(pkg)
|
||||
.pipe(
|
||||
Effect.catchCause((cause) => Effect.logError("failed to restore plugin package revision", { cause })),
|
||||
),
|
||||
)
|
||||
yield* activate().pipe(Effect.scoped, Effect.provideService(Npm.Service, npm))
|
||||
return failed
|
||||
}),
|
||||
)
|
||||
yield* Stream.concat(Stream.succeed(0), updates).pipe(
|
||||
// Keep observing updates while activation runs, retaining only the latest generation request.
|
||||
Stream.buffer({ capacity: 1, strategy: "sliding" }),
|
||||
Stream.debounce("100 millis"),
|
||||
Stream.runForEach((target) =>
|
||||
Effect.gen(function* () {
|
||||
yield* activate()
|
||||
yield* activationLock.withPermit(activate())
|
||||
if (observed === target) yield* ready.open
|
||||
}).pipe(Effect.catchCause((cause) => Effect.logError("failed to reload plugins", { cause }))),
|
||||
),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
return Service.of({ flush: ready.await })
|
||||
return Service.of({
|
||||
flush: ready.await,
|
||||
check,
|
||||
update: Effect.fn("PluginSupervisor.update")(function* (name) {
|
||||
const info = (yield* registry.list()).find((info) => pluginName(info) === name || info.id === name)
|
||||
if (!info) {
|
||||
return {
|
||||
name,
|
||||
source: { type: "package", package: name },
|
||||
status: "failed",
|
||||
error: "Plugin not found",
|
||||
}
|
||||
}
|
||||
const result = yield* updateOne(info)
|
||||
if (result.status === "updated" && result.source.type === "package") {
|
||||
const failed = yield* reload([result.source.package])
|
||||
if (failed.length > 0)
|
||||
return { ...result, status: "failed" as const, error: "Updated plugin failed to activate" }
|
||||
}
|
||||
return result
|
||||
}),
|
||||
updateAll: Effect.fn("PluginSupervisor.updateAll")(function* () {
|
||||
const results = yield* Effect.forEach(yield* registry.list(), updateOne)
|
||||
const packages = results.flatMap((result) =>
|
||||
result.status === "updated" && result.source.type === "package" ? [result.source.package] : [],
|
||||
)
|
||||
if (packages.length === 0) return results
|
||||
const failed = yield* reload(packages)
|
||||
return results.map((result) => {
|
||||
if (result.source.type !== "package" || !failed.includes(result.source.package)) return result
|
||||
return { ...result, status: "failed" as const, error: "Updated plugin failed to activate" }
|
||||
})
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -199,4 +326,10 @@ function pluginSource(target: string): Plugin.Source {
|
||||
return { type: "package", package: target }
|
||||
}
|
||||
|
||||
function pluginName(info: Plugin.Info) {
|
||||
if (info.source.type === "package") return info.source.package
|
||||
if (info.source.type === "local") return info.source.path
|
||||
return info.id ?? info.source.type
|
||||
}
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: nodeDeps })
|
||||
|
||||
@@ -48,6 +48,7 @@ const builtins = new Map<string, () => Promise<unknown>>([
|
||||
["@opencode-ai/ai/providers/azure", () => import("@opencode-ai/ai/providers/azure")],
|
||||
["@opencode-ai/ai/providers/azure/chat", () => import("@opencode-ai/ai/providers/azure/chat")],
|
||||
["@opencode-ai/ai/providers/azure/responses", () => import("@opencode-ai/ai/providers/azure/responses")],
|
||||
["@opencode-ai/ai/providers/cerebras", () => import("@opencode-ai/ai/providers/cerebras")],
|
||||
["@opencode-ai/ai/providers/google", () => import("@opencode-ai/ai/providers/google")],
|
||||
["@opencode-ai/ai/providers/google-vertex", () => import("@opencode-ai/ai/providers/google-vertex")],
|
||||
["@opencode-ai/ai/providers/google-vertex/gemini", () => import("@opencode-ai/ai/providers/google-vertex/gemini")],
|
||||
@@ -65,6 +66,7 @@ const builtins = new Map<string, () => Promise<unknown>>([
|
||||
["@opencode-ai/ai/providers/openai/responses", () => import("@opencode-ai/ai/providers/openai/responses")],
|
||||
["@opencode-ai/ai/providers/openai-compatible", () => import("@opencode-ai/ai/providers/openai-compatible")],
|
||||
["@opencode-ai/ai/providers/openrouter", () => import("@opencode-ai/ai/providers/openrouter")],
|
||||
["@opencode-ai/ai/providers/togetherai", () => import("@opencode-ai/ai/providers/togetherai")],
|
||||
["@opencode-ai/ai/providers/xai", () => import("@opencode-ai/ai/providers/xai")],
|
||||
])
|
||||
|
||||
|
||||
@@ -103,6 +103,7 @@ export type Outcome =
|
||||
| Pick<SessionMessage.CompactionFailed, "status" | "error">
|
||||
|
||||
export interface Interface extends State.Transformable<Draft> {
|
||||
readonly enabled: () => boolean
|
||||
readonly required: (input: RequiredInput) => boolean
|
||||
readonly compact: (input: AutoInput) => Effect.Effect<Outcome>
|
||||
readonly compactManual: (input: ManualInput) => Effect.Effect<Outcome>
|
||||
@@ -405,6 +406,7 @@ const make = (dependencies: Dependencies) => {
|
||||
return Service.of({
|
||||
transform: state.transform,
|
||||
reload: state.reload,
|
||||
enabled: () => state.get().auto,
|
||||
required,
|
||||
compact,
|
||||
compactManual,
|
||||
|
||||
@@ -508,6 +508,7 @@ const layer = Layer.effect(
|
||||
// restart the step instead of surfacing the provider error.
|
||||
if (
|
||||
recoverOverflow &&
|
||||
compaction.enabled() &&
|
||||
!publisher.record().outputStarted &&
|
||||
isContextOverflowFailure(overflowFailure ?? streamFailure) &&
|
||||
(yield* restore(compaction.compact(compactionInput))).status === "completed"
|
||||
|
||||
@@ -14,7 +14,6 @@ describe("AISDKNative", () => {
|
||||
reasoningEffort: "xhigh",
|
||||
reasoningSummary: "auto",
|
||||
include: ["reasoning.encrypted_content"],
|
||||
instructions: "Follow the repository instructions.",
|
||||
truncation: "auto",
|
||||
}),
|
||||
).toEqual({
|
||||
@@ -26,7 +25,6 @@ describe("AISDKNative", () => {
|
||||
reasoningEffort: "xhigh",
|
||||
reasoningSummary: "auto",
|
||||
include: ["reasoning.encrypted_content"],
|
||||
instructions: "Follow the repository instructions.",
|
||||
truncation: "auto",
|
||||
},
|
||||
organization: "org",
|
||||
@@ -63,6 +61,33 @@ describe("AISDKNative", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("maps Cerebras and Together AI settings, headers, and reasoning options to native providers", () => {
|
||||
for (const name of ["cerebras", "togetherai"]) {
|
||||
expect(
|
||||
map(`@ai-sdk/${name}`, {
|
||||
apiKey: "secret",
|
||||
baseURL: `https://${name}.example/v1`,
|
||||
headers: { "x-provider": name },
|
||||
name: "custom-provider",
|
||||
reasoningEffort: "high",
|
||||
customOption: { enabled: true },
|
||||
}),
|
||||
).toEqual({
|
||||
package: `@opencode-ai/ai/providers/${name}`,
|
||||
settings: {
|
||||
apiKey: "secret",
|
||||
baseURL: `https://${name}.example/v1`,
|
||||
providerOptions: { reasoningEffort: "high", customOption: { enabled: true } },
|
||||
},
|
||||
headers: { "x-provider": name },
|
||||
})
|
||||
expect(map(`@ai-sdk/${name}`, {})).toEqual({
|
||||
package: `@opencode-ai/ai/providers/${name}`,
|
||||
settings: {},
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
test("maps Google Vertex settings to the native provider", () => {
|
||||
expect(
|
||||
map("@ai-sdk/google-vertex", {
|
||||
|
||||
@@ -37,6 +37,30 @@ const staticIt = testEffect(
|
||||
)
|
||||
|
||||
describe("PluginSupervisor config", () => {
|
||||
it.live("reports local and builtin plugins as not updateable", () => {
|
||||
const plugin = path.join(import.meta.dir, "../plugin/fixtures/config-promise-plugin.ts")
|
||||
return withLocation(
|
||||
{ plugins: [plugin] },
|
||||
Effect.gen(function* () {
|
||||
yield* ready()
|
||||
const supervisor = yield* PluginSupervisor.Service
|
||||
const updates = yield* supervisor.check()
|
||||
|
||||
expect(updates.find((update) => update.name === plugin)).toEqual({
|
||||
name: plugin,
|
||||
source: { type: "local", path: plugin },
|
||||
status: "not-updateable",
|
||||
})
|
||||
expect(updates.find((update) => update.source.type === "builtin")?.status).toBe("not-updateable")
|
||||
expect(yield* supervisor.update(plugin)).toEqual({
|
||||
name: plugin,
|
||||
source: { type: "local", path: plugin },
|
||||
status: "not-updateable",
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it.live("applies selectors in order", () =>
|
||||
withLocation(
|
||||
{ plugins: ["-opencode.provider.*", "opencode.provider.openai"] },
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mkdir, mkdtemp, rm } from "node:fs/promises"
|
||||
import { mkdtemp, rm } from "node:fs/promises"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { Database } from "bun:sqlite"
|
||||
@@ -27,16 +27,6 @@ const makeDb = Effect.gen(function* () {
|
||||
return db
|
||||
})
|
||||
|
||||
const createMigrationsFolder = async () => {
|
||||
const migrationsFolder = await mkdtemp(join(tmpdir(), "effect-drizzle-sqlite-"))
|
||||
await mkdir(join(migrationsFolder, "20240101000000_create_migrated_users"), { recursive: true })
|
||||
await Bun.write(
|
||||
join(migrationsFolder, "20240101000000_create_migrated_users", "migration.sql"),
|
||||
"create table migrated_users (id integer primary key autoincrement, name text not null);",
|
||||
)
|
||||
return migrationsFolder
|
||||
}
|
||||
|
||||
test("selects rows through Effect-yieldable query builders", async () => {
|
||||
await run(
|
||||
Effect.gen(function* () {
|
||||
@@ -173,25 +163,3 @@ test("supports returning and rejects empty update sets", async () => {
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("runs migrations once and records migration metadata", async () => {
|
||||
const migrationsFolder = await createMigrationsFolder()
|
||||
try {
|
||||
await run(
|
||||
Effect.gen(function* () {
|
||||
const db = yield* EffectDrizzleSqlite.makeWithDefaults()
|
||||
|
||||
yield* EffectDrizzleSqlite.migrate(db, { migrationsFolder })
|
||||
yield* EffectDrizzleSqlite.migrate(db, { migrationsFolder })
|
||||
yield* db.run(sql`insert into migrated_users (name) values ('Margaret')`)
|
||||
|
||||
expect(yield* db.all<{ name: string }>(sql`select name from migrated_users`)).toEqual([{ name: "Margaret" }])
|
||||
expect(yield* db.all<{ name: string | null }>(sql`select name from __drizzle_migrations`)).toEqual([
|
||||
{ name: "20240101000000_create_migrated_users" },
|
||||
])
|
||||
}),
|
||||
)
|
||||
} finally {
|
||||
await rm(migrationsFolder, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -25,13 +25,18 @@ import { host } from "../plugin/host"
|
||||
|
||||
type WatcherEvent = { file: string; event: "add" | "change" | "unlink" }
|
||||
const describeNative = process.env.CI ? describe.skip : describe
|
||||
const pluginUpdates = {
|
||||
check: () => Effect.succeed([]),
|
||||
update: () => Effect.die("unused"),
|
||||
updateAll: () => Effect.succeed([]),
|
||||
}
|
||||
|
||||
const it = testEffect(AppNodeBuilder.build(LayerNode.group([FSUtil.node, Bus.node])))
|
||||
|
||||
const configLayer = Config.testLayer()
|
||||
const pluginNode = makeLocationNode({
|
||||
service: PluginSupervisor.Service,
|
||||
layer: Layer.succeed(PluginSupervisor.Service, PluginSupervisor.Service.of({ flush: Effect.void })),
|
||||
layer: Layer.succeed(PluginSupervisor.Service, PluginSupervisor.Service.of({ ...pluginUpdates, flush: Effect.void })),
|
||||
deps: [],
|
||||
})
|
||||
|
||||
@@ -314,7 +319,7 @@ describe("LocationWatcher subscriptions", () => {
|
||||
Effect.gen(function* () {
|
||||
const policy = yield* LocationWatcherPolicy.Service
|
||||
yield* policy.transform((draft) => draft.add([".git"]))
|
||||
return PluginSupervisor.Service.of({ flush: Effect.void })
|
||||
return PluginSupervisor.Service.of({ ...pluginUpdates, flush: Effect.void })
|
||||
}),
|
||||
),
|
||||
deps: [LocationWatcherPolicy.node],
|
||||
|
||||
@@ -12,7 +12,6 @@ export const tempGlobalLayer = Layer.unwrap(
|
||||
const data = path.join(tmp.path, "data")
|
||||
const cache = path.join(tmp.path, "cache")
|
||||
return Global.layerWith({
|
||||
home: path.join(tmp.path, "home"),
|
||||
data,
|
||||
cache,
|
||||
config: path.join(tmp.path, "config"),
|
||||
|
||||
@@ -52,10 +52,21 @@ describe("Integration", () => {
|
||||
const openai = Integration.ID.make("openai")
|
||||
|
||||
yield* integrations
|
||||
.transform((editor) => editor.update(openai, (integration) => (integration.name = "OpenAI")))
|
||||
.transform((editor) =>
|
||||
editor.update(openai, (integration) => {
|
||||
integration.name = "OpenAI"
|
||||
integration.metadata = { source: "plugin", featured: true }
|
||||
}),
|
||||
)
|
||||
.pipe(Scope.provide(scope))
|
||||
expect(yield* integrations.get(openai)).toEqual(
|
||||
Integration.Info.make({ id: openai, name: "OpenAI", methods: [], connections: [] }),
|
||||
Integration.Info.make({
|
||||
id: openai,
|
||||
name: "OpenAI",
|
||||
metadata: { source: "plugin", featured: true },
|
||||
methods: [],
|
||||
connections: [],
|
||||
}),
|
||||
)
|
||||
|
||||
yield* Scope.close(scope, Exit.void)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { LLM, LanguageModel } from "@opencode-ai/ai"
|
||||
import { LLM, LanguageModel, Message } from "@opencode-ai/ai"
|
||||
import { OpenAIChat } from "@opencode-ai/ai/protocols"
|
||||
import { compileRequest } from "@opencode-ai/ai/route/client"
|
||||
import { ConfigProvider, Effect, Layer } from "effect"
|
||||
@@ -391,6 +391,7 @@ describe("ModelResolver", () => {
|
||||
model(Provider.aisdk("@ai-sdk/openai-compatible"), {
|
||||
compatibility: {
|
||||
reasoningField: "vendor_reasoning",
|
||||
requireReasoning: true,
|
||||
maxTokensField: "max_completion_tokens",
|
||||
requireFinishReason: false,
|
||||
requireAssistantAfterTool: true,
|
||||
@@ -417,6 +418,7 @@ describe("ModelResolver", () => {
|
||||
expect(headers.authorization).toBe("Bearer settings-secret")
|
||||
expect(resolved.route.id).toBe("openai-compatible-chat")
|
||||
expect(resolved.compatibility?.reasoningField).toBe("vendor_reasoning")
|
||||
expect(resolved.compatibility?.requireReasoning).toBe(true)
|
||||
expect(resolved.compatibility?.maxTokensField).toBe("max_completion_tokens")
|
||||
expect(resolved.compatibility?.requireFinishReason).toBe(false)
|
||||
expect(resolved.compatibility?.requireAssistantAfterTool).toBe(true)
|
||||
@@ -677,7 +679,15 @@ describe("ModelResolver", () => {
|
||||
metadata: { accountID: "acct_123" },
|
||||
}),
|
||||
)
|
||||
const request = LLM.request({ model: resolved, prompt: "Hello" })
|
||||
const request = LLM.request({
|
||||
model: resolved,
|
||||
system: [
|
||||
{ type: "text", text: "Base instructions." },
|
||||
{ type: "text", text: "Project instructions." },
|
||||
],
|
||||
messages: [Message.user("Hello"), Message.system("Updated instructions.")],
|
||||
})
|
||||
const prepared = yield* compileRequest(request)
|
||||
const headers = yield* resolved.route.auth.apply({
|
||||
request,
|
||||
method: "POST",
|
||||
@@ -692,6 +702,13 @@ describe("ModelResolver", () => {
|
||||
})
|
||||
expect(resolved.route.defaults.headers).toMatchObject({ "chatgpt-account-id": "acct_123" })
|
||||
expect(headers.authorization).toBe("Bearer chatgpt-token")
|
||||
expect(prepared.body).toMatchObject({
|
||||
instructions: "Base instructions.\nProject instructions.",
|
||||
input: [
|
||||
{ role: "user", content: [{ type: "input_text", text: "Hello" }] },
|
||||
{ role: "developer", content: "Updated instructions." },
|
||||
],
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -862,6 +879,12 @@ describe("ModelResolver", () => {
|
||||
{ thinking: { type: "adaptive", display: "summarized" }, effort: "high" },
|
||||
{ thinking: { type: "adaptive", display: "summarized" }, effort: "high" },
|
||||
],
|
||||
[
|
||||
"@ai-sdk/cerebras",
|
||||
"@opencode-ai/ai/providers/cerebras",
|
||||
{ reasoningEffort: "high" },
|
||||
{ reasoningEffort: "high" },
|
||||
],
|
||||
[
|
||||
"@ai-sdk/openai-compatible",
|
||||
"@opencode-ai/ai/providers/openai-compatible",
|
||||
@@ -886,6 +909,12 @@ describe("ModelResolver", () => {
|
||||
{ reasoning: { effort: "high" } },
|
||||
{ reasoning: { effort: "high" } },
|
||||
],
|
||||
[
|
||||
"@ai-sdk/togetherai",
|
||||
"@opencode-ai/ai/providers/togetherai",
|
||||
{ reasoningEffort: "high" },
|
||||
{ reasoningEffort: "high" },
|
||||
],
|
||||
["@ai-sdk/xai", "@opencode-ai/ai/providers/xai", { reasoningEffort: "high" }, { reasoningEffort: "high" }],
|
||||
] as const
|
||||
|
||||
@@ -933,12 +962,14 @@ describe("ModelResolver", () => {
|
||||
"openai.gpt-oss-120b",
|
||||
],
|
||||
["@ai-sdk/azure", "@opencode-ai/ai/providers/azure/responses", "api-model"],
|
||||
["@ai-sdk/cerebras", "@opencode-ai/ai/providers/cerebras", "api-model"],
|
||||
["@ai-sdk/google", "@opencode-ai/ai/providers/google", "api-model"],
|
||||
["@ai-sdk/google-vertex", "@opencode-ai/ai/providers/google-vertex", "api-model"],
|
||||
["@ai-sdk/google-vertex/anthropic", "@opencode-ai/ai/providers/google-vertex/messages", "claude-sonnet-4-6"],
|
||||
["@ai-sdk/openai", "@opencode-ai/ai/providers/openai", "api-model"],
|
||||
["@ai-sdk/openai-compatible", "@opencode-ai/ai/providers/openai-compatible", "api-model"],
|
||||
["@openrouter/ai-sdk-provider", "@opencode-ai/ai/providers/openrouter", "api-model"],
|
||||
["@ai-sdk/togetherai", "@opencode-ai/ai/providers/togetherai", "api-model"],
|
||||
["@ai-sdk/xai", "@opencode-ai/ai/providers/xai", "api-model"],
|
||||
] as const
|
||||
|
||||
@@ -1053,6 +1084,12 @@ describe("ModelResolver", () => {
|
||||
settings: { reasoning: { effort: "high" } },
|
||||
}),
|
||||
)
|
||||
const cerebras = yield* ModelResolver.fromCatalogModel(
|
||||
model(Provider.aisdk("@ai-sdk/cerebras"), { settings: { reasoningEffort: "high" } }),
|
||||
)
|
||||
const togetherai = yield* ModelResolver.fromCatalogModel(
|
||||
model(Provider.aisdk("@ai-sdk/togetherai"), { settings: { reasoningEffort: "high" } }),
|
||||
)
|
||||
const xai = yield* ModelResolver.fromCatalogModel(
|
||||
model(Provider.aisdk("@ai-sdk/xai"), { settings: { reasoningEffort: "high" } }),
|
||||
)
|
||||
@@ -1073,6 +1110,12 @@ describe("ModelResolver", () => {
|
||||
expect(google.route.defaults.providerOptions).toEqual({ thinkingConfig: { thinkingBudget: 1_024 } })
|
||||
expect(openrouter.route.id).toBe("openrouter")
|
||||
expect(openrouter.route.defaults.providerOptions).toEqual({ reasoning: { effort: "high" } })
|
||||
expect(cerebras.route.id).toBe("cerebras-chat")
|
||||
expect(cerebras.route.defaults.providerOptions).toEqual({ reasoningEffort: "high" })
|
||||
expect(String(cerebras.provider)).toBe("test-provider")
|
||||
expect(togetherai.route.id).toBe("togetherai-chat")
|
||||
expect(togetherai.route.defaults.providerOptions).toEqual({ reasoningEffort: "high" })
|
||||
expect(String(togetherai.provider)).toBe("test-provider")
|
||||
expect(xai.route.id).toBe("openai-responses")
|
||||
expect(xai.route.defaults.providerOptions).toEqual({
|
||||
reasoningEffort: "high",
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { fileURLToPath, pathToFileURL } from "url"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Npm } from "@opencode-ai/util/npm"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
|
||||
const npmLayer = (cache: string) =>
|
||||
AppNodeBuilder.build(Npm.node, [[Global.node, Global.layerWith({ cache, state: path.join(cache, "state") })]])
|
||||
|
||||
describe("Npm plugin updates", () => {
|
||||
test("checks a moving Git ref without installing and explicitly updates its cached revision", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const repository = path.join(tmp.path, "plugin")
|
||||
const cache = path.join(tmp.path, "cache")
|
||||
await fs.mkdir(repository)
|
||||
await Bun.write(
|
||||
path.join(repository, "package.json"),
|
||||
JSON.stringify({ name: "fixture-plugin", version: "1.0.0", exports: "./index.js" }),
|
||||
)
|
||||
await Bun.write(path.join(repository, "index.js"), "export default 'first'\n")
|
||||
await Bun.$`git init -q -b main ${repository}`
|
||||
await commit(repository, "first")
|
||||
const first = await revision(repository)
|
||||
const spec = `git+${pathToFileURL(repository).href}#main`
|
||||
const layer = npmLayer(cache)
|
||||
|
||||
const installed = await Effect.gen(function* () {
|
||||
const npm = yield* Npm.Service
|
||||
return yield* npm.add(spec)
|
||||
}).pipe(Effect.scoped, Effect.provide(layer), Effect.runPromise)
|
||||
expect(installed.revision).toBe(first)
|
||||
|
||||
await Bun.write(path.join(repository, "index.js"), "export default 'second'\n")
|
||||
await commit(repository, "second")
|
||||
const second = await revision(repository)
|
||||
|
||||
const checked = await Effect.gen(function* () {
|
||||
const npm = yield* Npm.Service
|
||||
return yield* npm.check(spec)
|
||||
}).pipe(Effect.provide(layer), Effect.runPromise)
|
||||
expect(checked).toMatchObject({
|
||||
currentVersion: first,
|
||||
latestVersion: second,
|
||||
updateAvailable: true,
|
||||
})
|
||||
|
||||
const updated = await Effect.gen(function* () {
|
||||
const npm = yield* Npm.Service
|
||||
return yield* npm.update(spec)
|
||||
}).pipe(Effect.scoped, Effect.provide(layer), Effect.runPromise)
|
||||
expect(updated).toMatchObject({
|
||||
previousVersion: first,
|
||||
currentVersion: second,
|
||||
updated: true,
|
||||
})
|
||||
|
||||
const current = await Effect.gen(function* () {
|
||||
const npm = yield* Npm.Service
|
||||
return yield* npm.add(spec)
|
||||
}).pipe(Effect.scoped, Effect.provide(layer), Effect.runPromise)
|
||||
expect(current.directory).not.toBe(installed.directory)
|
||||
expect(current.revision).toBe(second)
|
||||
if (!current.entrypoint) throw new Error("Updated plugin entrypoint missing")
|
||||
expect(
|
||||
await Bun.file(
|
||||
current.entrypoint.startsWith("file:") ? fileURLToPath(current.entrypoint) : current.entrypoint,
|
||||
).text(),
|
||||
).toContain("second")
|
||||
|
||||
const unchanged = await Effect.gen(function* () {
|
||||
const npm = yield* Npm.Service
|
||||
return yield* npm.update(spec)
|
||||
}).pipe(Effect.scoped, Effect.provide(layer), Effect.runPromise)
|
||||
expect(unchanged).toMatchObject({
|
||||
previousVersion: second,
|
||||
currentVersion: second,
|
||||
latestVersion: second,
|
||||
updateAvailable: false,
|
||||
updated: false,
|
||||
})
|
||||
expect(
|
||||
await Effect.gen(function* () {
|
||||
const npm = yield* Npm.Service
|
||||
return yield* npm.resolve(spec)
|
||||
}).pipe(Effect.provide(layer), Effect.runPromise),
|
||||
).toMatchObject({ directory: current.directory, revision: second })
|
||||
|
||||
await Effect.gen(function* () {
|
||||
const npm = yield* Npm.Service
|
||||
yield* npm.rollback(spec)
|
||||
}).pipe(Effect.provide(layer), Effect.runPromise)
|
||||
expect(
|
||||
await Effect.gen(function* () {
|
||||
const npm = yield* Npm.Service
|
||||
return yield* npm.resolve(spec)
|
||||
}).pipe(Effect.provide(layer), Effect.runPromise),
|
||||
).toMatchObject({ directory: installed.directory, revision: first })
|
||||
})
|
||||
|
||||
test("resolves a Git semver selector without installing HEAD outside the configured range", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const repository = path.join(tmp.path, "plugin")
|
||||
const cache = path.join(tmp.path, "cache")
|
||||
await fs.mkdir(repository)
|
||||
await writePlugin(repository, "1.0.0", "one")
|
||||
await Bun.$`git init -q -b main ${repository}`
|
||||
await commit(repository, "one")
|
||||
await Bun.$`git -C ${repository} tag v1.0.0`
|
||||
const first = await revision(repository)
|
||||
const spec = `git+${pathToFileURL(repository).href}#semver:^1`
|
||||
const layer = npmLayer(cache)
|
||||
|
||||
const installed = await Effect.gen(function* () {
|
||||
const npm = yield* Npm.Service
|
||||
return yield* npm.add(spec)
|
||||
}).pipe(Effect.scoped, Effect.provide(layer), Effect.runPromise)
|
||||
expect(installed.revision).toBe(first)
|
||||
|
||||
await writePlugin(repository, "1.1.0", "one-one")
|
||||
await commit(repository, "one-one")
|
||||
await Bun.$`git -C ${repository} tag v1.1.0`
|
||||
const latest = await revision(repository)
|
||||
await writePlugin(repository, "2.0.0", "two")
|
||||
await commit(repository, "two")
|
||||
await Bun.$`git -C ${repository} tag v2.0.0`
|
||||
expect(await revision(repository)).not.toBe(latest)
|
||||
|
||||
const checked = await Effect.gen(function* () {
|
||||
const npm = yield* Npm.Service
|
||||
return yield* npm.check(spec)
|
||||
}).pipe(Effect.provide(layer), Effect.runPromise)
|
||||
expect(checked).toMatchObject({ currentVersion: first, latestVersion: latest, updateAvailable: true })
|
||||
|
||||
const updated = await Effect.gen(function* () {
|
||||
const npm = yield* Npm.Service
|
||||
return yield* npm.update(spec)
|
||||
}).pipe(Effect.scoped, Effect.provide(layer), Effect.runPromise)
|
||||
expect(updated).toMatchObject({ currentVersion: latest, latestVersion: latest, updated: true })
|
||||
})
|
||||
|
||||
test("fails checks for missing Git refs", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const repository = path.join(tmp.path, "plugin")
|
||||
await fs.mkdir(repository)
|
||||
await writePlugin(repository, "1.0.0", "one")
|
||||
await Bun.$`git init -q -b main ${repository}`
|
||||
await commit(repository, "one")
|
||||
const layer = npmLayer(path.join(tmp.path, "cache"))
|
||||
|
||||
await expect(
|
||||
Effect.gen(function* () {
|
||||
const npm = yield* Npm.Service
|
||||
return yield* npm.check(`git+${pathToFileURL(repository).href}#missing`)
|
||||
}).pipe(Effect.provide(layer), Effect.runPromise),
|
||||
).rejects.toMatchObject({ _tag: "NpmInstallFailedError" })
|
||||
})
|
||||
})
|
||||
|
||||
async function writePlugin(repository: string, version: string, value: string) {
|
||||
await Bun.write(
|
||||
path.join(repository, "package.json"),
|
||||
JSON.stringify({ name: "fixture-plugin", version, exports: "./index.js" }),
|
||||
)
|
||||
await Bun.write(path.join(repository, "index.js"), `export default '${value}'\n`)
|
||||
}
|
||||
|
||||
async function commit(repository: string, message: string) {
|
||||
await Bun.$`git -C ${repository} add .`
|
||||
await Bun.$`git -C ${repository} -c user.name=fixture -c user.email=fixture@example.com commit -qm ${message}`
|
||||
}
|
||||
|
||||
async function revision(repository: string) {
|
||||
return Bun.$`git -C ${repository} rev-parse HEAD`.text().then((value) => value.trim())
|
||||
}
|
||||
@@ -21,6 +21,39 @@ const writePackage = (dir: string, pkg: Record<string, unknown>) =>
|
||||
const npmLayer = (cache: string) =>
|
||||
AppNodeBuilder.build(Npm.node, [[Global.node, Global.layerWith({ cache, state: path.join(cache, "state") })]])
|
||||
|
||||
async function createGitFixture(directory: string) {
|
||||
const repository = path.join(directory, "repository")
|
||||
await fs.mkdir(path.join(repository, "dependency"), { recursive: true })
|
||||
await writePackage(repository, {
|
||||
name: "fixture-git-plugin",
|
||||
exports: "./index.js",
|
||||
dependencies: { "fixture-dependency": "file:./dependency" },
|
||||
})
|
||||
await writePackage(path.join(repository, "dependency"), { name: "fixture-dependency", exports: "./index.js" })
|
||||
await Bun.write(path.join(repository, "index.js"), "export default { root: true }\n")
|
||||
await Bun.write(path.join(repository, "dependency", "index.js"), "export const dependency = true\n")
|
||||
|
||||
const subdirectory = path.join(repository, "packages", "subdirectory-plugin")
|
||||
await fs.mkdir(path.join(subdirectory, "dependency"), { recursive: true })
|
||||
await writePackage(subdirectory, {
|
||||
name: "fixture-subdirectory-plugin",
|
||||
exports: "./index.js",
|
||||
dependencies: { "fixture-subdirectory-dependency": "file:./dependency" },
|
||||
})
|
||||
await writePackage(path.join(subdirectory, "dependency"), {
|
||||
name: "fixture-subdirectory-dependency",
|
||||
exports: "./index.js",
|
||||
})
|
||||
await Bun.write(path.join(subdirectory, "index.js"), "export default { subdirectory: true }\n")
|
||||
await Bun.write(path.join(subdirectory, "dependency", "index.js"), "export const dependency = true\n")
|
||||
|
||||
await Bun.$`git init -q -b fixture-branch ${repository}`
|
||||
await Bun.$`git -C ${repository} add .`
|
||||
await Bun.$`git -C ${repository} -c user.name=fixture -c user.email=fixture@example.com commit -qm fixture`
|
||||
const commit = await Bun.$`git -C ${repository} rev-parse HEAD`.text().then((value) => value.trim())
|
||||
return { repository, commit }
|
||||
}
|
||||
|
||||
describe("Npm.sanitize", () => {
|
||||
test("keeps normal scoped package specs unchanged", () => {
|
||||
expect(Npm.sanitize("@opencode/acme")).toBe("@opencode/acme")
|
||||
@@ -46,6 +79,33 @@ describe("Npm.isRegistryPackage", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("Npm.isInstallablePackage", () => {
|
||||
test("accepts registry and npm-compatible Git specs", async () => {
|
||||
expect(await Npm.isInstallablePackage("plugin@^1.2.0")).toBe(true)
|
||||
expect(await Npm.isInstallablePackage("github:acme/plugin#main")).toBe(true)
|
||||
expect(await Npm.isInstallablePackage("git+ssh://git@github.com/acme/plugin.git#main")).toBe(true)
|
||||
expect(await Npm.isInstallablePackage("git@github.com:acme/plugin.git")).toBe(true)
|
||||
expect(
|
||||
await Npm.isInstallablePackage(
|
||||
"git+https://github.com/acme/plugins.git#0123456789abcdef0123456789abcdef01234567::path:packages/plugin",
|
||||
),
|
||||
).toBe(true)
|
||||
expect(await Npm.isInstallablePackage("./plugin")).toBe(false)
|
||||
expect(await Npm.isInstallablePackage("https://example.com/plugin.tgz")).toBe(false)
|
||||
expect(await Npm.isInstallablePackage("alias@npm:plugin@1.0.0")).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("Npm.cacheKey", () => {
|
||||
test("preserves registry keys and hashes Git specs", async () => {
|
||||
expect(await Npm.cacheKey("@opencode/acme@1.0.0")).toBe(Npm.sanitize("@opencode/acme@1.0.0"))
|
||||
const spec = "git+ssh://git@github.com/acme/plugin.git#main"
|
||||
expect(await Npm.cacheKey(spec)).toMatch(/^git-[a-f0-9]{64}$/)
|
||||
expect(await Npm.cacheKey(spec)).toBe(await Npm.cacheKey(spec))
|
||||
expect(await Npm.cacheKey(`${spec}-other`)).not.toBe(await Npm.cacheKey(spec))
|
||||
})
|
||||
})
|
||||
|
||||
describe("Npm.add", () => {
|
||||
test("resolves cached scoped package specs without reifying", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
@@ -116,6 +176,51 @@ describe("Npm.add", () => {
|
||||
expect(entries.tui.entrypoint).toEndWith("/tui.js")
|
||||
expect(entries.fallback.entrypoint).toEndWith("/index.js")
|
||||
})
|
||||
|
||||
test("installs and resolves named and unnamed Git packages with dependencies", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const fixture = await createGitFixture(tmp.path)
|
||||
const cache = path.join(tmp.path, "cache")
|
||||
const specs = [
|
||||
`git+file://${fixture.repository}#${fixture.commit}`,
|
||||
`fixture-named-plugin@git+file://${fixture.repository}#fixture-branch`,
|
||||
]
|
||||
|
||||
for (const spec of specs) {
|
||||
const entries = await Effect.gen(function* () {
|
||||
const npm = yield* Npm.Service
|
||||
return {
|
||||
added: yield* npm.add(spec),
|
||||
cached: yield* npm.add(spec),
|
||||
resolved: yield* npm.resolve(spec),
|
||||
}
|
||||
}).pipe(Effect.scoped, Effect.provide(npmLayer(cache)), Effect.runPromise)
|
||||
|
||||
expect(entries.added.entrypoint).toEndWith("/index.js")
|
||||
expect(entries.cached).toEqual(entries.added)
|
||||
expect(entries.resolved).toEqual(entries.added)
|
||||
expect(
|
||||
await fs.stat(path.join(path.dirname(entries.added.directory), "fixture-dependency", "package.json")),
|
||||
).toBeTruthy()
|
||||
expect(entries.added.directory).toContain(path.join("packages", await Npm.cacheKey(spec), "node_modules"))
|
||||
}
|
||||
})
|
||||
|
||||
test("installs a Git package from an npm ::path: subdirectory", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const fixture = await createGitFixture(tmp.path)
|
||||
const spec = `git+file://${fixture.repository}#${fixture.commit}::path:packages/subdirectory-plugin`
|
||||
const entry = await Effect.gen(function* () {
|
||||
const npm = yield* Npm.Service
|
||||
return yield* npm.add(spec)
|
||||
}).pipe(Effect.scoped, Effect.provide(npmLayer(path.join(tmp.path, "cache"))), Effect.runPromise)
|
||||
|
||||
expect(entry.directory).toEndWith(path.join("node_modules", "fixture-subdirectory-plugin"))
|
||||
expect(entry.entrypoint).toEndWith("/index.js")
|
||||
expect(
|
||||
await fs.stat(path.join(path.dirname(entry.directory), "fixture-subdirectory-dependency", "package.json")),
|
||||
).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe("Npm.resolve", () => {
|
||||
|
||||
@@ -35,7 +35,10 @@ const npmLayer = Layer.succeed(
|
||||
Npm.Service,
|
||||
Npm.Service.of({
|
||||
add: () => Effect.succeed({ directory: "", entrypoint: undefined }),
|
||||
check: () => Effect.succeed({ updateable: false, pinned: false, updateAvailable: false }),
|
||||
resolve: () => Effect.succeed({ directory: "", entrypoint: undefined }),
|
||||
update: () => Effect.succeed({ updateable: false, pinned: false, updateAvailable: false, updated: false }),
|
||||
rollback: () => Effect.void,
|
||||
which: () => Effect.undefined,
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { AISDK } from "@opencode-ai/core/aisdk"
|
||||
import { describe, expect, mock } from "bun:test"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { Model } from "@opencode-ai/core/model"
|
||||
import { Plugin } from "@opencode-ai/core/plugin"
|
||||
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||
import { CerebrasPlugin } from "@opencode-ai/core/plugin/provider/cerebras"
|
||||
@@ -10,26 +8,14 @@ import { Provider } from "@opencode-ai/core/provider"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { PluginTestLayer } from "./fixture"
|
||||
|
||||
const cerebrasOptions: Record<string, unknown>[] = []
|
||||
const it = testEffect(PluginTestLayer)
|
||||
|
||||
const addPlugin = Effect.fn(function* () {
|
||||
const plugin = yield* Plugin.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
const host = yield* PluginHost.make(plugin)
|
||||
yield* CerebrasPlugin.effect(host)
|
||||
})
|
||||
|
||||
void mock.module("@ai-sdk/cerebras", () => ({
|
||||
createCerebras: (options: Record<string, unknown>) => {
|
||||
const snapshot = { ...options }
|
||||
cerebrasOptions.push(snapshot)
|
||||
return {
|
||||
languageModel: (modelID: string) => ({ modelID, provider: snapshot.name, specificationVersion: "v3" }),
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
describe("CerebrasPlugin", () => {
|
||||
it.effect("applies the legacy integration header", () =>
|
||||
Effect.gen(function* () {
|
||||
@@ -57,62 +43,21 @@ describe("CerebrasPlugin", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("creates a bundled Cerebras SDK with the model provider ID as the SDK name", () =>
|
||||
it.effect("applies the integration header to custom native Cerebras providers", () =>
|
||||
Effect.gen(function* () {
|
||||
cerebrasOptions.length = 0
|
||||
const plugin = yield* Plugin.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
yield* addPlugin()
|
||||
const result = yield* aisdk.runSDK({
|
||||
model: Model.Info.make({
|
||||
...Model.Info.default(Provider.ID.make("custom-cerebras"), Model.ID.make("llama-4-scout-17b-16e-instruct")),
|
||||
modelID: Model.ID.make("llama-4-scout-17b-16e-instruct"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
package: "@ai-sdk/cerebras",
|
||||
options: { name: "custom-cerebras", apiKey: "test" },
|
||||
const catalog = yield* Catalog.Service
|
||||
const providerID = Provider.ID.make("custom-cerebras")
|
||||
yield* catalog.transform((catalog) => {
|
||||
catalog.provider.update(providerID, (item) => {
|
||||
item.package = "@opencode-ai/ai/providers/cerebras"
|
||||
item.headers = { Existing: "1" }
|
||||
})
|
||||
})
|
||||
expect(cerebrasOptions).toEqual([{ name: "custom-cerebras", apiKey: "test" }])
|
||||
expect(result.sdk.languageModel("llama-4-scout-17b-16e-instruct").provider).toBe("custom-cerebras")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves an explicit bundled Cerebras SDK name option", () =>
|
||||
Effect.gen(function* () {
|
||||
cerebrasOptions.length = 0
|
||||
const plugin = yield* Plugin.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
yield* addPlugin()
|
||||
yield* aisdk.runSDK({
|
||||
model: Model.Info.make({
|
||||
...Model.Info.default(Provider.ID.make("custom-cerebras"), Model.ID.make("llama-4-scout-17b-16e-instruct")),
|
||||
modelID: Model.ID.make("llama-4-scout-17b-16e-instruct"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
package: "@ai-sdk/cerebras",
|
||||
options: { name: "configured-cerebras", apiKey: "test" },
|
||||
expect((yield* catalog.provider.get(providerID))?.headers).toEqual({
|
||||
Existing: "1",
|
||||
"X-Cerebras-3rd-Party-Integration": "opencode",
|
||||
})
|
||||
expect(cerebrasOptions).toEqual([{ name: "configured-cerebras", apiKey: "test" }])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("ignores non-Cerebras SDK packages", () =>
|
||||
Effect.gen(function* () {
|
||||
cerebrasOptions.length = 0
|
||||
const plugin = yield* Plugin.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
yield* addPlugin()
|
||||
const result = yield* aisdk.runSDK({
|
||||
model: Model.Info.make({
|
||||
...Model.Info.default(Provider.ID.make("custom-cerebras"), Model.ID.make("llama-4-scout-17b-16e-instruct")),
|
||||
modelID: Model.ID.make("llama-4-scout-17b-16e-instruct"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
package: "@ai-sdk/groq",
|
||||
options: { name: "custom-cerebras", apiKey: "test" },
|
||||
})
|
||||
expect(cerebrasOptions).toEqual([])
|
||||
expect(result.sdk).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -23,7 +23,10 @@ const itWithAISDK = testEffect(Layer.mergeAll(PluginTestLayer, AppNodeBuilder.bu
|
||||
function npmEntrypoint(entrypoint?: string) {
|
||||
return Npm.Service.of({
|
||||
add: () => Effect.succeed({ directory: "", entrypoint }),
|
||||
check: () => Effect.succeed({ updateable: false, pinned: false, updateAvailable: false }),
|
||||
resolve: () => Effect.succeed({ directory: "", entrypoint }),
|
||||
update: () => Effect.succeed({ updateable: false, pinned: false, updateAvailable: false, updated: false }),
|
||||
rollback: () => Effect.void,
|
||||
which: () => Effect.undefined,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ import { GatewayPlugin } from "@opencode-ai/core/plugin/provider/gateway"
|
||||
import { GroqPlugin } from "@opencode-ai/core/plugin/provider/groq"
|
||||
import { MistralPlugin } from "@opencode-ai/core/plugin/provider/mistral"
|
||||
import { PerplexityPlugin } from "@opencode-ai/core/plugin/provider/perplexity"
|
||||
import { TogetherAIPlugin } from "@opencode-ai/core/plugin/provider/togetherai"
|
||||
import { VenicePlugin } from "@opencode-ai/core/plugin/provider/venice"
|
||||
import { Provider } from "@opencode-ai/core/provider"
|
||||
import { testEffect } from "../lib/effect"
|
||||
@@ -27,7 +26,6 @@ const providers = [
|
||||
{ id: "groq", plugin: GroqPlugin, package: "@ai-sdk/groq", provider: "groq.chat" },
|
||||
{ id: "mistral", plugin: MistralPlugin, package: "@ai-sdk/mistral", provider: "mistral.chat" },
|
||||
{ id: "perplexity", plugin: PerplexityPlugin, package: "@ai-sdk/perplexity", provider: "perplexity" },
|
||||
{ id: "togetherai", plugin: TogetherAIPlugin, package: "@ai-sdk/togetherai", provider: "togetherai.chat" },
|
||||
{ id: "venice", plugin: VenicePlugin, package: "venice-ai-sdk-provider", provider: "custom-provider.chat" },
|
||||
] as const
|
||||
|
||||
|
||||
@@ -14,7 +14,10 @@ const fixtureProvider = new URL("./fixtures/provider-factory.ts", import.meta.ur
|
||||
const it = testEffect(PluginTestLayer)
|
||||
const npm = Npm.Service.of({
|
||||
add: () => Effect.succeed({ directory: "", entrypoint: undefined }),
|
||||
check: () => Effect.succeed({ updateable: false, pinned: false, updateAvailable: false }),
|
||||
resolve: () => Effect.succeed({ directory: "", entrypoint: undefined }),
|
||||
update: () => Effect.succeed({ updateable: false, pinned: false, updateAvailable: false, updated: false }),
|
||||
rollback: () => Effect.void,
|
||||
which: () => Effect.undefined,
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
|
||||
describe("Core test environment", () => {
|
||||
test("isolates global home and XDG roots", () => {
|
||||
const home = process.env.OPENCODE_TEST_HOME
|
||||
expect(home).toBeDefined()
|
||||
if (!home) return
|
||||
|
||||
expect(os.homedir()).toBe(home)
|
||||
expect(Global.Path.home).toBe(home)
|
||||
expect(Global.Path.config).toBe(path.join(home, ".config", "opencode"))
|
||||
expect(Global.Path.data).toBe(path.join(home, ".local", "share", "opencode"))
|
||||
expect(Global.Path.cache).toBe(path.join(home, ".cache", "opencode"))
|
||||
expect(Global.Path.state).toBe(path.join(home, ".local", "state", "opencode"))
|
||||
expect(os.tmpdir()).toBe(path.join(home, "tmp"))
|
||||
expect(process.env.OPENCODE_CONFIG_DIR).toBe(Global.Path.config)
|
||||
expect(process.env.OPENCODE_CONFIG).toBeUndefined()
|
||||
expect(process.env.OPENCODE_CONFIG_CONTENT).toBeUndefined()
|
||||
expect(process.env.AWS_REGION).toBeUndefined()
|
||||
expect(process.env.GOOGLE_VERTEX_PROJECT).toBeUndefined()
|
||||
expect(process.env.NPM_CONFIG_REGISTRY).toBeUndefined()
|
||||
expect(process.env.UIDOTSH_AUTHORIZATION).toBeUndefined()
|
||||
if (process.env.RECORD !== "true") expect(process.env.OPENAI_API_KEY).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -3,13 +3,15 @@ import { Effect } from "effect"
|
||||
import { Provider } from "@opencode-ai/core/provider"
|
||||
|
||||
describe("Provider", () => {
|
||||
test("loads Vertex native provider entrypoints", async () => {
|
||||
test("loads bundled native provider entrypoints", async () => {
|
||||
const packages = [
|
||||
"@opencode-ai/ai/providers/cerebras",
|
||||
"@opencode-ai/ai/providers/google-vertex",
|
||||
"@opencode-ai/ai/providers/google-vertex/gemini",
|
||||
"@opencode-ai/ai/providers/google-vertex/chat",
|
||||
"@opencode-ai/ai/providers/google-vertex/responses",
|
||||
"@opencode-ai/ai/providers/google-vertex/messages",
|
||||
"@opencode-ai/ai/providers/togetherai",
|
||||
]
|
||||
|
||||
for (const specifier of packages) {
|
||||
|
||||
@@ -110,7 +110,7 @@ const discovery = Layer.mock(InstructionDiscovery.Service, {
|
||||
const skills = Layer.mock(SkillInstructions.Service, { load: () => Effect.succeed(Instructions.empty) })
|
||||
const references = Layer.mock(ReferenceInstructions.Service, { load: () => Effect.succeed(Instructions.empty) })
|
||||
const mcp = Layer.mock(McpInstructions.Service, { load: () => Effect.succeed(Instructions.empty) })
|
||||
const plugins = Layer.mock(PluginSupervisor.Service, { flush: Effect.void })
|
||||
const plugins = Layer.mock(PluginSupervisor.Service, { ...PluginSupervisor.noUpdates, flush: Effect.void })
|
||||
const tools = Layer.mock(Tool.Service, {
|
||||
snapshot: () =>
|
||||
Effect.succeed({
|
||||
|
||||
@@ -76,12 +76,14 @@ const locations = Layer.effect(
|
||||
Layer.mock(Snapshot.Service, {
|
||||
capture: () =>
|
||||
ready ? Effect.undefined : Effect.die(new Error("Snapshot used before plugins were ready")),
|
||||
restore: () =>
|
||||
ready ? Effect.void : Effect.die(new Error("Snapshot used before plugins were ready")),
|
||||
restore: () => (ready ? Effect.void : Effect.die(new Error("Snapshot used before plugins were ready"))),
|
||||
}),
|
||||
Layer.succeed(
|
||||
PluginSupervisor.Service,
|
||||
PluginSupervisor.Service.of({ flush: Effect.sync(() => (ready = true)) }),
|
||||
PluginSupervisor.Service.of({
|
||||
...PluginSupervisor.noUpdates,
|
||||
flush: Effect.sync(() => (ready = true)),
|
||||
}),
|
||||
),
|
||||
)
|
||||
}),
|
||||
|
||||
@@ -16,6 +16,7 @@ import { SessionEnvironment } from "@opencode-ai/core/session/environment"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-services"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { globalProjectLayer } from "./lib/project"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
|
||||
const closed: Session.ID[] = []
|
||||
const transport = Layer.succeed(
|
||||
@@ -44,17 +45,22 @@ const it = testEffect(
|
||||
],
|
||||
),
|
||||
)
|
||||
const location = Location.Ref.make({ directory: AbsolutePath.make(import.meta.dir) })
|
||||
|
||||
describe("Session.remove", () => {
|
||||
it.effect("removes a session and its children", () =>
|
||||
Effect.gen(function* () {
|
||||
const temporary = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(directory) => Effect.promise(() => directory[Symbol.asyncDispose]()),
|
||||
)
|
||||
const location = Location.Ref.make({ directory: AbsolutePath.make(temporary.path) })
|
||||
const session = yield* Session.Service
|
||||
const parent = yield* session.create({ location })
|
||||
const child = yield* session.create({ parentID: parent.id })
|
||||
yield* session.environment({ sessionID: parent.id, variables: { SESSION_ENV: "parent" } })
|
||||
yield* session.environment({ sessionID: child.id, variables: { SESSION_ENV: "child" } })
|
||||
yield* (yield* LocationServiceMap.Service).contextEffect(location)
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
yield* Effect.acquireRelease(locations.contextEffect(location), () => locations.invalidate(location))
|
||||
closed.length = 0
|
||||
|
||||
yield* session.remove(parent.id)
|
||||
|
||||
@@ -85,7 +85,10 @@ const referenceInstructions = Layer.mock(ReferenceInstructions.Service, {
|
||||
})
|
||||
const mcpInstructions = Layer.mock(McpInstructions.Service, { load: () => Effect.succeed(Instructions.empty) })
|
||||
const config = Config.testLayer()
|
||||
const pluginSupervisor = Layer.succeed(PluginSupervisor.Service, PluginSupervisor.Service.of({ flush: Effect.void }))
|
||||
const pluginSupervisor = Layer.succeed(
|
||||
PluginSupervisor.Service,
|
||||
PluginSupervisor.Service.of({ ...PluginSupervisor.noUpdates, flush: Effect.void }),
|
||||
)
|
||||
const promptCatalog = Layer.mock(Catalog.Service, {
|
||||
provider: {
|
||||
get: () => Effect.undefined,
|
||||
|
||||
@@ -33,6 +33,7 @@ import { Session } from "@opencode-ai/core/session"
|
||||
import { Snapshot } from "@opencode-ai/core/snapshot"
|
||||
import { SessionEvent } from "@opencode-ai/core/session/event"
|
||||
import { SessionContext } from "@opencode-ai/core/session/context"
|
||||
import { SessionCompaction } from "@opencode-ai/core/session/compaction"
|
||||
import { SessionInbox } from "@opencode-ai/core/session/inbox"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { SessionModelRequest } from "@opencode-ai/core/session/model-request"
|
||||
@@ -373,6 +374,7 @@ let pluginFlushHook = Effect.void
|
||||
const pluginSupervisor = Layer.succeed(
|
||||
PluginSupervisor.Service,
|
||||
PluginSupervisor.Service.of({
|
||||
...PluginSupervisor.noUpdates,
|
||||
flush: Effect.suspend(() => pluginFlushHook),
|
||||
}),
|
||||
)
|
||||
@@ -458,6 +460,7 @@ const it = testEffect(
|
||||
Config.node,
|
||||
Snapshot.node,
|
||||
SessionContext.node,
|
||||
SessionCompaction.node,
|
||||
SessionModelRequest.node,
|
||||
SessionRunnerLLM.node,
|
||||
SessionExecution.node,
|
||||
@@ -2560,6 +2563,34 @@ describe("SessionRunnerLLM", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not recover provider context overflow when automatic compaction is disabled", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setupOverflowRecovery
|
||||
const compaction = yield* SessionCompaction.Service
|
||||
yield* compaction.transform((draft) => draft.configure({ auto: false }))
|
||||
yield* TestLLM.push(
|
||||
[LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" })],
|
||||
TestLLM.text("Must not compact", "text-unexpected-summary"),
|
||||
TestLLM.text("Must not retry", "text-unexpected-retry"),
|
||||
)
|
||||
yield* admit(session, "Continue")
|
||||
expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("prompt too long")
|
||||
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(yield* session.context(sessionID)).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "assistant",
|
||||
finish: "error",
|
||||
error: expect.objectContaining({ message: "prompt too long" }),
|
||||
}),
|
||||
)
|
||||
expect(yield* session.context(sessionID)).not.toContainEqual(expect.objectContaining({ type: "compaction" }))
|
||||
expect(yield* recordedEventTypes(sessionID)).not.toContain(
|
||||
Bus.versionedType(SessionEvent.Compaction.Started.type, 1),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("recovers from provider context overflow without a configured context limit", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setupOverflowRecovery
|
||||
|
||||
@@ -125,7 +125,9 @@ const shellPluginSupervisor = makeLocationNode({
|
||||
service: PluginSupervisor.Service,
|
||||
layer: Layer.effect(
|
||||
PluginSupervisor.Service,
|
||||
registerToolPlugin(ShellTool.Plugin).pipe(Effect.as(PluginSupervisor.Service.of({ flush: Effect.void }))),
|
||||
registerToolPlugin(ShellTool.Plugin).pipe(
|
||||
Effect.as(PluginSupervisor.Service.of({ ...PluginSupervisor.noUpdates, flush: Effect.void })),
|
||||
),
|
||||
),
|
||||
deps: [
|
||||
Config.node,
|
||||
|
||||
@@ -100,7 +100,9 @@ const subagentPluginSupervisor = makeLocationNode({
|
||||
service: PluginSupervisor.Service,
|
||||
layer: Layer.effect(
|
||||
PluginSupervisor.Service,
|
||||
registerToolPlugin(SubagentTool.Plugin).pipe(Effect.as(PluginSupervisor.Service.of({ flush: Effect.void }))),
|
||||
registerToolPlugin(SubagentTool.Plugin).pipe(
|
||||
Effect.as(PluginSupervisor.Service.of({ ...PluginSupervisor.noUpdates, flush: Effect.void })),
|
||||
),
|
||||
),
|
||||
deps: [Agent.node, Config.node, Permission.node, PluginRuntime.node, Tool.node],
|
||||
})
|
||||
|
||||
@@ -288,10 +288,30 @@ export function createMermaidCodeBlockRenderer(
|
||||
} catch (error) {
|
||||
if (error instanceof MermaidSyntaxError) {
|
||||
const previous = key ? lastGood.get(key) : undefined
|
||||
if (!previous || previous.kind !== kind) return undefined
|
||||
const diagram = new StaticDiagramRenderable(ctx, previous)
|
||||
claimLastGood(key!, previous, diagram, lastGood)
|
||||
return diagram
|
||||
if (previous?.kind === kind) {
|
||||
const diagram = new StaticDiagramRenderable(ctx, previous)
|
||||
claimLastGood(key!, previous, diagram, lastGood)
|
||||
return diagram
|
||||
}
|
||||
|
||||
const lines = token.text.split("\n")
|
||||
if (error.lineNumber <= 2 || lines.slice(error.lineNumber).some((line) => line.trim())) return undefined
|
||||
|
||||
try {
|
||||
const prepared = prepareDiagram(
|
||||
kind,
|
||||
lines.slice(0, error.lineNumber - 1).join("\n"),
|
||||
options,
|
||||
layoutMaxWidth,
|
||||
)
|
||||
if (!prepared.height) return undefined
|
||||
const diagram = new StaticDiagramRenderable(ctx, prepared)
|
||||
if (key) claimLastGood(key, prepared, diagram, lastGood)
|
||||
return diagram
|
||||
} catch (error) {
|
||||
if (error instanceof MermaidSyntaxError || error instanceof DiagramCanvasSizeError) return undefined
|
||||
throw error
|
||||
}
|
||||
}
|
||||
if (error instanceof DiagramCanvasSizeError) return undefined
|
||||
throw error
|
||||
|
||||
@@ -211,6 +211,32 @@ flowchart LR
|
||||
expect(testRenderer.captureCharFrame()).toContain("Current")
|
||||
})
|
||||
|
||||
test("renders the valid prefix of an interrupted Mermaid fence", async () => {
|
||||
const testRenderer = await createTestRenderer({ width: 100, height: 18 })
|
||||
renderer = testRenderer.renderer
|
||||
const markdown = new MarkdownRenderable(renderer, {
|
||||
id: "markdown-interrupted-mermaid",
|
||||
content: `\`\`\`mermaid
|
||||
flowchart TD
|
||||
A[Resolve Project] --> B[Current directory]
|
||||
B --> C[Search ancestors]
|
||||
C --> D[Marker found]
|
||||
C --> E[No marker found]
|
||||
E --> G[Project root is`,
|
||||
syntaxStyle,
|
||||
internalBlockMode: "top-level",
|
||||
renderNode: createMermaidMarkdownRenderer(renderer),
|
||||
})
|
||||
|
||||
renderer.root.add(markdown)
|
||||
await renderMarkdown(markdown, testRenderer.renderOnce)
|
||||
|
||||
const frame = testRenderer.captureCharFrame()
|
||||
expect(frame).toContain("Resolve Project")
|
||||
expect(frame).toContain("No marker found")
|
||||
expect(frame).not.toContain("flowchart TD")
|
||||
})
|
||||
|
||||
test("renders a Mermaid sequence fence inside MarkdownRenderable", async () => {
|
||||
const testRenderer = await createTestRenderer({ width: 80, height: 14 })
|
||||
renderer = testRenderer.renderer
|
||||
|
||||
@@ -31,7 +31,7 @@ export interface Context {
|
||||
readonly mcp: MCPDomain
|
||||
readonly generate: GenerateApi<unknown>
|
||||
readonly permission: PermissionDomain
|
||||
readonly plugin: PluginApi<unknown>
|
||||
readonly plugin: Pick<PluginApi<unknown>, "list">
|
||||
readonly reference: ReferenceDomain
|
||||
readonly session: SessionDomain
|
||||
readonly shell: ShellDomain
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user