feat(aircoding): AirCoding V2 baseline — deterministic multi-agent architecture
Forked from OpenCode v1.17.4 with multi-agent system: - 5 agents: aircoding, scheduler, worker, architect, reviewer - Deterministic DAG scheduling engine (coordinator_tick) - Tool whitelists as hard enforcement - AirCoding validation plugin - V1 requirements: C4 docs, ADR, AGENTS.md, debug-log.md - Design documents in docs/
This commit is contained in:
360
packages/opencode/test/provider/amazon-bedrock.test.ts
Normal file
360
packages/opencode/test/provider/amazon-bedrock.test.ts
Normal file
@@ -0,0 +1,360 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import path from "path"
|
||||
import { unlink } from "fs/promises"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { Env } from "../../src/env"
|
||||
import { Provider } from "@/provider/provider"
|
||||
|
||||
import { disposeAllInstances } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
|
||||
const it = testEffect(Layer.mergeAll(Provider.defaultLayer, Env.defaultLayer))
|
||||
|
||||
const originalEnv = new Map<string, string | undefined>()
|
||||
|
||||
const set = (k: string, v: string) =>
|
||||
Effect.gen(function* () {
|
||||
if (!originalEnv.has(k)) originalEnv.set(k, process.env[k])
|
||||
process.env[k] = v
|
||||
yield* Env.use.set(k, v)
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
for (const [key, value] of originalEnv) {
|
||||
if (value === undefined) delete process.env[key]
|
||||
else process.env[key] = value
|
||||
}
|
||||
originalEnv.clear()
|
||||
await disposeAllInstances()
|
||||
})
|
||||
|
||||
const list = Provider.use.list()
|
||||
|
||||
const mantleModelConfig = {
|
||||
provider: { npm: "@ai-sdk/amazon-bedrock/mantle" },
|
||||
limit: { context: 272_000, output: 32_000 },
|
||||
modalities: {
|
||||
input: ["text", "image", "pdf"] as Array<"text" | "image" | "pdf">,
|
||||
output: ["text"] as Array<"text">,
|
||||
},
|
||||
}
|
||||
|
||||
const withAuthJson = (contents: string) =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(async () => {
|
||||
const authPath = path.join(Global.Path.data, "auth.json")
|
||||
let original: string | undefined
|
||||
try {
|
||||
original = await Filesystem.readText(authPath)
|
||||
} catch {
|
||||
original = undefined
|
||||
}
|
||||
await Filesystem.write(authPath, contents)
|
||||
return { authPath, original }
|
||||
}),
|
||||
({ authPath, original }) =>
|
||||
Effect.promise(async () => {
|
||||
if (original !== undefined) {
|
||||
await Filesystem.write(authPath, original)
|
||||
return
|
||||
}
|
||||
await unlink(authPath).catch(() => undefined)
|
||||
}),
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"Bedrock: config region takes precedence over AWS_REGION env var",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
yield* set("AWS_REGION", "us-east-1")
|
||||
yield* set("AWS_PROFILE", "default")
|
||||
const providers = yield* list
|
||||
expect(providers[ProviderV2.ID.amazonBedrock]).toBeDefined()
|
||||
expect(providers[ProviderV2.ID.amazonBedrock].options?.region).toBe("eu-west-1")
|
||||
}),
|
||||
{ config: { provider: { "amazon-bedrock": { options: { region: "eu-west-1" } } } } },
|
||||
)
|
||||
|
||||
it.instance("Bedrock: falls back to AWS_REGION env var when no config region", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* set("AWS_REGION", "eu-west-1")
|
||||
yield* set("AWS_PROFILE", "default")
|
||||
const providers = yield* list
|
||||
expect(providers[ProviderV2.ID.amazonBedrock]).toBeDefined()
|
||||
expect(providers[ProviderV2.ID.amazonBedrock].options?.region).toBe("eu-west-1")
|
||||
}),
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"Bedrock: loads when bearer token from auth.json is present",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
yield* withAuthJson(JSON.stringify({ "amazon-bedrock": { type: "api", key: "test-bearer-token" } }))
|
||||
yield* set("AWS_PROFILE", "")
|
||||
yield* set("AWS_ACCESS_KEY_ID", "")
|
||||
yield* set("AWS_BEARER_TOKEN_BEDROCK", "")
|
||||
const providers = yield* list
|
||||
expect(providers[ProviderV2.ID.amazonBedrock]).toBeDefined()
|
||||
expect(providers[ProviderV2.ID.amazonBedrock].options?.region).toBe("eu-west-1")
|
||||
}),
|
||||
{ config: { provider: { "amazon-bedrock": { options: { region: "eu-west-1" } } } } },
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"Bedrock Mantle: GPT-5.5 uses Responses API and OpenAI base path",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
yield* set("AWS_REGION", "")
|
||||
yield* set("AWS_PROFILE", "")
|
||||
yield* set("AWS_ACCESS_KEY_ID", "")
|
||||
yield* set("AWS_BEARER_TOKEN_BEDROCK", "")
|
||||
const model = yield* Provider.use.getModel(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("openai.gpt-5.5"))
|
||||
const language = yield* Provider.use.getLanguage(model)
|
||||
expect((language as { provider: string }).provider).toBe("bedrock-mantle.responses")
|
||||
expect((language as { modelId: string }).modelId).toBe("openai.gpt-5.5")
|
||||
expect(
|
||||
(language as unknown as { config: { url: (input: { path: string; modelId: string }) => string } }).config.url({
|
||||
path: "/responses",
|
||||
modelId: "openai.gpt-5.5",
|
||||
}),
|
||||
).toBe("https://bedrock-mantle.us-east-2.api.aws/openai/v1/responses")
|
||||
}),
|
||||
{
|
||||
config: {
|
||||
provider: {
|
||||
"amazon-bedrock": {
|
||||
options: { region: "us-east-2", apiKey: "test-bearer-token" },
|
||||
models: {
|
||||
"openai.gpt-5.5": {
|
||||
...mantleModelConfig,
|
||||
provider: {
|
||||
npm: "@ai-sdk/amazon-bedrock/mantle",
|
||||
api: "https://bedrock-mantle.${AWS_REGION}.api.aws/openai/v1",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"Bedrock Mantle: GPT OSS safeguard uses Chat Completions and Mantle base path",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
yield* set("AWS_BEARER_TOKEN_BEDROCK", "test-bearer-token")
|
||||
const model = yield* Provider.use.getModel(
|
||||
ProviderV2.ID.amazonBedrock,
|
||||
ModelV2.ID.make("openai.gpt-oss-safeguard-120b"),
|
||||
)
|
||||
const language = yield* Provider.use.getLanguage(model)
|
||||
expect((language as { provider: string }).provider).toBe("bedrock-mantle.chat")
|
||||
expect((language as { modelId: string }).modelId).toBe("openai.gpt-oss-safeguard-120b")
|
||||
expect(
|
||||
(language as unknown as { config: { url: (input: { path: string; modelId: string }) => string } }).config.url({
|
||||
path: "/chat/completions",
|
||||
modelId: "openai.gpt-oss-safeguard-120b",
|
||||
}),
|
||||
).toBe("https://bedrock-mantle.us-east-1.api.aws/v1/chat/completions")
|
||||
}),
|
||||
{
|
||||
config: {
|
||||
provider: {
|
||||
"amazon-bedrock": {
|
||||
options: { region: "us-east-1" },
|
||||
models: { "openai.gpt-oss-safeguard-120b": mantleModelConfig },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"Bedrock: config profile takes precedence over AWS_PROFILE env var",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
yield* set("AWS_PROFILE", "default")
|
||||
yield* set("AWS_ACCESS_KEY_ID", "test-key-id")
|
||||
const providers = yield* list
|
||||
expect(providers[ProviderV2.ID.amazonBedrock]).toBeDefined()
|
||||
expect(providers[ProviderV2.ID.amazonBedrock].options?.region).toBe("us-east-1")
|
||||
}),
|
||||
{
|
||||
config: {
|
||||
provider: { "amazon-bedrock": { options: { profile: "my-custom-profile", region: "us-east-1" } } },
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"Bedrock: includes custom endpoint in options when specified",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
yield* set("AWS_PROFILE", "default")
|
||||
const providers = yield* list
|
||||
expect(providers[ProviderV2.ID.amazonBedrock]).toBeDefined()
|
||||
expect(providers[ProviderV2.ID.amazonBedrock].options?.endpoint).toBe(
|
||||
"https://bedrock-runtime.us-east-1.vpce-xxxxx.amazonaws.com",
|
||||
)
|
||||
}),
|
||||
{
|
||||
config: {
|
||||
provider: {
|
||||
"amazon-bedrock": {
|
||||
options: { endpoint: "https://bedrock-runtime.us-east-1.vpce-xxxxx.amazonaws.com" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"Bedrock: autoloads when AWS_WEB_IDENTITY_TOKEN_FILE is present",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
yield* set("AWS_WEB_IDENTITY_TOKEN_FILE", "/var/run/secrets/eks.amazonaws.com/serviceaccount/token")
|
||||
yield* set("AWS_ROLE_ARN", "arn:aws:iam::123456789012:role/my-eks-role")
|
||||
yield* set("AWS_PROFILE", "")
|
||||
yield* set("AWS_ACCESS_KEY_ID", "")
|
||||
const providers = yield* list
|
||||
expect(providers[ProviderV2.ID.amazonBedrock]).toBeDefined()
|
||||
expect(providers[ProviderV2.ID.amazonBedrock].options?.region).toBe("us-east-1")
|
||||
}),
|
||||
{ config: { provider: { "amazon-bedrock": { options: { region: "us-east-1" } } } } },
|
||||
)
|
||||
|
||||
// Cross-region inference profile prefix handling.
|
||||
// Models from models.dev may come with prefixes already (e.g. us., eu., global.).
|
||||
// These should NOT be double-prefixed when passed to the SDK.
|
||||
|
||||
it.instance(
|
||||
"Bedrock: model with us. prefix should not be double-prefixed",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
yield* set("AWS_PROFILE", "default")
|
||||
const providers = yield* list
|
||||
expect(providers[ProviderV2.ID.amazonBedrock]).toBeDefined()
|
||||
expect(providers[ProviderV2.ID.amazonBedrock].models["us.anthropic.claude-opus-4-5-20251101-v1:0"]).toBeDefined()
|
||||
}),
|
||||
{
|
||||
config: {
|
||||
provider: {
|
||||
"amazon-bedrock": {
|
||||
options: { region: "us-east-1" },
|
||||
models: { "us.anthropic.claude-opus-4-5-20251101-v1:0": { name: "Claude Opus 4.5 (US)" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"Bedrock: model with global. prefix should not be prefixed",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
yield* set("AWS_PROFILE", "default")
|
||||
const providers = yield* list
|
||||
expect(providers[ProviderV2.ID.amazonBedrock]).toBeDefined()
|
||||
expect(
|
||||
providers[ProviderV2.ID.amazonBedrock].models["global.anthropic.claude-opus-4-5-20251101-v1:0"],
|
||||
).toBeDefined()
|
||||
}),
|
||||
{
|
||||
config: {
|
||||
provider: {
|
||||
"amazon-bedrock": {
|
||||
options: { region: "us-east-1" },
|
||||
models: { "global.anthropic.claude-opus-4-5-20251101-v1:0": { name: "Claude Opus 4.5 (Global)" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"Bedrock: model with eu. prefix should not be double-prefixed",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
yield* set("AWS_PROFILE", "default")
|
||||
const providers = yield* list
|
||||
expect(providers[ProviderV2.ID.amazonBedrock]).toBeDefined()
|
||||
expect(providers[ProviderV2.ID.amazonBedrock].models["eu.anthropic.claude-opus-4-5-20251101-v1:0"]).toBeDefined()
|
||||
}),
|
||||
{
|
||||
config: {
|
||||
provider: {
|
||||
"amazon-bedrock": {
|
||||
options: { region: "eu-west-1" },
|
||||
models: { "eu.anthropic.claude-opus-4-5-20251101-v1:0": { name: "Claude Opus 4.5 (EU)" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"Bedrock: model without prefix in US region should get us. prefix added",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
yield* set("AWS_PROFILE", "default")
|
||||
const providers = yield* list
|
||||
expect(providers[ProviderV2.ID.amazonBedrock]).toBeDefined()
|
||||
expect(providers[ProviderV2.ID.amazonBedrock].models["anthropic.claude-opus-4-5-20251101-v1:0"]).toBeDefined()
|
||||
}),
|
||||
{
|
||||
config: {
|
||||
provider: {
|
||||
"amazon-bedrock": {
|
||||
options: { region: "us-east-1" },
|
||||
models: { "anthropic.claude-opus-4-5-20251101-v1:0": { name: "Claude Opus 4.5" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
// Direct unit tests for cross-region inference profile prefix detection.
|
||||
describe("Bedrock cross-region prefix detection", () => {
|
||||
const crossRegionPrefixes = ["global.", "us.", "eu.", "jp.", "apac.", "au."]
|
||||
|
||||
test("should detect global. prefix", () => {
|
||||
expect(crossRegionPrefixes.some((p) => "global.anthropic.claude-opus-4-5-20251101-v1:0".startsWith(p))).toBe(true)
|
||||
})
|
||||
|
||||
test("should detect us. prefix", () => {
|
||||
expect(crossRegionPrefixes.some((p) => "us.anthropic.claude-opus-4-5-20251101-v1:0".startsWith(p))).toBe(true)
|
||||
})
|
||||
|
||||
test("should detect eu. prefix", () => {
|
||||
expect(crossRegionPrefixes.some((p) => "eu.anthropic.claude-opus-4-5-20251101-v1:0".startsWith(p))).toBe(true)
|
||||
})
|
||||
|
||||
test("should detect jp. prefix", () => {
|
||||
expect(crossRegionPrefixes.some((p) => "jp.anthropic.claude-sonnet-4-20250514-v1:0".startsWith(p))).toBe(true)
|
||||
})
|
||||
|
||||
test("should detect apac. prefix", () => {
|
||||
expect(crossRegionPrefixes.some((p) => "apac.anthropic.claude-sonnet-4-20250514-v1:0".startsWith(p))).toBe(true)
|
||||
})
|
||||
|
||||
test("should detect au. prefix", () => {
|
||||
expect(crossRegionPrefixes.some((p) => "au.anthropic.claude-sonnet-4-5-20250929-v1:0".startsWith(p))).toBe(true)
|
||||
})
|
||||
|
||||
test("should NOT detect prefix for non-prefixed model", () => {
|
||||
expect(crossRegionPrefixes.some((p) => "anthropic.claude-opus-4-5-20251101-v1:0".startsWith(p))).toBe(false)
|
||||
})
|
||||
|
||||
test("should NOT detect prefix for amazon nova models", () => {
|
||||
expect(crossRegionPrefixes.some((p) => "amazon.nova-pro-v1:0".startsWith(p))).toBe(false)
|
||||
})
|
||||
|
||||
test("should NOT detect prefix for cohere models", () => {
|
||||
expect(crossRegionPrefixes.some((p) => "cohere.command-r-plus-v1:0".startsWith(p))).toBe(false)
|
||||
})
|
||||
})
|
||||
132
packages/opencode/test/provider/cf-ai-gateway-e2e.test.ts
Normal file
132
packages/opencode/test/provider/cf-ai-gateway-e2e.test.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
// End-to-end regression test for opencode#24432.
|
||||
//
|
||||
// Routes through the actual ai-gateway-provider + @ai-sdk/openai-compatible
|
||||
// chain that provider.ts:811 builds at runtime, with only the network boundary
|
||||
// stubbed. Asserts that `reasoning_effort` (and other provider options the
|
||||
// transform emits) actually land in the body Cloudflare AI Gateway forwards
|
||||
// upstream, which is the only place the bug was observable.
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
|
||||
import type { JSONValue } from "ai"
|
||||
import { generateText } from "ai"
|
||||
import { createAiGateway } from "ai-gateway-provider"
|
||||
import { createUnified } from "ai-gateway-provider/providers/unified"
|
||||
import { ProviderTransform } from "@/provider/transform"
|
||||
import type * as Provider from "@/provider/provider"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
|
||||
type Captured = { url: string; outerBody: unknown }
|
||||
type ProviderOptions = Record<string, Record<string, JSONValue>>
|
||||
|
||||
const realFetch = globalThis.fetch
|
||||
let captured: Captured | null = null
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
captured = null
|
||||
const handle = async (input: Parameters<typeof fetch>[0], init?: Parameters<typeof fetch>[1]): Promise<Response> => {
|
||||
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url
|
||||
if (url.startsWith("https://gateway.ai.cloudflare.com/")) {
|
||||
const bodyText = typeof init?.body === "string" ? init.body : ""
|
||||
captured = { url, outerBody: bodyText ? JSON.parse(bodyText) : null }
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
id: "chatcmpl-test",
|
||||
object: "chat.completion",
|
||||
created: 0,
|
||||
model: "openai/gpt-5.4",
|
||||
choices: [{ index: 0, message: { role: "assistant", content: "ok" }, finish_reason: "stop" }],
|
||||
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
)
|
||||
}
|
||||
return realFetch(input, init)
|
||||
}
|
||||
// `typeof fetch` includes Bun's `preconnect` method; preserve it from realFetch.
|
||||
const stubFetch: typeof fetch = Object.assign(handle, { preconnect: realFetch.preconnect.bind(realFetch) })
|
||||
globalThis.fetch = stubFetch
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = realFetch
|
||||
})
|
||||
|
||||
const cfModel = (apiId: string, releaseDate = "2026-03-05"): Provider.Model => ({
|
||||
id: ModelV2.ID.make(`cloudflare-ai-gateway/${apiId}`),
|
||||
providerID: ProviderV2.ID.make("cloudflare-ai-gateway"),
|
||||
name: apiId,
|
||||
api: { id: apiId, url: "https://gateway.ai.cloudflare.com/v1/compat", npm: "ai-gateway-provider" },
|
||||
capabilities: {
|
||||
reasoning: true,
|
||||
temperature: false,
|
||||
attachment: true,
|
||||
toolcall: true,
|
||||
input: { text: true, audio: false, image: true, video: false, pdf: true },
|
||||
output: { text: true, audio: false, image: false, video: false, pdf: false },
|
||||
interleaved: false,
|
||||
},
|
||||
cost: { input: 1, output: 1, cache: { read: 0, write: 0 } },
|
||||
limit: { context: 1_000_000, output: 128_000 },
|
||||
status: "active",
|
||||
options: {},
|
||||
headers: {},
|
||||
release_date: releaseDate,
|
||||
})
|
||||
|
||||
// ai-gateway-provider sends an array of step descriptors; each entry's `query`
|
||||
// is the body forwarded to the upstream provider.
|
||||
function extractUpstreamQuery(body: unknown): Record<string, unknown> | undefined {
|
||||
if (!Array.isArray(body) || body.length === 0) return undefined
|
||||
const first = body[0]
|
||||
if (!isRecord(first)) return undefined
|
||||
const query = first.query
|
||||
return isRecord(query) ? query : undefined
|
||||
}
|
||||
|
||||
async function callThroughGateway(apiId: string, providerOptions: ProviderOptions) {
|
||||
const aigateway = createAiGateway({ accountId: "test", gateway: "test", apiKey: "test" })
|
||||
const unified = createUnified()
|
||||
await generateText({ model: aigateway(unified(apiId)), prompt: "hi", providerOptions })
|
||||
return extractUpstreamQuery(captured?.outerBody)
|
||||
}
|
||||
|
||||
describe("cf-ai-gateway end-to-end (regression: #24432)", () => {
|
||||
test("ProviderTransform.providerOptions output puts reasoning_effort on the wire", async () => {
|
||||
// The full chain the runtime exercises:
|
||||
// transform.providerOptions() -> openaiCompatible key
|
||||
// -> @ai-sdk/openai-compatible reads it as compatibleOptions
|
||||
// -> emits body.reasoning_effort
|
||||
// -> ai-gateway-provider wraps the body and forwards to gateway.ai.cloudflare.com
|
||||
const opts = ProviderTransform.providerOptions(cfModel("openai/gpt-5.4"), { reasoningEffort: "xhigh" })
|
||||
expect(opts).toEqual({ openaiCompatible: { reasoningEffort: "xhigh" } })
|
||||
|
||||
const upstream = await callThroughGateway("openai/gpt-5.4", opts)
|
||||
expect(upstream?.reasoning_effort).toBe("xhigh")
|
||||
})
|
||||
|
||||
test("variants() output for openai/gpt-5.4 lands xhigh on the wire", async () => {
|
||||
// The other half of the bug: workflow `variant: xhigh` flows through variants()
|
||||
// and must reach the wire. variants() returns the providerOptions payload
|
||||
// unwrapped; providerOptions() wraps it under the SDK key.
|
||||
const variants = ProviderTransform.variants(cfModel("openai/gpt-5.4"))
|
||||
expect(variants.xhigh).toEqual({ reasoningEffort: "xhigh" })
|
||||
|
||||
const opts = ProviderTransform.providerOptions(cfModel("openai/gpt-5.4"), variants.xhigh)
|
||||
const upstream = await callThroughGateway("openai/gpt-5.4", opts)
|
||||
expect(upstream?.reasoning_effort).toBe("xhigh")
|
||||
})
|
||||
|
||||
test("legacy buggy key 'cloudflare-ai-gateway' does NOT reach the wire (proves the bug)", async () => {
|
||||
// Sanity: confirms the bug class. If a future change accidentally restores
|
||||
// providerID-keyed providerOptions, this test fails before users notice.
|
||||
const upstream = await callThroughGateway("openai/gpt-5.4", {
|
||||
"cloudflare-ai-gateway": { reasoningEffort: "high" },
|
||||
})
|
||||
expect(upstream?.reasoning_effort).toBeUndefined()
|
||||
})
|
||||
})
|
||||
123
packages/opencode/test/provider/digitalocean.test.ts
Normal file
123
packages/opencode/test/provider/digitalocean.test.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { expect } from "bun:test"
|
||||
import { Provider } from "../../src/provider/provider"
|
||||
|
||||
import { Effect } from "effect"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
|
||||
const DIGITALOCEAN = ProviderV2.ID.make("digitalocean")
|
||||
const it = testEffect(Provider.defaultLayer)
|
||||
|
||||
const withEnv = <A, E, R>(values: Record<string, string>, effect: Effect.Effect<A, E, R>) =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
const previous = Object.fromEntries(Object.keys(values).map((key) => [key, process.env[key]] as const))
|
||||
Object.assign(process.env, values)
|
||||
return previous
|
||||
}),
|
||||
() => effect,
|
||||
(previous) =>
|
||||
Effect.sync(() => {
|
||||
for (const [key, value] of Object.entries(previous)) {
|
||||
if (value === undefined) delete process.env[key]
|
||||
else process.env[key] = value
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
const withAuth = <A, E, R>(metadata: Record<string, string> | undefined, effect: Effect.Effect<A, E, R>) =>
|
||||
withEnv(
|
||||
{
|
||||
OPENCODE_AUTH_CONTENT: JSON.stringify({
|
||||
digitalocean: {
|
||||
type: "api",
|
||||
key: "sk_do_test",
|
||||
...(metadata ? { metadata } : {}),
|
||||
},
|
||||
}),
|
||||
},
|
||||
effect,
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"digitalocean provider autoloads from DIGITALOCEAN_ACCESS_TOKEN",
|
||||
() =>
|
||||
withEnv(
|
||||
{ DIGITALOCEAN_ACCESS_TOKEN: "test-token" },
|
||||
Effect.gen(function* () {
|
||||
const provider = yield* Provider.Service
|
||||
const providers = yield* provider.list()
|
||||
expect(providers[DIGITALOCEAN]).toBeDefined()
|
||||
expect(providers[DIGITALOCEAN].source).toBe("env")
|
||||
const baseModel = Object.values(providers[DIGITALOCEAN].models)[0]
|
||||
expect(baseModel.api.url).toBe("https://inference.do-ai.run/v1")
|
||||
expect(baseModel.api.npm).toBe("@ai-sdk/openai-compatible")
|
||||
const routerEntries = Object.keys(providers[DIGITALOCEAN].models).filter((id) => id.startsWith("router:"))
|
||||
expect(routerEntries.length).toBe(0)
|
||||
}),
|
||||
),
|
||||
{ config: {} },
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"digitalocean provider.models surfaces cached routers from auth metadata",
|
||||
() =>
|
||||
withAuth(
|
||||
{
|
||||
routers: JSON.stringify([
|
||||
{ name: "my-router", uuid: "11f1499a-aaaa-bbbb-cccc-4e013e2ddde4" },
|
||||
{ name: "other-router", uuid: "22f1499a-aaaa-bbbb-cccc-4e013e2ddde4" },
|
||||
]),
|
||||
routers_fetched_at: String(Date.now()),
|
||||
oauth_access: "doo_v1_test",
|
||||
oauth_expires: String(Date.now() + 60 * 60 * 1000),
|
||||
},
|
||||
Effect.gen(function* () {
|
||||
const provider = yield* Provider.Service
|
||||
const providers = yield* provider.list()
|
||||
const models = providers[DIGITALOCEAN].models
|
||||
expect(models["router:my-router"]).toBeDefined()
|
||||
expect(models["router:my-router"].api.id).toBe("router:my-router")
|
||||
expect(models["router:my-router"].api.url).toBe("https://inference.do-ai.run/v1")
|
||||
expect(models["router:my-router"].api.npm).toBe("@ai-sdk/openai-compatible")
|
||||
expect(models["router:other-router"]).toBeDefined()
|
||||
}),
|
||||
),
|
||||
{ config: {} },
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"digitalocean provider.models skips refresh when oauth bearer is expired",
|
||||
() =>
|
||||
withAuth(
|
||||
{
|
||||
routers: JSON.stringify([{ name: "stale-router", uuid: "stale" }]),
|
||||
routers_fetched_at: "0",
|
||||
oauth_access: "doo_v1_expired",
|
||||
oauth_expires: "1",
|
||||
},
|
||||
Effect.gen(function* () {
|
||||
const provider = yield* Provider.Service
|
||||
const providers = yield* provider.list()
|
||||
const models = providers[DIGITALOCEAN].models
|
||||
expect(models["router:stale-router"]).toBeDefined()
|
||||
}),
|
||||
),
|
||||
{ config: {} },
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"digitalocean provider.models passes through base models when no auth metadata",
|
||||
() =>
|
||||
withEnv(
|
||||
{ DIGITALOCEAN_ACCESS_TOKEN: "test-token" },
|
||||
Effect.gen(function* () {
|
||||
const provider = yield* Provider.Service
|
||||
const providers = yield* provider.list()
|
||||
const models = providers[DIGITALOCEAN].models
|
||||
expect(Object.keys(models).length).toBeGreaterThan(0)
|
||||
expect(Object.keys(models).filter((id) => id.startsWith("router:")).length).toBe(0)
|
||||
}),
|
||||
),
|
||||
{ config: {} },
|
||||
)
|
||||
412
packages/opencode/test/provider/gitlab-duo.test.ts
Normal file
412
packages/opencode/test/provider/gitlab-duo.test.ts
Normal file
@@ -0,0 +1,412 @@
|
||||
export {}
|
||||
// TODO: UNCOMMENT WHEN GITLAB SUPPORT IS COMPLETED
|
||||
//
|
||||
//
|
||||
//
|
||||
// import { test, expect, describe } from "bun:test"
|
||||
// import path from "path"
|
||||
|
||||
// import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
// import { tmpdir, withTestInstance } from "../fixture/fixture"
|
||||
// import { Provider } from "@/provider/provider"
|
||||
// import { Env } from "../../src/env"
|
||||
// import { Global } from "@opencode-ai/core/global"
|
||||
// import { GitLabWorkflowLanguageModel } from "gitlab-ai-provider"
|
||||
|
||||
// test("GitLab Duo: loads provider with API key from environment", async () => {
|
||||
// await using tmp = await tmpdir({
|
||||
// init: async (dir) => {
|
||||
// await Bun.write(
|
||||
// path.join(dir, "opencode.json"),
|
||||
// JSON.stringify({
|
||||
// $schema: "https://opencode.ai/config.json",
|
||||
// }),
|
||||
// )
|
||||
// },
|
||||
// })
|
||||
// await withTestInstance({
|
||||
// directory: tmp.path,
|
||||
// init: async () => {
|
||||
// Env.set("GITLAB_TOKEN", "test-gitlab-token")
|
||||
// },
|
||||
// fn: async () => {
|
||||
// const providers = await list()
|
||||
// expect(providers[ProviderID.gitlab]).toBeDefined()
|
||||
// expect(providers[ProviderID.gitlab].key).toBe("test-gitlab-token")
|
||||
// },
|
||||
// })
|
||||
// })
|
||||
|
||||
// test("GitLab Duo: config instanceUrl option sets baseURL", async () => {
|
||||
// await using tmp = await tmpdir({
|
||||
// init: async (dir) => {
|
||||
// await Bun.write(
|
||||
// path.join(dir, "opencode.json"),
|
||||
// JSON.stringify({
|
||||
// $schema: "https://opencode.ai/config.json",
|
||||
// provider: {
|
||||
// gitlab: {
|
||||
// options: {
|
||||
// instanceUrl: "https://gitlab.example.com",
|
||||
// },
|
||||
// },
|
||||
// },
|
||||
// }),
|
||||
// )
|
||||
// },
|
||||
// })
|
||||
// await withTestInstance({
|
||||
// directory: tmp.path,
|
||||
// init: async () => {
|
||||
// Env.set("GITLAB_TOKEN", "test-token")
|
||||
// Env.set("GITLAB_INSTANCE_URL", "https://gitlab.example.com")
|
||||
// },
|
||||
// fn: async () => {
|
||||
// const providers = await list()
|
||||
// expect(providers[ProviderID.gitlab]).toBeDefined()
|
||||
// expect(providers[ProviderID.gitlab].options?.instanceUrl).toBe("https://gitlab.example.com")
|
||||
// },
|
||||
// })
|
||||
// })
|
||||
|
||||
// test("GitLab Duo: loads with OAuth token from auth.json", async () => {
|
||||
// await using tmp = await tmpdir({
|
||||
// init: async (dir) => {
|
||||
// await Bun.write(
|
||||
// path.join(dir, "opencode.json"),
|
||||
// JSON.stringify({
|
||||
// $schema: "https://opencode.ai/config.json",
|
||||
// }),
|
||||
// )
|
||||
// },
|
||||
// })
|
||||
|
||||
// const authPath = path.join(Global.Path.data, "auth.json")
|
||||
// await Bun.write(
|
||||
// authPath,
|
||||
// JSON.stringify({
|
||||
// gitlab: {
|
||||
// type: "oauth",
|
||||
// access: "test-access-token",
|
||||
// refresh: "test-refresh-token",
|
||||
// expires: Date.now() + 3600000,
|
||||
// },
|
||||
// }),
|
||||
// )
|
||||
|
||||
// await withTestInstance({
|
||||
// directory: tmp.path,
|
||||
// init: async () => {
|
||||
// Env.set("GITLAB_TOKEN", "")
|
||||
// },
|
||||
// fn: async () => {
|
||||
// const providers = await list()
|
||||
// expect(providers[ProviderID.gitlab]).toBeDefined()
|
||||
// },
|
||||
// })
|
||||
// })
|
||||
|
||||
// test("GitLab Duo: loads with Personal Access Token from auth.json", async () => {
|
||||
// await using tmp = await tmpdir({
|
||||
// init: async (dir) => {
|
||||
// await Bun.write(
|
||||
// path.join(dir, "opencode.json"),
|
||||
// JSON.stringify({
|
||||
// $schema: "https://opencode.ai/config.json",
|
||||
// }),
|
||||
// )
|
||||
// },
|
||||
// })
|
||||
|
||||
// const authPath2 = path.join(Global.Path.data, "auth.json")
|
||||
// await Bun.write(
|
||||
// authPath2,
|
||||
// JSON.stringify({
|
||||
// gitlab: {
|
||||
// type: "api",
|
||||
// key: "glpat-test-pat-token",
|
||||
// },
|
||||
// }),
|
||||
// )
|
||||
|
||||
// await withTestInstance({
|
||||
// directory: tmp.path,
|
||||
// init: async () => {
|
||||
// Env.set("GITLAB_TOKEN", "")
|
||||
// },
|
||||
// fn: async () => {
|
||||
// const providers = await list()
|
||||
// expect(providers[ProviderID.gitlab]).toBeDefined()
|
||||
// expect(providers[ProviderID.gitlab].key).toBe("glpat-test-pat-token")
|
||||
// },
|
||||
// })
|
||||
// })
|
||||
|
||||
// test("GitLab Duo: supports self-hosted instance configuration", async () => {
|
||||
// await using tmp = await tmpdir({
|
||||
// init: async (dir) => {
|
||||
// await Bun.write(
|
||||
// path.join(dir, "opencode.json"),
|
||||
// JSON.stringify({
|
||||
// $schema: "https://opencode.ai/config.json",
|
||||
// provider: {
|
||||
// gitlab: {
|
||||
// options: {
|
||||
// instanceUrl: "https://gitlab.company.internal",
|
||||
// apiKey: "glpat-internal-token",
|
||||
// },
|
||||
// },
|
||||
// },
|
||||
// }),
|
||||
// )
|
||||
// },
|
||||
// })
|
||||
// await withTestInstance({
|
||||
// directory: tmp.path,
|
||||
// init: async () => {
|
||||
// Env.set("GITLAB_INSTANCE_URL", "https://gitlab.company.internal")
|
||||
// },
|
||||
// fn: async () => {
|
||||
// const providers = await list()
|
||||
// expect(providers[ProviderID.gitlab]).toBeDefined()
|
||||
// expect(providers[ProviderID.gitlab].options?.instanceUrl).toBe("https://gitlab.company.internal")
|
||||
// },
|
||||
// })
|
||||
// })
|
||||
|
||||
// test("GitLab Duo: config apiKey takes precedence over environment variable", async () => {
|
||||
// await using tmp = await tmpdir({
|
||||
// init: async (dir) => {
|
||||
// await Bun.write(
|
||||
// path.join(dir, "opencode.json"),
|
||||
// JSON.stringify({
|
||||
// $schema: "https://opencode.ai/config.json",
|
||||
// provider: {
|
||||
// gitlab: {
|
||||
// options: {
|
||||
// apiKey: "config-token",
|
||||
// },
|
||||
// },
|
||||
// },
|
||||
// }),
|
||||
// )
|
||||
// },
|
||||
// })
|
||||
// await withTestInstance({
|
||||
// directory: tmp.path,
|
||||
// init: async () => {
|
||||
// Env.set("GITLAB_TOKEN", "env-token")
|
||||
// },
|
||||
// fn: async () => {
|
||||
// const providers = await list()
|
||||
// expect(providers[ProviderID.gitlab]).toBeDefined()
|
||||
// },
|
||||
// })
|
||||
// })
|
||||
|
||||
// test("GitLab Duo: includes context-1m beta header in aiGatewayHeaders", async () => {
|
||||
// await using tmp = await tmpdir({
|
||||
// init: async (dir) => {
|
||||
// await Bun.write(
|
||||
// path.join(dir, "opencode.json"),
|
||||
// JSON.stringify({
|
||||
// $schema: "https://opencode.ai/config.json",
|
||||
// }),
|
||||
// )
|
||||
// },
|
||||
// })
|
||||
// await withTestInstance({
|
||||
// directory: tmp.path,
|
||||
// init: async () => {
|
||||
// Env.set("GITLAB_TOKEN", "test-token")
|
||||
// },
|
||||
// fn: async () => {
|
||||
// const providers = await list()
|
||||
// expect(providers[ProviderID.gitlab]).toBeDefined()
|
||||
// expect(providers[ProviderID.gitlab].options?.aiGatewayHeaders?.["anthropic-beta"]).toContain(
|
||||
// "context-1m-2025-08-07",
|
||||
// )
|
||||
// },
|
||||
// })
|
||||
// })
|
||||
|
||||
// test("GitLab Duo: supports feature flags configuration", async () => {
|
||||
// await using tmp = await tmpdir({
|
||||
// init: async (dir) => {
|
||||
// await Bun.write(
|
||||
// path.join(dir, "opencode.json"),
|
||||
// JSON.stringify({
|
||||
// $schema: "https://opencode.ai/config.json",
|
||||
// provider: {
|
||||
// gitlab: {
|
||||
// options: {
|
||||
// featureFlags: {
|
||||
// duo_agent_platform_agentic_chat: true,
|
||||
// duo_agent_platform: true,
|
||||
// },
|
||||
// },
|
||||
// },
|
||||
// },
|
||||
// }),
|
||||
// )
|
||||
// },
|
||||
// })
|
||||
// await withTestInstance({
|
||||
// directory: tmp.path,
|
||||
// init: async () => {
|
||||
// Env.set("GITLAB_TOKEN", "test-token")
|
||||
// },
|
||||
// fn: async () => {
|
||||
// const providers = await list()
|
||||
// expect(providers[ProviderID.gitlab]).toBeDefined()
|
||||
// expect(providers[ProviderID.gitlab].options?.featureFlags).toBeDefined()
|
||||
// expect(providers[ProviderID.gitlab].options?.featureFlags?.duo_agent_platform_agentic_chat).toBe(true)
|
||||
// },
|
||||
// })
|
||||
// })
|
||||
|
||||
// test("GitLab Duo: has multiple agentic chat models available", async () => {
|
||||
// await using tmp = await tmpdir({
|
||||
// init: async (dir) => {
|
||||
// await Bun.write(
|
||||
// path.join(dir, "opencode.json"),
|
||||
// JSON.stringify({
|
||||
// $schema: "https://opencode.ai/config.json",
|
||||
// }),
|
||||
// )
|
||||
// },
|
||||
// })
|
||||
// await withTestInstance({
|
||||
// directory: tmp.path,
|
||||
// init: async () => {
|
||||
// Env.set("GITLAB_TOKEN", "test-token")
|
||||
// },
|
||||
// fn: async () => {
|
||||
// const providers = await list()
|
||||
// expect(providers[ProviderID.gitlab]).toBeDefined()
|
||||
// const models = Object.keys(providers[ProviderID.gitlab].models)
|
||||
// expect(models.length).toBeGreaterThan(0)
|
||||
// expect(models).toContain("duo-chat-haiku-4-5")
|
||||
// expect(models).toContain("duo-chat-sonnet-4-5")
|
||||
// expect(models).toContain("duo-chat-opus-4-5")
|
||||
// },
|
||||
// })
|
||||
// })
|
||||
|
||||
// describe("GitLab Duo: workflow model routing", () => {
|
||||
// test("duo-workflow-* model routes through workflowChat", async () => {
|
||||
// await using tmp = await tmpdir({
|
||||
// init: async (dir) => {
|
||||
// await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json" }))
|
||||
// },
|
||||
// })
|
||||
// await withTestInstance({
|
||||
// directory: tmp.path,
|
||||
// init: async () => {
|
||||
// Env.set("GITLAB_TOKEN", "test-token")
|
||||
// },
|
||||
// fn: async () => {
|
||||
// const providers = await list()
|
||||
// const gitlab = providers[ProviderID.gitlab]
|
||||
// expect(gitlab).toBeDefined()
|
||||
// gitlab.models["duo-workflow-sonnet-4-6"] = {
|
||||
// id: ModelID.make("duo-workflow-sonnet-4-6"),
|
||||
// providerID: ProviderID.make("gitlab"),
|
||||
// name: "Agent Platform (Claude Sonnet 4.6)",
|
||||
// family: "",
|
||||
// api: { id: "duo-workflow-sonnet-4-6", url: "https://gitlab.com", npm: "gitlab-ai-provider" },
|
||||
// status: "active",
|
||||
// headers: {},
|
||||
// options: { workflowRef: "claude_sonnet_4_6" },
|
||||
// cost: { input: 0, output: 0, cache: { read: 0, write: 0 } },
|
||||
// limit: { context: 200000, output: 64000 },
|
||||
// capabilities: {
|
||||
// temperature: false,
|
||||
// reasoning: true,
|
||||
// attachment: true,
|
||||
// toolcall: true,
|
||||
// input: { text: true, audio: false, image: true, video: false, pdf: true },
|
||||
// output: { text: true, audio: false, image: false, video: false, pdf: false },
|
||||
// interleaved: false,
|
||||
// },
|
||||
// release_date: "",
|
||||
// variants: {},
|
||||
// }
|
||||
// const model = await getModel(ProviderID.gitlab, ModelID.make("duo-workflow-sonnet-4-6"))
|
||||
// expect(model).toBeDefined()
|
||||
// expect(model.options?.workflowRef).toBe("claude_sonnet_4_6")
|
||||
// const language = await getLanguage(model)
|
||||
// expect(language).toBeDefined()
|
||||
// expect(language).toBeInstanceOf(GitLabWorkflowLanguageModel)
|
||||
// },
|
||||
// })
|
||||
// })
|
||||
|
||||
// test("duo-chat-* model routes through agenticChat (not workflow)", async () => {
|
||||
// await using tmp = await tmpdir({
|
||||
// init: async (dir) => {
|
||||
// await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json" }))
|
||||
// },
|
||||
// })
|
||||
// await withTestInstance({
|
||||
// directory: tmp.path,
|
||||
// init: async () => {
|
||||
// Env.set("GITLAB_TOKEN", "test-token")
|
||||
// },
|
||||
// fn: async () => {
|
||||
// const providers = await list()
|
||||
// expect(providers[ProviderID.gitlab]).toBeDefined()
|
||||
// const model = await getModel(ProviderID.gitlab, ModelID.make("duo-chat-sonnet-4-5"))
|
||||
// expect(model).toBeDefined()
|
||||
// const language = await getLanguage(model)
|
||||
// expect(language).toBeDefined()
|
||||
// expect(language).not.toBeInstanceOf(GitLabWorkflowLanguageModel)
|
||||
// },
|
||||
// })
|
||||
// })
|
||||
|
||||
// test("model.options merged with provider.options in getLanguage", async () => {
|
||||
// await using tmp = await tmpdir({
|
||||
// init: async (dir) => {
|
||||
// await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json" }))
|
||||
// },
|
||||
// })
|
||||
// await withTestInstance({
|
||||
// directory: tmp.path,
|
||||
// init: async () => {
|
||||
// Env.set("GITLAB_TOKEN", "test-token")
|
||||
// },
|
||||
// fn: async () => {
|
||||
// const providers = await list()
|
||||
// const gitlab = providers[ProviderID.gitlab]
|
||||
// expect(gitlab.options?.featureFlags).toBeDefined()
|
||||
// const model = await getModel(ProviderID.gitlab, ModelID.make("duo-chat-sonnet-4-5"))
|
||||
// expect(model).toBeDefined()
|
||||
// expect(model.options).toBeDefined()
|
||||
// },
|
||||
// })
|
||||
// })
|
||||
// })
|
||||
|
||||
// describe("GitLab Duo: static models", () => {
|
||||
// test("static duo-chat models always present regardless of discovery", async () => {
|
||||
// await using tmp = await tmpdir({
|
||||
// init: async (dir) => {
|
||||
// await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json" }))
|
||||
// },
|
||||
// })
|
||||
// await withTestInstance({
|
||||
// directory: tmp.path,
|
||||
// init: async () => {
|
||||
// Env.set("GITLAB_TOKEN", "test-token")
|
||||
// },
|
||||
// fn: async () => {
|
||||
// const providers = await list()
|
||||
// const models = Object.keys(providers[ProviderID.gitlab].models)
|
||||
// expect(models).toContain("duo-chat-haiku-4-5")
|
||||
// expect(models).toContain("duo-chat-sonnet-4-5")
|
||||
// expect(models).toContain("duo-chat-opus-4-5")
|
||||
// },
|
||||
// })
|
||||
// })
|
||||
// })
|
||||
233
packages/opencode/test/provider/header-timeout.test.ts
Normal file
233
packages/opencode/test/provider/header-timeout.test.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
import { afterEach, expect } from "bun:test"
|
||||
import { createServer, type Server } from "node:http"
|
||||
import { streamText } from "ai"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { disposeAllInstances, provideTmpdirInstance } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { testProviderConfig } from "../lib/test-provider"
|
||||
import { Env } from "@/env"
|
||||
import { Plugin } from "@/plugin"
|
||||
import { Provider } from "@/provider/provider"
|
||||
import { ProviderError } from "@/provider/error"
|
||||
|
||||
afterEach(async () => {
|
||||
await disposeAllInstances()
|
||||
})
|
||||
|
||||
const it = testEffect(
|
||||
Layer.mergeAll(Provider.defaultLayer, Env.defaultLayer, Plugin.defaultLayer, CrossSpawnSpawner.defaultLayer),
|
||||
)
|
||||
|
||||
it.live("headerTimeout does not abort delayed SSE body after headers arrive", () =>
|
||||
Effect.gen(function* () {
|
||||
const server = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => delayedBodyServer(1_000)),
|
||||
(server) => Effect.sync(() => server.server.close()),
|
||||
)
|
||||
|
||||
yield* provideTmpdirInstance(
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const provider = yield* Provider.Service
|
||||
const model = yield* provider.getModel(ProviderV2.ID.make("test"), ModelV2.ID.make("test-model"))
|
||||
const result = streamText({
|
||||
model: yield* provider.getLanguage(model),
|
||||
messages: [{ role: "user", content: "hello" }],
|
||||
})
|
||||
|
||||
expect(yield* Effect.promise(() => result.text)).toBe("late")
|
||||
}),
|
||||
{ config: providerConfig(server.url, { headerTimeout: 500 }) },
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("chunkTimeout raises a response stream error when SSE body stalls", () =>
|
||||
Effect.gen(function* () {
|
||||
const server = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => delayedBodyServer(250)),
|
||||
(server) => Effect.sync(() => server.server.close()),
|
||||
)
|
||||
|
||||
yield* provideTmpdirInstance(
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const provider = yield* Provider.Service
|
||||
const model = yield* provider.getModel(ProviderV2.ID.make("test"), ModelV2.ID.make("test-model"))
|
||||
const result = streamText({
|
||||
model: yield* provider.getLanguage(model),
|
||||
onError() {},
|
||||
messages: [{ role: "user", content: "hello" }],
|
||||
})
|
||||
|
||||
const error = yield* Effect.promise(async () => {
|
||||
try {
|
||||
for await (const part of result.fullStream) {
|
||||
if (part.type === "error") return part.error
|
||||
}
|
||||
} catch (error) {
|
||||
return error
|
||||
}
|
||||
})
|
||||
expect(error).toBeInstanceOf(ProviderError.ResponseStreamError)
|
||||
}),
|
||||
{ config: providerConfig(server.url, { chunkTimeout: 50 }) },
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("headerTimeout aborts when response headers do not arrive", () =>
|
||||
Effect.gen(function* () {
|
||||
const server = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => delayedHeaderServer(250)),
|
||||
(server) => Effect.sync(() => server.server.close()),
|
||||
)
|
||||
|
||||
yield* provideTmpdirInstance(
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const provider = yield* Provider.Service
|
||||
const model = yield* provider.getModel(ProviderV2.ID.make("test"), ModelV2.ID.make("test-model"))
|
||||
const result = streamText({
|
||||
model: yield* provider.getLanguage(model),
|
||||
onError() {},
|
||||
messages: [{ role: "user", content: "hello" }],
|
||||
})
|
||||
|
||||
const errors = yield* Effect.promise(async () => {
|
||||
const errors: string[] = []
|
||||
for await (const part of result.fullStream) {
|
||||
if (part.type === "error") errors.push(String(part.error))
|
||||
}
|
||||
return errors
|
||||
})
|
||||
expect(errors.join("\n")).toContain("response headers timed out")
|
||||
}),
|
||||
{ config: providerConfig(server.url, { headerTimeout: 50 }) },
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("headerTimeout is opt-in for non-OpenAI providers", () =>
|
||||
Effect.gen(function* () {
|
||||
const server = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => delayedHeaderServer(100)),
|
||||
(server) => Effect.sync(() => server.server.close()),
|
||||
)
|
||||
|
||||
yield* provideTmpdirInstance(
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const provider = yield* Provider.Service
|
||||
const model = yield* provider.getModel(ProviderV2.ID.make("test"), ModelV2.ID.make("test-model"))
|
||||
const result = streamText({
|
||||
model: yield* provider.getLanguage(model),
|
||||
messages: [{ role: "user", content: "hello" }],
|
||||
})
|
||||
|
||||
expect(yield* Effect.promise(() => result.text)).toBe("ok")
|
||||
}),
|
||||
{ config: providerConfig(server.url) },
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("OpenAI Codex headerTimeout default can be disabled by config", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* withAuthContent(
|
||||
Effect.gen(function* () {
|
||||
yield* provideTmpdirInstance(
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const provider = yield* Provider.Service
|
||||
const openai = yield* provider.getProvider(ProviderV2.ID.openai)
|
||||
expect(openai.options.headerTimeout).toBe(false)
|
||||
}),
|
||||
{ config: { provider: { openai: { options: { headerTimeout: false } } } } },
|
||||
)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("OpenAI API auth gets default headerTimeout", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* withAuthContent(
|
||||
Effect.gen(function* () {
|
||||
yield* provideTmpdirInstance(() =>
|
||||
Effect.gen(function* () {
|
||||
const provider = yield* Provider.Service
|
||||
const openai = yield* provider.getProvider(ProviderV2.ID.openai)
|
||||
expect(openai.options.headerTimeout).toBe(10_000)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
{ openai: { type: "api", key: "sk-test" } },
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
function providerConfig(url: string, options: Record<string, unknown> = {}) {
|
||||
const config = testProviderConfig(url)
|
||||
return {
|
||||
...config,
|
||||
provider: {
|
||||
test: {
|
||||
...config.provider.test,
|
||||
options: { ...config.provider.test.options, ...options },
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function delayedHeaderServer(delay: number): Promise<{ server: Server; url: string }> {
|
||||
const server = createServer((_, res) => {
|
||||
setTimeout(() => {
|
||||
res.writeHead(200, { "content-type": "text/event-stream" })
|
||||
res.end('data: {"choices":[{"delta":{"content":"ok"}}]}\n\ndata: [DONE]\n\n')
|
||||
}, delay)
|
||||
})
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve))
|
||||
const address = server.address()
|
||||
if (!address || typeof address === "string") throw new Error("server did not bind to a TCP port")
|
||||
return { server, url: `http://127.0.0.1:${address.port}` }
|
||||
}
|
||||
|
||||
async function delayedBodyServer(delay: number): Promise<{ server: Server; url: string }> {
|
||||
const server = createServer((_, res) => {
|
||||
res.writeHead(200, { "content-type": "text/event-stream" })
|
||||
res.flushHeaders()
|
||||
setTimeout(() => {
|
||||
res.end('data: {"choices":[{"delta":{"content":"late"}}]}\n\ndata: [DONE]\n\n')
|
||||
}, delay)
|
||||
})
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve))
|
||||
const address = server.address()
|
||||
if (!address || typeof address === "string") throw new Error("server did not bind to a TCP port")
|
||||
return { server, url: `http://127.0.0.1:${address.port}` }
|
||||
}
|
||||
|
||||
function withAuthContent<A, E, R>(self: Effect.Effect<A, E, R>, value: Record<string, unknown> = defaultAuthContent()) {
|
||||
return Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
const previous = process.env.OPENCODE_AUTH_CONTENT
|
||||
process.env.OPENCODE_AUTH_CONTENT = JSON.stringify(value)
|
||||
return previous
|
||||
}),
|
||||
() => self,
|
||||
(previous) =>
|
||||
Effect.sync(() => {
|
||||
if (previous === undefined) delete process.env.OPENCODE_AUTH_CONTENT
|
||||
else process.env.OPENCODE_AUTH_CONTENT = previous
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function defaultAuthContent() {
|
||||
return {
|
||||
openai: { type: "oauth", refresh: "refresh", access: "access", expires: Date.now() + 60_000 },
|
||||
}
|
||||
}
|
||||
61
packages/opencode/test/provider/model-status.test.ts
Normal file
61
packages/opencode/test/provider/model-status.test.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Schema } from "effect"
|
||||
import { ConfigProviderV1 } from "@opencode-ai/core/v1/config/provider"
|
||||
import { CatalogModelStatus, ModelStatus } from "@/provider/model-status"
|
||||
import { ModelsDev } from "@opencode-ai/core/models-dev"
|
||||
import { Provider } from "@/provider/provider"
|
||||
|
||||
describe("provider model status schemas", () => {
|
||||
test("keeps catalog status separate from normalized provider status", () => {
|
||||
expect(Schema.decodeUnknownSync(CatalogModelStatus)("deprecated")).toBe("deprecated")
|
||||
expect(() => Schema.decodeUnknownSync(CatalogModelStatus)("active")).toThrow()
|
||||
expect(Schema.decodeUnknownSync(ModelStatus)("active")).toBe("active")
|
||||
})
|
||||
|
||||
test("accepts active status across public provider schemas", () => {
|
||||
expect(Schema.decodeUnknownSync(ConfigProviderV1.Model)({ status: "active" }).status).toBe("active")
|
||||
expect(
|
||||
Schema.decodeUnknownSync(ModelsDev.Model)({
|
||||
id: "test-model",
|
||||
name: "Test Model",
|
||||
release_date: "2026-01-01",
|
||||
attachment: false,
|
||||
reasoning: false,
|
||||
temperature: true,
|
||||
tool_call: true,
|
||||
limit: { context: 128000, output: 8192 },
|
||||
}).status,
|
||||
).toBeUndefined()
|
||||
expect(
|
||||
Schema.decodeUnknownSync(Provider.Model)({
|
||||
id: "test-model",
|
||||
providerID: "test-provider",
|
||||
api: {
|
||||
id: "test-model",
|
||||
url: "",
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
},
|
||||
name: "Test Model",
|
||||
capabilities: {
|
||||
temperature: true,
|
||||
reasoning: false,
|
||||
attachment: false,
|
||||
toolcall: true,
|
||||
input: { text: true, audio: false, image: false, video: false, pdf: false },
|
||||
output: { text: true, audio: false, image: false, video: false, pdf: false },
|
||||
interleaved: false,
|
||||
},
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cache: { read: 0, write: 0 },
|
||||
},
|
||||
limit: { context: 128000, output: 8192 },
|
||||
status: "active",
|
||||
options: {},
|
||||
headers: {},
|
||||
release_date: "2026-01-01",
|
||||
}).status,
|
||||
).toBe("active")
|
||||
})
|
||||
})
|
||||
1793
packages/opencode/test/provider/provider.test.ts
Normal file
1793
packages/opencode/test/provider/provider.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
4207
packages/opencode/test/provider/transform.test.ts
Normal file
4207
packages/opencode/test/provider/transform.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user