chore(observability): merge v2

This commit is contained in:
starptech 2026-07-08 18:16:17 +02:00
commit a18e5de4af
435 changed files with 18249 additions and 12191 deletions

View file

@ -0,0 +1,8 @@
import { describe, expect, test } from "bun:test"
import { isContextOverflow } from "../src"
describe("provider error classification", () => {
test("classifies Z.AI GLM token limit messages as context overflow", () => {
expect(isContextOverflow("tokens in request more than max tokens allowed")).toBe(true)
})
})

View file

@ -10,10 +10,15 @@ describe("provider package entrypoints", () => {
import("@opencode-ai/llm/providers/anthropic"),
import("@opencode-ai/llm/providers/openai-compatible"),
import("@opencode-ai/llm/providers/amazon-bedrock"),
import("@opencode-ai/llm/providers/azure"),
import("@opencode-ai/llm/providers/azure/responses"),
import("@opencode-ai/llm/providers/azure/chat"),
import("@opencode-ai/llm/providers/google"),
])
for (const module of modules) expect(module.model).toBeFunction()
expect(modules[0].model).toBe(modules[1].model)
expect(modules[6].model).toBe(modules[7].model)
})
test("maps package settings onto the executable model", () => {
@ -49,4 +54,49 @@ describe("provider package entrypoints", () => {
"OpenAI-Project": "proj_123",
})
})
test("selects Azure API entrypoints with the same model contract", async () => {
const Azure = await import("@opencode-ai/llm/providers/azure")
const AzureChat = await import("@opencode-ai/llm/providers/azure/chat")
const AzureResponses = await import("@opencode-ai/llm/providers/azure/responses")
const settings = {
apiKey: "fixture",
resourceName: "opencode-test",
headers: { "x-application": "opencode" },
body: { service_tier: "priority" },
limits: { context: 200_000, output: 64_000 },
}
const responses = AzureResponses.model("deployment", settings)
const chat = AzureChat.model("deployment", settings)
expect(Azure.model("deployment", settings).route.id).toBe("azure-openai-responses")
expect(responses.route.id).toBe("azure-openai-responses")
expect(responses.route.endpoint.baseURL).toBe("https://opencode-test.openai.azure.com/openai/v1")
expect(responses.route.defaults.headers).toEqual({ "x-application": "opencode" })
expect(responses.route.defaults.http?.body).toEqual({ service_tier: "priority" })
expect(responses.route.defaults.limits).toEqual({ context: 200_000, output: 64_000 })
expect(chat.route.id).toBe("azure-openai-chat")
})
test("maps Google package settings onto the Gemini model", async () => {
const Google = await import("@opencode-ai/llm/providers/google")
const selected = Google.model("gemini-2.5-flash", {
apiKey: "fixture",
baseURL: "https://generativelanguage.test/v1beta",
headers: { "x-application": "opencode" },
body: { safetySettings: [] },
limits: { context: 1_000_000, output: 65_536 },
providerOptions: { gemini: { thinkingConfig: { thinkingBudget: 1_024 } } },
})
expect(selected.route.id).toBe("gemini")
expect(selected.route.endpoint.baseURL).toBe("https://generativelanguage.test/v1beta")
expect(selected.route.defaults.headers).toEqual({ "x-application": "opencode" })
expect(selected.route.defaults.http?.body).toEqual({ safetySettings: [] })
expect(selected.route.defaults.limits).toEqual({ context: 1_000_000, output: 65_536 })
expect(selected.route.defaults.providerOptions).toEqual({
gemini: { thinkingConfig: { thinkingBudget: 1_024 } },
})
})
})

View file

