mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-01 16:03:19 -04:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b1f86ee72b | |||
| 003b22edda | |||
| d5f6c088f0 | |||
| e872bd3c8f |
@@ -16,7 +16,6 @@ import {
|
||||
|
||||
const patterns = [
|
||||
/prompt is too long/i,
|
||||
/request_too_large/i,
|
||||
/input is too long for requested model/i,
|
||||
/exceeds the context window/i,
|
||||
/exceeds (?:the )?(?:model'?s )?maximum context length(?: of [\d,]+ tokens?|\s*\([\d,]+\))/i,
|
||||
@@ -33,7 +32,6 @@ const patterns = [
|
||||
/context window exceeds limit/i,
|
||||
/exceeded model token limit/i,
|
||||
/context[_ ]length[_ ]exceeded/i,
|
||||
/request entity too large/i,
|
||||
/context length is only \d+ tokens/i,
|
||||
/input length.*exceeds.*context length/i,
|
||||
/prompt too long; exceeded (?:max )?context length/i,
|
||||
@@ -44,11 +42,15 @@ const patterns = [
|
||||
/token limit exceeded/i,
|
||||
]
|
||||
|
||||
const payloadPatterns = [/request_too_large/i, /request entity too large/i, /payload too large/i, /request too large/i]
|
||||
|
||||
const exclusions = [/^(throttling error|service unavailable):/i, /rate limit/i, /too many requests/i]
|
||||
|
||||
export const isContextOverflow = (message: string) =>
|
||||
!exclusions.some((pattern) => pattern.test(message)) &&
|
||||
(patterns.some((pattern) => pattern.test(message)) || /^4(00|13)\s*(status code)?\s*\(no body\)/i.test(message))
|
||||
(patterns.some((pattern) => pattern.test(message)) || /^400\s*(status code)?\s*\(no body\)/i.test(message))
|
||||
|
||||
export const isPayloadTooLarge = (message: string) => payloadPatterns.some((pattern) => pattern.test(message))
|
||||
|
||||
export const isContextOverflowFailure = (failure: unknown) =>
|
||||
failure instanceof LLMError
|
||||
@@ -100,6 +102,8 @@ export function classifyProviderFailure(input: ProviderFailure): LLMError["reaso
|
||||
isContextOverflow(text))
|
||||
)
|
||||
return new InvalidRequestReason({ ...common, classification: "context-overflow" })
|
||||
if (input.status === 413 || isPayloadTooLarge(text))
|
||||
return new InvalidRequestReason({ ...common, classification: "payload-too-large" })
|
||||
if (CONTENT_POLICY_TEXT.test(text)) return new ContentPolicyReason(common)
|
||||
if (codes.some((code) => QUOTA_CODES.has(code)) || (input.status === 429 && QUOTA_TEXT.test(text)))
|
||||
return new QuotaExceededReason(common)
|
||||
@@ -142,12 +146,7 @@ export function classifyProviderFailure(input: ProviderFailure): LLMError["reaso
|
||||
retryAfterMs: input.retryAfterMs,
|
||||
})
|
||||
if (codes.some((code) => INVALID_REQUEST_CODES.has(code))) return new InvalidRequestReason(common)
|
||||
if (
|
||||
input.status === 400 ||
|
||||
input.status === 404 ||
|
||||
input.status === 413 ||
|
||||
input.status === 422
|
||||
)
|
||||
if (input.status === 400 || input.status === 404 || input.status === 413 || input.status === 422)
|
||||
return new InvalidRequestReason(common)
|
||||
return new UnknownProviderReason({ ...common, status: input.status })
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Endpoint, type EndpointPatch } from "./endpoint"
|
||||
import { RequestExecutor } from "./executor"
|
||||
import { Framing } from "./framing"
|
||||
import { HttpTransport } from "./transport"
|
||||
import type { HttpRequestTransform, Transport, TransportRuntime } from "./transport"
|
||||
import type { HttpMiddleware, Transport, TransportRuntime } from "./transport"
|
||||
import { WebSocketExecutor } from "./transport"
|
||||
import type { Protocol } from "./protocol"
|
||||
import { applyCachePolicy } from "../cache-policy"
|
||||
@@ -96,7 +96,10 @@ export interface RoutePatch<Body, Prepared> extends RouteDefaultsInput {
|
||||
|
||||
type RouteMappedModelInput = RouteModelInput | RouteRoutedModelInput
|
||||
|
||||
const makeRouteModel = <Options extends ProviderOptions = ProviderOptions>(route: AnyRoute, mapped: RouteMappedModelInput) => {
|
||||
const makeRouteModel = <Options extends ProviderOptions = ProviderOptions>(
|
||||
route: AnyRoute,
|
||||
mapped: RouteMappedModelInput,
|
||||
) => {
|
||||
const provider = route.provider ?? ("provider" in mapped ? mapped.provider : undefined)
|
||||
if (!provider) throw new Error(`Route.model(${route.id}) requires a provider`)
|
||||
if (!endpointBaseURL(route.endpoint))
|
||||
@@ -150,7 +153,7 @@ export interface Interface {
|
||||
}
|
||||
|
||||
export interface StreamOptions {
|
||||
readonly transform?: HttpRequestTransform
|
||||
readonly http?: HttpMiddleware
|
||||
}
|
||||
|
||||
export interface StreamMethod {
|
||||
@@ -302,7 +305,7 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
|
||||
auth: routeInput.auth ?? Auth.none,
|
||||
encodeBody,
|
||||
headers: routeInput.headers,
|
||||
transform: options?.transform,
|
||||
middleware: options?.http,
|
||||
}),
|
||||
streamPrepared: (prepared: Prepared, request: LLMRequest, runtime: TransportRuntime) => {
|
||||
const route = `${request.model.provider}/${request.model.route.id}`
|
||||
|
||||
@@ -23,4 +23,4 @@ export type { ApiKeyMode, AuthOverride, ProviderAuthOption } from "./auth-option
|
||||
export type { Definition as EndpointFn, EndpointInput } from "./endpoint"
|
||||
export type { Definition as FramingDef } from "./framing"
|
||||
export type { Protocol as ProtocolDef } from "./protocol"
|
||||
export type { HttpRequest, HttpRequestTransform, Transport as TransportDef, TransportRuntime } from "./transport"
|
||||
export type { HttpHandler, HttpMiddleware, Transport as TransportDef, TransportRuntime } from "./transport"
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Effect, Stream } from "effect"
|
||||
import { Headers, HttpClientRequest } from "effect/unstable/http"
|
||||
import { Headers, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import { Auth } from "../auth"
|
||||
import { render as renderEndpoint } from "../endpoint"
|
||||
import { Framing } from "../framing"
|
||||
import type { Transport, TransportPrepareInput } from "./index"
|
||||
import type { HttpMiddleware, Transport, TransportPrepareInput } from "./index"
|
||||
import * as ProviderShared from "../../protocols/shared"
|
||||
import { mergeJsonRecords, type LLMRequest } from "../../schema"
|
||||
import { LLMError, mergeJsonRecords, type LLMRequest } from "../../schema"
|
||||
|
||||
export type JsonRequestInput<Body> = TransportPrepareInput<Body>
|
||||
|
||||
@@ -18,7 +18,9 @@ export interface JsonRequestParts<Body = unknown> {
|
||||
|
||||
export interface HttpPrepared<Frame> {
|
||||
readonly request: HttpClientRequest.HttpClientRequest
|
||||
readonly web: Request
|
||||
readonly framing: Framing.Definition<Frame>
|
||||
readonly middleware?: HttpMiddleware
|
||||
}
|
||||
|
||||
const applyQuery = (url: string, query: Record<string, string> | undefined) => {
|
||||
@@ -74,21 +76,62 @@ export const httpJson = <Body, Frame>(input: HttpJsonInput<Body, Frame>): HttpJs
|
||||
prepare: (prepareInput) =>
|
||||
Effect.gen(function* () {
|
||||
const parts = yield* jsonRequestParts({ ...prepareInput })
|
||||
const request = { url: parts.url, method: "POST", headers: { ...parts.headers }, body: parts.bodyText }
|
||||
yield* (prepareInput.transform?.(request) ?? Effect.void)
|
||||
const request = ProviderShared.jsonPost({
|
||||
url: parts.url,
|
||||
body: parts.bodyText,
|
||||
headers: parts.headers,
|
||||
})
|
||||
return {
|
||||
request: ProviderShared.jsonPost({
|
||||
url: request.url,
|
||||
body: request.body ?? "",
|
||||
headers: Headers.fromInput(request.headers),
|
||||
}),
|
||||
request,
|
||||
web: new Request(parts.url, { method: "POST", headers: parts.headers, body: parts.bodyText }),
|
||||
framing: input.framing,
|
||||
middleware: prepareInput.middleware,
|
||||
}
|
||||
}),
|
||||
frames: (prepared, request, runtime) =>
|
||||
Stream.unwrap(
|
||||
runtime.http
|
||||
.execute(prepared.request)
|
||||
Effect.gen(function* () {
|
||||
const request = prepared.web
|
||||
const execute = (input: Request) =>
|
||||
Effect.tryPromise({
|
||||
try: () => input.text(),
|
||||
catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))),
|
||||
}).pipe(
|
||||
Effect.flatMap((body) =>
|
||||
runtime.http.execute(
|
||||
ProviderShared.jsonPost({
|
||||
url: input.url,
|
||||
body,
|
||||
headers: Headers.fromInput(input.headers),
|
||||
}),
|
||||
),
|
||||
),
|
||||
Effect.flatMap((response) =>
|
||||
Stream.toReadableStreamEffect(response.stream).pipe(
|
||||
Effect.map(
|
||||
(body) =>
|
||||
new Response(body, {
|
||||
status: response.status,
|
||||
headers: response.headers,
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
return yield* prepared.middleware ? prepared.middleware(request, execute) : execute(request)
|
||||
})
|
||||
.pipe(
|
||||
Effect.mapError((error) =>
|
||||
error instanceof LLMError
|
||||
? error
|
||||
: ProviderShared.eventError(
|
||||
`${request.model.provider}/${request.model.route.id}`,
|
||||
`Failed to execute ${request.model.provider}/${request.model.route.id} request`,
|
||||
ProviderShared.errorText(error),
|
||||
),
|
||||
),
|
||||
Effect.map((response) => HttpClientResponse.fromWeb(prepared.request, response)),
|
||||
)
|
||||
.pipe(
|
||||
Effect.map((response) =>
|
||||
prepared.framing.frame(
|
||||
|
||||
@@ -10,14 +10,8 @@ export interface TransportRuntime {
|
||||
readonly webSocket?: WebSocketExecutorInterface
|
||||
}
|
||||
|
||||
export interface HttpRequest {
|
||||
url: string
|
||||
readonly method: string
|
||||
headers: Record<string, string>
|
||||
body: string | undefined
|
||||
}
|
||||
|
||||
export type HttpRequestTransform = (request: HttpRequest) => Effect.Effect<void>
|
||||
export type HttpHandler = (request: Request) => Effect.Effect<Response, Error>
|
||||
export type HttpMiddleware = (request: Request, handler: HttpHandler) => Effect.Effect<Response, Error>
|
||||
|
||||
export interface Transport<Body, Prepared, Frame> {
|
||||
readonly id: string
|
||||
@@ -36,7 +30,7 @@ export interface TransportPrepareInput<Body> {
|
||||
readonly auth: Auth.Definition
|
||||
readonly encodeBody: (body: Body) => string
|
||||
readonly headers?: (input: { readonly request: LLMRequest }) => Record<string, string>
|
||||
readonly transform?: HttpRequestTransform
|
||||
readonly middleware?: HttpMiddleware
|
||||
}
|
||||
|
||||
export * as HttpTransport from "./http"
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Schema } from "effect"
|
||||
import { Tool } from "@opencode-ai/schema/tool"
|
||||
import { ModelID, ProviderID, ProviderMetadata, RouteID } from "./ids"
|
||||
|
||||
export const ProviderFailureClassification = Schema.Literal("context-overflow")
|
||||
export const ProviderFailureClassification = Schema.Literals(["context-overflow", "payload-too-large"])
|
||||
export type ProviderFailureClassification = typeof ProviderFailureClassification.Type
|
||||
|
||||
export class HttpRequestDetails extends Schema.Class<HttpRequestDetails>("LLM.HttpRequestDetails")({
|
||||
|
||||
@@ -146,12 +146,18 @@ describe("request option precedence", () => {
|
||||
prompt: "Say hello.",
|
||||
}),
|
||||
{
|
||||
transform: (request) =>
|
||||
Effect.sync(() => {
|
||||
expect(request.headers.authorization).toBe("Bearer fresh-key")
|
||||
request.url = "https://proxy.test/v1/chat/completions"
|
||||
request.headers["x-plugin"] = "transformed"
|
||||
request.body = JSON.stringify({ transformed: true })
|
||||
http: (request, handler) =>
|
||||
Effect.gen(function* () {
|
||||
expect(request.headers.get("authorization")).toBe("Bearer fresh-key")
|
||||
const headers = new Headers(request.headers)
|
||||
headers.set("x-plugin", "transformed")
|
||||
return yield* handler(
|
||||
new Request("https://proxy.test/v1/chat/completions", {
|
||||
method: request.method,
|
||||
headers,
|
||||
body: JSON.stringify({ transformed: true }),
|
||||
}),
|
||||
)
|
||||
}),
|
||||
},
|
||||
).pipe(
|
||||
@@ -171,6 +177,42 @@ describe("request option precedence", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("transforms the HTTP response before protocol decoding", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(
|
||||
LLM.request({
|
||||
model: OpenAIChat.route
|
||||
.with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") })
|
||||
.model({ id: "gpt-4o-mini" }),
|
||||
prompt: "Say hello.",
|
||||
}),
|
||||
{
|
||||
http: (request, handler) =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* handler(request)
|
||||
const body = yield* Effect.promise(() => response.text())
|
||||
return new Response(body.replace("network", "hooked"), {
|
||||
status: response.status,
|
||||
headers: response.headers,
|
||||
})
|
||||
}),
|
||||
},
|
||||
).pipe(
|
||||
Effect.provide(
|
||||
dynamicResponse((input) =>
|
||||
Effect.succeed(
|
||||
input.respond(sseEvents(deltaChunk({ content: "network" }, "stop")), {
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.text).toBe("hooked")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("applies raw body overlays after protocol lowering", () =>
|
||||
LLMClient.generate(
|
||||
LLM.request({
|
||||
|
||||
@@ -85,14 +85,17 @@ describe("RequestExecutor", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("does not classify generic HTTP 413 payload errors as context overflow", () =>
|
||||
it.effect("classifies generic HTTP 413 payload errors", () =>
|
||||
Effect.gen(function* () {
|
||||
const executor = yield* RequestExecutor.Service
|
||||
const error = yield* executor.execute(request).pipe(Effect.flip)
|
||||
|
||||
expectLLMError(error)
|
||||
expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
|
||||
expect("classification" in error.reason ? error.reason.classification : undefined).toBeUndefined()
|
||||
expect(error.reason).toMatchObject({
|
||||
_tag: "InvalidRequest",
|
||||
classification: "payload-too-large",
|
||||
http: { response: { status: 413 } },
|
||||
})
|
||||
}).pipe(Effect.provide(responsesLayer([new Response("request too large", { status: 413 })]))),
|
||||
)
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ describe("provider error classification", () => {
|
||||
test("classifies provider token limit messages as context overflow", () => {
|
||||
const messages = [
|
||||
"tokens in request more than max tokens allowed",
|
||||
'{"error":{"type":"request_too_large","message":"Request exceeds the maximum size"}}',
|
||||
"Requested token count exceeds the model's maximum context length of 131072 tokens.",
|
||||
"Input length (265330) exceeds model's maximum context length (262144).",
|
||||
"Input length 131393 exceeds the maximum allowed input length of 131040 tokens.",
|
||||
@@ -19,6 +18,24 @@ describe("provider error classification", () => {
|
||||
expect(messages.every(isContextOverflow)).toBe(true)
|
||||
})
|
||||
|
||||
test("classifies request size failures separately from context overflow", () => {
|
||||
const failures = [
|
||||
classifyProviderFailure({ message: "request too large", status: 413 }),
|
||||
classifyProviderFailure({
|
||||
message: '{"error":{"type":"request_too_large","message":"Request exceeds the maximum size"}}',
|
||||
status: 400,
|
||||
}),
|
||||
classifyProviderFailure({ message: "upstream request entity too large", status: 502 }),
|
||||
]
|
||||
|
||||
expect(failures).toEqual(
|
||||
failures.map((failure) =>
|
||||
expect.objectContaining({ _tag: "InvalidRequest", classification: "payload-too-large" }),
|
||||
),
|
||||
)
|
||||
expect(isContextOverflow("413 status code (no body)")).toBe(false)
|
||||
})
|
||||
|
||||
test("does not classify rate limits as context overflow", () => {
|
||||
const messages = [
|
||||
"Throttling error: Too many tokens, please wait before trying again.",
|
||||
@@ -59,9 +76,10 @@ describe("provider error classification", () => {
|
||||
})
|
||||
|
||||
test("classifies transient client statuses as provider internal", () => {
|
||||
expect(
|
||||
[408, 409].map((status) => classifyProviderFailure({ message: `HTTP ${status}`, status })._tag),
|
||||
).toEqual(["ProviderInternal", "ProviderInternal"])
|
||||
expect([408, 409].map((status) => classifyProviderFailure({ message: `HTTP ${status}`, status })._tag)).toEqual([
|
||||
"ProviderInternal",
|
||||
"ProviderInternal",
|
||||
])
|
||||
})
|
||||
|
||||
test("classifies nested provider codes when a top-level code is also present", () => {
|
||||
|
||||
@@ -398,7 +398,7 @@ export type Endpoint5_26Output =
|
||||
readonly location?: Location.Ref | undefined
|
||||
readonly data: {
|
||||
readonly sessionID: Session.ID
|
||||
readonly error: { readonly type: string; readonly message: string }
|
||||
readonly error: { readonly type: string; readonly message: string; readonly status?: number | undefined }
|
||||
}
|
||||
}
|
||||
| {
|
||||
@@ -524,7 +524,7 @@ export type Endpoint5_26Output =
|
||||
readonly data: {
|
||||
readonly sessionID: Session.ID
|
||||
readonly assistantMessageID: SessionMessage.ID
|
||||
readonly error: { readonly type: string; readonly message: string }
|
||||
readonly error: { readonly type: string; readonly message: string; readonly status?: number | undefined }
|
||||
readonly cost?: (number & Brand.Brand<"Money.USD">) | undefined
|
||||
readonly tokens?:
|
||||
| {
|
||||
@@ -686,7 +686,7 @@ export type Endpoint5_26Output =
|
||||
readonly sessionID: Session.ID
|
||||
readonly assistantMessageID: SessionMessage.ID
|
||||
readonly callID: string
|
||||
readonly error: { readonly type: string; readonly message: string }
|
||||
readonly error: { readonly type: string; readonly message: string; readonly status?: number | undefined }
|
||||
readonly content?:
|
||||
| readonly [
|
||||
(
|
||||
@@ -726,7 +726,7 @@ export type Endpoint5_26Output =
|
||||
readonly assistantMessageID: SessionMessage.ID
|
||||
readonly attempt: number
|
||||
readonly at: number
|
||||
readonly error: { readonly type: string; readonly message: string }
|
||||
readonly error: { readonly type: string; readonly message: string; readonly status?: number | undefined }
|
||||
}
|
||||
}
|
||||
| {
|
||||
@@ -776,7 +776,7 @@ export type Endpoint5_26Output =
|
||||
readonly data: {
|
||||
readonly sessionID: Session.ID
|
||||
readonly reason: "auto" | "manual"
|
||||
readonly error: { readonly type: string; readonly message: string }
|
||||
readonly error: { readonly type: string; readonly message: string; readonly status?: number | undefined }
|
||||
readonly inputID?: SessionMessage.ID | undefined
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,7 +108,7 @@ export type ToolTextContent = { type: "text"; text: string }
|
||||
|
||||
export type ToolFileContent = { type: "file"; uri: string; mime: string; name?: string | null }
|
||||
|
||||
export type SessionStructuredError = { type: string; message: string }
|
||||
export type SessionStructuredError = { type: string; message: string; status?: number }
|
||||
|
||||
export type SessionMessageCompactionRunning = {
|
||||
type: "compaction"
|
||||
|
||||
+36
-13
@@ -1,5 +1,6 @@
|
||||
export * as AISDK from "./aisdk"
|
||||
|
||||
import { AsyncLocalStorage } from "node:async_hooks"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import type {
|
||||
JSONSchema7,
|
||||
@@ -29,7 +30,7 @@ import {
|
||||
type ToolDefinition,
|
||||
type UsageInput,
|
||||
} from "@opencode-ai/ai"
|
||||
import { Auth, Endpoint, type AnyRoute } from "@opencode-ai/ai/route"
|
||||
import { Auth, Endpoint, type AnyRoute, type HttpHandler, type HttpMiddleware } from "@opencode-ai/ai/route"
|
||||
import { ProviderShared } from "@opencode-ai/ai/protocols/shared"
|
||||
import { Cause, Context, Effect, Layer, Option, Schema, Scope, Stream } from "effect"
|
||||
import type { ID, Info } from "./model"
|
||||
@@ -103,7 +104,7 @@ function wrapSSE(res: Response, ms: number, ctl: AbortController) {
|
||||
})
|
||||
}
|
||||
|
||||
function prepareOptions(model: Info, pkg: string) {
|
||||
function prepareOptions(model: Info, pkg: string, requests: AsyncLocalStorage<HttpMiddleware>) {
|
||||
const projected = mapBodyToProviderOptions(model, pkg)
|
||||
const options: Record<string, any> = {
|
||||
name: model.providerID,
|
||||
@@ -150,10 +151,20 @@ function prepareOptions(model: Info, pkg: string) {
|
||||
}
|
||||
}
|
||||
|
||||
const res = await (typeof customFetch === "function" ? customFetch : fetch)(input, {
|
||||
...opts,
|
||||
timeout: false,
|
||||
})
|
||||
const requestInit: RequestInit = opts
|
||||
const request =
|
||||
input instanceof Request
|
||||
? new Request(input, requestInit)
|
||||
: input instanceof URL
|
||||
? new Request(input.href, requestInit)
|
||||
: new Request(input, requestInit)
|
||||
const handler: HttpHandler = (input) =>
|
||||
Effect.tryPromise({
|
||||
try: () => (typeof customFetch === "function" ? customFetch(input) : fetch(input)),
|
||||
catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))),
|
||||
})
|
||||
const middleware = requests.getStore()
|
||||
const res = await Effect.runPromise(middleware ? middleware(request, handler) : handler(request))
|
||||
if (!chunkAbortCtl || typeof chunkTimeout !== "number") return res
|
||||
return wrapSSE(res, chunkTimeout, chunkAbortCtl)
|
||||
}
|
||||
@@ -194,6 +205,7 @@ export const locationLayer = Layer.effect(
|
||||
let languageHooks: ((event: LanguageEvent) => Effect.Effect<void> | void)[] = []
|
||||
const languages = new Map<string, LanguageModelV3>()
|
||||
const sdks = new Map<string, SDK>()
|
||||
const requests = new AsyncLocalStorage<HttpMiddleware>()
|
||||
const functionIDs = new WeakMap<object, number>()
|
||||
let nextFunctionID = 0
|
||||
const cacheKey = (input: unknown) =>
|
||||
@@ -267,7 +279,7 @@ export const locationLayer = Layer.effect(
|
||||
})
|
||||
|
||||
const packageName = Provider.packageName(model.package)
|
||||
const options = prepareOptions(model, packageName)
|
||||
const options = prepareOptions(model, packageName, requests)
|
||||
const sdkKey = cacheKey({
|
||||
providerID: model.providerID,
|
||||
package: packageName,
|
||||
@@ -292,14 +304,14 @@ export const locationLayer = Layer.effect(
|
||||
return language
|
||||
}),
|
||||
model: Effect.fn("AISDK.model")(function* (model) {
|
||||
return modelFromLanguage(model, yield* service.language(model))
|
||||
return modelFromLanguage(model, yield* service.language(model), requests)
|
||||
}),
|
||||
})
|
||||
return service
|
||||
}),
|
||||
)
|
||||
|
||||
function modelFromLanguage(info: Info, language: LanguageModelV3) {
|
||||
function modelFromLanguage(info: Info, language: LanguageModelV3, requests: AsyncLocalStorage<HttpMiddleware>) {
|
||||
const packageName = Provider.packageName(info.package!)
|
||||
const projected = mapBodyToProviderOptions(info, packageName)
|
||||
const optionKey = providerOptionKey(packageName, info.providerID)
|
||||
@@ -339,8 +351,11 @@ function modelFromLanguage(info: Info, language: LanguageModelV3) {
|
||||
},
|
||||
with: () => route,
|
||||
model: (input) => Model.make({ ...input, provider: "provider" in input ? input.provider : info.providerID, route }),
|
||||
prepareTransport: (body) => Effect.succeed(body),
|
||||
streamPrepared: (prepared) => streamLanguage(language, prepared as LanguageModelV3CallOptions),
|
||||
prepareTransport: (body, _request, options) => Effect.succeed({ body, middleware: options?.http }),
|
||||
streamPrepared: (prepared) => {
|
||||
const input = prepared as { body: LanguageModelV3CallOptions; middleware?: HttpMiddleware }
|
||||
return streamLanguage(language, input.body, requests, input.middleware)
|
||||
},
|
||||
}
|
||||
return Model.make({
|
||||
id: info.modelID ?? info.id,
|
||||
@@ -529,13 +544,21 @@ function providerOptions(input: LLMRequest["providerOptions"]): SharedV3Provider
|
||||
return Object.fromEntries(Object.entries(input).map(([key, value]) => [key, jsonObject(value)]))
|
||||
}
|
||||
|
||||
function streamLanguage(language: LanguageModelV3, options: LanguageModelV3CallOptions) {
|
||||
function streamLanguage(
|
||||
language: LanguageModelV3,
|
||||
options: LanguageModelV3CallOptions,
|
||||
requests?: AsyncLocalStorage<HttpMiddleware>,
|
||||
middleware?: HttpMiddleware,
|
||||
) {
|
||||
const state = { step: 0, toolNames: {} as Record<string, string> }
|
||||
return Stream.concat(
|
||||
Stream.make(LLMEvent.stepStart({ index: state.step })),
|
||||
Stream.unwrap(
|
||||
Effect.tryPromise({
|
||||
try: () => language.doStream(options),
|
||||
try: () =>
|
||||
requests && middleware
|
||||
? requests.run(middleware, () => language.doStream(options))
|
||||
: language.doStream(options),
|
||||
catch: (error) => llmError("doStream", error),
|
||||
}).pipe(
|
||||
Effect.map((result) =>
|
||||
|
||||
@@ -194,7 +194,9 @@ export function fromPromise(plugin: Plugin) {
|
||||
),
|
||||
),
|
||||
refresh:
|
||||
refresh === undefined ? undefined : (credential) => Effect.promise(() => refresh(credential)),
|
||||
refresh === undefined
|
||||
? undefined
|
||||
: (credential) => Effect.promise(() => refresh(credential)),
|
||||
})
|
||||
},
|
||||
remove: draft.method.remove,
|
||||
@@ -263,8 +265,34 @@ export function fromPromise(plugin: Plugin) {
|
||||
),
|
||||
},
|
||||
session: {
|
||||
hook: (name, callback) =>
|
||||
register(host.session.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))),
|
||||
hook: (name, callback) => {
|
||||
if (name !== "http")
|
||||
return register(
|
||||
host.session.hook(name, (event) =>
|
||||
Effect.promise(() => Promise.resolve(Reflect.apply(callback, undefined, [event]))),
|
||||
),
|
||||
)
|
||||
return register(
|
||||
host.session.hook("http", (event) => {
|
||||
const request = event.request
|
||||
const output = {
|
||||
...event,
|
||||
request: (input: Request) => Effect.runPromiseWith(context)(request(input)),
|
||||
}
|
||||
return Effect.promise(() => Promise.resolve(Reflect.apply(callback, undefined, [output]))).pipe(
|
||||
Effect.tap(() =>
|
||||
Effect.sync(() => {
|
||||
event.request = (input) =>
|
||||
Effect.tryPromise({
|
||||
try: () => output.request(input),
|
||||
catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))),
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
},
|
||||
create: (input) =>
|
||||
run(
|
||||
host.session.create(
|
||||
|
||||
@@ -225,15 +225,25 @@ export const OpenAIPlugin = define({
|
||||
})
|
||||
}
|
||||
})
|
||||
yield* ctx.session.hook("request", (evt) =>
|
||||
yield* ctx.session.hook("http", (evt) =>
|
||||
Effect.sync(() => {
|
||||
if (!chatgpt || evt.model.providerID !== Provider.ID.openai) return
|
||||
const url = new URL(evt.url)
|
||||
if (url.origin === "https://api.openai.com") {
|
||||
evt.url = `${codexBaseURL}${url.pathname.replace(/^\/v1/, "")}${url.search}`
|
||||
const request = evt.request
|
||||
evt.request = (input) => {
|
||||
const url = new URL(input.url)
|
||||
const headers = new Headers(input.headers)
|
||||
headers.set("originator", "opencode")
|
||||
headers.set("session-id", evt.sessionID)
|
||||
if (url.origin !== "https://api.openai.com") return request(new Request(input, { headers }))
|
||||
return request(
|
||||
new Request(`${codexBaseURL}${url.pathname.replace(/^\/v1/, "")}${url.search}`, {
|
||||
method: input.method,
|
||||
headers,
|
||||
body: input.body,
|
||||
signal: input.signal,
|
||||
}),
|
||||
)
|
||||
}
|
||||
evt.headers.originator = "opencode"
|
||||
evt.headers["session-id"] = evt.sessionID
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -220,24 +220,15 @@ export const layer = Layer.effect(
|
||||
toolChoice: stepLimitReached ? "none" : undefined,
|
||||
})
|
||||
const options: StreamOptions = {
|
||||
transform: (request) =>
|
||||
http: (request, handler) =>
|
||||
hooks
|
||||
.trigger("session", "request", {
|
||||
.trigger("session", "http", {
|
||||
sessionID: session.id,
|
||||
agent: agent.id,
|
||||
model: resolved.ref,
|
||||
...request,
|
||||
request: handler,
|
||||
})
|
||||
.pipe(
|
||||
Effect.tap((event) =>
|
||||
Effect.sync(() => {
|
||||
request.url = event.url
|
||||
request.headers = event.headers
|
||||
request.body = event.body
|
||||
}),
|
||||
),
|
||||
Effect.asVoid,
|
||||
),
|
||||
.pipe(Effect.flatMap((event) => event.request(request))),
|
||||
}
|
||||
if (promptCacheSnapshots) {
|
||||
const current = PromptCacheDiagnostics.snapshot(request)
|
||||
|
||||
@@ -11,25 +11,25 @@ export function toSessionError(cause: unknown): SessionError.Error {
|
||||
if (cause instanceof LLMError) {
|
||||
switch (cause.reason._tag) {
|
||||
case "RateLimit":
|
||||
return { type: "provider.rate-limit", message: cause.reason.message }
|
||||
return providerError("provider.rate-limit", cause.reason)
|
||||
case "Authentication":
|
||||
return { type: "provider.auth", message: cause.reason.message }
|
||||
return providerError("provider.auth", cause.reason)
|
||||
case "QuotaExceeded":
|
||||
return { type: "provider.quota", message: cause.reason.message }
|
||||
return providerError("provider.quota", cause.reason)
|
||||
case "ContentPolicy":
|
||||
return { type: "provider.content-filter", message: cause.reason.message }
|
||||
return providerError("provider.content-filter", cause.reason)
|
||||
case "Transport":
|
||||
return { type: "provider.transport", message: cause.reason.message }
|
||||
return providerError("provider.transport", cause.reason)
|
||||
case "ProviderInternal":
|
||||
return { type: "provider.internal", message: cause.reason.message }
|
||||
return providerError("provider.internal", cause.reason)
|
||||
case "InvalidProviderOutput":
|
||||
return { type: "provider.invalid-output", message: cause.reason.message }
|
||||
return providerError("provider.invalid-output", cause.reason)
|
||||
case "InvalidRequest":
|
||||
return { type: "provider.invalid-request", message: cause.reason.message }
|
||||
return providerError("provider.invalid-request", cause.reason)
|
||||
case "NoRoute":
|
||||
return { type: "provider.no-route", message: cause.reason.message }
|
||||
return providerError("provider.no-route", cause.reason)
|
||||
case "UnknownProvider":
|
||||
return { type: "provider.unknown", message: cause.reason.message }
|
||||
return providerError("provider.unknown", cause.reason)
|
||||
default: {
|
||||
const exhaustive: never = cause.reason
|
||||
return exhaustive
|
||||
@@ -58,3 +58,9 @@ export function toSessionError(cause: unknown): SessionError.Error {
|
||||
if (cause instanceof Integration.AuthorizationError) return { type: "provider.auth", message: cause.message }
|
||||
return { type: "unknown", message: cause instanceof Error ? cause.message : String(cause) }
|
||||
}
|
||||
|
||||
function providerError(type: string, reason: LLMError["reason"]): SessionError.Error {
|
||||
const status =
|
||||
("http" in reason ? reason.http?.response?.status : undefined) ?? ("status" in reason ? reason.status : undefined)
|
||||
return { type, message: reason.message, ...(status === undefined ? {} : { status }) }
|
||||
}
|
||||
|
||||
@@ -51,6 +51,50 @@ const client = LLMClient.layer.pipe(
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("applies HTTP middleware to AI SDK requests and responses", () =>
|
||||
Effect.gen(function* () {
|
||||
const aisdk = yield* AISDK.Service
|
||||
yield* aisdk.hook.sdk((event) => {
|
||||
const request = event.options.fetch
|
||||
event.sdk = {
|
||||
languageModel: () => ({
|
||||
...streamModel([]),
|
||||
doStream: async () => {
|
||||
const response = await request("https://provider.test/v1/chat", { method: "POST", body: "before" })
|
||||
const text = await response.text()
|
||||
return {
|
||||
stream: new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue({ type: "text-start", id: "text" })
|
||||
controller.enqueue({ type: "text-delta", id: "text", delta: text })
|
||||
controller.enqueue({ type: "text-end", id: "text" })
|
||||
controller.enqueue({ type: "finish", finishReason: { unified: "stop", raw: "stop" }, usage })
|
||||
controller.close()
|
||||
},
|
||||
}),
|
||||
}
|
||||
},
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
const resolved = yield* aisdk.model(
|
||||
model("middleware-test", {
|
||||
fetch: async (request: Request) => new Response(await request.text()),
|
||||
}),
|
||||
)
|
||||
const response = yield* LLMClient.generate(LLM.request({ model: resolved, prompt: "test" }), {
|
||||
http: (request, handler) =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* handler(new Request(request, { method: request.method, body: "request-hooked" }))
|
||||
return new Response(`${yield* Effect.promise(() => response.text())}-response-hooked`)
|
||||
}),
|
||||
}).pipe(Effect.provide(client))
|
||||
|
||||
expect(response.text).toBe("request-hooked-response-hooked")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keys language models by package and flattened overlays", () =>
|
||||
Effect.gen(function* () {
|
||||
const aisdk = yield* AISDK.Service
|
||||
|
||||
@@ -221,6 +221,39 @@ describe("fromPromise", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("adapts promise session HTTP hooks", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* Plugin.Service
|
||||
const hooks = yield* PluginHooks.Service
|
||||
const host = yield* PluginHost.make(plugin)
|
||||
yield* PluginPromise.fromPromise(
|
||||
define({
|
||||
id: "promise-session-http",
|
||||
setup: async (ctx) => {
|
||||
await ctx.session.hook("http", (event) => {
|
||||
const request = event.request
|
||||
event.request = async (input) => {
|
||||
const response = await request(new Request(input, { headers: { "x-hook": "promise" } }))
|
||||
return new Response(`${await response.text()}-response`)
|
||||
}
|
||||
})
|
||||
},
|
||||
}),
|
||||
).effect(host)
|
||||
const event: SessionHooks["http"] = {
|
||||
sessionID: Session.ID.make("ses_promise_session_http"),
|
||||
agent: Agent.ID.make("build"),
|
||||
model: Model.Ref.make({ providerID: Provider.ID.make("test"), id: Model.ID.make("model") }),
|
||||
request: (input) => Effect.succeed(new Response(input.headers.get("x-hook") ?? "missing")),
|
||||
}
|
||||
|
||||
yield* hooks.trigger("session", "http", event)
|
||||
const response = yield* event.request(new Request("https://provider.test"))
|
||||
|
||||
expect(yield* Effect.promise(() => response.text())).toBe("promise-response")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("disposes a hook registration on request", () =>
|
||||
Effect.gen(function* () {
|
||||
const agents = yield* Agent.Service
|
||||
|
||||
@@ -29,6 +29,21 @@ function required<T>(value: T | undefined): T {
|
||||
return value
|
||||
}
|
||||
|
||||
const http = Effect.fn(function* (providerID: Provider.ID, url: string) {
|
||||
const event = yield* (yield* PluginHooks.Service).trigger("session", "http", {
|
||||
sessionID: Session.ID.make("ses_test"),
|
||||
agent: Agent.ID.make("build"),
|
||||
model: Model.Ref.make({ providerID, id: Model.ID.make("gpt-5.5") }),
|
||||
request: (input) => {
|
||||
const headers = new Headers(input.headers)
|
||||
headers.set("x-seen-url", input.url)
|
||||
return Effect.succeed(new Response(null, { headers }))
|
||||
},
|
||||
})
|
||||
const response = yield* event.request(new Request(url, { method: "POST", body: "{}" }))
|
||||
return { url: response.headers.get("x-seen-url"), headers: Object.fromEntries(response.headers.entries()) }
|
||||
})
|
||||
|
||||
describe("OpenAIPlugin", () => {
|
||||
it.effect("registers browser and headless ChatGPT OAuth methods", () =>
|
||||
Effect.gen(function* () {
|
||||
@@ -100,33 +115,9 @@ describe("OpenAIPlugin", () => {
|
||||
})
|
||||
yield* addPlugin()
|
||||
|
||||
const request = yield* (yield* PluginHooks.Service).trigger("session", "request", {
|
||||
sessionID: Session.ID.make("ses_test"),
|
||||
agent: Agent.ID.make("build"),
|
||||
model: Model.Ref.make({ providerID: Provider.ID.openai, id: Model.ID.make("gpt-5.5") }),
|
||||
url: "https://api.openai.com/v1/responses",
|
||||
method: "POST",
|
||||
headers: {},
|
||||
body: "{}",
|
||||
})
|
||||
const custom = yield* (yield* PluginHooks.Service).trigger("session", "request", {
|
||||
sessionID: Session.ID.make("ses_test"),
|
||||
agent: Agent.ID.make("build"),
|
||||
model: Model.Ref.make({ providerID: Provider.ID.make("custom-openai"), id: Model.ID.make("gpt-5.5") }),
|
||||
url: "https://custom.example/v1/responses",
|
||||
method: "POST",
|
||||
headers: {},
|
||||
body: "{}",
|
||||
})
|
||||
const proxy = yield* (yield* PluginHooks.Service).trigger("session", "request", {
|
||||
sessionID: Session.ID.make("ses_test"),
|
||||
agent: Agent.ID.make("build"),
|
||||
model: Model.Ref.make({ providerID: Provider.ID.openai, id: Model.ID.make("gpt-5.5") }),
|
||||
url: "https://proxy.example/v1/responses?region=us",
|
||||
method: "POST",
|
||||
headers: {},
|
||||
body: "{}",
|
||||
})
|
||||
const request = yield* http(Provider.ID.openai, "https://api.openai.com/v1/responses")
|
||||
const custom = yield* http(Provider.ID.make("custom-openai"), "https://custom.example/v1/responses")
|
||||
const proxy = yield* http(Provider.ID.openai, "https://proxy.example/v1/responses?region=us")
|
||||
|
||||
const provider = required(yield* catalog.provider.get(Provider.ID.openai))
|
||||
expect(provider.package).toBe("@opencode-ai/ai/providers/openai")
|
||||
@@ -134,7 +125,7 @@ describe("OpenAIPlugin", () => {
|
||||
expect(provider.headers).toMatchObject({ "chatgpt-account-id": "acct_123" })
|
||||
expect(request.url).toBe("https://chatgpt.com/backend-api/codex/responses")
|
||||
expect(request.headers).toMatchObject({ originator: "opencode", "session-id": "ses_test" })
|
||||
expect(custom.headers).toEqual({})
|
||||
expect(custom.headers).not.toHaveProperty("originator")
|
||||
expect(proxy.url).toBe("https://proxy.example/v1/responses?region=us")
|
||||
expect(proxy.headers).toMatchObject({ originator: "opencode", "session-id": "ses_test" })
|
||||
const eligible = required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.5")))
|
||||
@@ -184,21 +175,13 @@ describe("OpenAIPlugin", () => {
|
||||
})
|
||||
yield* addPlugin()
|
||||
|
||||
const request = yield* (yield* PluginHooks.Service).trigger("session", "request", {
|
||||
sessionID: Session.ID.make("ses_test"),
|
||||
agent: Agent.ID.make("build"),
|
||||
model: Model.Ref.make({ providerID: Provider.ID.openai, id: Model.ID.make("gpt-5.5") }),
|
||||
url: "https://api.openai.com/v1/responses",
|
||||
method: "POST",
|
||||
headers: {},
|
||||
body: "{}",
|
||||
})
|
||||
const request = yield* http(Provider.ID.openai, "https://api.openai.com/v1/responses")
|
||||
|
||||
const model = required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.5")))
|
||||
expect(model.package).toBe("@opencode-ai/ai/providers/openai")
|
||||
expect(model.enabled).toBe(true)
|
||||
expect(model.limit).toEqual({ context: 1_050_000, input: 922_000, output: 128_000 })
|
||||
expect(request.headers).toEqual({})
|
||||
expect(request.headers).not.toHaveProperty("originator")
|
||||
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-4.1"))).enabled).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -14,6 +14,9 @@ import {
|
||||
TransportReason,
|
||||
UnknownProviderReason,
|
||||
ToolFailure,
|
||||
HttpContext,
|
||||
HttpRequestDetails,
|
||||
HttpResponseDetails,
|
||||
} from "@opencode-ai/ai"
|
||||
import { Permission } from "@opencode-ai/core/permission"
|
||||
import { Tool } from "@opencode-ai/schema/tool"
|
||||
@@ -71,6 +74,23 @@ describe("toSessionError", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("preserves provider HTTP status", () => {
|
||||
const http = new HttpContext({
|
||||
request: new HttpRequestDetails({ method: "POST", url: "https://example.com", headers: {} }),
|
||||
response: new HttpResponseDetails({ status: 413, headers: {} }),
|
||||
})
|
||||
expect(toSessionError(llm(new InvalidRequestReason({ message: "too large", http })))).toEqual({
|
||||
type: "provider.invalid-request",
|
||||
message: "too large",
|
||||
status: 413,
|
||||
})
|
||||
expect(toSessionError(llm(new ProviderInternalReason({ message: "bad gateway", status: 502 })))).toEqual({
|
||||
type: "provider.internal",
|
||||
message: "bad gateway",
|
||||
status: 502,
|
||||
})
|
||||
})
|
||||
|
||||
test("retries only rate limits, provider-internal failures, and transport failures", () => {
|
||||
const eligible = [
|
||||
llm(new RateLimitReason({ message: "rate" })),
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import type { SessionApi } from "@opencode-ai/client/effect/api"
|
||||
import type { Message, SystemPart } from "@opencode-ai/ai"
|
||||
import type { HttpRequest } from "@opencode-ai/ai/route"
|
||||
import type { Agent } from "@opencode-ai/schema/agent"
|
||||
import type { Model } from "@opencode-ai/schema/model"
|
||||
import type { Session } from "@opencode-ai/schema/session"
|
||||
import type { JsonSchema } from "effect"
|
||||
import type { Effect, JsonSchema } from "effect"
|
||||
import type { Hooks } from "./registration.js"
|
||||
|
||||
export interface SessionContext {
|
||||
@@ -16,15 +15,16 @@ export interface SessionContext {
|
||||
tools: Record<string, { description: string; input: JsonSchema.JsonSchema }>
|
||||
}
|
||||
|
||||
export interface SessionRequest extends HttpRequest {
|
||||
export interface SessionHttp {
|
||||
readonly sessionID: Session.ID
|
||||
readonly agent: Agent.ID
|
||||
readonly model: Model.Ref
|
||||
request: (input: Request) => Effect.Effect<Response, Error>
|
||||
}
|
||||
|
||||
export interface SessionHooks {
|
||||
readonly context: SessionContext
|
||||
readonly request: SessionRequest
|
||||
readonly http: SessionHttp
|
||||
}
|
||||
|
||||
export type SessionDomain = Pick<
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { SessionApi } from "@opencode-ai/client/promise/api"
|
||||
import type { Message, SystemPart } from "@opencode-ai/ai"
|
||||
import type { HttpRequest } from "@opencode-ai/ai/route"
|
||||
import type { Agent } from "@opencode-ai/schema/agent"
|
||||
import type { Model } from "@opencode-ai/schema/model"
|
||||
import type { Session } from "@opencode-ai/schema/session"
|
||||
@@ -16,15 +15,16 @@ export interface SessionContext {
|
||||
tools: Record<string, { description: string; input: JsonSchema.JsonSchema }>
|
||||
}
|
||||
|
||||
export interface SessionRequest extends HttpRequest {
|
||||
export interface SessionHttp {
|
||||
readonly sessionID: Session.ID
|
||||
readonly agent: Agent.ID
|
||||
readonly model: Model.Ref
|
||||
request: (input: Request) => Promise<Response>
|
||||
}
|
||||
|
||||
export interface SessionHooks {
|
||||
readonly context: SessionContext
|
||||
readonly request: SessionRequest
|
||||
readonly http: SessionHttp
|
||||
}
|
||||
|
||||
export type SessionDomain = Pick<
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
export * as SessionError from "./session-error.js"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { optional } from "./schema.js"
|
||||
|
||||
export interface Error extends Schema.Schema.Type<typeof Error> {}
|
||||
export const Error = Schema.Struct({
|
||||
type: Schema.String,
|
||||
message: Schema.String,
|
||||
status: Schema.Int.check(Schema.isBetween({ minimum: 100, maximum: 599 })).pipe(optional),
|
||||
}).annotate({ identifier: "Session.StructuredError" })
|
||||
|
||||
@@ -63,15 +63,15 @@ export function DialogModel(props: { providerID?: string }) {
|
||||
.filter((model) => (props.providerID ? model.providerID === props.providerID : true))
|
||||
.map((model) => {
|
||||
const provider = providers().get(model.providerID)
|
||||
const favorite = favorites.some((item) => item.providerID === model.providerID && item.modelID === model.id)
|
||||
return {
|
||||
value: { providerID: model.providerID, modelID: model.id },
|
||||
providerID: model.providerID,
|
||||
providerName: provider?.name ?? model.providerID,
|
||||
title: model.name,
|
||||
releaseDate: model.time.released,
|
||||
description: favorites.some((item) => item.providerID === model.providerID && item.modelID === model.id)
|
||||
? "(Favorite)"
|
||||
: undefined,
|
||||
favorite,
|
||||
description: favorite ? "(Favorite)" : undefined,
|
||||
category: connected() ? (provider?.name ?? model.providerID) : undefined,
|
||||
footer: free(model) ? "Free" : undefined,
|
||||
onSelect() {
|
||||
@@ -96,7 +96,9 @@ export function DialogModel(props: { providerID?: string }) {
|
||||
)
|
||||
|
||||
if (needle) {
|
||||
return fuzzysort.go(needle, modelOptions, { keys: ["title", "category"] }).map((item) => item.obj)
|
||||
return prioritizeFavorites(
|
||||
fuzzysort.go(needle, modelOptions, { keys: ["title", "category"] }).map((item) => item.obj),
|
||||
)
|
||||
}
|
||||
|
||||
return [...favoriteOptions, ...recentOptions, ...modelOptions]
|
||||
@@ -160,6 +162,10 @@ export function DialogModel(props: { providerID?: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
export function prioritizeFavorites<T extends { favorite: boolean }>(options: T[]) {
|
||||
return options.toSorted((a, b) => Number(b.favorite) - Number(a.favorite))
|
||||
}
|
||||
|
||||
export function sortModelOptions<
|
||||
T extends { providerID?: string; providerName?: string; releaseDate: string | number; title: string },
|
||||
>(options: T[]) {
|
||||
|
||||
@@ -7,7 +7,7 @@ const money = new Intl.NumberFormat("en-US", {
|
||||
currency: "USD",
|
||||
})
|
||||
|
||||
function View(props: { context: Plugin.Context; sessionID: string }) {
|
||||
export function SidebarContext(props: { context: Plugin.Context; sessionID: string }) {
|
||||
const theme = props.context.theme
|
||||
const msg = createMemo(() => props.context.data.session.message.list(props.sessionID))
|
||||
const session = createMemo(() => props.context.data.session.get(props.sessionID))
|
||||
@@ -18,28 +18,32 @@ function View(props: { context: Plugin.Context; sessionID: string }) {
|
||||
)
|
||||
|
||||
return (
|
||||
<box>
|
||||
<text fg={theme.text.default}>
|
||||
<b>Context</b>
|
||||
</text>
|
||||
<Show when={state()} fallback={<text fg={theme.text.subdued}>Not measured</text>}>
|
||||
{(value) => (
|
||||
<>
|
||||
<text fg={theme.text.subdued}>{value().tokens.toLocaleString()} tokens</text>
|
||||
<Show when={value().percent !== undefined}>
|
||||
<text fg={theme.text.subdued}>{value().percent}% used</text>
|
||||
</Show>
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
<text fg={theme.text.subdued}>{money.format(cost())} spent</text>
|
||||
</box>
|
||||
<Show when={state() || cost() > 0}>
|
||||
<box>
|
||||
<text fg={theme.text.default}>
|
||||
<b>Context</b>
|
||||
</text>
|
||||
<Show when={state()}>
|
||||
{(value) => (
|
||||
<>
|
||||
<text fg={theme.text.subdued}>{value().tokens.toLocaleString()} tokens</text>
|
||||
<Show when={value().percent !== undefined}>
|
||||
<text fg={theme.text.subdued}>{value().percent}% used</text>
|
||||
</Show>
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={cost() > 0}>
|
||||
<text fg={theme.text.subdued}>{money.format(cost())} spent</text>
|
||||
</Show>
|
||||
</box>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
export default Plugin.define({
|
||||
id: "internal:sidebar-context",
|
||||
setup(context) {
|
||||
context.ui.slot("sidebar.content", (props) => <View context={context} sessionID={props.sessionID} />)
|
||||
context.ui.slot("sidebar.content", (props) => <SidebarContext context={context} sessionID={props.sessionID} />)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -27,9 +27,11 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
|
||||
position={props.overlay ? "absolute" : "relative"}
|
||||
>
|
||||
<scrollbox
|
||||
ref={(scroll) => queueMicrotask(() => scroll.verticalScrollBar.resetVisibilityControl())}
|
||||
flexGrow={1}
|
||||
scrollAcceleration={scrollAcceleration()}
|
||||
verticalScrollbarOptions={{
|
||||
visible: false,
|
||||
trackOptions: {
|
||||
backgroundColor: theme.background.default,
|
||||
foregroundColor: theme.scrollbar.default,
|
||||
|
||||
@@ -1,5 +1,23 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { sortModelOptions } from "../../../../src/component/dialog-model"
|
||||
import { prioritizeFavorites, sortModelOptions } from "../../../../src/component/dialog-model"
|
||||
|
||||
describe("prioritizeFavorites", () => {
|
||||
test("moves favorites first while preserving fuzzy result order", () => {
|
||||
const prioritized = prioritizeFavorites([
|
||||
{ title: "Best match", favorite: false },
|
||||
{ title: "Favorite match", favorite: true },
|
||||
{ title: "Second best match", favorite: false },
|
||||
{ title: "Second favorite match", favorite: true },
|
||||
])
|
||||
|
||||
expect(prioritized.map((model) => model.title)).toEqual([
|
||||
"Favorite match",
|
||||
"Second favorite match",
|
||||
"Best match",
|
||||
"Second best match",
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe("sortModelOptions", () => {
|
||||
test("orders opencode models before other providers", () => {
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import { expect, test } from "bun:test"
|
||||
import { RGBA } from "@opentui/core"
|
||||
import { testRender } from "@opentui/solid"
|
||||
import type { Context } from "@opencode-ai/plugin/tui/context"
|
||||
import { SidebarContext } from "../../src/feature-plugins/sidebar/context"
|
||||
|
||||
function context(options?: { cost?: number; tokens?: number }) {
|
||||
const color = RGBA.fromInts(200, 200, 200)
|
||||
return {
|
||||
theme: { text: { default: color, subdued: color } },
|
||||
data: {
|
||||
session: {
|
||||
get: () => ({ location: { directory: "/workspace" } }),
|
||||
cost: () => options?.cost ?? 0,
|
||||
message: {
|
||||
list: () =>
|
||||
options?.tokens
|
||||
? [
|
||||
{
|
||||
id: "message",
|
||||
type: "assistant",
|
||||
model: { providerID: "provider", id: "model" },
|
||||
tokens: {
|
||||
input: options.tokens,
|
||||
output: 0,
|
||||
reasoning: 0,
|
||||
cache: { read: 0, write: 0 },
|
||||
},
|
||||
},
|
||||
]
|
||||
: [],
|
||||
},
|
||||
},
|
||||
location: {
|
||||
model: { list: () => [] },
|
||||
},
|
||||
},
|
||||
} as unknown as Context
|
||||
}
|
||||
|
||||
test("sidebar omits context before usage is available", async () => {
|
||||
const app = await testRender(() => <SidebarContext context={context()} sessionID="session" />, {
|
||||
width: 42,
|
||||
height: 8,
|
||||
})
|
||||
|
||||
try {
|
||||
await app.renderOnce()
|
||||
expect(app.captureCharFrame()).not.toContain("Context")
|
||||
expect(app.captureCharFrame()).not.toContain("Not measured")
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("sidebar shows available context usage", async () => {
|
||||
const app = await testRender(() => <SidebarContext context={context({ tokens: 1234 })} sessionID="session" />, {
|
||||
width: 42,
|
||||
height: 8,
|
||||
})
|
||||
|
||||
try {
|
||||
await app.renderOnce()
|
||||
expect(app.captureCharFrame()).toContain("Context")
|
||||
expect(app.captureCharFrame()).toContain("1,234 tokens")
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
+3
-2
@@ -246,7 +246,8 @@ mutable fields:
|
||||
| ------------------------------------------- | ------------------------------------------------------------------------------ |
|
||||
| `ctx.aisdk.hook("sdk", callback)` | `sdk`, after inspecting `model`, `package`, and `options` |
|
||||
| `ctx.aisdk.hook("language", callback)` | `language`, after inspecting `model`, `sdk`, and `options` |
|
||||
| `ctx.session.hook("request", callback)` | `system`, `messages`, and the `tools` record immediately before model dispatch |
|
||||
| `ctx.session.hook("context", callback)` | `system`, `messages`, and the `tools` record immediately before model dispatch |
|
||||
| `ctx.session.hook("http", callback)` | `request`, wrapping the model's HTTP request and response |
|
||||
| `ctx.tool.hook("execute.before", callback)` | `input`, before the selected tool executes |
|
||||
| `ctx.tool.hook("execute.after", callback)` | Terminal `result` on success or `error` on failure |
|
||||
|
||||
@@ -259,7 +260,7 @@ import { Plugin } from "@opencode-ai/plugin"
|
||||
export default Plugin.define({
|
||||
id: "acme.guards",
|
||||
setup: async (ctx) => {
|
||||
await ctx.session.hook("request", (event) => {
|
||||
await ctx.session.hook("context", (event) => {
|
||||
delete event.tools.write
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user