@ -12,7 +12,6 @@ const openAI = OpenAI.configure({
})
const openAIChat = openAI.chat("gpt-4o-mini")
const openAIResponses = openAI.responses("gpt-5.5")
const openAIResponsesWebSocket = openAI.responsesWebSocket("gpt-4.1-mini")
const anthropic = Anthropic.configure({
apiKey: process.env.ANTHROPIC_API_KEY ?? "fixture",
})
@ -89,14 +88,6 @@ describeRecordedGoldenScenarios([
{ id: "image-tool-result", temperature: false, maxTokens: 40 },
],
},
{
name: "OpenAI Responses WebSocket gpt-4.1-mini",
prefix: "openai-responses-websocket",
model: openAIResponsesWebSocket,
transport: "websocket",
requires: ["OPENAI_API_KEY"],
scenarios: ["tool-loop"],
},
{
name: "Anthropic Haiku 4.5",
prefix: "anthropic-messages",

View file

@ -28,7 +28,7 @@ type TargetInput = {
readonly transport?: Transport
readonly prefix?: string
readonly tags?: ReadonlyArray<string>
readonly metadata?: Record<string, unknown>
readonly metadata?: HttpRecorder.CassetteMetadata
readonly options?: HttpRecorder.RecorderOptions
readonly scenarios: ReadonlyArray<ScenarioInput>
}
@ -43,7 +43,7 @@ const defaultPrefix = (target: TargetInput) => {
const metadata = (target: TargetInput) => ({
provider: target.model.provider,
protocol: target.protocol,
...(target.protocol ? { protocol: target.protocol } : {}),
route: target.model.route.id,
transport: target.transport ?? "http",
model: target.model.id,

View file

@ -1,3 +1,4 @@
import type { HttpRecorder } from "@opencode-ai/http-recorder"
import { test, type TestOptions } from "bun:test"
import { Effect, type Layer } from "effect"
import { testEffect } from "./lib/effect"
@ -11,7 +12,7 @@ export type RecordedGroupOptions = {
readonly protocol?: string
readonly requires?: ReadonlyArray<string>
readonly tags?: ReadonlyArray<string>
readonly metadata?: Record<string, unknown>
readonly metadata?: HttpRecorder.CassetteMetadata
}
export type RecordedCaseOptions = {
@ -21,7 +22,7 @@ export type RecordedCaseOptions = {
readonly protocol?: string
readonly requires?: ReadonlyArray<string>
readonly tags?: ReadonlyArray<string>
readonly metadata?: Record<string, unknown>
readonly metadata?: HttpRecorder.CassetteMetadata
}
export const recordedEffectGroup = <
@ -36,7 +37,7 @@ export const recordedEffectGroup = <
readonly layer: (input: {
readonly cassette: string
readonly tags: ReadonlyArray<string>
readonly metadata: Record<string, unknown>
readonly metadata: HttpRecorder.CassetteMetadata
readonly recording: boolean
readonly options: Options
readonly caseOptions: CaseOptions

View file

@ -1,11 +1,8 @@
import { NodeFileSystem } from "@effect/platform-node"
import { HttpRecorder } from "@opencode-ai/http-recorder"
import { HttpRecorderInternal } from "@opencode-ai/http-recorder/internal"
import { Layer } from "effect"
import { FetchHttpClient } from "effect/unstable/http"
import * as path from "node:path"
import { fileURLToPath } from "node:url"
import { LLMClient, RequestExecutor } from "../src/route"
import { LLMClient, RequestExecutor, WebSocketExecutor } from "../src/route"
import type { Service as LLMClientService } from "../src/route/client"
import type { Service as RequestExecutorService } from "../src/route/executor"
import type { Service as WebSocketExecutorService } from "../src/route/transport/websocket"
@ -14,7 +11,6 @@ import {
type RecordedCaseOptions as RunnerCaseOptions,
type RecordedGroupOptions,
} from "./recorded-runner"
import { webSocketCassetteLayer } from "./recorded-websocket"
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const FIXTURES_DIR = path.resolve(__dirname, "fixtures", "recordings")
@ -64,31 +60,27 @@ export const recordedTests = (options: RecordedTestsOptions) =>
recordedEffectGroup<RecordedEnv, never, RecordedTestsOptions, RecordedCaseOptions>({
duplicateLabel: "recorded cassette",
options,
cassetteExists: (cassette) => HttpRecorderInternal.hasCassetteSync(cassette, { directory: FIXTURES_DIR }),
cassetteExists: (cassette) => HttpRecorder.hasCassetteSync(cassette, { directory: FIXTURES_DIR }),
layer: ({ cassette, metadata, options, caseOptions, recording }) => {
const recorderOptions = mergeOptions(options.options, caseOptions.options)
const recorderMetadata = {
...recorderOptions?.metadata,
...metadata,
}
const mode = recording ? "record" : "replay"
const cassetteService = HttpRecorderInternal.Cassette.fileSystem({ directory: FIXTURES_DIR }).pipe(
Layer.provide(NodeFileSystem.layer),
)
if (recording) {
if (process.env.CI !== undefined) throw new Error("Unset CI before recording HTTP cassettes")
HttpRecorder.removeCassetteSync(cassette, { directory: FIXTURES_DIR })
}
const requestExecutor = RequestExecutor.layer.pipe(
Layer.provide(
HttpRecorderInternal.recordingLayer(cassette, {
mode,
HttpRecorder.layerFetch(cassette, {
...recorderOptions,
directory: FIXTURES_DIR,
metadata: recorderMetadata,
redactor: HttpRecorderInternal.Redactor.make(recorderOptions?.redact),
match: recorderOptions?.match,
}).pipe(Layer.provide(FetchHttpClient.layer)),
}),
),
)
const deps = Layer.mergeAll(
requestExecutor,
webSocketCassetteLayer(cassette, { metadata: recorderMetadata, mode }),
)
return Layer.mergeAll(deps, LLMClient.layer.pipe(Layer.provide(deps))).pipe(Layer.provide(cassetteService))
const deps = Layer.mergeAll(requestExecutor, WebSocketExecutor.layer)
return Layer.mergeAll(deps, LLMClient.layer.pipe(Layer.provide(deps)))
},
})

View file

@ -1,26 +0,0 @@
import { HttpRecorderInternal } from "@opencode-ai/http-recorder/internal"
import { Effect, Layer } from "effect"
import { WebSocketExecutor } from "../src/route"
import type { Service as WebSocketExecutorService } from "../src/route/transport/websocket"
const liveWebSocket = WebSocketExecutor.open
export const webSocketCassetteLayer = (
cassette: string,
input: { readonly metadata?: Record<string, unknown>; readonly mode: HttpRecorderInternal.RecordReplayMode },
): Layer.Layer<WebSocketExecutorService, never, HttpRecorderInternal.Cassette.Service> =>
Layer.effect(
WebSocketExecutor.Service,
Effect.gen(function* () {
const cassetteService = yield* HttpRecorderInternal.Cassette.Service
const executor = yield* HttpRecorderInternal.makeWebSocketExecutor({
name: cassette,
mode: input.mode,
metadata: input.metadata,
cassette: cassetteService,
live: { open: liveWebSocket },
compareClientMessagesAsJson: true,
})
return WebSocketExecutor.Service.of(executor)
}),
)