mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-31 15:34:02 -04:00
Compare commits
58 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 84dd56ed34 | |||
| f92e490bb6 | |||
| 0481dba88e | |||
| dd0e41c633 | |||
| b69e51d835 | |||
| e60e8d9387 | |||
| 3bfce3fd2d | |||
| b498a5c6c4 | |||
| db9e942398 | |||
| 0e26116f68 | |||
| 3468aa0140 | |||
| 7757f712a2 | |||
| 7129ed6e62 | |||
| 6c37842520 | |||
| dc3c996892 | |||
| 7fd12c560c | |||
| 7aaf4e7750 | |||
| 1311d909a7 | |||
| d07d9ae0da | |||
| 1d18459cdc | |||
| db45026c6c | |||
| 77df98db51 | |||
| ff2b184af1 | |||
| 671e164f8d | |||
| 5b4ebf3e9d | |||
| 98229d466d | |||
| 5c7e2b5042 | |||
| 0a6a5d3e80 | |||
| eb95bd27fe | |||
| 865f512a44 | |||
| a460f02f67 | |||
| 146fdb9de1 | |||
| b866900417 | |||
| 8e3e94aa26 | |||
| cc1289048e | |||
| 7814568ba0 | |||
| 16b247f756 | |||
| 9abd9594de | |||
| 22d2012f75 | |||
| cc883058df | |||
| 8ebc4c6f85 | |||
| b0ed9990b3 | |||
| e1c04dcce6 | |||
| 188c642d8c | |||
| 9d55b223bb | |||
| ce7a7e4e23 | |||
| cba5ba03e3 | |||
| 49081a4e24 | |||
| d6371f2fcd | |||
| 7625cbdf47 | |||
| 0ffec67ca3 | |||
| bd132f2614 | |||
| dcb0df4ac1 | |||
| 02f3504055 | |||
| f23ee5e4a8 | |||
| 77aa85c589 | |||
| a618946b7e | |||
| f9de608dea |
@@ -67,6 +67,7 @@
|
||||
"@opencode-ai/sdk": "file:vendor/opencode-ai-sdk-1.18.8-dev.tgz",
|
||||
"@opencode-ai/session-ui": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
"@pierre/trees": "1.0.0-beta.4",
|
||||
"@sentry/solid": "catalog:",
|
||||
"@shikijs/transformers": "3.9.2",
|
||||
@@ -143,7 +144,10 @@
|
||||
"open": "10.1.2",
|
||||
"semver": "catalog:",
|
||||
"solid-js": "catalog:",
|
||||
"tree-sitter-bash": "0.25.0",
|
||||
"tree-sitter-powershell": "0.25.10",
|
||||
"uqr": "0.1.3",
|
||||
"web-tree-sitter": "0.25.10",
|
||||
"ws": "8.21.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -517,6 +521,7 @@
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/session-ui": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
"@pierre/diffs": "catalog:",
|
||||
"@solidjs/meta": "catalog:",
|
||||
"@solidjs/router": "catalog:",
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
|
||||
Per-type constructors live on the type, not as top-level re-exports. Use `Message.system(...)`, `Message.user(...)`, `Message.assistant(...)`, `Message.tool(...)`, `Model.make(...)`, `ToolDefinition.make(...)`, `ToolCallPart.make(...)`, `ToolResultPart.make(...)`, `ToolChoice.make(...)`, `ToolChoice.named(...)`, `SystemPart.make(...)`, and `GenerationOptions.make(...)` directly. The top-level `LLM` namespace is reserved for request-shaped call APIs: `LLM.request`, `LLM.generate`, `LLM.stream`, `LLM.updateRequest`, and `LLM.generateObject`. Two ways to construct the same thing is one too many.
|
||||
|
||||
- Keep provider-defined string enums forward-compatible. Expose known values for autocomplete while accepting future values with `Known | (string & {})`; use `Schema.String` at runtime unless rejecting unknown values is required for correctness.
|
||||
|
||||
## Tests
|
||||
|
||||
- Use `testEffect(...)` from `test/lib/effect.ts` for tests requiring Effect layers.
|
||||
|
||||
@@ -38,7 +38,7 @@ const resolve = (policy: CachePolicy | undefined): CachePolicyObject => {
|
||||
// Protocols whose wire format ignores inline cache markers (OpenAI's implicit
|
||||
// prefix caching, Gemini's implicit + out-of-band CachedContent). Skip the
|
||||
// whole policy pass for these — emitting hints would be harmless but pointless.
|
||||
const RESPECTS_INLINE_HINTS = new Set(["anthropic-messages", "bedrock-converse"])
|
||||
const RESPECTS_INLINE_HINTS = new Set(["anthropic-messages", "bedrock-converse", "openrouter"])
|
||||
|
||||
const makeHint = (ttlSeconds: number | undefined): CacheHint =>
|
||||
ttlSeconds !== undefined ? new CacheHint({ type: "ephemeral", ttlSeconds }) : new CacheHint({ type: "ephemeral" })
|
||||
@@ -133,6 +133,7 @@ const countHints = (request: LLMRequest) =>
|
||||
|
||||
export const applyCachePolicy = (request: LLMRequest): LLMRequest => {
|
||||
if (!RESPECTS_INLINE_HINTS.has(request.model.route.id)) return request
|
||||
if (request.model.route.id === "openrouter" && (request.cache === undefined || request.cache === "auto")) return request
|
||||
const policy = resolve(request.cache)
|
||||
if (!policy.tools && !policy.system && !policy.messages) return request
|
||||
|
||||
|
||||
@@ -29,9 +29,30 @@ export const DEFAULT_BASE_URL = "https://generativelanguage.googleapis.com/v1bet
|
||||
|
||||
export interface OptionsInput {
|
||||
readonly [key: string]: unknown
|
||||
readonly cachedContent?: string
|
||||
readonly safetySettings?: ReadonlyArray<{
|
||||
readonly category:
|
||||
| "HARM_CATEGORY_UNSPECIFIED"
|
||||
| "HARM_CATEGORY_HATE_SPEECH"
|
||||
| "HARM_CATEGORY_DANGEROUS_CONTENT"
|
||||
| "HARM_CATEGORY_HARASSMENT"
|
||||
| "HARM_CATEGORY_SEXUALLY_EXPLICIT"
|
||||
| "HARM_CATEGORY_CIVIC_INTEGRITY"
|
||||
| (string & {})
|
||||
readonly threshold:
|
||||
| "HARM_BLOCK_THRESHOLD_UNSPECIFIED"
|
||||
| "BLOCK_LOW_AND_ABOVE"
|
||||
| "BLOCK_MEDIUM_AND_ABOVE"
|
||||
| "BLOCK_ONLY_HIGH"
|
||||
| "BLOCK_NONE"
|
||||
| "OFF"
|
||||
| (string & {})
|
||||
}>
|
||||
readonly serviceTier?: "standard" | "flex" | "priority" | (string & {})
|
||||
readonly thinkingConfig?: {
|
||||
readonly thinkingBudget?: number
|
||||
readonly includeThoughts?: boolean
|
||||
readonly thinkingLevel?: "minimal" | "low" | "medium" | "high" | (string & {})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,6 +132,12 @@ const GeminiToolConfig = Schema.Struct({
|
||||
const GeminiThinkingConfig = Schema.Struct({
|
||||
thinkingBudget: Schema.optional(Schema.Number),
|
||||
includeThoughts: Schema.optional(Schema.Boolean),
|
||||
thinkingLevel: Schema.optional(Schema.String),
|
||||
})
|
||||
|
||||
const GeminiSafetySetting = Schema.Struct({
|
||||
category: Schema.String,
|
||||
threshold: Schema.String,
|
||||
})
|
||||
|
||||
const GeminiGenerationConfig = Schema.Struct({
|
||||
@@ -123,7 +150,10 @@ const GeminiGenerationConfig = Schema.Struct({
|
||||
})
|
||||
|
||||
const GeminiBodyFields = {
|
||||
cachedContent: Schema.optional(Schema.String),
|
||||
contents: Schema.Array(GeminiContent),
|
||||
safetySettings: optionalArray(GeminiSafetySetting),
|
||||
serviceTier: Schema.optional(Schema.String),
|
||||
systemInstruction: Schema.optional(GeminiSystemInstruction),
|
||||
tools: optionalArray(GeminiTool),
|
||||
toolConfig: Schema.optional(GeminiToolConfig),
|
||||
@@ -316,17 +346,38 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
|
||||
})
|
||||
|
||||
const resolveOptions = (request: LLMRequest) => {
|
||||
const value = request.providerOptions?.gemini?.thinkingConfig
|
||||
if (!ProviderShared.isRecord(value)) return {}
|
||||
const input = request.providerOptions?.gemini
|
||||
const value = input?.thinkingConfig
|
||||
const thinkingConfig = {
|
||||
thinkingBudget: typeof value.thinkingBudget === "number" ? value.thinkingBudget : undefined,
|
||||
includeThoughts: typeof value.includeThoughts === "boolean" ? value.includeThoughts : undefined,
|
||||
thinkingBudget:
|
||||
ProviderShared.isRecord(value) && typeof value.thinkingBudget === "number" ? value.thinkingBudget : undefined,
|
||||
includeThoughts:
|
||||
ProviderShared.isRecord(value) && typeof value.includeThoughts === "boolean"
|
||||
? value.includeThoughts
|
||||
: ProviderShared.isRecord(value)
|
||||
? true
|
||||
: undefined,
|
||||
thinkingLevel:
|
||||
ProviderShared.isRecord(value) && typeof value.thinkingLevel === "string" ? value.thinkingLevel : undefined,
|
||||
}
|
||||
return {
|
||||
cachedContent: typeof input?.cachedContent === "string" ? input.cachedContent : undefined,
|
||||
safetySettings: mapSafetySettings(input?.safetySettings),
|
||||
serviceTier: typeof input?.serviceTier === "string" ? input.serviceTier : undefined,
|
||||
thinkingConfig: Object.values(thinkingConfig).some((item) => item !== undefined) ? thinkingConfig : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
function mapSafetySettings(value: unknown) {
|
||||
if (!Array.isArray(value)) return undefined
|
||||
const settings = value.flatMap((item) =>
|
||||
ProviderShared.isRecord(item) && typeof item.category === "string" && typeof item.threshold === "string"
|
||||
? [{ category: item.category, threshold: item.threshold }]
|
||||
: [],
|
||||
)
|
||||
return settings
|
||||
}
|
||||
|
||||
const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMRequest) {
|
||||
const hasTools = request.tools.length > 0
|
||||
const generation = request.generation
|
||||
@@ -342,7 +393,10 @@ const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMReque
|
||||
}
|
||||
|
||||
return {
|
||||
cachedContent: options.cachedContent,
|
||||
contents: yield* lowerMessages(request),
|
||||
safetySettings: options.safetySettings,
|
||||
serviceTier: options.serviceTier,
|
||||
systemInstruction:
|
||||
request.system.length === 0 ? undefined : { parts: [{ text: ProviderShared.joinText(request.system) }] },
|
||||
tools: hasTools
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
Usage,
|
||||
type FinishReason,
|
||||
type FinishReasonDetails,
|
||||
type CacheHint,
|
||||
type JsonSchema,
|
||||
type LLMRequest,
|
||||
type MediaPart,
|
||||
@@ -38,6 +39,11 @@ export const PATH = "/chat/completions"
|
||||
// The body schema is the provider-native JSON body. `fromRequest` below builds
|
||||
// this shape from the common `LLMRequest`, then `Route.make` validates and
|
||||
// JSON-encodes it before transport.
|
||||
const OpenAIChatCacheControl = Schema.Struct({
|
||||
type: Schema.Literal("ephemeral"),
|
||||
ttl: Schema.optional(Schema.String),
|
||||
})
|
||||
|
||||
const OpenAIChatFunction = Schema.Struct({
|
||||
name: Schema.String,
|
||||
description: Schema.String,
|
||||
@@ -47,6 +53,7 @@ const OpenAIChatFunction = Schema.Struct({
|
||||
const OpenAIChatTool = Schema.Struct({
|
||||
type: Schema.tag("function"),
|
||||
function: OpenAIChatFunction,
|
||||
cache_control: Schema.optional(OpenAIChatCacheControl),
|
||||
})
|
||||
type OpenAIChatTool = Schema.Schema.Type<typeof OpenAIChatTool>
|
||||
|
||||
@@ -61,7 +68,11 @@ const OpenAIChatAssistantToolCall = Schema.Struct({
|
||||
type OpenAIChatAssistantToolCall = Schema.Schema.Type<typeof OpenAIChatAssistantToolCall>
|
||||
|
||||
const OpenAIChatUserContent = Schema.Union([
|
||||
Schema.Struct({ type: Schema.Literal("text"), text: Schema.String }),
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("text"),
|
||||
text: Schema.String,
|
||||
cache_control: Schema.optional(OpenAIChatCacheControl),
|
||||
}),
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("image_url"),
|
||||
image_url: Schema.Struct({ url: Schema.String }),
|
||||
@@ -69,7 +80,10 @@ const OpenAIChatUserContent = Schema.Union([
|
||||
])
|
||||
|
||||
const OpenAIChatMessage = Schema.Union([
|
||||
Schema.Struct({ role: Schema.Literal("system"), content: Schema.String }),
|
||||
Schema.Struct({
|
||||
role: Schema.Literal("system"),
|
||||
content: Schema.Union([Schema.String, Schema.Array(OpenAIChatUserContent)]),
|
||||
}),
|
||||
Schema.Struct({
|
||||
role: Schema.Literal("user"),
|
||||
content: Schema.Union([Schema.String, Schema.Array(OpenAIChatUserContent)]),
|
||||
@@ -83,10 +97,16 @@ const OpenAIChatMessage = Schema.Union([
|
||||
reasoning: Schema.optional(Schema.String),
|
||||
reasoning_text: Schema.optional(Schema.String),
|
||||
reasoning_details: Schema.optional(Schema.Unknown),
|
||||
cache_control: Schema.optional(OpenAIChatCacheControl),
|
||||
}),
|
||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||
),
|
||||
Schema.Struct({ role: Schema.Literal("tool"), tool_call_id: Schema.String, content: Schema.String }),
|
||||
Schema.Struct({
|
||||
role: Schema.Literal("tool"),
|
||||
tool_call_id: Schema.String,
|
||||
content: Schema.String,
|
||||
cache_control: Schema.optional(OpenAIChatCacheControl),
|
||||
}),
|
||||
]).pipe(Schema.toTaggedUnion("role"))
|
||||
type OpenAIChatMessage = Schema.Schema.Type<typeof OpenAIChatMessage>
|
||||
|
||||
@@ -107,6 +127,7 @@ export const bodyFields = {
|
||||
stream_options: Schema.optional(Schema.Struct({ include_usage: Schema.Boolean })),
|
||||
store: Schema.optional(Schema.Boolean),
|
||||
reasoning_effort: Schema.optional(OpenAIOptions.OpenAIReasoningEffort),
|
||||
max_completion_tokens: Schema.optional(Schema.Number),
|
||||
max_tokens: Schema.optional(Schema.Number),
|
||||
temperature: Schema.optional(Schema.Number),
|
||||
top_p: Schema.optional(Schema.Number),
|
||||
@@ -209,13 +230,20 @@ export interface ParserState {
|
||||
// Lowering is the only place that knows how common LLM messages map onto the
|
||||
// OpenAI Chat wire format. Keep provider quirks here instead of leaking native
|
||||
// fields into `LLMRequest`.
|
||||
const lowerTool = (tool: ToolDefinition, inputSchema: JsonSchema): OpenAIChatTool => ({
|
||||
interface LoweringOptions {
|
||||
readonly cacheControl?: (
|
||||
cache: CacheHint | undefined,
|
||||
) => Schema.Schema.Type<typeof OpenAIChatCacheControl> | undefined
|
||||
}
|
||||
|
||||
const lowerTool = (tool: ToolDefinition, inputSchema: JsonSchema, options: LoweringOptions): OpenAIChatTool => ({
|
||||
type: "function",
|
||||
function: {
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
parameters: ToolSchemaProjection.openAI(inputSchema),
|
||||
},
|
||||
cache_control: options.cacheControl?.(tool.cache),
|
||||
})
|
||||
|
||||
const lowerToolChoice = (toolChoice: NonNullable<LLMRequest["toolChoice"]>) =>
|
||||
@@ -257,11 +285,14 @@ const reasoningDetails = (parts: ReadonlyArray<ReasoningPart>, native: unknown)
|
||||
if (isRecord(native) && Array.isArray(native.reasoning_details)) return native.reasoning_details
|
||||
}
|
||||
|
||||
const lowerUserMessage = Effect.fn("OpenAIChat.lowerUserMessage")(function* (message: OpenAIChatRequestMessage) {
|
||||
const lowerUserMessage = Effect.fn("OpenAIChat.lowerUserMessage")(function* (
|
||||
message: OpenAIChatRequestMessage,
|
||||
options: LoweringOptions,
|
||||
) {
|
||||
const content: Array<Schema.Schema.Type<typeof OpenAIChatUserContent>> = []
|
||||
for (const part of message.content) {
|
||||
if (part.type === "text") {
|
||||
content.push({ type: "text", text: part.text })
|
||||
content.push({ type: "text", text: part.text, cache_control: options.cacheControl?.(part.cache) })
|
||||
continue
|
||||
}
|
||||
if (part.type === "media") {
|
||||
@@ -270,14 +301,18 @@ const lowerUserMessage = Effect.fn("OpenAIChat.lowerUserMessage")(function* (mes
|
||||
}
|
||||
return yield* ProviderShared.unsupportedContent("OpenAI Chat", "user", ["text", "media"])
|
||||
}
|
||||
if (content.every((part) => part.type === "text"))
|
||||
return { role: "user" as const, content: content.map((part) => part.text).join("") }
|
||||
if (content.every((part) => part.type === "text" && part.cache_control === undefined))
|
||||
return {
|
||||
role: "user" as const,
|
||||
content: content.map((part) => (part.type === "text" ? part.text : "")).join(""),
|
||||
}
|
||||
return { role: "user" as const, content }
|
||||
})
|
||||
|
||||
const lowerAssistantMessage = Effect.fn("OpenAIChat.lowerAssistantMessage")(function* (
|
||||
message: OpenAIChatRequestMessage,
|
||||
configuredField?: string,
|
||||
options: LoweringOptions = {},
|
||||
) {
|
||||
const content: TextPart[] = []
|
||||
const reasoning: ReasoningPart[] = []
|
||||
@@ -315,29 +350,44 @@ const lowerAssistantMessage = Effect.fn("OpenAIChat.lowerAssistantMessage")(func
|
||||
if (reasoning.length === 0) return nativeReasoning
|
||||
return text
|
||||
})()
|
||||
const cached = message.content.findLast((part) => "cache" in part && part.cache !== undefined)
|
||||
const result = {
|
||||
role: "assistant" as const,
|
||||
content: content.length === 0 ? null : ProviderShared.joinText(content),
|
||||
tool_calls: toolCalls.length === 0 ? undefined : toolCalls,
|
||||
reasoning_details: details,
|
||||
cache_control: options.cacheControl?.(cached && "cache" in cached ? cached.cache : undefined),
|
||||
}
|
||||
if (field === undefined || reasoningText === undefined) return result
|
||||
return { ...result, [field]: reasoningText }
|
||||
})
|
||||
|
||||
const lowerToolMessages = Effect.fn("OpenAIChat.lowerToolMessages")(function* (message: OpenAIChatRequestMessage) {
|
||||
const lowerToolMessages = Effect.fn("OpenAIChat.lowerToolMessages")(function* (
|
||||
message: OpenAIChatRequestMessage,
|
||||
options: LoweringOptions,
|
||||
) {
|
||||
const messages: OpenAIChatMessage[] = []
|
||||
const images: Array<Schema.Schema.Type<typeof OpenAIChatUserContent>> = []
|
||||
for (const part of message.content) {
|
||||
if (!ProviderShared.supportsContent(part, ["tool-result"]))
|
||||
return yield* ProviderShared.unsupportedContent("OpenAI Chat", "tool", ["tool-result"])
|
||||
if (part.result.type !== "content") {
|
||||
messages.push({ role: "tool", tool_call_id: part.id, content: ProviderShared.toolResultText(part) })
|
||||
messages.push({
|
||||
role: "tool",
|
||||
tool_call_id: part.id,
|
||||
content: ProviderShared.toolResultText(part),
|
||||
cache_control: options.cacheControl?.(part.cache),
|
||||
})
|
||||
continue
|
||||
}
|
||||
const content: ReadonlyArray<Tool.Content> = part.result.value
|
||||
const text = content.filter((item) => item.type === "text").map((item) => item.text)
|
||||
messages.push({ role: "tool", tool_call_id: part.id, content: text.join("\n") })
|
||||
messages.push({
|
||||
role: "tool",
|
||||
tool_call_id: part.id,
|
||||
content: text.join("\n"),
|
||||
cache_control: options.cacheControl?.(part.cache),
|
||||
})
|
||||
const files = content.filter((item) => item.type === "file")
|
||||
images.push(
|
||||
...(yield* Effect.forEach(files, (item) =>
|
||||
@@ -351,15 +401,29 @@ const lowerToolMessages = Effect.fn("OpenAIChat.lowerToolMessages")(function* (m
|
||||
const lowerMessage = Effect.fn("OpenAIChat.lowerMessage")(function* (
|
||||
message: OpenAIChatRequestMessage,
|
||||
reasoningField?: string,
|
||||
options: LoweringOptions = {},
|
||||
) {
|
||||
if (message.role === "user") return [yield* lowerUserMessage(message)]
|
||||
if (message.role === "assistant") return [yield* lowerAssistantMessage(message, reasoningField)]
|
||||
return (yield* lowerToolMessages(message)).messages
|
||||
if (message.role === "user") return [yield* lowerUserMessage(message, options)]
|
||||
if (message.role === "assistant") return [yield* lowerAssistantMessage(message, reasoningField, options)]
|
||||
return (yield* lowerToolMessages(message, options)).messages
|
||||
})
|
||||
|
||||
const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request: LLMRequest) {
|
||||
const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request: LLMRequest, options: LoweringOptions) {
|
||||
const system: OpenAIChatMessage[] =
|
||||
request.system.length === 0 ? [] : [{ role: "system", content: ProviderShared.joinText(request.system) }]
|
||||
request.system.length === 0
|
||||
? []
|
||||
: request.system.some((part) => part.cache !== undefined) && options.cacheControl !== undefined
|
||||
? [
|
||||
{
|
||||
role: "system",
|
||||
content: request.system.map((part) => ({
|
||||
type: "text",
|
||||
text: part.text,
|
||||
cache_control: options.cacheControl?.(part.cache),
|
||||
})),
|
||||
},
|
||||
]
|
||||
: [{ role: "system", content: ProviderShared.joinText(request.system) }]
|
||||
const messages = [...system]
|
||||
const pendingImages: Array<Schema.Schema.Type<typeof OpenAIChatUserContent>> = []
|
||||
const flushImages = () => {
|
||||
@@ -370,28 +434,53 @@ const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request:
|
||||
if (message.role === "system") {
|
||||
const part = yield* ProviderShared.wrappedSystemUpdate("OpenAI Chat", message)
|
||||
if (pendingImages.length > 0) {
|
||||
messages.push({ role: "user", content: [...pendingImages.splice(0), { type: "text", text: part.text }] })
|
||||
messages.push({
|
||||
role: "user",
|
||||
content: [
|
||||
...pendingImages.splice(0),
|
||||
{ type: "text", text: part.text, cache_control: options.cacheControl?.(part.cache) },
|
||||
],
|
||||
})
|
||||
continue
|
||||
}
|
||||
const previous = messages.at(-1)
|
||||
if (previous?.role === "user" && typeof previous.content === "string")
|
||||
messages[messages.length - 1] = { role: "user", content: `${previous.content}\n${part.text}` }
|
||||
messages[messages.length - 1] = options.cacheControl?.(part.cache)
|
||||
? {
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: previous.content },
|
||||
{ type: "text", text: part.text, cache_control: options.cacheControl(part.cache) },
|
||||
],
|
||||
}
|
||||
: { role: "user", content: `${previous.content}\n${part.text}` }
|
||||
else if (previous?.role === "user" && Array.isArray(previous.content))
|
||||
messages[messages.length - 1] = {
|
||||
role: "user",
|
||||
content: [...previous.content, { type: "text", text: part.text }],
|
||||
content: [
|
||||
...previous.content,
|
||||
{ type: "text", text: part.text, cache_control: options.cacheControl?.(part.cache) },
|
||||
],
|
||||
}
|
||||
else messages.push({ role: "user", content: part.text })
|
||||
else
|
||||
messages.push(
|
||||
options.cacheControl?.(part.cache)
|
||||
? {
|
||||
role: "user",
|
||||
content: [{ type: "text", text: part.text, cache_control: options.cacheControl(part.cache) }],
|
||||
}
|
||||
: { role: "user", content: part.text },
|
||||
)
|
||||
continue
|
||||
}
|
||||
if (message.role === "tool") {
|
||||
const lowered = yield* lowerToolMessages(message)
|
||||
const lowered = yield* lowerToolMessages(message, options)
|
||||
messages.push(...lowered.messages)
|
||||
pendingImages.push(...lowered.images)
|
||||
continue
|
||||
}
|
||||
flushImages()
|
||||
messages.push(...(yield* lowerMessage(message, request.model.compatibility?.reasoningField)))
|
||||
messages.push(...(yield* lowerMessage(message, request.model.compatibility?.reasoningField, options)))
|
||||
}
|
||||
flushImages()
|
||||
return messages
|
||||
@@ -405,7 +494,10 @@ const lowerOptions = (request: LLMRequest) => {
|
||||
}
|
||||
}
|
||||
|
||||
const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* (request: LLMRequest) {
|
||||
export const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* (
|
||||
request: LLMRequest,
|
||||
options: LoweringOptions = {},
|
||||
) {
|
||||
// `fromRequest` returns the provider body only. Endpoint, auth, framing,
|
||||
// validation, and HTTP execution are composed by `Route.make`.
|
||||
const reasoningField = request.model.compatibility?.reasoningField
|
||||
@@ -415,19 +507,26 @@ const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* (request: LLMR
|
||||
)
|
||||
const generation = request.generation
|
||||
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
|
||||
const maxTokensField = request.model.compatibility?.maxTokensField ?? "max_tokens"
|
||||
return {
|
||||
model: request.model.id,
|
||||
messages: yield* lowerMessages(request),
|
||||
messages: yield* lowerMessages(request, options),
|
||||
tools:
|
||||
request.tools.length === 0
|
||||
? undefined
|
||||
: request.tools.map((tool) =>
|
||||
lowerTool(tool, ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility)),
|
||||
lowerTool(
|
||||
tool,
|
||||
ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility),
|
||||
options,
|
||||
),
|
||||
),
|
||||
tool_choice: request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined,
|
||||
stream: true as const,
|
||||
stream_options: { include_usage: true },
|
||||
max_tokens: generation?.maxTokens,
|
||||
...(maxTokensField === "max_completion_tokens"
|
||||
? { max_completion_tokens: generation?.maxTokens }
|
||||
: { max_tokens: generation?.maxTokens }),
|
||||
temperature: generation?.temperature,
|
||||
top_p: generation?.topP,
|
||||
frequency_penalty: generation?.frequencyPenalty,
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import type { Model, ProviderOptions } from "./schema"
|
||||
|
||||
export interface Settings extends Readonly<Record<string, unknown>> {
|
||||
readonly baseURL?: string
|
||||
readonly headers?: Readonly<Record<string, string>>
|
||||
readonly body?: Readonly<Record<string, unknown>>
|
||||
readonly limits?: {
|
||||
readonly context: number
|
||||
readonly input?: number
|
||||
readonly output: number
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,21 +4,72 @@ import { Endpoint } from "../route/endpoint"
|
||||
import { Framing } from "../route/framing"
|
||||
import { Protocol } from "../route/protocol"
|
||||
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options"
|
||||
import { ProviderID, type ModelID, type ProviderOptions } from "../schema"
|
||||
import { ProviderID, type CacheHint, type ModelID, type ProviderOptions } from "../schema"
|
||||
import type { ProviderPackage } from "../provider-package"
|
||||
import * as OpenAICompatibleProfiles from "./openai-compatible-profile"
|
||||
import * as OpenAIChat from "../protocols/openai-chat"
|
||||
import { newBreakpoints, ttlBucket } from "../protocols/utils/cache"
|
||||
import { isRecord } from "../protocols/shared"
|
||||
|
||||
export const profile = OpenAICompatibleProfiles.profiles.openrouter
|
||||
export const id = ProviderID.make(profile.provider)
|
||||
const ADAPTER = "openrouter"
|
||||
|
||||
type OpenRouterString<Known extends string> = Known | (string & {})
|
||||
|
||||
export interface OpenRouterProviderRouting {
|
||||
readonly [key: string]: unknown
|
||||
readonly order?: ReadonlyArray<string>
|
||||
readonly allow_fallbacks?: boolean
|
||||
readonly require_parameters?: boolean
|
||||
readonly data_collection?: OpenRouterString<"allow" | "deny">
|
||||
readonly only?: ReadonlyArray<string>
|
||||
readonly ignore?: ReadonlyArray<string>
|
||||
readonly quantizations?: ReadonlyArray<string>
|
||||
readonly sort?: OpenRouterString<"price" | "throughput" | "latency">
|
||||
readonly max_price?: Readonly<{
|
||||
prompt?: number | string
|
||||
completion?: number | string
|
||||
image?: number | string
|
||||
audio?: number | string
|
||||
request?: number | string
|
||||
}>
|
||||
readonly zdr?: boolean
|
||||
}
|
||||
|
||||
export type OpenRouterPlugin =
|
||||
| Readonly<{
|
||||
id: "web"
|
||||
max_results?: number
|
||||
search_prompt?: string
|
||||
engine?: OpenRouterString<"native" | "exa">
|
||||
}>
|
||||
| Readonly<{ id: "file-parser"; max_files?: number; pdf?: { engine?: string } }>
|
||||
| Readonly<{ id: "moderation" }>
|
||||
| Readonly<{ id: "response-healing" }>
|
||||
| Readonly<{ id: "auto-router"; allowed_models?: ReadonlyArray<string> }>
|
||||
| Readonly<{ id: string & {}; [key: string]: unknown }>
|
||||
|
||||
export interface OpenRouterOptions {
|
||||
readonly [key: string]: unknown
|
||||
readonly usage?: boolean | Record<string, unknown>
|
||||
readonly reasoning?: Record<string, unknown>
|
||||
readonly debug?: Readonly<{ echo_upstream_body?: boolean }>
|
||||
readonly models?: ReadonlyArray<string>
|
||||
readonly plugins?: ReadonlyArray<OpenRouterPlugin>
|
||||
readonly promptCacheKey?: string
|
||||
readonly provider?: OpenRouterProviderRouting
|
||||
readonly reasoning?: Readonly<{
|
||||
enabled?: boolean
|
||||
exclude?: boolean
|
||||
effort?: OpenRouterString<"none" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max">
|
||||
max_tokens?: number
|
||||
}>
|
||||
readonly usage?: boolean | Readonly<{ include: boolean }>
|
||||
readonly user?: string
|
||||
readonly web_search_options?: Readonly<{
|
||||
max_results?: number
|
||||
search_prompt?: string
|
||||
engine?: OpenRouterString<"native" | "exa">
|
||||
}>
|
||||
}
|
||||
|
||||
export type OpenRouterProviderOptionsInput = ProviderOptions & {
|
||||
@@ -47,7 +98,7 @@ export const protocol = Protocol.make({
|
||||
body: {
|
||||
schema: OpenRouterBody,
|
||||
from: (request) =>
|
||||
OpenAIChat.protocol.body.from(request).pipe(
|
||||
OpenAIChat.fromRequest(request, { cacheControl: cacheControl() }).pipe(
|
||||
Effect.map((body) => {
|
||||
const sourceAssistants = request.messages.filter((message) => message.role === "assistant")
|
||||
let assistantIndex = 0
|
||||
@@ -78,16 +129,39 @@ export const protocol = Protocol.make({
|
||||
stream: OpenAIChat.protocol.stream,
|
||||
})
|
||||
|
||||
const cacheControl = () => {
|
||||
const breakpoints = newBreakpoints(4)
|
||||
return (cache: CacheHint | undefined) => {
|
||||
if (cache === undefined || breakpoints.remaining === 0) return undefined
|
||||
breakpoints.remaining -= 1
|
||||
return {
|
||||
type: "ephemeral" as const,
|
||||
...(ttlBucket(cache.ttlSeconds) === "1h" ? { ttl: "1h" } : {}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const bodyOptions = (input: unknown) => {
|
||||
const openrouter = isRecord(input) ? input : {}
|
||||
const { usage, models, provider, plugins, web_search_options, debug, user, reasoning, promptCacheKey, ...options } =
|
||||
openrouter
|
||||
return {
|
||||
...(openrouter.usage === true
|
||||
...options,
|
||||
...(usage === undefined || usage === true
|
||||
? { usage: { include: true } }
|
||||
: isRecord(openrouter.usage)
|
||||
? { usage: openrouter.usage }
|
||||
: {}),
|
||||
...(isRecord(openrouter.reasoning) ? { reasoning: openrouter.reasoning } : {}),
|
||||
...(typeof openrouter.promptCacheKey === "string" ? { prompt_cache_key: openrouter.promptCacheKey } : {}),
|
||||
: usage === false
|
||||
? { usage: { include: false } }
|
||||
: isRecord(usage)
|
||||
? { usage }
|
||||
: {}),
|
||||
...(Array.isArray(models) ? { models } : {}),
|
||||
...(isRecord(provider) ? { provider } : {}),
|
||||
...(Array.isArray(plugins) ? { plugins } : {}),
|
||||
...(isRecord(web_search_options) ? { web_search_options } : {}),
|
||||
...(isRecord(debug) ? { debug } : {}),
|
||||
...(typeof user === "string" ? { user } : {}),
|
||||
...(isRecord(reasoning) ? { reasoning } : {}),
|
||||
...(typeof promptCacheKey === "string" ? { prompt_cache_key: promptCacheKey } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,38 +1,62 @@
|
||||
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options"
|
||||
import type { RouteDefaultsInput } from "../route/client"
|
||||
import { HttpOptions, ProviderID, type ModelID } from "../schema"
|
||||
import { Route, type RouteDefaultsInput } from "../route/client"
|
||||
import { Endpoint } from "../route/endpoint"
|
||||
import { HttpOptions, ProviderID, type ModelID, type ProviderOptions } from "../schema"
|
||||
import * as OpenAICompatibleProfiles from "./openai-compatible-profile"
|
||||
import * as OpenAICompatibleChat from "../protocols/openai-compatible-chat"
|
||||
import * as OpenAIChat from "../protocols/openai-chat"
|
||||
import * as OpenAIResponses from "../protocols/openai-responses"
|
||||
import { XAIImages } from "../protocols/xai-images"
|
||||
import type { OpenAIProviderOptionsInput } from "./openai-options"
|
||||
import type { OpenAIOptionsInput } from "./openai-options"
|
||||
import type { ProviderPackage } from "../provider-package"
|
||||
|
||||
export const id = ProviderID.make("xai")
|
||||
|
||||
export type XAIProviderOptionsInput = ProviderOptions & {
|
||||
readonly xai?: OpenAIOptionsInput
|
||||
}
|
||||
|
||||
export type ModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
|
||||
ProviderAuthOption<"optional"> & {
|
||||
readonly baseURL?: string
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
readonly providerOptions?: XAIProviderOptionsInput
|
||||
}
|
||||
|
||||
export interface Settings extends ProviderPackage.Settings {
|
||||
readonly apiKey?: string
|
||||
readonly baseURL?: string
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
readonly providerOptions?: XAIProviderOptionsInput
|
||||
}
|
||||
|
||||
export type { XAIImageOptions } from "../protocols/xai-images"
|
||||
|
||||
export const routes = [OpenAIResponses.route, OpenAICompatibleChat.route]
|
||||
const responsesRoute = Route.make({
|
||||
id: "openai-responses",
|
||||
provider: id,
|
||||
providerMetadataKey: "xai",
|
||||
protocol: OpenAIResponses.protocol,
|
||||
endpoint: Endpoint.path("/responses", { baseURL: OpenAICompatibleProfiles.profiles.xai.baseURL }),
|
||||
transport: OpenAIResponses.httpTransport,
|
||||
defaults: { providerOptions: { xai: { store: false } } },
|
||||
})
|
||||
|
||||
const chatRoute = Route.make({
|
||||
id: "openai-compatible-chat",
|
||||
provider: id,
|
||||
providerMetadataKey: "xai",
|
||||
protocol: OpenAIChat.protocol,
|
||||
endpoint: Endpoint.path("/chat/completions", { baseURL: OpenAICompatibleProfiles.profiles.xai.baseURL }),
|
||||
transport: OpenAICompatibleChat.route.transport,
|
||||
})
|
||||
|
||||
export const routes = [responsesRoute, chatRoute]
|
||||
|
||||
const auth = (options: ProviderAuthOption<"optional">) => AuthOptions.bearer(options, "XAI_API_KEY")
|
||||
|
||||
const configuredResponsesRoute = (input: ModelOptions) => {
|
||||
const { apiKey: _, auth: _auth, baseURL, ...rest } = input
|
||||
return OpenAIResponses.route.with({
|
||||
return responsesRoute.with({
|
||||
...rest,
|
||||
provider: id,
|
||||
endpoint: { baseURL: baseURL ?? OpenAICompatibleProfiles.profiles.xai.baseURL },
|
||||
auth: auth(input),
|
||||
})
|
||||
@@ -40,9 +64,8 @@ const configuredResponsesRoute = (input: ModelOptions) => {
|
||||
|
||||
const configuredChatRoute = (input: ModelOptions) => {
|
||||
const { apiKey: _, auth: _auth, baseURL, ...rest } = input
|
||||
return OpenAICompatibleChat.route.with({
|
||||
return chatRoute.with({
|
||||
...rest,
|
||||
provider: id,
|
||||
endpoint: { baseURL: baseURL ?? OpenAICompatibleProfiles.profiles.xai.baseURL },
|
||||
auth: auth(input),
|
||||
})
|
||||
@@ -51,8 +74,8 @@ const configuredChatRoute = (input: ModelOptions) => {
|
||||
export const configure = (input: ModelOptions = {}) => {
|
||||
const responsesRoute = configuredResponsesRoute(input)
|
||||
const chatRoute = configuredChatRoute(input)
|
||||
const responses = (modelID: string | ModelID) => responsesRoute.model<OpenAIProviderOptionsInput>({ id: modelID })
|
||||
const chat = (modelID: string | ModelID) => chatRoute.model<OpenAIProviderOptionsInput>({ id: modelID })
|
||||
const responses = (modelID: string | ModelID) => responsesRoute.model<XAIProviderOptionsInput>({ id: modelID })
|
||||
const chat = (modelID: string | ModelID) => chatRoute.model<XAIProviderOptionsInput>({ id: modelID })
|
||||
const image = (modelID: string | ModelID) =>
|
||||
XAIImages.model({
|
||||
id: modelID,
|
||||
@@ -72,7 +95,7 @@ export const configure = (input: ModelOptions = {}) => {
|
||||
}
|
||||
|
||||
export const provider = configure()
|
||||
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) =>
|
||||
export const model: ProviderPackage.Definition<Settings, XAIProviderOptionsInput>["model"] = (modelID, settings) =>
|
||||
configure({
|
||||
apiKey: settings.apiKey,
|
||||
baseURL: settings.baseURL,
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Endpoint, type EndpointPatch } from "./endpoint"
|
||||
import { RequestExecutor } from "./executor"
|
||||
import { Framing } from "./framing"
|
||||
import { HttpTransport } from "./transport"
|
||||
import type { Transport, TransportRuntime } from "./transport"
|
||||
import type { HttpRequestTransform, Transport, TransportRuntime } from "./transport"
|
||||
import { WebSocketExecutor } from "./transport"
|
||||
import type { Protocol } from "./protocol"
|
||||
import { applyCachePolicy } from "../cache-policy"
|
||||
@@ -46,7 +46,11 @@ export interface Route<Body, Prepared = unknown> {
|
||||
readonly body: RouteBody<Body>
|
||||
readonly with: (patch: RoutePatch<Body, Prepared>) => Route<Body, Prepared>
|
||||
readonly model: <Options extends ProviderOptions = ProviderOptions>(input: RouteMappedModelInput) => Model<Options>
|
||||
readonly prepareTransport: (body: Body, request: LLMRequest) => Effect.Effect<Prepared, LLMError>
|
||||
readonly prepareTransport: (
|
||||
body: Body,
|
||||
request: LLMRequest,
|
||||
options?: StreamOptions,
|
||||
) => Effect.Effect<Prepared, LLMError>
|
||||
readonly streamPrepared: (
|
||||
prepared: Prepared,
|
||||
request: LLMRequest,
|
||||
@@ -145,12 +149,16 @@ export interface Interface {
|
||||
readonly generate: GenerateMethod
|
||||
}
|
||||
|
||||
export interface StreamOptions {
|
||||
readonly transform?: HttpRequestTransform
|
||||
}
|
||||
|
||||
export interface StreamMethod {
|
||||
(request: LLMRequest): Stream.Stream<LLMEvent, LLMError>
|
||||
(request: LLMRequest, options?: StreamOptions): Stream.Stream<LLMEvent, LLMError>
|
||||
}
|
||||
|
||||
export interface GenerateMethod {
|
||||
(request: LLMRequest): Effect.Effect<LLMResponse, LLMError>
|
||||
(request: LLMRequest, options?: StreamOptions): Effect.Effect<LLMResponse, LLMError>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/LLMClient") {}
|
||||
@@ -286,7 +294,7 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
|
||||
},
|
||||
model: <Options extends ProviderOptions = ProviderOptions>(input: RouteMappedModelInput) =>
|
||||
makeRouteModel<Options>(route, input),
|
||||
prepareTransport: (body, request) =>
|
||||
prepareTransport: (body, request, options) =>
|
||||
routeInput.transport.prepare({
|
||||
body,
|
||||
request,
|
||||
@@ -294,6 +302,7 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
|
||||
auth: routeInput.auth ?? Auth.none,
|
||||
encodeBody,
|
||||
headers: routeInput.headers,
|
||||
transform: options?.transform,
|
||||
}),
|
||||
streamPrepared: (prepared: Prepared, request: LLMRequest, runtime: TransportRuntime) => {
|
||||
const route = `${request.model.provider}/${request.model.route.id}`
|
||||
@@ -359,14 +368,14 @@ export function make<Body, Prepared, Frame, Event, State>(
|
||||
})
|
||||
}
|
||||
|
||||
const compile = Effect.fn("LLM.compile")(function* (request: LLMRequest) {
|
||||
const compile = Effect.fn("LLM.compile")(function* (request: LLMRequest, options?: StreamOptions) {
|
||||
const resolved = applyCachePolicy(resolveRequestOptions(request))
|
||||
const route = resolved.model.route
|
||||
|
||||
const body = yield* route.body
|
||||
.from(resolved)
|
||||
.pipe(Effect.flatMap(ProviderShared.validateWith(Schema.decodeUnknownEffect(route.body.schema))))
|
||||
const prepared = yield* route.prepareTransport(body, resolved)
|
||||
const prepared = yield* route.prepareTransport(body, resolved, options)
|
||||
|
||||
return {
|
||||
request: resolved,
|
||||
@@ -389,17 +398,17 @@ export const compileRequest = Effect.fn("LLM.compileRequest")(function* (request
|
||||
}
|
||||
})
|
||||
|
||||
const streamRequestWith = (runtime: TransportRuntime) => (request: LLMRequest) =>
|
||||
const streamRequestWith = (runtime: TransportRuntime) => (request: LLMRequest, options?: StreamOptions) =>
|
||||
Stream.unwrap(
|
||||
Effect.gen(function* () {
|
||||
const compiled = yield* compile(request)
|
||||
const compiled = yield* compile(request, options)
|
||||
return compiled.route.streamPrepared(compiled.prepared, compiled.request, runtime)
|
||||
}),
|
||||
)
|
||||
|
||||
const generateWith = (stream: Interface["stream"]) =>
|
||||
Effect.fn("LLM.generate")(function* (request: LLMRequest) {
|
||||
const state = yield* stream(request).pipe(Stream.runFold(LLMResponse.empty, LLMResponse.reduce))
|
||||
Effect.fn("LLM.generate")(function* (request: LLMRequest, options?: StreamOptions) {
|
||||
const state = yield* stream(request, options).pipe(Stream.runFold(LLMResponse.empty, LLMResponse.reduce))
|
||||
const response = LLMResponse.complete(state)
|
||||
if (response) return response
|
||||
return yield* ProviderShared.eventError(
|
||||
@@ -408,24 +417,24 @@ const generateWith = (stream: Interface["stream"]) =>
|
||||
)
|
||||
})
|
||||
|
||||
export function stream(request: LLMRequest): Stream.Stream<LLMEvent, LLMError> {
|
||||
export function stream(request: LLMRequest, options?: StreamOptions): Stream.Stream<LLMEvent, LLMError> {
|
||||
return Stream.unwrap(
|
||||
Effect.gen(function* () {
|
||||
return (yield* Service).stream(request)
|
||||
return (yield* Service).stream(request, options)
|
||||
}),
|
||||
) as Stream.Stream<LLMEvent, LLMError>
|
||||
}
|
||||
|
||||
export function generate(request: LLMRequest): Effect.Effect<LLMResponse, LLMError> {
|
||||
export function generate(request: LLMRequest, options?: StreamOptions): Effect.Effect<LLMResponse, LLMError> {
|
||||
return Effect.gen(function* () {
|
||||
return yield* (yield* Service).generate(request)
|
||||
return yield* (yield* Service).generate(request, options)
|
||||
}) as Effect.Effect<LLMResponse, LLMError>
|
||||
}
|
||||
|
||||
export const streamRequest = (request: LLMRequest) =>
|
||||
export const streamRequest = (request: LLMRequest, options?: StreamOptions) =>
|
||||
Stream.unwrap(
|
||||
Effect.gen(function* () {
|
||||
return (yield* Service).stream(request)
|
||||
return (yield* Service).stream(request, options)
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ export type {
|
||||
AnyRoute,
|
||||
Interface as LLMClientShape,
|
||||
Service as LLMClientService,
|
||||
StreamOptions,
|
||||
} from "./client"
|
||||
export * from "./executor"
|
||||
export { Auth } from "./auth"
|
||||
@@ -22,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 { Transport as TransportDef, TransportRuntime } from "./transport"
|
||||
export type { HttpRequest, HttpRequestTransform, Transport as TransportDef, TransportRuntime } from "./transport"
|
||||
|
||||
@@ -28,57 +28,9 @@ const applyQuery = (url: string, query: Record<string, string> | undefined) => {
|
||||
return next.toString()
|
||||
}
|
||||
|
||||
const PROTOCOL_BODY_OVERLAY_DENYLIST = new Set([
|
||||
"anthropic_version",
|
||||
"content",
|
||||
"contents",
|
||||
"frequencyPenalty",
|
||||
"frequency_penalty",
|
||||
"generationConfig",
|
||||
"inferenceConfig",
|
||||
"input",
|
||||
"maxTokens",
|
||||
"max_tokens",
|
||||
"messages",
|
||||
"model",
|
||||
"presencePenalty",
|
||||
"presence_penalty",
|
||||
"responseFormat",
|
||||
"response_format",
|
||||
"seed",
|
||||
"stop",
|
||||
"stopSequences",
|
||||
"stop_sequences",
|
||||
"stream",
|
||||
"streamOptions",
|
||||
"stream_options",
|
||||
"system",
|
||||
"systemInstruction",
|
||||
"system_instruction",
|
||||
"temperature",
|
||||
"thinking",
|
||||
"toolChoice",
|
||||
"toolConfig",
|
||||
"tool_choice",
|
||||
"tool_config",
|
||||
"tools",
|
||||
"topK",
|
||||
"topP",
|
||||
"top_k",
|
||||
"top_p",
|
||||
])
|
||||
|
||||
const forbiddenBodyOverlayKeys = (body: Record<string, unknown>) =>
|
||||
Object.keys(body).filter((key) => PROTOCOL_BODY_OVERLAY_DENYLIST.has(key))
|
||||
|
||||
const bodyWithOverlay = <Body>(body: Body, request: LLMRequest, encodeBody: (body: Body) => string) =>
|
||||
Effect.gen(function* () {
|
||||
if (request.http?.body === undefined) return { jsonBody: body, bodyText: encodeBody(body) }
|
||||
const forbiddenKeys = forbiddenBodyOverlayKeys(request.http.body)
|
||||
if (forbiddenKeys.length > 0)
|
||||
return yield* ProviderShared.invalidRequest(
|
||||
`http.body cannot overlay protocol-owned field(s): ${forbiddenKeys.join(", ")}`,
|
||||
)
|
||||
if (ProviderShared.isRecord(body)) {
|
||||
const overlaid = mergeJsonRecords(body, request.http.body) ?? {}
|
||||
return { jsonBody: overlaid, bodyText: ProviderShared.encodeJson(overlaid) }
|
||||
@@ -120,14 +72,19 @@ export const httpJson = <Body, Frame>(input: HttpJsonInput<Body, Frame>): HttpJs
|
||||
id: "http-json",
|
||||
with: (patch) => httpJson({ ...input, ...patch }),
|
||||
prepare: (prepareInput) =>
|
||||
jsonRequestParts({
|
||||
...prepareInput,
|
||||
}).pipe(
|
||||
Effect.map((parts) => ({
|
||||
request: ProviderShared.jsonPost({ url: parts.url, body: parts.bodyText, headers: parts.headers }),
|
||||
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)
|
||||
return {
|
||||
request: ProviderShared.jsonPost({
|
||||
url: request.url,
|
||||
body: request.body ?? "",
|
||||
headers: Headers.fromInput(request.headers),
|
||||
}),
|
||||
framing: input.framing,
|
||||
})),
|
||||
),
|
||||
}
|
||||
}),
|
||||
frames: (prepared, request, runtime) =>
|
||||
Stream.unwrap(
|
||||
runtime.http
|
||||
|
||||
@@ -10,6 +10,15 @@ 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 interface Transport<Body, Prepared, Frame> {
|
||||
readonly id: string
|
||||
readonly prepare: (input: TransportPrepareInput<Body>) => Effect.Effect<Prepared, LLMError>
|
||||
@@ -27,6 +36,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
|
||||
}
|
||||
|
||||
export * as HttpTransport from "./http"
|
||||
|
||||
@@ -124,6 +124,7 @@ export const ToolCallPart = Object.assign(
|
||||
name: Schema.String,
|
||||
input: Schema.Unknown,
|
||||
providerExecuted: Schema.optional(Schema.Boolean),
|
||||
cache: Schema.optional(CacheHint),
|
||||
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
}).annotate({ identifier: "LLM.Content.ToolCall" }),
|
||||
@@ -168,6 +169,7 @@ export const ReasoningPart = Schema.Struct({
|
||||
type: Schema.Literal("reasoning"),
|
||||
text: Schema.String,
|
||||
encrypted: Schema.optional(Schema.String),
|
||||
cache: Schema.optional(CacheHint),
|
||||
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
}).annotate({ identifier: "LLM.Content.Reasoning" })
|
||||
|
||||
@@ -123,6 +123,7 @@ export const mergeGenerationOptions = (...items: ReadonlyArray<GenerationOptions
|
||||
|
||||
export class ModelLimits extends Schema.Class<ModelLimits>("LLM.ModelLimits")({
|
||||
context: Schema.optional(Schema.Number),
|
||||
input: Schema.optional(Schema.Number),
|
||||
output: Schema.optional(Schema.Number),
|
||||
}) {}
|
||||
|
||||
@@ -166,9 +167,13 @@ export namespace ModelDefaults {
|
||||
export const ModelToolSchemaCompatibility = Schema.Literals(["gemini", "moonshot"])
|
||||
export type ModelToolSchemaCompatibility = Schema.Schema.Type<typeof ModelToolSchemaCompatibility>
|
||||
|
||||
export const ModelMaxTokensFieldCompatibility = Schema.Literals(["max_completion_tokens", "max_tokens"])
|
||||
export type ModelMaxTokensFieldCompatibility = Schema.Schema.Type<typeof ModelMaxTokensFieldCompatibility>
|
||||
|
||||
export class ModelCompatibility extends Schema.Class<ModelCompatibility>("LLM.ModelCompatibility")({
|
||||
toolSchema: Schema.optional(ModelToolSchemaCompatibility),
|
||||
reasoningField: Schema.optional(Schema.String),
|
||||
maxTokensField: Schema.optional(ModelMaxTokensFieldCompatibility),
|
||||
}) {}
|
||||
|
||||
export namespace ModelCompatibility {
|
||||
|
||||
@@ -137,24 +137,61 @@ describe("request option precedence", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("rejects raw body overlays for protocol-owned roots", () =>
|
||||
Effect.gen(function* () {
|
||||
const model = OpenAIChat.route
|
||||
.with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") })
|
||||
.model({ id: "gpt-4o-mini" })
|
||||
const error = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
prompt: "Say hello.",
|
||||
http: { body: { model: "gpt-5", messages: [], tools: [] } },
|
||||
}),
|
||||
).pipe(Effect.flip)
|
||||
it.effect("transforms the final HTTP request after serialization and authentication", () =>
|
||||
LLMClient.generate(
|
||||
LLM.request({
|
||||
model: OpenAIChat.route
|
||||
.with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("fresh-key") })
|
||||
.model({ id: "gpt-4o-mini" }),
|
||||
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 })
|
||||
}),
|
||||
},
|
||||
).pipe(
|
||||
Effect.provide(
|
||||
dynamicResponse((input) =>
|
||||
Effect.gen(function* () {
|
||||
const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
|
||||
expect(web.url).toBe("https://proxy.test/v1/chat/completions")
|
||||
expect(web.headers.get("x-plugin")).toBe("transformed")
|
||||
expect(decodeJson(input.text)).toEqual({ transformed: true })
|
||||
return input.respond(sseEvents(deltaChunk({}, "stop")), {
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
})
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(error.reason).toMatchObject({
|
||||
_tag: "InvalidRequest",
|
||||
message: "http.body cannot overlay protocol-owned field(s): model, messages, tools",
|
||||
})
|
||||
}),
|
||||
it.effect("applies raw body overlays after protocol lowering", () =>
|
||||
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: { body: { model: "gpt-5", messages: [], tools: [] } },
|
||||
}),
|
||||
).pipe(
|
||||
Effect.provide(
|
||||
dynamicResponse((input) =>
|
||||
Effect.gen(function* () {
|
||||
expect(decodeJson(input.text)).toMatchObject({ model: "gpt-5", messages: [], tools: [] })
|
||||
return input.respond(sseEvents(deltaChunk({}, "stop")), {
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
})
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("uses model output limits after route limits and before call maxTokens", () =>
|
||||
|
||||
@@ -9,9 +9,39 @@ LLM.request({
|
||||
providerOptions: { gemini: { thinkingConfig: { thinkingBudget: 1024 } } },
|
||||
})
|
||||
|
||||
LLM.request({
|
||||
model,
|
||||
prompt: "Hello",
|
||||
providerOptions: {
|
||||
gemini: {
|
||||
// @ts-expect-error Gemini safety settings require a threshold for every category.
|
||||
safetySettings: [{ category: "HARM_CATEGORY_HATE_SPEECH" }],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
LLM.request({
|
||||
model,
|
||||
prompt: "Hello",
|
||||
providerOptions: {
|
||||
gemini: {
|
||||
cachedContent: "cachedContents/example",
|
||||
safetySettings: [{ category: "HARM_CATEGORY_HATE_SPEECH", threshold: "BLOCK_ONLY_HIGH" }],
|
||||
serviceTier: "future-tier",
|
||||
thinkingConfig: { thinkingLevel: "high", includeThoughts: true },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
LLM.request({
|
||||
model,
|
||||
prompt: "Hello",
|
||||
// @ts-expect-error Gemini thinking budgets must be numeric.
|
||||
providerOptions: { gemini: { thinkingConfig: { thinkingBudget: "large" } } },
|
||||
})
|
||||
|
||||
LLM.request({
|
||||
model,
|
||||
prompt: "Hello",
|
||||
providerOptions: { gemini: { thinkingConfig: { thinkingLevel: "maximum" } } },
|
||||
})
|
||||
|
||||
@@ -5,6 +5,28 @@ const model = OpenRouter.provider.model("anthropic/claude-sonnet-4.5")
|
||||
|
||||
LLM.request({ model, prompt: "Hello", providerOptions: { openrouter: { usage: true } } })
|
||||
|
||||
LLM.request({
|
||||
model,
|
||||
prompt: "Hello",
|
||||
providerOptions: {
|
||||
openrouter: {
|
||||
models: ["google/gemini-3.1-pro"],
|
||||
provider: {
|
||||
order: ["anthropic"],
|
||||
require_parameters: true,
|
||||
data_collection: "future-policy",
|
||||
sort: "future-sort",
|
||||
max_price: { prompt: "0.50" },
|
||||
},
|
||||
reasoning: { effort: "future-effort", exclude: false },
|
||||
plugins: [{ id: "future-plugin", enabled: true }],
|
||||
web_search_options: { engine: "future-engine" },
|
||||
debug: { echo_upstream_body: true },
|
||||
user: "user_123",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
LLM.request({
|
||||
model,
|
||||
prompt: "Hello",
|
||||
|
||||
@@ -3,11 +3,11 @@ import { XAI } from "../../src/providers"
|
||||
|
||||
const model = XAI.provider.model("grok-4")
|
||||
|
||||
LLM.request({ model, prompt: "Hello", providerOptions: { openai: { reasoningEffort: "high" } } })
|
||||
LLM.request({ model, prompt: "Hello", providerOptions: { xai: { reasoningEffort: "high" } } })
|
||||
|
||||
LLM.request({
|
||||
model,
|
||||
prompt: "Hello",
|
||||
// @ts-expect-error xAI's OpenAI-compatible reasoning effort must be a string.
|
||||
providerOptions: { openai: { reasoningEffort: true } },
|
||||
providerOptions: { xai: { reasoningEffort: true } },
|
||||
})
|
||||
|
||||
@@ -47,7 +47,7 @@ describe("provider package entrypoints", () => {
|
||||
})
|
||||
const xai = XAI.model("grok-4", {
|
||||
...settings,
|
||||
providerOptions: { openai: { reasoningEffort: "high" } },
|
||||
providerOptions: { xai: { reasoningEffort: "high" } },
|
||||
})
|
||||
|
||||
for (const selected of [openrouter, xai]) {
|
||||
@@ -57,7 +57,7 @@ describe("provider package entrypoints", () => {
|
||||
expect(selected.route.defaults.limits).toEqual(settings.limits)
|
||||
}
|
||||
expect(openrouter.route.defaults.providerOptions).toEqual({ openrouter: { usage: true } })
|
||||
expect(xai.route.defaults.providerOptions).toEqual({ openai: { reasoningEffort: "high", store: false } })
|
||||
expect(xai.route.defaults.providerOptions).toMatchObject({ xai: { reasoningEffort: "high", store: false } })
|
||||
})
|
||||
|
||||
test("maps package settings onto the executable model", () => {
|
||||
|
||||
@@ -41,7 +41,14 @@ describe("Gemini route", () => {
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLMRequest.update(request, {
|
||||
providerOptions: { gemini: { thinkingConfig: { thinkingBudget: 0, includeThoughts: false } } },
|
||||
providerOptions: {
|
||||
gemini: {
|
||||
cachedContent: "cachedContents/example",
|
||||
safetySettings: [{ category: "HARM_CATEGORY_HATE_SPEECH", threshold: "BLOCK_ONLY_HIGH" }],
|
||||
serviceTier: "priority",
|
||||
thinkingConfig: { thinkingBudget: 0, includeThoughts: false, thinkingLevel: "high" },
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
const filtered = yield* compileRequest(
|
||||
@@ -49,12 +56,33 @@ describe("Gemini route", () => {
|
||||
providerOptions: { gemini: { thinkingConfig: { thinkingBudget: "invalid", includeThoughts: false } } },
|
||||
}),
|
||||
)
|
||||
const defaulted = yield* compileRequest(
|
||||
LLMRequest.update(request, {
|
||||
providerOptions: { gemini: { thinkingConfig: { thinkingLevel: "high" } } },
|
||||
}),
|
||||
)
|
||||
const emptySafetySettings = yield* compileRequest(
|
||||
LLMRequest.update(request, {
|
||||
providerOptions: { gemini: { safetySettings: [] } },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.generationConfig?.thinkingConfig).toEqual({
|
||||
thinkingBudget: 0,
|
||||
includeThoughts: false,
|
||||
thinkingLevel: "high",
|
||||
})
|
||||
expect(prepared.body.cachedContent).toBe("cachedContents/example")
|
||||
expect(prepared.body.safetySettings).toEqual([
|
||||
{ category: "HARM_CATEGORY_HATE_SPEECH", threshold: "BLOCK_ONLY_HIGH" },
|
||||
])
|
||||
expect(prepared.body.serviceTier).toBe("priority")
|
||||
expect(filtered.body.generationConfig?.thinkingConfig).toEqual({ includeThoughts: false })
|
||||
expect(defaulted.body.generationConfig?.thinkingConfig).toEqual({
|
||||
includeThoughts: true,
|
||||
thinkingLevel: "high",
|
||||
})
|
||||
expect(emptySafetySettings.body.safetySettings).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -181,23 +181,6 @@ describe("Google Vertex providers", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("protects the Vertex Messages API version from body overlays", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: GoogleVertexMessages.configure({
|
||||
accessToken: "vertex-token",
|
||||
http: { body: { anthropic_version: "wrong" } },
|
||||
project: "vertex-project",
|
||||
}).model("claude-sonnet-4-6"),
|
||||
prompt: "Say hello.",
|
||||
}),
|
||||
).pipe(Effect.flip)
|
||||
|
||||
expect(error.message).toContain("http.body cannot overlay protocol-owned field(s): anthropic_version")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("routes tuned Gemini models through their deployed endpoint", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(
|
||||
|
||||
@@ -144,6 +144,20 @@ describe("OpenAI-compatible Chat route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("configures the max tokens request field", () =>
|
||||
Effect.gen(function* () {
|
||||
const compatible = OpenAICompatibleChat.route
|
||||
.with({ provider: "custom", endpoint: { baseURL: "https://api.custom.test/v1" } })
|
||||
.model({ id: "custom-model", compatibility: { maxTokensField: "max_completion_tokens" } })
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({ model: compatible, prompt: "Say hello.", generation: { maxTokens: 20 } }),
|
||||
)
|
||||
|
||||
expect(prepared.body).toMatchObject({ max_completion_tokens: 20 })
|
||||
expect(prepared.body).not.toHaveProperty("max_tokens")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("matches AI SDK compatible tool request body fixture", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { LLM, Message } from "../../src"
|
||||
import { CacheHint, LLM, Message } from "../../src"
|
||||
import { LLMClient } from "../../src/route"
|
||||
import { compileRequest } from "../../src/route/client"
|
||||
import * as OpenRouter from "../../src/providers/openrouter"
|
||||
@@ -27,10 +27,131 @@ describe("OpenRouter", () => {
|
||||
model: "openai/gpt-4o-mini",
|
||||
messages: [{ role: "user", content: "Say hello." }],
|
||||
stream: true,
|
||||
usage: { include: true },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("lowers the native cache policy to OpenRouter cache controls", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
|
||||
system: [
|
||||
{ type: "text", text: "Base agent", cache: new CacheHint({ type: "ephemeral", ttlSeconds: 3_600 }) },
|
||||
{ type: "text", text: "Project instructions" },
|
||||
],
|
||||
tools: [{ name: "lookup", description: "Lookup", inputSchema: { type: "object", properties: {} } }],
|
||||
prompt: "Hello",
|
||||
cache: { tools: true, system: true, messages: { tail: 1 } },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body).toMatchObject({
|
||||
tools: [{ cache_control: { type: "ephemeral" } }],
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content: [
|
||||
{ text: "Base agent", cache_control: { type: "ephemeral", ttl: "1h" } },
|
||||
{ text: "Project instructions", cache_control: { type: "ephemeral" } },
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: [{ text: "Hello", cache_control: { type: "ephemeral" } }],
|
||||
},
|
||||
],
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("lowers manual assistant and tool-result cache hints", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
|
||||
cache: "none",
|
||||
messages: [
|
||||
Message.user("Call the tool"),
|
||||
Message.assistant([
|
||||
{ type: "text", text: "Calling", cache: new CacheHint({ type: "ephemeral" }) },
|
||||
{ type: "tool-call", id: "call_1", name: "lookup", input: {} },
|
||||
]),
|
||||
Message.tool({
|
||||
id: "call_1",
|
||||
name: "lookup",
|
||||
result: "Done",
|
||||
cache: new CacheHint({ type: "ephemeral", ttlSeconds: 3_600 }),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.messages).toMatchObject([
|
||||
{ role: "user", content: "Call the tool" },
|
||||
{ role: "assistant", content: "Calling", cache_control: { type: "ephemeral" } },
|
||||
{ role: "tool", content: '"Done"', cache_control: { type: "ephemeral", ttl: "1h" } },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("caps manual cache controls at four breakpoints", () =>
|
||||
Effect.gen(function* () {
|
||||
const cache = new CacheHint({ type: "ephemeral" })
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
|
||||
cache: "none",
|
||||
system: [1, 2, 3, 4, 5].map((index) => ({ type: "text" as const, text: `System ${index}`, cache })),
|
||||
prompt: "Hello",
|
||||
}),
|
||||
)
|
||||
|
||||
const system = prepared.body.messages[0]
|
||||
expect(system?.role).toBe("system")
|
||||
expect(
|
||||
system && Array.isArray(system.content)
|
||||
? system.content.filter((part) => "cache_control" in part && part.cache_control !== undefined)
|
||||
: [],
|
||||
).toHaveLength(4)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves cache policy hints on reasoning-only assistant messages", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
|
||||
cache: { messages: "latest-assistant" },
|
||||
messages: [Message.user("Think"), Message.assistant([{ type: "reasoning", text: "Reasoning" }])],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.messages).toMatchObject([
|
||||
{ role: "user", content: "Think" },
|
||||
{ role: "assistant", cache_control: { type: "ephemeral" } },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("allows usage accounting to be disabled explicitly", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: OpenRouter.configure({
|
||||
apiKey: "test-key",
|
||||
providerOptions: { openrouter: { usage: false } },
|
||||
}).model("openai/gpt-4o-mini"),
|
||||
cache: "none",
|
||||
prompt: "Hello",
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.usage).toEqual({ include: false })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("applies OpenRouter payload options from the model helper", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
@@ -42,6 +163,13 @@ describe("OpenRouter", () => {
|
||||
usage: true,
|
||||
reasoning: { effort: "high" },
|
||||
promptCacheKey: "session_123",
|
||||
models: ["anthropic/claude-sonnet-4.6", "google/gemini-3.1-pro"],
|
||||
provider: { order: ["anthropic", "google"], require_parameters: true },
|
||||
plugins: [{ id: "response-healing" }],
|
||||
web_search_options: { engine: "native", max_results: 3 },
|
||||
debug: { echo_upstream_body: true },
|
||||
user: "user_123",
|
||||
future_option: { enabled: true },
|
||||
},
|
||||
},
|
||||
}).model("anthropic/claude-3.7-sonnet:thinking"),
|
||||
@@ -53,10 +181,54 @@ describe("OpenRouter", () => {
|
||||
usage: { include: true },
|
||||
reasoning: { effort: "high" },
|
||||
prompt_cache_key: "session_123",
|
||||
models: ["anthropic/claude-sonnet-4.6", "google/gemini-3.1-pro"],
|
||||
provider: { order: ["anthropic", "google"], require_parameters: true },
|
||||
plugins: [{ id: "response-healing" }],
|
||||
web_search_options: { engine: "native", max_results: 3 },
|
||||
debug: { echo_upstream_body: true },
|
||||
user: "user_123",
|
||||
future_option: { enabled: true },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("filters invalid known OpenRouter options while preserving extensions", () =>
|
||||
Effect.gen(function* () {
|
||||
const invalid: Record<string, unknown> = {
|
||||
usage: "yes",
|
||||
models: "anthropic/claude-sonnet-4.6",
|
||||
provider: [],
|
||||
plugins: {},
|
||||
web_search_options: [],
|
||||
debug: [],
|
||||
user: 123,
|
||||
reasoning: [],
|
||||
promptCacheKey: 123,
|
||||
future_option: { enabled: true },
|
||||
}
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: OpenRouter.configure({
|
||||
apiKey: "test-key",
|
||||
providerOptions: { openrouter: invalid },
|
||||
}).model("openai/gpt-4o-mini"),
|
||||
prompt: "Hello",
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body).toMatchObject({ future_option: { enabled: true } })
|
||||
expect(prepared.body).not.toHaveProperty("usage")
|
||||
expect(prepared.body).not.toHaveProperty("models")
|
||||
expect(prepared.body).not.toHaveProperty("provider")
|
||||
expect(prepared.body).not.toHaveProperty("plugins")
|
||||
expect(prepared.body).not.toHaveProperty("web_search_options")
|
||||
expect(prepared.body).not.toHaveProperty("debug")
|
||||
expect(prepared.body).not.toHaveProperty("user")
|
||||
expect(prepared.body).not.toHaveProperty("reasoning")
|
||||
expect(prepared.body).not.toHaveProperty("prompt_cache_key")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves the upstream provider finish reason", () =>
|
||||
Effect.gen(function* () {
|
||||
const model = OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6")
|
||||
@@ -104,6 +276,7 @@ describe("OpenRouter", () => {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
|
||||
cache: "none",
|
||||
messages: [
|
||||
Message.assistant([
|
||||
{
|
||||
@@ -137,6 +310,7 @@ describe("OpenRouter", () => {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
|
||||
cache: "none",
|
||||
messages: [
|
||||
Message.assistant({
|
||||
type: "reasoning",
|
||||
@@ -162,6 +336,7 @@ describe("OpenRouter", () => {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
|
||||
cache: "none",
|
||||
messages: [
|
||||
Message.assistant({
|
||||
type: "reasoning",
|
||||
@@ -183,6 +358,7 @@ describe("OpenRouter", () => {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
|
||||
cache: "none",
|
||||
messages: [Message.assistant({ type: "reasoning", text: "Thinking" })],
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -59,6 +59,7 @@
|
||||
"@opencode-ai/sdk": "file:vendor/opencode-ai-sdk-1.18.8-dev.tgz",
|
||||
"@opencode-ai/session-ui": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
"@pierre/trees": "1.0.0-beta.4",
|
||||
"@sentry/solid": "catalog:",
|
||||
"@shikijs/transformers": "3.9.2",
|
||||
|
||||
@@ -4,6 +4,7 @@ import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||
import { createMutation } from "@tanstack/solid-query"
|
||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||
import { displayLabel } from "@opencode-ai/util/session-title-fallback"
|
||||
import { useGlobal } from "@/context/global"
|
||||
import { ServerConnection, serverName } from "@/context/server"
|
||||
import { displayName, projectForSession } from "@/pages/layout/helpers"
|
||||
@@ -54,7 +55,10 @@ export function TabNavItem(props: {
|
||||
if (!session) return
|
||||
return projectForSession(session, serverCtx()?.projects.list() ?? [])
|
||||
})
|
||||
const title = createMemo(() => props.session()?.title ?? props.fallbackTitle)
|
||||
const title = createMemo(() => {
|
||||
const session = props.session()
|
||||
return session ? displayLabel(session) : props.fallbackTitle
|
||||
})
|
||||
|
||||
const projectName = createMemo(() => {
|
||||
const session = props.session()
|
||||
@@ -143,7 +147,7 @@ export function TabNavItem(props: {
|
||||
if (!canOpenTabRename(props.dragging, editing(), rename.isPending)) return
|
||||
const session = props.session()
|
||||
if (!session) return
|
||||
titleEl.textContent = session.title
|
||||
titleEl.textContent = session.title ?? ""
|
||||
setEditing(true)
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
@@ -302,7 +306,7 @@ export function TabNavItem(props: {
|
||||
}}
|
||||
data={{
|
||||
projectName: projectName(),
|
||||
title: props.session()?.title,
|
||||
title: title(),
|
||||
path: previewPath(),
|
||||
serverName: serverLabel(),
|
||||
}}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useLanguage } from "@/context/language"
|
||||
import { serverName } from "@/context/server"
|
||||
import { displayName } from "@/pages/layout/helpers"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { displayLabel } from "@opencode-ai/util/session-title-fallback"
|
||||
import { createMemo, onCleanup } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import type { HomeController } from "./home-controller"
|
||||
@@ -23,7 +24,7 @@ export function createHomeSessionSearchController(home: HomeController, sessions
|
||||
if (!value) return []
|
||||
return sessions.data
|
||||
.searchRecords()
|
||||
.filter((record) => `${record.session.title} ${record.projectName}`.toLowerCase().includes(value))
|
||||
.filter((record) => `${displayLabel(record.session)} ${record.projectName}`.toLowerCase().includes(value))
|
||||
})
|
||||
const active = createMemo(() => {
|
||||
const records = results()
|
||||
|
||||
@@ -6,10 +6,10 @@ import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||
import { displayLabel } from "@opencode-ai/util/session-title-fallback"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { ServerConnection } from "@/context/server"
|
||||
import { SessionTabAvatarView } from "@/pages/layout/session-tab-avatar"
|
||||
import { sessionTitle } from "@/utils/session-title"
|
||||
import { shouldOpenSessionInBackground } from "../home-session-open"
|
||||
import {
|
||||
HomeSessionStatusController,
|
||||
@@ -344,7 +344,7 @@ function HomeSessionSearchResultRow(
|
||||
selected: boolean
|
||||
},
|
||||
) {
|
||||
const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id)
|
||||
const title = createMemo(() => displayLabel(props.record.session))
|
||||
const showProjectName = () => props.showProjectName() && props.record.projectName
|
||||
const key = () => homeSessionSearchKey(props.record)
|
||||
|
||||
@@ -415,7 +415,7 @@ function HomeSessionGroupHeader(props: {
|
||||
}
|
||||
|
||||
function HomeSessionRow(props: HomeSessionsViewProps & { record: HomeSessionRecord }) {
|
||||
const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id)
|
||||
const title = createMemo(() => displayLabel(props.record.session))
|
||||
const showProjectName = () => props.showProjectName() && props.record.projectName
|
||||
|
||||
return (
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { Spinner } from "@opencode-ai/ui/spinner"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { displayLabel } from "@opencode-ai/util/session-title-fallback"
|
||||
import { getFilename } from "@opencode-ai/core/util/path"
|
||||
import { A, useParams } from "@solidjs/router"
|
||||
import { type Accessor, createMemo, For, type JSX, Match, Show, Switch } from "solid-js"
|
||||
@@ -14,7 +15,6 @@ import { getAvatarColors, type LocalProject, useLayout } from "@/context/layout"
|
||||
import { useNotification } from "@/context/notification"
|
||||
import { usePermission } from "@/context/permission"
|
||||
import { messageAgentColor } from "@/utils/agent"
|
||||
import { sessionTitle } from "@/utils/session-title"
|
||||
import { sessionPermissionRequest } from "../session/composer/session-request-tree"
|
||||
import { childSessionOnPath, getProjectAvatarSource, hasProjectPermissions } from "./helpers"
|
||||
|
||||
@@ -104,7 +104,7 @@ const SessionRow = (props: {
|
||||
warmPress: () => void
|
||||
warmFocus: () => void
|
||||
}): JSX.Element => {
|
||||
const title = () => sessionTitle(props.session.title)
|
||||
const title = () => displayLabel(props.session)
|
||||
|
||||
return (
|
||||
<A
|
||||
@@ -229,7 +229,7 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
|
||||
fallback={
|
||||
<Tooltip
|
||||
placement={props.mobile ? "bottom" : "right"}
|
||||
value={sessionTitle(props.session.title)}
|
||||
value={displayLabel(props.session)}
|
||||
gutter={10}
|
||||
class="min-w-0 w-full"
|
||||
>
|
||||
|
||||
@@ -70,7 +70,7 @@ import { legacySessionHref, requireServerKey, sessionHref } from "@/utils/sessio
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { notifySessionTabsRemoved } from "@/components/titlebar-session-events"
|
||||
import { sessionTitle } from "@/utils/session-title"
|
||||
import { displayLabel } from "@opencode-ai/util/session-title-fallback"
|
||||
import { scheduleConnectedMeasure } from "./measure"
|
||||
import { observeElementOffsetReconnectAware } from "./observe-element-offset"
|
||||
import { createTimelineProjection } from "./projection"
|
||||
@@ -296,8 +296,11 @@ export function MessageTimeline(props: {
|
||||
if (!id) return
|
||||
return sync().session.get(id)
|
||||
})
|
||||
const titleValue = createMemo(() => info()?.title)
|
||||
const titleLabel = createMemo(() => sessionTitle(titleValue()))
|
||||
const titleLabel = createMemo(() => {
|
||||
const session = info()
|
||||
if (!session) return
|
||||
return displayLabel(session)
|
||||
})
|
||||
const shareUrl = createMemo(() => info()?.share?.url)
|
||||
const shareEnabled = createMemo(() => sync().data.config.share !== "disabled")
|
||||
const parentID = createMemo(() => info()?.parentID)
|
||||
@@ -311,7 +314,10 @@ export function MessageTimeline(props: {
|
||||
if (!id) return emptyMessages
|
||||
return sync().data.message[id] ?? emptyMessages
|
||||
})
|
||||
const parentTitle = createMemo(() => sessionTitle(parent()?.title) ?? language.t("command.session.new"))
|
||||
const parentTitle = createMemo(() => {
|
||||
const session = parent()
|
||||
return session ? displayLabel(session) : language.t("command.session.new")
|
||||
})
|
||||
const getMsgParts = (msgId: string) => sync().data.part[msgId] ?? emptyParts
|
||||
const getMsgPart = (messageID: string, partID: string) => getMsgParts(messageID).find((part) => part.id === partID)
|
||||
const childTaskDescription = createMemo(() => {
|
||||
@@ -329,7 +335,7 @@ export function MessageTimeline(props: {
|
||||
if (value) return value
|
||||
return language.t("command.session.new")
|
||||
})
|
||||
const showHeader = createMemo(() => !!(titleValue() || parentID()))
|
||||
const showHeader = createMemo(() => !!(titleLabel() || parentID()))
|
||||
const projection = createTimelineProjection({
|
||||
messages: sessionMessages,
|
||||
userMessages: () => props.userMessages,
|
||||
@@ -912,9 +918,10 @@ export function MessageTimeline(props: {
|
||||
}
|
||||
|
||||
function DialogDeleteSession(props: { sessionID: string }) {
|
||||
const name = createMemo(
|
||||
() => sessionTitle(sync().session.get(props.sessionID)?.title) ?? language.t("command.session.new"),
|
||||
)
|
||||
const name = createMemo(() => {
|
||||
const session = sync().session.get(props.sessionID)
|
||||
return session ? displayLabel(session) : language.t("command.session.new")
|
||||
})
|
||||
const handleDelete = async () => {
|
||||
await deleteSession(props.sessionID)
|
||||
dialog.close()
|
||||
|
||||
@@ -38,17 +38,19 @@ test("reports a divergent native offset once and ignores equal offsets and unrel
|
||||
instance.scrollOffset = offset
|
||||
})
|
||||
|
||||
document.body.append(unrelated)
|
||||
unrelated.remove()
|
||||
await frames(2)
|
||||
expect(calls).toEqual([])
|
||||
|
||||
route.remove()
|
||||
document.body.append(route)
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
await frames(3)
|
||||
expect(calls).toEqual([[0, false]])
|
||||
|
||||
instance.scrollOffset = 79_400
|
||||
document.body.append(unrelated)
|
||||
unrelated.remove()
|
||||
await frames(2)
|
||||
expect(calls).toEqual([[0, false]])
|
||||
|
||||
instance.scrollOffset = 0
|
||||
route.remove()
|
||||
document.body.append(route)
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
const pattern = /^(New session|Child session) - \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/
|
||||
|
||||
export function sessionTitle(title?: string) {
|
||||
if (!title) return title
|
||||
const match = title.match(pattern)
|
||||
return match?.[1] ?? title
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { SessionApi, SessionInfo, SessionListInput } from "@opencode-ai/client/promise"
|
||||
import type { Session } from "@opencode-ai/sdk/v2/client"
|
||||
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
|
||||
|
||||
export function normalizeSessionInfo(input: SessionInfo | Session): Session {
|
||||
if (!("location" in input)) return input
|
||||
@@ -13,7 +14,7 @@ export function normalizeSessionInfo(input: SessionInfo | Session): Session {
|
||||
parentID: input.parentID,
|
||||
cost: input.cost,
|
||||
tokens: input.tokens,
|
||||
title: input.title,
|
||||
title: withTimestampedFallback(input),
|
||||
agent: input.agent,
|
||||
model: input.model,
|
||||
version: "",
|
||||
|
||||
@@ -58,8 +58,9 @@ export async function collectNodeAssets(target: NodeTarget) {
|
||||
source: path.join(ptyRoot, relative),
|
||||
})),
|
||||
]
|
||||
await Promise.all(assets.map((asset) => stat(asset.source)))
|
||||
return assets
|
||||
const unique = [...new Map(assets.map((asset) => [asset.key, asset])).values()]
|
||||
await Promise.all(unique.map((asset) => stat(asset.source)))
|
||||
return unique
|
||||
}
|
||||
|
||||
export async function hashNodeAssets(assets: readonly NodeAsset[]) {
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
type SessionMessageInfo,
|
||||
type SkillInfo,
|
||||
} from "@opencode-ai/client/promise"
|
||||
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
|
||||
import type {
|
||||
AgentSideConnection,
|
||||
AuthenticateRequest,
|
||||
@@ -213,7 +214,7 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
|
||||
sessions: page.data.map((session) => ({
|
||||
sessionId: session.id,
|
||||
cwd: session.location.directory,
|
||||
title: session.title,
|
||||
title: withTimestampedFallback(session),
|
||||
updatedAt: new Date(session.time.updated).toISOString(),
|
||||
})),
|
||||
...(page.cursor.next ? { nextCursor: page.cursor.next } : {}),
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { collectNodeAssets } from "../script/node-assets"
|
||||
import { nodeTarget, shellParserWasmAssets } from "../src/node/target"
|
||||
|
||||
test("collects each SEA asset key once", async () => {
|
||||
const assets = await collectNodeAssets(nodeTarget(process.platform, process.arch))
|
||||
const keys = assets.map((asset) => asset.key)
|
||||
|
||||
expect(new Set(keys).size).toBe(keys.length)
|
||||
expect(assets.filter((asset) => asset.key === shellParserWasmAssets.runtime)).toEqual([
|
||||
{
|
||||
key: shellParserWasmAssets.runtime,
|
||||
source: fileURLToPath(import.meta.resolve(shellParserWasmAssets.runtime)),
|
||||
},
|
||||
])
|
||||
})
|
||||
@@ -1757,7 +1757,7 @@ export type SessionInfo = {
|
||||
cost: MoneyUSD
|
||||
tokens: TokenUsageInfo
|
||||
time: { created: number; updated: number; archived?: number }
|
||||
title: string
|
||||
title?: string
|
||||
location: LocationRef
|
||||
subpath?: string
|
||||
revert?: SessionRevert
|
||||
@@ -2006,7 +2006,7 @@ export type SessionV1Info = {
|
||||
cost?: number
|
||||
tokens?: { input: number; output: number; reasoning: number; cache: { read: number; write: number } }
|
||||
share?: { url: string }
|
||||
title: string
|
||||
title?: string
|
||||
agent?: string
|
||||
model?: { id: string; providerID: string; variant?: string }
|
||||
version: string
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "sqlite",
|
||||
"id": "db37a97f-9b5e-4c87-be8b-4feace35136c",
|
||||
"id": "e43ed7e2-b9fc-4178-beae-3646e4a976e1",
|
||||
"prevIds": [
|
||||
"a4ba73b4-21bc-41ab-a415-94e2ca38d798"
|
||||
"db37a97f-9b5e-4c87-be8b-4feace35136c"
|
||||
],
|
||||
"ddl": [
|
||||
{
|
||||
@@ -1302,7 +1302,7 @@
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": true,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
export * as AISDKNative from "./aisdk-native"
|
||||
|
||||
import { isRecord } from "@opencode-ai/ai/utils/record"
|
||||
import { Provider } from "./provider"
|
||||
|
||||
export interface Mapping {
|
||||
readonly package: string
|
||||
readonly settings: Readonly<Record<string, unknown>>
|
||||
readonly headers?: Readonly<Record<string, string>>
|
||||
readonly body?: Readonly<Record<string, unknown>>
|
||||
}
|
||||
|
||||
export function map(packageName: string | undefined, settings: Readonly<Record<string, unknown>>): Mapping | undefined {
|
||||
const baseSettings = mapBaseSettings(settings)
|
||||
switch (packageName) {
|
||||
case "@ai-sdk/google":
|
||||
return {
|
||||
package: "@opencode-ai/ai/providers/google",
|
||||
settings: {
|
||||
...baseSettings,
|
||||
...mapAPIKey(settings),
|
||||
...mapGoogleOptions(settings),
|
||||
},
|
||||
}
|
||||
case "@openrouter/ai-sdk-provider":
|
||||
return mapOpenRouter(settings, baseSettings)
|
||||
case "@ai-sdk/xai":
|
||||
return {
|
||||
package: "@opencode-ai/ai/providers/xai",
|
||||
settings: {
|
||||
...baseSettings,
|
||||
...mapAPIKey(settings),
|
||||
...mapXAIOptions(settings),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function mapBaseSettings(settings: Readonly<Record<string, unknown>>) {
|
||||
return {
|
||||
...(typeof settings.baseURL === "string" ? { baseURL: settings.baseURL } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
function mapAPIKey(settings: Readonly<Record<string, unknown>>) {
|
||||
return typeof settings.apiKey === "string" ? { apiKey: settings.apiKey } : {}
|
||||
}
|
||||
|
||||
function mapGoogleOptions(settings: Readonly<Record<string, unknown>>) {
|
||||
const input = settings.thinkingConfig
|
||||
const thinkingConfig = {
|
||||
...(isRecord(input) && typeof input.thinkingBudget === "number" ? { thinkingBudget: input.thinkingBudget } : {}),
|
||||
...(isRecord(input) && typeof input.includeThoughts === "boolean"
|
||||
? { includeThoughts: input.includeThoughts }
|
||||
: {}),
|
||||
...(isRecord(input) && typeof input.thinkingLevel === "string" ? { thinkingLevel: input.thinkingLevel } : {}),
|
||||
}
|
||||
const options = {
|
||||
...(typeof settings.cachedContent === "string" ? { cachedContent: settings.cachedContent } : {}),
|
||||
...(Array.isArray(settings.safetySettings) ? { safetySettings: settings.safetySettings } : {}),
|
||||
...(typeof settings.serviceTier === "string" ? { serviceTier: settings.serviceTier } : {}),
|
||||
...(Object.keys(thinkingConfig).length > 0 ? { thinkingConfig } : {}),
|
||||
}
|
||||
if (Object.keys(options).length === 0) return {}
|
||||
return { providerOptions: { gemini: options } }
|
||||
}
|
||||
|
||||
function mapOpenRouter(
|
||||
settings: Readonly<Record<string, unknown>>,
|
||||
baseSettings: Readonly<Record<string, unknown>>,
|
||||
): Mapping {
|
||||
const headers =
|
||||
Provider.mergeHeaders(
|
||||
{
|
||||
...(typeof settings.appName === "string" ? { "X-OpenRouter-Title": settings.appName } : {}),
|
||||
...(typeof settings.appUrl === "string" ? { "HTTP-Referer": settings.appUrl } : {}),
|
||||
...(isStringRecord(settings.api_keys) && Object.keys(settings.api_keys).length > 0
|
||||
? { "X-Provider-API-Keys": JSON.stringify(settings.api_keys) }
|
||||
: {}),
|
||||
},
|
||||
isStringRecord(settings.headers) ? settings.headers : undefined,
|
||||
) ?? {}
|
||||
return {
|
||||
package: "@opencode-ai/ai/providers/openrouter",
|
||||
settings: {
|
||||
...baseSettings,
|
||||
...mapAPIKey(settings),
|
||||
...mapOpenRouterOptions(settings),
|
||||
},
|
||||
...(Object.keys(headers).length > 0 ? { headers } : {}),
|
||||
...(isRecord(settings.extraBody) ? { body: settings.extraBody } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
function mapOpenRouterOptions(settings: Readonly<Record<string, unknown>>) {
|
||||
const options = Object.fromEntries(
|
||||
Object.entries(settings).filter(
|
||||
([key]) =>
|
||||
![
|
||||
"apiKey",
|
||||
"api_keys",
|
||||
"appName",
|
||||
"appUrl",
|
||||
"authToken",
|
||||
"baseURL",
|
||||
"chunkTimeout",
|
||||
"compatibility",
|
||||
"extraBody",
|
||||
"fetch",
|
||||
"headers",
|
||||
"timeout",
|
||||
].includes(key),
|
||||
),
|
||||
)
|
||||
if (Object.keys(options).length === 0) return {}
|
||||
return { providerOptions: { openrouter: options } }
|
||||
}
|
||||
|
||||
function isStringRecord(value: unknown): value is Readonly<Record<string, string>> {
|
||||
return isRecord(value) && Object.values(value).every((item) => typeof item === "string")
|
||||
}
|
||||
|
||||
function mapXAIOptions(settings: Readonly<Record<string, unknown>>) {
|
||||
const options = {
|
||||
...(typeof settings.reasoningEffort === "string" ? { reasoningEffort: settings.reasoningEffort } : {}),
|
||||
...(typeof settings.store === "boolean" ? { store: settings.store } : {}),
|
||||
...(typeof settings.promptCacheKey === "string" ? { promptCacheKey: settings.promptCacheKey } : {}),
|
||||
}
|
||||
if (Object.keys(options).length === 0) return {}
|
||||
return { providerOptions: { xai: options } }
|
||||
}
|
||||
@@ -332,7 +332,7 @@ function modelFromLanguage(info: Info, language: LanguageModelV3) {
|
||||
body: projected.body === undefined ? undefined : { ...projected.body },
|
||||
headers: info.headers,
|
||||
},
|
||||
limits: { context: info.limit.context, output: info.limit.output },
|
||||
limits: { context: info.limit.context, input: info.limit.input, output: info.limit.output },
|
||||
providerOptions,
|
||||
},
|
||||
body: {
|
||||
|
||||
@@ -16,7 +16,7 @@ import { Global } from "@opencode-ai/util/global"
|
||||
import { Location } from "./location"
|
||||
import { AbsolutePath } from "./schema"
|
||||
import { ConfigAgent } from "./config/agent"
|
||||
import { ConfigAttachments } from "./config/attachments"
|
||||
import { ConfigMedia } from "./config/media"
|
||||
import { ConfigCompaction } from "./config/compaction"
|
||||
import { ConfigCommand } from "./config/command"
|
||||
import { ConfigExperimental } from "./config/experimental"
|
||||
@@ -85,8 +85,8 @@ export class Info extends Schema.Class<Info>("Config.Info")({
|
||||
lsp: ConfigLSP.Info.pipe(Schema.optional).annotate({
|
||||
description: "Enable built-in language servers or configure server overrides",
|
||||
}),
|
||||
attachments: ConfigAttachments.Info.pipe(Schema.optional).annotate({
|
||||
description: "Attachment processing configuration",
|
||||
media: ConfigMedia.Info.pipe(Schema.optional).annotate({
|
||||
description: "Media processing configuration",
|
||||
}),
|
||||
tool_output: ConfigToolOutput.Info.pipe(Schema.optional).annotate({
|
||||
description: "Tool output truncation thresholds",
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
export * as ConfigAttachments from "./attachments"
|
||||
export * as ConfigMedia from "./media"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { PositiveInt } from "../schema"
|
||||
|
||||
export class Image extends Schema.Class<Image>("Config.Attachments.Image")({
|
||||
export class Image extends Schema.Class<Image>("Config.Media.Image")({
|
||||
auto_resize: Schema.Boolean.pipe(Schema.optional),
|
||||
max_width: PositiveInt.pipe(Schema.optional),
|
||||
max_height: PositiveInt.pipe(Schema.optional),
|
||||
max_base64_bytes: PositiveInt.pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
export class Info extends Schema.Class<Info>("Config.Attachments")({
|
||||
export class Info extends Schema.Class<Info>("Config.Media")({
|
||||
image: Image.pipe(Schema.optional),
|
||||
}) {}
|
||||
+1
@@ -58,5 +58,6 @@ export const migrations = (
|
||||
import("./migration/20260722011141_delete_tool_progress_events"),
|
||||
import("./migration/20260722170000_canonical_tool_results"),
|
||||
import("./migration/20260729022634_session_fork_boundary"),
|
||||
import("./migration/20260730195856_optional_session_title"),
|
||||
])
|
||||
).map((module) => module.default) satisfies DatabaseMigration.Migration[]
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Effect } from "effect"
|
||||
import type { DatabaseMigration } from "../migration"
|
||||
|
||||
export default {
|
||||
id: "20260730195856_optional_session_title",
|
||||
up(tx) {
|
||||
return Effect.gen(function* () {
|
||||
yield* tx.run(`ALTER TABLE \`session\` RENAME COLUMN \`title\` TO \`title_old\``)
|
||||
yield* tx.run(`ALTER TABLE \`session\` ADD COLUMN \`title\` text`)
|
||||
yield* tx.run(`UPDATE \`session\` SET \`title\` = \`title_old\``)
|
||||
yield* tx.run(`ALTER TABLE \`session\` DROP COLUMN \`title_old\``)
|
||||
})
|
||||
},
|
||||
} satisfies DatabaseMigration.Migration
|
||||
@@ -217,7 +217,7 @@ export default {
|
||||
\`slug\` text NOT NULL,
|
||||
\`directory\` text NOT NULL,
|
||||
\`path\` text,
|
||||
\`title\` text NOT NULL,
|
||||
\`title\` text,
|
||||
\`version\` text NOT NULL,
|
||||
\`share_url\` text,
|
||||
\`summary_additions\` integer,
|
||||
|
||||
@@ -54,9 +54,6 @@ const layer = Layer.effect(
|
||||
})
|
||||
formatters = builtIns
|
||||
if (configured === true) return
|
||||
if (configured.ruff?.disabled || configured.uv?.disabled) {
|
||||
formatters = formatters.filter((formatter) => formatter.name !== "ruff" && formatter.name !== "uv")
|
||||
}
|
||||
|
||||
for (const [name, entry] of Object.entries(configured)) {
|
||||
const index = formatters.findIndex((formatter) => formatter.name === name)
|
||||
|
||||
@@ -18,7 +18,6 @@ export function make(input: {
|
||||
readonly fs: FSUtil.Interface
|
||||
readonly npm: Npm.Interface
|
||||
readonly processes: AppProcess.Interface
|
||||
readonly experimentalOxfmt?: boolean
|
||||
}) {
|
||||
const disabled = false as const
|
||||
const findUp = (target: string) => input.fs.findUp(target, input.directory, input.worktree)
|
||||
|
||||
@@ -201,7 +201,7 @@ const layer = Layer.effect(
|
||||
locks.withLock(repository.gitDirectory)(effect)
|
||||
|
||||
const discover = Effect.fn("Git.repo.discover")(function* (input: AbsolutePath) {
|
||||
const dotgit = yield* fs.up({ targets: [".git"], start: input }).pipe(
|
||||
const dotgit = yield* fs.up({ targets: [".git"], start: input, mode: "first" }).pipe(
|
||||
Effect.map((matches) => matches[0]),
|
||||
Effect.catch(() => Effect.succeed(undefined)),
|
||||
)
|
||||
|
||||
@@ -61,7 +61,7 @@ const layer = Layer.effect(
|
||||
const image = Object.assign(
|
||||
{},
|
||||
...(yield* config.entries()).flatMap((entry) =>
|
||||
entry.type === "document" && entry.info.attachments?.image ? [entry.info.attachments.image] : [],
|
||||
entry.type === "document" && entry.info.media?.image ? [entry.info.media.image] : [],
|
||||
),
|
||||
)
|
||||
const normalize = yield* loadAdapter
|
||||
|
||||
@@ -12,12 +12,12 @@ import { Auth, type AnyRoute } from "@opencode-ai/ai/route"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { produce } from "immer"
|
||||
import { AISDK } from "./aisdk"
|
||||
import { AISDKNative } from "./aisdk-native"
|
||||
import { Catalog } from "./catalog"
|
||||
import { Credential } from "./credential"
|
||||
import { Integration } from "./integration"
|
||||
import { Capabilities, ID, Info, Ref, VariantID } from "./model"
|
||||
import { Npm } from "@opencode-ai/util/npm"
|
||||
import { OpenAICodex } from "./plugin/provider/openai-codex"
|
||||
import { Provider } from "./provider"
|
||||
|
||||
export class VariantUnavailableError extends Schema.TaggedErrorClass<VariantUnavailableError>()(
|
||||
@@ -81,7 +81,7 @@ const withDefaults = (model: Info, route: AnyRoute) =>
|
||||
headers: providerHeaders(model),
|
||||
providerOptions: providerOptions(model),
|
||||
http: model.body === undefined ? undefined : { body: model.body },
|
||||
limits: { context: model.limit.context, output: model.limit.output },
|
||||
limits: { context: model.limit.context, input: model.limit.input, output: model.limit.output },
|
||||
})
|
||||
|
||||
const providerHeaders = (model: Info) => {
|
||||
@@ -96,9 +96,7 @@ const providerHeaders = (model: Info) => {
|
||||
return Provider.mergeHeaders(generated.size === 0 ? undefined : Object.fromEntries(generated), model.headers)
|
||||
}
|
||||
|
||||
const providerOptions = (
|
||||
model: Info,
|
||||
): { readonly [key: string]: { readonly [key: string]: unknown } } | undefined => {
|
||||
const providerOptions = (model: Info): { readonly [key: string]: { readonly [key: string]: unknown } } | undefined => {
|
||||
if (!Provider.isAISDK(model.package) || model.settings === undefined) return undefined
|
||||
const { apiKey: _, baseURL: _baseURL, ...settings } = model.settings
|
||||
if (Object.keys(settings).length === 0) return undefined
|
||||
@@ -152,12 +150,7 @@ export const fromCatalogModel = (
|
||||
const packageName = Provider.packageName(resolved.package)
|
||||
const key = apiKey(resolved, credential)
|
||||
|
||||
if (OpenAICodex.isChatGPT(credential) && !Provider.isAISDK(resolved.package) && isNativeOpenAI(resolved.package)) {
|
||||
return Effect.succeed(codexModel(resolved, credential, key))
|
||||
}
|
||||
|
||||
if (Provider.isAISDK(resolved.package) && packageName === "@ai-sdk/openai") {
|
||||
if (OpenAICodex.isChatGPT(credential)) return Effect.succeed(codexModel(resolved, credential, key))
|
||||
return Effect.succeed(
|
||||
withDefaults(resolved, OpenAIResponses.route)
|
||||
.with({ auth: key === undefined ? Auth.none : Auth.bearer(key) })
|
||||
@@ -182,7 +175,10 @@ export const fromCatalogModel = (
|
||||
.model({ id: resolved.modelID ?? resolved.id, compatibility: resolved.compatibility }),
|
||||
)
|
||||
}
|
||||
if (Provider.isAISDK(resolved.package)) {
|
||||
const configured = { ...resolved.settings, ...credential?.metadata }
|
||||
const mapping = Provider.isAISDK(resolved.package) ? AISDKNative.map(packageName, configured) : undefined
|
||||
const native = mapping?.package ?? resolved.package
|
||||
if (Provider.isAISDK(resolved.package) && !mapping) {
|
||||
if (!dependencies?.loadAISDK) return Effect.fail(unsupported(resolved))
|
||||
const runtime = produce(resolved, (draft) => {
|
||||
draft.settings = Provider.mergeOverlay(draft.settings, {
|
||||
@@ -193,20 +189,20 @@ export const fromCatalogModel = (
|
||||
})
|
||||
return dependencies.loadAISDK(runtime).pipe(Effect.mapError(() => unsupported(resolved)))
|
||||
}
|
||||
if (!resolved.package) return Effect.fail(unsupported(resolved))
|
||||
if (!native) return Effect.fail(unsupported(resolved))
|
||||
|
||||
const specifier = resolved.package
|
||||
const specifier = native
|
||||
return Effect.gen(function* () {
|
||||
const module = yield* (dependencies?.loadPackage ?? Provider.loadPackage)(specifier).pipe(
|
||||
Effect.mapError(() => unsupported(resolved)),
|
||||
)
|
||||
const configured = { ...resolved.settings, ...credential?.metadata }
|
||||
const mapped = mapping?.settings ?? configured
|
||||
const settings = {
|
||||
...(credential ? withoutNativeAuthSettings(configured) : configured),
|
||||
...(credential ? withoutNativeAuthSettings(mapped) : mapped),
|
||||
...nativeCredentialSettings(specifier, credential),
|
||||
headers: resolved.headers,
|
||||
body: resolved.body,
|
||||
limits: { context: resolved.limit.context, output: resolved.limit.output },
|
||||
headers: Provider.mergeHeaders(mapping?.headers, resolved.headers),
|
||||
body: Provider.mergeOverlay(mapping?.body, resolved.body),
|
||||
limits: { context: resolved.limit.context, input: resolved.limit.input, output: resolved.limit.output },
|
||||
}
|
||||
return yield* Effect.try({
|
||||
try: () => {
|
||||
@@ -223,10 +219,6 @@ export const fromCatalogModel = (
|
||||
})
|
||||
}
|
||||
|
||||
const isNativeOpenAI = (packageName: string | undefined) =>
|
||||
packageName === "@opencode-ai/ai/providers/openai" ||
|
||||
packageName?.startsWith("@opencode-ai/ai/providers/openai/") === true
|
||||
|
||||
const nativeCredentialSettings = (specifier: string, credential: Credential.Value | undefined) => {
|
||||
if (!credential) return {}
|
||||
if (credential.type === "key") return { apiKey: credential.key }
|
||||
@@ -248,22 +240,6 @@ const withoutNativeAuthSettings = (settings: Record<string, unknown>) => {
|
||||
return rest
|
||||
}
|
||||
|
||||
const codexModel = (
|
||||
model: Info,
|
||||
credential: Credential.Value | undefined,
|
||||
key: ReturnType<typeof Auth.value> | undefined,
|
||||
) => {
|
||||
const account = OpenAICodex.accountID(credential)
|
||||
return withDefaults(model, OpenAIResponses.route)
|
||||
.with({
|
||||
endpoint: { baseURL: OpenAICodex.baseURL },
|
||||
auth: (key === undefined ? Auth.none : Auth.bearer(key)).andThen(
|
||||
account === undefined ? Auth.none : Auth.headers({ "chatgpt-account-id": account }),
|
||||
),
|
||||
})
|
||||
.model({ id: model.modelID ?? model.id, compatibility: model.compatibility })
|
||||
}
|
||||
|
||||
const unsupported = (model: Info) =>
|
||||
new UnsupportedPackageError({
|
||||
providerID: model.providerID,
|
||||
@@ -288,10 +264,7 @@ export const layer = Layer.effect(
|
||||
const integrations = yield* Integration.Service
|
||||
const npm = yield* Npm.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
const load = Effect.fn("ModelResolver.resolveModel")(function* (
|
||||
selected: Info,
|
||||
variant?: VariantID,
|
||||
) {
|
||||
const load = Effect.fn("ModelResolver.resolveModel")(function* (selected: Info, variant?: VariantID) {
|
||||
const provider = yield* catalog.provider.get(selected.providerID)
|
||||
const connection = yield* integrations.connection.active(
|
||||
provider?.integrationID ?? Integration.ID.make(selected.providerID),
|
||||
|
||||
@@ -11,8 +11,6 @@ import { Permission } from "../permission"
|
||||
// Combined output files written by the Shell service, e.g. `<data>/shell/<projectID>/<shellID>.out`.
|
||||
// Whitelisted so agents can read a command's full captured output without an external-directory prompt.
|
||||
const SHELL_OUTPUT_GLOB = path.join(Global.Path.data, "shell", "*", "*")
|
||||
const BUILD_SYSTEM =
|
||||
"You are an AI coding agent. Help the user accomplish software engineering tasks by inspecting the workspace, making targeted changes, and using tools according to the configured permissions."
|
||||
|
||||
const PROMPT_EXPLORE = `You are a file search specialist. You excel at thoroughly navigating and exploring codebases.
|
||||
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
export * as OpenAICodex from "./openai-codex"
|
||||
|
||||
// TEMPORARY SEAM (#34765): plugins have no hook into LLM route construction, so
|
||||
// Codex routing lives in ModelResolver and catalog filtering.
|
||||
// in OpenAIPlugin, sharing this module. Once the native provider packages land
|
||||
// (#33689/#33925/#34462) this should collapse into the native OpenAI provider.
|
||||
// The eligibility rules mirror V1's CodexAuthPlugin allowlist; models.dev has no
|
||||
// plan-eligibility data for OpenAI today, but models other vendors' subscriptions
|
||||
// as dedicated providers (e.g. zai-coding-plan) - a future openai-chatgpt-plan
|
||||
// provider entry could replace the hardcoded rules with catalog data.
|
||||
|
||||
/** ChatGPT-plan requests must target the codex backend instead of the public API. */
|
||||
export const baseURL = "https://chatgpt.com/backend-api/codex"
|
||||
|
||||
const methodIDs: readonly string[] = ["chatgpt-browser", "chatgpt-headless"]
|
||||
|
||||
/** Structural credential shape so both core and plugin-facing credential types fit. */
|
||||
type CredentialLike = {
|
||||
readonly type: string
|
||||
readonly methodID?: string
|
||||
readonly metadata?: Record<string, unknown> | undefined
|
||||
}
|
||||
|
||||
export const isChatGPT = (credential: CredentialLike | undefined) =>
|
||||
credential?.type === "oauth" && credential.methodID !== undefined && methodIDs.includes(credential.methodID)
|
||||
|
||||
export const accountID = (credential: CredentialLike | undefined) => {
|
||||
if (!isChatGPT(credential)) return undefined
|
||||
const value = credential?.metadata?.accountID
|
||||
return typeof value === "string" ? value : undefined
|
||||
}
|
||||
|
||||
const allowed = new Set(["gpt-5.5", "gpt-5.3-codex-spark", "gpt-5.4", "gpt-5.4-mini"])
|
||||
const disallowed = new Set(["gpt-5.5-pro", "gpt-5.6"])
|
||||
|
||||
/** Which API model ids a ChatGPT subscription may call through the codex backend. */
|
||||
export const eligible = (apiID: string) => {
|
||||
if (allowed.has(apiID)) return true
|
||||
if (disallowed.has(apiID)) return false
|
||||
const match = apiID.match(/^gpt-(\d+\.\d+)/)
|
||||
return match ? Number.parseFloat(match[1]) > 5.4 : false
|
||||
}
|
||||
@@ -10,14 +10,16 @@ import { Model } from "../../model"
|
||||
import { OauthCallbackPage } from "../../oauth/page"
|
||||
import { Provider } from "../../provider"
|
||||
import type { PluginInternal } from "../internal"
|
||||
import { OpenAICodex } from "./openai-codex"
|
||||
|
||||
const clientID = "app_EMoamEEZ73f0CkXaXp7hrann"
|
||||
const issuer = "https://auth.openai.com"
|
||||
const callbackPort = 1455
|
||||
const pollingSafetyMargin = 3000
|
||||
const codexBaseURL = "https://chatgpt.com/backend-api/codex"
|
||||
const browserMethodID = Integration.MethodID.make("chatgpt-browser")
|
||||
const headlessMethodID = Integration.MethodID.make("chatgpt-headless")
|
||||
const codexAllowed = new Set(["gpt-5.5", "gpt-5.3-codex-spark", "gpt-5.4", "gpt-5.4-mini"])
|
||||
const codexDisallowed = new Set(["gpt-5.5-pro", "gpt-5.6"])
|
||||
|
||||
type Pkce = {
|
||||
verifier: string
|
||||
@@ -164,14 +166,18 @@ export const OpenAIPlugin = define({
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const bus = yield* Bus.Service
|
||||
const loading = Semaphore.makeUnsafe(1)
|
||||
let chatgpt = false
|
||||
let chatgpt: Credential.OAuth | undefined
|
||||
|
||||
const load = Effect.fn("OpenAIPlugin.load")(function* () {
|
||||
const connection = yield* ctx.integration.connection.active("openai")
|
||||
const credential = connection
|
||||
? yield* ctx.integration.connection.resolve(connection).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
: undefined
|
||||
chatgpt = OpenAICodex.isChatGPT(credential)
|
||||
chatgpt =
|
||||
credential?.type === "oauth" &&
|
||||
(credential.methodID === browserMethodID || credential.methodID === headlessMethodID)
|
||||
? credential
|
||||
: undefined
|
||||
})
|
||||
|
||||
yield* ctx.integration.transform((draft) => {
|
||||
@@ -183,16 +189,19 @@ export const OpenAIPlugin = define({
|
||||
for (const item of evt.provider.list()) {
|
||||
if (!Provider.isAISDK(item.provider.package)) continue
|
||||
if (Provider.packageName(item.provider.package) !== "@ai-sdk/openai") continue
|
||||
if (!item.models.has(Model.ID.make("gpt-5-chat-latest"))) continue
|
||||
evt.model.update(item.provider.id, Model.ID.make("gpt-5-chat-latest"), (model) => {
|
||||
// OpenAIPlugin sends OpenAI models through Responses; this alias is a
|
||||
// chat-completions-only model, so hide it only from OpenAI's catalog.
|
||||
model.enabled = false
|
||||
evt.provider.update(item.provider.id, (provider) => {
|
||||
provider.package = "@opencode-ai/ai/providers/openai"
|
||||
})
|
||||
}
|
||||
if (!chatgpt) return
|
||||
const item = evt.provider.get(Provider.ID.openai)
|
||||
if (!item) return
|
||||
item.provider.settings = Provider.mergeOverlay(item.provider.settings, { baseURL: codexBaseURL })
|
||||
const account = chatgpt.metadata?.accountID
|
||||
item.provider.headers = Provider.mergeHeaders(
|
||||
item.provider.headers,
|
||||
typeof account === "string" ? { "chatgpt-account-id": account } : undefined,
|
||||
)
|
||||
for (const model of item.models.values()) {
|
||||
// ChatGPT-plan tokens only authorize codex-eligible models, and the
|
||||
// subscription covers usage, so hide the rest and zero the cost.
|
||||
@@ -201,14 +210,32 @@ export const OpenAIPlugin = define({
|
||||
draft.enabled = false
|
||||
return
|
||||
}
|
||||
if (!OpenAICodex.eligible(draft.modelID ?? draft.id)) {
|
||||
const apiID = draft.modelID ?? draft.id
|
||||
const match = apiID.match(/^gpt-(\d+\.\d+)/)
|
||||
if (
|
||||
!codexAllowed.has(apiID) &&
|
||||
(codexDisallowed.has(apiID) || !match || Number.parseFloat(match[1]) <= 5.4)
|
||||
) {
|
||||
draft.enabled = false
|
||||
return
|
||||
}
|
||||
draft.cost = []
|
||||
// Match Codex CLI so context consumption and subscription usage stay consistent between clients.
|
||||
draft.limit = { ...draft.limit, context: 272_000, input: 272_000 }
|
||||
})
|
||||
}
|
||||
})
|
||||
yield* ctx.session.hook("request", (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}`
|
||||
}
|
||||
evt.headers.originator = "opencode"
|
||||
evt.headers["session-id"] = evt.sessionID
|
||||
}),
|
||||
)
|
||||
|
||||
const refresh = () => loading.withPermit(load().pipe(Effect.andThen(ctx.catalog.reload())))
|
||||
yield* bus.subscribe(Integration.Event.ConnectionUpdated).pipe(
|
||||
@@ -216,21 +243,6 @@ export const OpenAIPlugin = define({
|
||||
Stream.runForEach(refresh),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
yield* ctx.aisdk.hook(
|
||||
"sdk",
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.package !== "@ai-sdk/openai") return
|
||||
const mod = yield* Effect.promise(() => import("@ai-sdk/openai"))
|
||||
evt.sdk = mod.createOpenAI(evt.options)
|
||||
}),
|
||||
)
|
||||
yield* ctx.aisdk.hook(
|
||||
"language",
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.model.providerID !== Provider.ID.openai) return
|
||||
evt.language = evt.sdk.responses(evt.model.modelID ?? evt.model.id)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
} satisfies PluginInternal.InternalPlugin)
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ export const root = Effect.fn("Project.root")(function* (
|
||||
fs: FSUtil.Interface,
|
||||
input: AbsolutePath,
|
||||
) {
|
||||
return yield* fs.up({ targets: [".git", ".hg"], start: input }).pipe(
|
||||
return yield* fs.up({ targets: [".git", ".hg"], start: input, mode: "first" }).pipe(
|
||||
Effect.map((matches) => matches[0] ? AbsolutePath.make(path.dirname(matches[0])) : undefined),
|
||||
Effect.catch(() => Effect.succeed(undefined)),
|
||||
)
|
||||
@@ -224,7 +224,7 @@ const layer = Layer.effect(
|
||||
})
|
||||
|
||||
const hgDiscover = Effect.fnUntraced(function* (input: AbsolutePath) {
|
||||
const dotHg = yield* fs.up({ targets: [".hg"], start: input }).pipe(
|
||||
const dotHg = yield* fs.up({ targets: [".hg"], start: input, mode: "first" }).pipe(
|
||||
Effect.map((matches) => matches[0]),
|
||||
Effect.catch(() => Effect.succeed(undefined)),
|
||||
)
|
||||
@@ -246,12 +246,13 @@ const layer = Layer.effect(
|
||||
if (repo) {
|
||||
const previous = yield* cached(repo.commonDirectory)
|
||||
const id = (yield* remote(repo)) ?? previous ?? (yield* root(repo))
|
||||
const canonical = yield* git.worktree
|
||||
.list(repo)
|
||||
.pipe(
|
||||
Effect.map((items) => items.find((item) => item.kind === "main")?.directory ?? repo.worktree),
|
||||
Effect.catch(() => Effect.succeed(repo.worktree)),
|
||||
)
|
||||
const canonical =
|
||||
repo.gitDirectory === repo.commonDirectory
|
||||
? repo.worktree
|
||||
: yield* git.worktree.list(repo).pipe(
|
||||
Effect.map((items) => items.find((item) => item.kind === "main")?.directory ?? repo.worktree),
|
||||
Effect.catch(() => Effect.succeed(repo.worktree)),
|
||||
)
|
||||
return yield* persist({
|
||||
previous,
|
||||
id: id ?? ID.global,
|
||||
|
||||
@@ -374,7 +374,7 @@ const layer = Layer.effect(
|
||||
directory: location.directory,
|
||||
path: path.relative(project.directory, location.directory).replaceAll("\\", "/"),
|
||||
workspaceID: location.workspaceID ? Workspace.ID.make(location.workspaceID) : undefined,
|
||||
title: input.title ?? `New session - ${new Date(now).toISOString()}`,
|
||||
title: input.title,
|
||||
agent: input.agent,
|
||||
model: input.model
|
||||
? {
|
||||
@@ -938,7 +938,7 @@ const materializeAttachment = Effect.fn("Session.materializeAttachment")(functio
|
||||
: resolved.bytes
|
||||
const normalized = yield* normalizeImageAttachment(
|
||||
input,
|
||||
Base64.make(Buffer.from(content).toString("base64")),
|
||||
Buffer.from(content).toString("base64"),
|
||||
mime,
|
||||
image,
|
||||
)
|
||||
@@ -954,11 +954,11 @@ const materializeAttachment = Effect.fn("Session.materializeAttachment")(functio
|
||||
|
||||
const normalizeImageAttachment = Effect.fn("Session.normalizeImageAttachment")(function* (
|
||||
input: PromptInput.FileAttachment,
|
||||
data: Base64,
|
||||
data: string,
|
||||
mime: string,
|
||||
image: Effect.Effect<Image.Interface>,
|
||||
) {
|
||||
if (!mime.startsWith("image/")) return { data, mime }
|
||||
if (!mime.startsWith("image/")) return { data: Base64.make(data), mime }
|
||||
const service = yield* image
|
||||
const label = input.name ?? (input.uri.startsWith("data:") ? "inline attachment" : input.uri)
|
||||
const content = { uri: label, content: data, encoding: "base64" as const, mime }
|
||||
|
||||
@@ -152,14 +152,11 @@ const settings = (documents: readonly Config.Entry[]) => {
|
||||
const configured = documents
|
||||
.filter((entry): entry is Config.Document => entry.type === "document")
|
||||
.flatMap((entry) => (entry.info.compaction ? [entry.info.compaction] : []))
|
||||
return configured.reduce<Settings>(
|
||||
(result, current) => ({
|
||||
auto: current.auto ?? result.auto,
|
||||
buffer: current.buffer ?? result.buffer,
|
||||
tokens: current.keep?.tokens ?? result.tokens,
|
||||
}),
|
||||
{ auto: true, buffer: DEFAULT_BUFFER, tokens: DEFAULT_KEEP_TOKENS },
|
||||
)
|
||||
return {
|
||||
auto: configured.findLast((value) => value.auto !== undefined)?.auto ?? true,
|
||||
buffer: configured.findLast((value) => value.buffer !== undefined)?.buffer ?? DEFAULT_BUFFER,
|
||||
tokens: configured.findLast((value) => value.keep?.tokens !== undefined)?.keep?.tokens ?? DEFAULT_KEEP_TOKENS,
|
||||
}
|
||||
}
|
||||
|
||||
const select = (
|
||||
@@ -350,11 +347,16 @@ const make = (dependencies: Dependencies) => {
|
||||
message.type === "assistant" && message.tokens !== undefined,
|
||||
)
|
||||
if (!last) return false
|
||||
const output = Math.min(input.model.route.defaults.limits?.output ?? 0, OUTPUT_TOKEN_MAX)
|
||||
const limits = input.model.route.defaults.limits
|
||||
const output = Math.min(limits?.output ?? 0, OUTPUT_TOKEN_MAX)
|
||||
const promptCeiling = Math.min(
|
||||
limits?.input === undefined ? Number.POSITIVE_INFINITY : limits.input - config.buffer,
|
||||
context - Math.max(output, config.buffer),
|
||||
)
|
||||
const used =
|
||||
last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write
|
||||
if (used <= 0) return false
|
||||
return used >= context - (output || config.buffer)
|
||||
return used >= promptCeiling
|
||||
}
|
||||
const compactManual = Effect.fn("SessionCompaction.compactManual")(function* (input: ManualInput) {
|
||||
const content = planContent(input.messages, config.tokens)
|
||||
|
||||
@@ -71,7 +71,7 @@ export const layer = Layer.effect(
|
||||
LLM.request({
|
||||
model: model.model,
|
||||
http: { headers: SessionModelHeaders.make(selection.session, app) },
|
||||
providerOptions: { openai: { promptCacheKey } },
|
||||
providerOptions: { [providerMetadataKey]: { promptCacheKey } },
|
||||
system: contextEvent.system,
|
||||
messages: contextEvent.messages,
|
||||
tools: hookedTools,
|
||||
|
||||
@@ -117,21 +117,20 @@ export const preview = Effect.fn("SessionHistory.preview")(function* (
|
||||
.pipe(Effect.catch((error) => (error instanceof Instructions.InitializationBlocked ? error : Effect.die(error))))
|
||||
})
|
||||
|
||||
/** Returns the session's sole user message, or `undefined` once a second one exists. */
|
||||
export const firstUserMessageIfOnly = Effect.fn("SessionHistory.firstUserMessageIfOnly")(function* (
|
||||
/** Returns the session's first user message. */
|
||||
export const firstUserMessage = Effect.fn("SessionHistory.firstUserMessage")(function* (
|
||||
db: DatabaseService,
|
||||
sessionID: SessionSchema.ID,
|
||||
) {
|
||||
const rows = yield* db
|
||||
const row = yield* db
|
||||
.select()
|
||||
.from(SessionMessageTable)
|
||||
.where(and(eq(SessionMessageTable.session_id, sessionID), eq(SessionMessageTable.type, "user")))
|
||||
.orderBy(asc(SessionMessageTable.seq))
|
||||
.limit(2)
|
||||
.all()
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (rows.length !== 1) return undefined
|
||||
const message = yield* decodeMessageRow(rows[0]).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
if (!row) return undefined
|
||||
const message = yield* decodeMessageRow(row).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
return message?.type === "user" ? message : undefined
|
||||
})
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ export function fromRow(row: typeof SessionTable.$inferSelect): SessionSchema.In
|
||||
return SessionSchema.Info.make({
|
||||
id: SessionSchema.ID.make(row.id),
|
||||
projectID: Project.ID.make(row.project_id),
|
||||
title: row.title,
|
||||
title: row.title ?? undefined,
|
||||
parentID: row.parent_id ? SessionSchema.ID.make(row.parent_id) : undefined,
|
||||
fork:
|
||||
row.fork_session_id && row.fork_boundary
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export * as SessionModelRequest from "./model-request"
|
||||
|
||||
import { LLM, Message, SystemPart, type LLMRequest } from "@opencode-ai/ai"
|
||||
import type { StreamOptions } from "@opencode-ai/ai/route"
|
||||
import type { Content } from "@opencode-ai/schema/tool"
|
||||
import { SessionError } from "@opencode-ai/schema/session-error"
|
||||
import { Cause, Config, Context, Effect, Layer, Result } from "effect"
|
||||
@@ -18,6 +19,11 @@ import { MAX_STEPS_PROMPT } from "./runner/max-steps"
|
||||
import PROMPT_DEFAULT from "./runner/prompt/base.txt"
|
||||
import { toLLMMessages } from "./runner/to-llm-message"
|
||||
|
||||
const IMAGE_BYTES_TRIGGER = 25 * 1024 * 1024 // 25 MiB
|
||||
const IMAGE_BYTES_TARGET = 15 * 1024 * 1024 // 15 MiB
|
||||
const IMAGE_REMOVED =
|
||||
"[This image was removed to reduce the request size and is no longer visible. Do not make claims about its contents from memory. If needed, retrieve it again with an available tool or ask the user to attach it again.]"
|
||||
|
||||
/** Failures a prepared execution can surface: infrastructure errors plus user declines resurfaced from the defect tunnel. */
|
||||
export type ExecuteError = Tool.Error | Permission.DeclinedError | QuestionTool.CancelledError
|
||||
|
||||
@@ -37,6 +43,7 @@ const declineDefect = (cause: Cause.Cause<Tool.Error>) => {
|
||||
|
||||
interface Prepared {
|
||||
readonly request: LLMRequest
|
||||
readonly options: StreamOptions
|
||||
/**
|
||||
* One request-scoped execution operation. Unknown, hook-removed, and
|
||||
* step-limit-violating calls fail individually through the same seam.
|
||||
@@ -92,6 +99,56 @@ export const unsupportedParts = (messages: LLMRequest["messages"], capabilities:
|
||||
}),
|
||||
)
|
||||
|
||||
export const boundImages = (messages: LLMRequest["messages"]) => {
|
||||
const isImage = (mime: string) => mime.toLowerCase().startsWith("image/")
|
||||
const size = (data: string | Uint8Array) =>
|
||||
typeof data === "string" ? Buffer.byteLength(data) : Math.ceil(data.byteLength / 3) * 4
|
||||
const imageBytes = messages.reduce(
|
||||
(total, message) =>
|
||||
total +
|
||||
message.content.reduce((sum, part) => {
|
||||
if (part.type === "media" && isImage(part.mediaType)) return sum + size(part.data)
|
||||
if (part.type !== "tool-result" || part.result.type !== "content") return sum
|
||||
return (
|
||||
sum +
|
||||
part.result.value.reduce(
|
||||
(bytes: number, item: Content) =>
|
||||
bytes + (item.type === "file" && isImage(item.mime) ? Buffer.byteLength(item.uri) : 0),
|
||||
0,
|
||||
)
|
||||
)
|
||||
}, 0),
|
||||
0,
|
||||
)
|
||||
if (imageBytes <= IMAGE_BYTES_TRIGGER) return messages
|
||||
|
||||
let removed = 0
|
||||
return messages.map((message) =>
|
||||
Message.make({
|
||||
...message,
|
||||
content: message.content.map((part) => {
|
||||
if (part.type === "media" && isImage(part.mediaType) && imageBytes - removed > IMAGE_BYTES_TARGET) {
|
||||
removed += size(part.data)
|
||||
return Message.text(IMAGE_REMOVED)
|
||||
}
|
||||
if (part.type !== "tool-result" || part.result.type !== "content") return part
|
||||
return {
|
||||
...part,
|
||||
result: {
|
||||
...part.result,
|
||||
value: part.result.value.map((item: Content) => {
|
||||
if (item.type !== "file" || !isImage(item.mime) || imageBytes - removed <= IMAGE_BYTES_TARGET)
|
||||
return item
|
||||
removed += Buffer.byteLength(item.uri)
|
||||
return { type: "text" as const, text: IMAGE_REMOVED }
|
||||
}),
|
||||
},
|
||||
}
|
||||
}),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds an outbound model request and captures the tool-call capability that
|
||||
* must remain paired with it. It does not execute the request or mutate
|
||||
@@ -156,12 +213,32 @@ export const layer = Layer.effect(
|
||||
http: {
|
||||
headers: SessionModelHeaders.make(session, app),
|
||||
},
|
||||
providerOptions: { openai: { promptCacheKey } },
|
||||
providerOptions: { [providerMetadataKey]: { promptCacheKey } },
|
||||
system: contextEvent.system,
|
||||
messages: unsupportedParts(contextEvent.messages, resolved.capabilities),
|
||||
messages: boundImages(unsupportedParts(contextEvent.messages, resolved.capabilities)),
|
||||
tools: hookedTools,
|
||||
toolChoice: stepLimitReached ? "none" : undefined,
|
||||
})
|
||||
const options: StreamOptions = {
|
||||
transform: (request) =>
|
||||
hooks
|
||||
.trigger("session", "request", {
|
||||
sessionID: session.id,
|
||||
agent: agent.id,
|
||||
model: resolved.ref,
|
||||
...request,
|
||||
})
|
||||
.pipe(
|
||||
Effect.tap((event) =>
|
||||
Effect.sync(() => {
|
||||
request.url = event.url
|
||||
request.headers = event.headers
|
||||
request.body = event.body
|
||||
}),
|
||||
),
|
||||
Effect.asVoid,
|
||||
),
|
||||
}
|
||||
if (promptCacheSnapshots) {
|
||||
const current = PromptCacheDiagnostics.snapshot(request)
|
||||
const comparison = PromptCacheDiagnostics.compare(promptCacheSnapshots.get(session.id), current)
|
||||
@@ -190,6 +267,7 @@ export const layer = Layer.effect(
|
||||
}
|
||||
return {
|
||||
request,
|
||||
options,
|
||||
executeTool,
|
||||
stepLimitReached,
|
||||
}
|
||||
|
||||
@@ -43,7 +43,8 @@ type Usage = {
|
||||
|
||||
const ForkBatchSize = 500
|
||||
|
||||
const forkTitle = (value: string) => {
|
||||
const forkTitle = (value?: string) => {
|
||||
if (value === undefined) return
|
||||
const match = value.match(/^(.+) \(fork #(\d+)\)$/)
|
||||
if (match) return `${match[1]} (fork #${Number.parseInt(match[2], 10) + 1})`
|
||||
return `${value} (fork #1)`
|
||||
@@ -216,7 +217,7 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* (
|
||||
slug: Slug.create(),
|
||||
directory: parent.directory,
|
||||
path: parent.path,
|
||||
title: forkTitle(parent.title),
|
||||
title: forkTitle(parent.title ?? undefined),
|
||||
agent: parent.agent,
|
||||
model: parent.model,
|
||||
version: parent.version,
|
||||
@@ -657,6 +658,12 @@ const layer = Layer.effectDiscard(
|
||||
input: event.data.input,
|
||||
timeCreated: event.created,
|
||||
})
|
||||
yield* db
|
||||
.update(SessionTable)
|
||||
.set({ time_updated: DateTime.toEpochMillis(event.created) })
|
||||
.where(eq(SessionTable.id, event.data.sessionID))
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
}),
|
||||
)
|
||||
yield* bus.project(SessionEvent.Compaction.Admitted, (event) =>
|
||||
|
||||
@@ -93,10 +93,9 @@ const layer = Layer.effect(
|
||||
const db = (yield* Database.Service).db
|
||||
const compaction = yield* SessionCompaction.Service
|
||||
const title = yield* SessionTitle.Service
|
||||
// Title generation is a side effect of the first step; it must not delay step continuation.
|
||||
// Tracked per process so repeated wakes before the second user message arrives don't
|
||||
// re-fire a redundant LLM call; `SessionTitle` itself is idempotent based on durable history.
|
||||
const titleStarted = new Set<SessionSchema.ID>()
|
||||
// Title generation is a side effect of a successful step; it must not delay continuation.
|
||||
// The in-flight set coalesces overlapping steps while title presence records success durably.
|
||||
const titlesRunning = new Set<SessionSchema.ID>()
|
||||
const forkTitle = yield* FiberSet.makeRuntime<never, void, never>()
|
||||
/**
|
||||
* Drains eligible manual compaction and user input until the Session becomes idle.
|
||||
@@ -125,7 +124,7 @@ const layer = Layer.effect(
|
||||
let step = 1
|
||||
while (true) {
|
||||
const result = yield* runStep(sessionID, promotable, step)
|
||||
yield* startTitleOnce(sessionID)
|
||||
if (step === 1) yield* startTitle(sessionID)
|
||||
yield* runPendingCompaction(sessionID)
|
||||
if (!result.needsContinuation && !(yield* SessionPending.has(db, sessionID, "steer"))) return
|
||||
promotable = "steer"
|
||||
@@ -279,7 +278,7 @@ const layer = Layer.effect(
|
||||
// event durably, fork one fiber per local tool call, and hold back a virgin
|
||||
// context-overflow provider error so settlement may recover it via compaction.
|
||||
let overflowFailure: ProviderErrorEvent | undefined
|
||||
const providerStream = llm.stream(prepared.request).pipe(
|
||||
const providerStream = llm.stream(prepared.request, prepared.options).pipe(
|
||||
Stream.runForEach((event) =>
|
||||
Effect.gen(function* () {
|
||||
if (overflowFailure || publisher.hasProviderError()) return
|
||||
@@ -481,11 +480,20 @@ const layer = Layer.effect(
|
||||
}
|
||||
})
|
||||
|
||||
/** Fires title generation once per process after the first step makes a user message visible. */
|
||||
const startTitleOnce = Effect.fnUntraced(function* (sessionID: SessionSchema.ID) {
|
||||
if (titleStarted.has(sessionID)) return
|
||||
titleStarted.add(sessionID)
|
||||
forkTitle(title.generateForFirstPrompt(yield* getSession(sessionID)).pipe(Effect.ignore))
|
||||
/** Starts one title request at a time after a successful step makes user input visible. */
|
||||
const startTitle = Effect.fnUntraced(function* (sessionID: SessionSchema.ID) {
|
||||
if (titlesRunning.has(sessionID)) return
|
||||
titlesRunning.add(sessionID)
|
||||
forkTitle(
|
||||
title.generateForFirstPrompt(sessionID).pipe(
|
||||
Effect.ignore,
|
||||
Effect.ensuring(
|
||||
Effect.sync(() => {
|
||||
titlesRunning.delete(sessionID)
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
const getSession = Effect.fn("SessionRunner.getSession")(function* (sessionID: SessionSchema.ID) {
|
||||
|
||||
@@ -36,7 +36,7 @@ export const SessionTable = sqliteTable(
|
||||
slug: text().notNull(),
|
||||
directory: directoryColumn().notNull(),
|
||||
path: pathColumn(),
|
||||
title: text().notNull(),
|
||||
title: text(),
|
||||
version: text().notNull(),
|
||||
share_url: text(),
|
||||
summary_additions: integer(),
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
export * as SessionTitle from "./title"
|
||||
|
||||
import { LLM, LLMClient, LLMError, LLMEvent, Message, type LLMRequest } from "@opencode-ai/ai"
|
||||
import { Context, Effect, Layer, Stream } from "effect"
|
||||
import { Context, DateTime, Effect, Layer, Stream } from "effect"
|
||||
import { Agent } from "../agent"
|
||||
import { Database } from "../database/database"
|
||||
import { Bus } from "../bus"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { isExactRootFallback } from "@opencode-ai/util/session-title-fallback"
|
||||
import { App } from "../app"
|
||||
import { llmClient } from "../effect/app-node-platform"
|
||||
import { SessionEvent } from "./event"
|
||||
@@ -14,8 +15,10 @@ import { SessionModelHeaders } from "./model-headers"
|
||||
import { SessionRunnerModel } from "./runner/model"
|
||||
import { SessionSchema } from "./schema"
|
||||
import { SessionUsage } from "./usage"
|
||||
import { SessionStore } from "./store"
|
||||
|
||||
const MAX_LENGTH = 100
|
||||
const titleChanged = Symbol("Session title changed")
|
||||
|
||||
type Dependencies = {
|
||||
readonly app: App.Info
|
||||
@@ -25,24 +28,33 @@ type Dependencies = {
|
||||
}
|
||||
readonly agents: Agent.Interface
|
||||
readonly models: SessionRunnerModel.Interface
|
||||
readonly store: SessionStore.Interface
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
/** Generates a title from the session's first user message and renames the session. Runs at most once per session. */
|
||||
readonly generateForFirstPrompt: (session: SessionSchema.Info) => Effect.Effect<void>
|
||||
/** Generates a title from the session's first user message when the session remains untitled. */
|
||||
readonly generateForFirstPrompt: (sessionID: SessionSchema.ID) => Effect.Effect<void>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionTitle") {}
|
||||
|
||||
const truncate = (value: string) => (value.length <= MAX_LENGTH ? value : `${value.slice(0, MAX_LENGTH - 3)}...`)
|
||||
const isUntitled = (session: SessionSchema.Info) =>
|
||||
isExactRootFallback({
|
||||
title: session.title,
|
||||
time: { created: DateTime.toEpochMillis(session.time.created) },
|
||||
})
|
||||
|
||||
const make = (dependencies: Dependencies) => {
|
||||
const generateForFirstPrompt = Effect.fn("SessionTitle.generateForFirstPrompt")(function* (
|
||||
db: Database.Interface["db"],
|
||||
session: SessionSchema.Info,
|
||||
sessionID: SessionSchema.ID,
|
||||
) {
|
||||
const session = yield* dependencies.store.get(sessionID)
|
||||
if (!session) return
|
||||
if (session.parentID) return
|
||||
const firstUser = yield* SessionHistory.firstUserMessageIfOnly(db, session.id)
|
||||
if (!isUntitled(session)) return
|
||||
const firstUser = yield* SessionHistory.firstUserMessage(db, session.id)
|
||||
if (!firstUser) return
|
||||
const agent = yield* dependencies.agents.get(Agent.ID.make("title"))
|
||||
if (!agent) return
|
||||
@@ -96,10 +108,19 @@ const make = (dependencies: Dependencies) => {
|
||||
.map((line) => line.trim())
|
||||
.find((line) => line.length > 0)
|
||||
if (!title) return
|
||||
yield* dependencies.bus.publish(SessionEvent.Renamed, {
|
||||
sessionID: session.id,
|
||||
title: truncate(title),
|
||||
})
|
||||
const expectedSequence = (yield* Bus.latestSequence(db, sessionID)) + 1
|
||||
const current = yield* dependencies.store.get(sessionID)
|
||||
if (!current || !isUntitled(current)) return
|
||||
yield* dependencies.bus
|
||||
.publish(
|
||||
SessionEvent.Renamed,
|
||||
{
|
||||
sessionID: session.id,
|
||||
title: truncate(title),
|
||||
},
|
||||
{ commit: (sequence) => (sequence === expectedSequence ? Effect.void : Effect.die(titleChanged)) },
|
||||
)
|
||||
.pipe(Effect.catchDefect((defect) => (defect === titleChanged ? Effect.void : Effect.die(defect))))
|
||||
})
|
||||
return { generateForFirstPrompt }
|
||||
}
|
||||
@@ -111,11 +132,12 @@ export const layer = Layer.effect(
|
||||
const llm = yield* LLMClient.Service
|
||||
const agents = yield* Agent.Service
|
||||
const models = yield* SessionRunnerModel.Service
|
||||
const store = yield* SessionStore.Service
|
||||
const database = yield* Database.Service
|
||||
const app = yield* App.Metadata
|
||||
const title = make({ bus, llm, agents, models, app })
|
||||
const title = make({ bus, llm, agents, models, store, app })
|
||||
return Service.of({
|
||||
generateForFirstPrompt: (session) => title.generateForFirstPrompt(database.db, session),
|
||||
generateForFirstPrompt: (sessionID) => title.generateForFirstPrompt(database.db, sessionID),
|
||||
})
|
||||
}),
|
||||
)
|
||||
@@ -123,5 +145,5 @@ export const layer = Layer.effect(
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [Bus.node, llmClient, Agent.node, SessionRunnerModel.node, Database.node, App.node],
|
||||
deps: [Bus.node, llmClient, Agent.node, SessionRunnerModel.node, SessionStore.node, Database.node, App.node],
|
||||
})
|
||||
|
||||
@@ -63,7 +63,7 @@ export function migrate(info: typeof ConfigV1.Info.Type) {
|
||||
watcher: info.watcher,
|
||||
formatter: info.formatter,
|
||||
lsp: info.lsp,
|
||||
attachments: info.attachment,
|
||||
media: info.attachment,
|
||||
tool_output: info.tool_output,
|
||||
mcp: mcp(info),
|
||||
compaction: info.compaction && {
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { AISDKNative } from "@opencode-ai/core/aisdk-native"
|
||||
|
||||
describe("AISDKNative", () => {
|
||||
test("maps OpenRouter settings to native destinations", () => {
|
||||
expect(
|
||||
AISDKNative.map("@openrouter/ai-sdk-provider", {
|
||||
appName: "OpenCode",
|
||||
appUrl: "https://opencode.ai",
|
||||
headers: { "x-openrouter-title": "Configured", "x-provider-api-keys": "Configured BYOK" },
|
||||
api_keys: { anthropic: "provider-key" },
|
||||
extraBody: { transforms: ["middle-out"] },
|
||||
models: ["anthropic/claude-sonnet-4.6"],
|
||||
provider: { only: ["anthropic"], require_parameters: true },
|
||||
reasoning: { effort: "high" },
|
||||
promptCacheKey: "session_123",
|
||||
future_option: { enabled: true },
|
||||
}),
|
||||
).toEqual({
|
||||
package: "@opencode-ai/ai/providers/openrouter",
|
||||
settings: {
|
||||
providerOptions: {
|
||||
openrouter: {
|
||||
models: ["anthropic/claude-sonnet-4.6"],
|
||||
provider: { only: ["anthropic"], require_parameters: true },
|
||||
reasoning: { effort: "high" },
|
||||
promptCacheKey: "session_123",
|
||||
future_option: { enabled: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
headers: {
|
||||
"x-openrouter-title": "Configured",
|
||||
"HTTP-Referer": "https://opencode.ai",
|
||||
"x-provider-api-keys": "Configured BYOK",
|
||||
},
|
||||
body: { transforms: ["middle-out"] },
|
||||
})
|
||||
})
|
||||
|
||||
test("maps every Google thinking setting", () => {
|
||||
expect(
|
||||
AISDKNative.map("@ai-sdk/google", {
|
||||
cachedContent: "cachedContents/example",
|
||||
safetySettings: [{ category: "HARM_CATEGORY_HARASSMENT", threshold: "BLOCK_NONE" }],
|
||||
serviceTier: "flex",
|
||||
thinkingConfig: {
|
||||
thinkingBudget: 0,
|
||||
includeThoughts: false,
|
||||
thinkingLevel: "high",
|
||||
unknown: true,
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
package: "@opencode-ai/ai/providers/google",
|
||||
settings: {
|
||||
providerOptions: {
|
||||
gemini: {
|
||||
cachedContent: "cachedContents/example",
|
||||
safetySettings: [{ category: "HARM_CATEGORY_HARASSMENT", threshold: "BLOCK_NONE" }],
|
||||
serviceTier: "flex",
|
||||
thinkingConfig: {
|
||||
thinkingBudget: 0,
|
||||
includeThoughts: false,
|
||||
thinkingLevel: "high",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("maps Google thinking settings independently", () => {
|
||||
for (const thinkingConfig of [{ thinkingBudget: -1 }, { includeThoughts: true }, { thinkingLevel: "medium" }]) {
|
||||
expect(AISDKNative.map("@ai-sdk/google", { thinkingConfig })).toMatchObject({
|
||||
settings: { providerOptions: { gemini: { thinkingConfig } } },
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
test("maps Google request options without thinking settings", () => {
|
||||
expect(
|
||||
AISDKNative.map("@ai-sdk/google", {
|
||||
cachedContent: "cachedContents/example",
|
||||
safetySettings: [{ category: "HARM_CATEGORY_HARASSMENT", threshold: "BLOCK_NONE" }],
|
||||
serviceTier: "future-tier",
|
||||
}),
|
||||
).toMatchObject({
|
||||
settings: {
|
||||
providerOptions: {
|
||||
gemini: {
|
||||
cachedContent: "cachedContents/example",
|
||||
safetySettings: [{ category: "HARM_CATEGORY_HARASSMENT", threshold: "BLOCK_NONE" }],
|
||||
serviceTier: "future-tier",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("maps supported xAI settings", () => {
|
||||
expect(
|
||||
AISDKNative.map("@ai-sdk/xai", {
|
||||
apiKey: "secret",
|
||||
baseURL: "https://xai.example/v1",
|
||||
reasoningEffort: "custom",
|
||||
store: true,
|
||||
promptCacheKey: "cache-key",
|
||||
}),
|
||||
).toEqual({
|
||||
package: "@opencode-ai/ai/providers/xai",
|
||||
settings: {
|
||||
apiKey: "secret",
|
||||
baseURL: "https://xai.example/v1",
|
||||
providerOptions: {
|
||||
xai: {
|
||||
reasoningEffort: "custom",
|
||||
store: true,
|
||||
promptCacheKey: "cache-key",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("omits invalid and unsupported xAI settings", () => {
|
||||
expect(
|
||||
AISDKNative.map("@ai-sdk/xai", {
|
||||
reasoningEffort: 10,
|
||||
store: "yes",
|
||||
include: ["unknown"],
|
||||
logprobs: true,
|
||||
topLogprobs: 8,
|
||||
previousResponseId: "response-id",
|
||||
searchParameters: { mode: "auto" },
|
||||
}),
|
||||
).toEqual({
|
||||
package: "@opencode-ai/ai/providers/xai",
|
||||
settings: {},
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -803,7 +803,7 @@ describe("Config", () => {
|
||||
custom: { command: ["custom-fmt", "$FILE"], extensions: [".foo"] },
|
||||
},
|
||||
lsp: { typescript: { disabled: true }, custom: { command: ["custom-lsp"], extensions: [".foo"] } },
|
||||
attachments: {
|
||||
media: {
|
||||
image: { auto_resize: false, max_width: 1200, max_height: 900, max_base64_bytes: 1048576 },
|
||||
},
|
||||
tool_output: { max_lines: 1000, max_bytes: 32768 },
|
||||
@@ -890,7 +890,7 @@ describe("Config", () => {
|
||||
typescript: { disabled: true },
|
||||
custom: { command: ["custom-lsp"], extensions: [".foo"] },
|
||||
})
|
||||
expect(documents[0]?.info.attachments).toEqual({
|
||||
expect(documents[0]?.info.media).toEqual({
|
||||
image: { auto_resize: false, max_width: 1200, max_height: 900, max_base64_bytes: 1048576 },
|
||||
})
|
||||
expect(documents[0]?.info.tool_output).toEqual({ max_lines: 1000, max_bytes: 32768 })
|
||||
@@ -1097,7 +1097,7 @@ describe("Config", () => {
|
||||
expect(documents[0]?.info.references).toEqual({
|
||||
docs: { path: "../docs", description: "Use for product documentation", hidden: true },
|
||||
})
|
||||
expect(documents[0]?.info.attachments).toEqual({ image: { auto_resize: false, max_width: 1200 } })
|
||||
expect(documents[0]?.info.media).toEqual({ image: { auto_resize: false, max_width: 1200 } })
|
||||
expect(documents[0]?.info.providers?.custom).toMatchObject({
|
||||
settings: { apiKey: "secret" },
|
||||
models: {
|
||||
|
||||
@@ -26,6 +26,7 @@ import timeSuspendedMigration from "@opencode-ai/core/database/migration/2026070
|
||||
import instructionSyncMigration from "@opencode-ai/core/database/migration/20260710025429_instruction_sync"
|
||||
import deleteToolProgressEventsMigration from "@opencode-ai/core/database/migration/20260722011141_delete_tool_progress_events"
|
||||
import canonicalToolResultsMigration from "@opencode-ai/core/database/migration/20260722170000_canonical_tool_results"
|
||||
import optionalSessionTitleMigration from "@opencode-ai/core/database/migration/20260730195856_optional_session_title"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
@@ -399,6 +400,40 @@ describe("DatabaseMigration", () => {
|
||||
).rejects.toThrow("Database is not empty and has no session table")
|
||||
})
|
||||
|
||||
test("makes session titles nullable without deleting dependent rows", async () => {
|
||||
await run(
|
||||
Effect.gen(function* () {
|
||||
const db = yield* makeDb
|
||||
yield* db.run(sql`PRAGMA foreign_keys = ON`)
|
||||
yield* db.run(sql`
|
||||
CREATE TABLE session (
|
||||
id text PRIMARY KEY,
|
||||
title text NOT NULL
|
||||
)
|
||||
`)
|
||||
yield* db.run(sql`
|
||||
CREATE TABLE message (
|
||||
id text PRIMARY KEY,
|
||||
session_id text NOT NULL REFERENCES session(id) ON DELETE CASCADE
|
||||
)
|
||||
`)
|
||||
yield* db.run(sql`INSERT INTO session VALUES ('ses_existing', 'Existing title')`)
|
||||
yield* db.run(sql`INSERT INTO message VALUES ('msg_existing', 'ses_existing')`)
|
||||
|
||||
yield* DatabaseMigration.applyOnly(db, [optionalSessionTitleMigration])
|
||||
|
||||
expect(yield* db.get(sql`SELECT title FROM session WHERE id = 'ses_existing'`)).toEqual({
|
||||
title: "Existing title",
|
||||
})
|
||||
expect(yield* db.get(sql`SELECT id FROM message WHERE id = 'msg_existing'`)).toEqual({ id: "msg_existing" })
|
||||
expect(
|
||||
yield* db.get<{ notnull: number }>(sql`SELECT "notnull" FROM pragma_table_info('session') WHERE name = 'title'`),
|
||||
).toEqual({ notnull: 0 })
|
||||
expect(yield* db.get<{ foreign_keys: number }>(sql`PRAGMA foreign_keys`)).toEqual({ foreign_keys: 1 })
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("backfills existing Context Epoch rows to the build agent", async () => {
|
||||
await run(
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, test, expect } from "bun:test"
|
||||
import { Effect, FileSystem } from "effect"
|
||||
import { Effect, FileSystem, Layer } from "effect"
|
||||
import { LayerNodePlatform } from "@opencode-ai/util/effect/app-node-platform"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
@@ -267,6 +267,33 @@ describe("FSUtil", () => {
|
||||
expect(result).toContain(path.join(tmp, "b.txt"))
|
||||
}),
|
||||
)
|
||||
|
||||
it(
|
||||
"stops at the first match when requested",
|
||||
Effect.gen(function* () {
|
||||
const filesys = yield* FileSystem.FileSystem
|
||||
const tmp = yield* filesys.makeTempDirectoryScoped()
|
||||
yield* filesys.writeFileString(path.join(tmp, "marker"), "root")
|
||||
const child = path.join(tmp, "sub")
|
||||
yield* filesys.makeDirectory(child)
|
||||
yield* filesys.writeFileString(path.join(child, "marker"), "child")
|
||||
const checked: string[] = []
|
||||
const instrumented = FileSystem.FileSystem.of({
|
||||
...filesys,
|
||||
exists: (target) => Effect.sync(() => checked.push(target)).pipe(Effect.andThen(filesys.exists(target))),
|
||||
})
|
||||
const search = yield* FSUtil.Service.pipe(
|
||||
Effect.provide(
|
||||
FSUtil.layer.pipe(Layer.fresh, Layer.provide(Layer.succeed(FileSystem.FileSystem, instrumented))),
|
||||
),
|
||||
)
|
||||
|
||||
expect(yield* search.up({ targets: ["marker", "other"], start: child, mode: "first" })).toEqual([
|
||||
path.join(child, "marker"),
|
||||
])
|
||||
expect(checked).toEqual([path.join(child, "marker")])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
describe("glob", () => {
|
||||
|
||||
@@ -15,7 +15,7 @@ import { testEffect } from "./lib/effect"
|
||||
|
||||
const selected = Info.make({
|
||||
...Info.default(Provider.ID.make("test-provider"), ID.make("gemini")),
|
||||
package: Provider.aisdk("@ai-sdk/google"),
|
||||
package: Provider.aisdk("@ai-sdk/mistral"),
|
||||
})
|
||||
const runtime = Model.make({ id: "gemini", provider: "test-provider", route: OpenAIChat.route })
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import path from "node:path"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js"
|
||||
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"
|
||||
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"
|
||||
import { Server } from "@modelcontextprotocol/sdk/server/index.js"
|
||||
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"
|
||||
@@ -559,6 +560,42 @@ test("lists, reads, and reports MCP resource changes", async () => {
|
||||
)
|
||||
})
|
||||
|
||||
test("does not reconnect an SSE stream after a JSON-RPC error response", async () => {
|
||||
let requests = 0
|
||||
const transport = new StreamableHTTPClientTransport(new URL("http://mcp.invalid"), {
|
||||
fetch: async () => {
|
||||
requests += 1
|
||||
return new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode("id: prime\nretry: 1\ndata:\n\n"))
|
||||
controller.enqueue(
|
||||
new TextEncoder().encode(
|
||||
'id: error\ndata: {"jsonrpc":"2.0","error":{"code":-32601,"message":"Method not found"},"id":1}\n\n',
|
||||
),
|
||||
)
|
||||
controller.close()
|
||||
},
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "text/event-stream" } },
|
||||
)
|
||||
},
|
||||
reconnectionOptions: {
|
||||
initialReconnectionDelay: 1,
|
||||
maxReconnectionDelay: 1,
|
||||
reconnectionDelayGrowFactor: 1,
|
||||
maxRetries: 2,
|
||||
},
|
||||
})
|
||||
|
||||
await transport.start()
|
||||
await transport.send({ jsonrpc: "2.0", method: "resources/list", id: 1 })
|
||||
await Bun.sleep(25)
|
||||
await transport.close()
|
||||
|
||||
expect(requests).toBe(1)
|
||||
})
|
||||
|
||||
test("skips MCP resource requests when the capability is absent", async () => {
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { LLM, Model } from "@opencode-ai/ai"
|
||||
import { OpenAIChat } from "@opencode-ai/ai/protocols"
|
||||
import { compileRequest } from "@opencode-ai/ai/route/client"
|
||||
import { Effect } from "effect"
|
||||
import { Headers } from "effect/unstable/http"
|
||||
@@ -17,6 +18,7 @@ interface ModelOptions {
|
||||
readonly headers?: Info["headers"]
|
||||
readonly body?: Info["body"]
|
||||
readonly variants?: Info["variants"]
|
||||
readonly limit?: Info["limit"]
|
||||
}
|
||||
|
||||
const model = (packageName: string | undefined, options: ModelOptions = {}) =>
|
||||
@@ -36,7 +38,7 @@ const model = (packageName: string | undefined, options: ModelOptions = {}) =>
|
||||
cost: [],
|
||||
status: "active",
|
||||
enabled: true,
|
||||
limit: { context: 100, output: 20 },
|
||||
limit: options.limit ?? { context: 100, output: 20 },
|
||||
})
|
||||
|
||||
describe("ModelResolver", () => {
|
||||
@@ -44,6 +46,7 @@ describe("ModelResolver", () => {
|
||||
Effect.gen(function* () {
|
||||
const catalog = model(Provider.aisdk("@ai-sdk/openai"), {
|
||||
settings: { baseURL: "https://openai.example/v1" },
|
||||
limit: { context: 100, input: 80, output: 20 },
|
||||
})
|
||||
const resolved = yield* ModelResolver.fromCatalogModel(catalog)
|
||||
|
||||
@@ -55,7 +58,7 @@ describe("ModelResolver", () => {
|
||||
endpoint: { baseURL: "https://openai.example/v1" },
|
||||
defaults: {
|
||||
headers: { "x-test": "header" },
|
||||
limits: { context: 100, output: 20 },
|
||||
limits: { context: 100, input: 80, output: 20 },
|
||||
http: { body: { custom_extension: { enabled: true } } },
|
||||
},
|
||||
})
|
||||
@@ -307,12 +310,12 @@ describe("ModelResolver", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("routes ChatGPT OAuth credentials to the codex backend", () =>
|
||||
it.effect("applies plugin-projected OpenAI endpoint and headers", () =>
|
||||
Effect.gen(function* () {
|
||||
const resolved = yield* ModelResolver.fromCatalogModel(
|
||||
model(Provider.aisdk("@ai-sdk/openai"), {
|
||||
settings: { baseURL: "https://openai.example/v1" },
|
||||
headers: {},
|
||||
model("@opencode-ai/ai/providers/openai", {
|
||||
settings: { baseURL: "https://chatgpt.com/backend-api/codex" },
|
||||
headers: { "chatgpt-account-id": "acct_123" },
|
||||
body: {},
|
||||
}),
|
||||
Credential.OAuth.make({
|
||||
@@ -337,37 +340,8 @@ describe("ModelResolver", () => {
|
||||
id: "openai-responses",
|
||||
endpoint: { baseURL: "https://chatgpt.com/backend-api/codex" },
|
||||
})
|
||||
expect(resolved.route.defaults.headers).toMatchObject({ "chatgpt-account-id": "acct_123" })
|
||||
expect(headers.authorization).toBe("Bearer chatgpt-token")
|
||||
expect(headers["chatgpt-account-id"]).toBe("acct_123")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("routes native OpenAI provider packages with ChatGPT credentials to the codex backend", () =>
|
||||
Effect.gen(function* () {
|
||||
const resolved = yield* ModelResolver.fromCatalogModel(
|
||||
model("@opencode-ai/ai/providers/openai", {
|
||||
settings: { baseURL: "https://openai.example/v1" },
|
||||
}),
|
||||
Credential.OAuth.make({
|
||||
type: "oauth",
|
||||
methodID: Integration.MethodID.make("chatgpt-browser"),
|
||||
access: "chatgpt-token",
|
||||
refresh: "refresh",
|
||||
expires: Date.now() + 60_000,
|
||||
metadata: { accountID: "acct_123" },
|
||||
}),
|
||||
)
|
||||
const headers = yield* resolved.route.auth.apply({
|
||||
request: LLM.request({ model: resolved, prompt: "Hello" }),
|
||||
method: "POST",
|
||||
url: "https://chatgpt.com/backend-api/codex/responses",
|
||||
body: "{}",
|
||||
headers: Headers.empty,
|
||||
})
|
||||
|
||||
expect(resolved.route.endpoint.baseURL).toBe("https://chatgpt.com/backend-api/codex")
|
||||
expect(headers.authorization).toBe("Bearer chatgpt-token")
|
||||
expect(headers["chatgpt-account-id"]).toBe("acct_123")
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -407,37 +381,6 @@ describe("ModelResolver", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("routes ChatGPT OAuth credentials without an account id to the codex backend", () =>
|
||||
Effect.gen(function* () {
|
||||
const resolved = yield* ModelResolver.fromCatalogModel(
|
||||
model(Provider.aisdk("@ai-sdk/openai"), {
|
||||
settings: { baseURL: "https://openai.example/v1" },
|
||||
headers: {},
|
||||
body: {},
|
||||
}),
|
||||
Credential.OAuth.make({
|
||||
type: "oauth",
|
||||
methodID: Integration.MethodID.make("chatgpt-headless"),
|
||||
access: "chatgpt-token",
|
||||
refresh: "refresh",
|
||||
expires: Date.now() + 60_000,
|
||||
}),
|
||||
)
|
||||
const request = LLM.request({ model: resolved, prompt: "Hello" })
|
||||
const headers = yield* resolved.route.auth.apply({
|
||||
request,
|
||||
method: "POST",
|
||||
url: "https://chatgpt.com/backend-api/codex/responses",
|
||||
body: "{}",
|
||||
headers: Headers.empty,
|
||||
})
|
||||
|
||||
expect(resolved.route.endpoint.baseURL).toBe("https://chatgpt.com/backend-api/codex")
|
||||
expect(headers.authorization).toBe("Bearer chatgpt-token")
|
||||
expect(headers["chatgpt-account-id"]).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps non-ChatGPT OAuth credentials on the configured endpoint", () =>
|
||||
Effect.gen(function* () {
|
||||
const resolved = yield* ModelResolver.fromCatalogModel(
|
||||
@@ -546,6 +489,122 @@ describe("ModelResolver", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("routes supported AISDK catalog packages through native provider packages", () =>
|
||||
Effect.gen(function* () {
|
||||
const native = yield* ModelResolver.fromCatalogModel(model(Provider.aisdk("@ai-sdk/openai")))
|
||||
const packages = [
|
||||
[
|
||||
"@ai-sdk/google",
|
||||
"@opencode-ai/ai/providers/google",
|
||||
{ thinkingConfig: { thinkingLevel: "high" } },
|
||||
{ gemini: { thinkingConfig: { thinkingLevel: "high" } } },
|
||||
],
|
||||
[
|
||||
"@openrouter/ai-sdk-provider",
|
||||
"@opencode-ai/ai/providers/openrouter",
|
||||
{ reasoning: { effort: "high" } },
|
||||
{ openrouter: { reasoning: { effort: "high" } } },
|
||||
],
|
||||
[
|
||||
"@ai-sdk/xai",
|
||||
"@opencode-ai/ai/providers/xai",
|
||||
{ reasoningEffort: "high" },
|
||||
{ xai: { reasoningEffort: "high" } },
|
||||
],
|
||||
] as const
|
||||
|
||||
yield* Effect.forEach(packages, ([catalogPackage, nativePackage, sourceOptions, providerOptions]) =>
|
||||
ModelResolver.fromCatalogModel(
|
||||
model(Provider.aisdk(catalogPackage), {
|
||||
modelID: "api-model",
|
||||
settings: { baseURL: "https://provider.example/v1", ...sourceOptions },
|
||||
headers: { "x-provider": "header" },
|
||||
body: { custom: true },
|
||||
}),
|
||||
Credential.Key.make({ type: "key", key: "secret" }),
|
||||
{
|
||||
loadPackage: (specifier) => {
|
||||
expect(specifier).toBe(nativePackage)
|
||||
return Effect.succeed({
|
||||
model: (modelID, settings) => {
|
||||
expect(modelID).toBe("api-model")
|
||||
expect(settings).toMatchObject({
|
||||
apiKey: "secret",
|
||||
baseURL: "https://provider.example/v1",
|
||||
headers: { "x-provider": "header" },
|
||||
body: { custom: true },
|
||||
limits: { context: 100, output: 20 },
|
||||
providerOptions,
|
||||
})
|
||||
return Model.make({ id: modelID, provider: "native-provider", route: native.route })
|
||||
},
|
||||
})
|
||||
},
|
||||
loadAISDK: () => Effect.die("AI SDK loader should not be called"),
|
||||
},
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("merges mapped OpenRouter headers and body with catalog overlays", () =>
|
||||
ModelResolver.fromCatalogModel(
|
||||
model(Provider.aisdk("@openrouter/ai-sdk-provider"), {
|
||||
settings: {
|
||||
appName: "OpenCode",
|
||||
appUrl: "https://opencode.ai",
|
||||
extraBody: { transforms: ["middle-out"], provider: { sort: "price" } },
|
||||
},
|
||||
headers: { "X-OpenRouter-Title": "Custom" },
|
||||
body: { provider: { only: ["anthropic"] } },
|
||||
}),
|
||||
undefined,
|
||||
{
|
||||
loadPackage: () =>
|
||||
Effect.succeed({
|
||||
model: (modelID, settings) => {
|
||||
expect(settings.headers).toEqual({
|
||||
"HTTP-Referer": "https://opencode.ai",
|
||||
"X-OpenRouter-Title": "Custom",
|
||||
})
|
||||
expect(settings.body).toEqual({
|
||||
transforms: ["middle-out"],
|
||||
provider: { sort: "price", only: ["anthropic"] },
|
||||
})
|
||||
return Model.make({ id: modelID, provider: "openrouter", route: OpenAIChat.route })
|
||||
},
|
||||
}),
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("loads supported AISDK catalog packages as native routes", () =>
|
||||
Effect.gen(function* () {
|
||||
const google = yield* ModelResolver.fromCatalogModel(
|
||||
model(Provider.aisdk("@ai-sdk/google"), { settings: { thinkingConfig: { thinkingBudget: 1_024 } } }),
|
||||
)
|
||||
const openrouter = yield* ModelResolver.fromCatalogModel(
|
||||
model(Provider.aisdk("@openrouter/ai-sdk-provider"), {
|
||||
settings: { reasoning: { effort: "high" } },
|
||||
}),
|
||||
)
|
||||
const xai = yield* ModelResolver.fromCatalogModel(
|
||||
model(Provider.aisdk("@ai-sdk/xai"), { settings: { reasoningEffort: "high" } }),
|
||||
)
|
||||
|
||||
expect(google.route.id).toBe("gemini")
|
||||
expect(google.route.defaults.providerOptions).toEqual({
|
||||
gemini: { thinkingConfig: { thinkingBudget: 1_024 } },
|
||||
})
|
||||
expect(openrouter.route.id).toBe("openrouter")
|
||||
expect(openrouter.route.defaults.providerOptions).toEqual({ openrouter: { reasoning: { effort: "high" } } })
|
||||
expect(xai.route.id).toBe("openai-responses")
|
||||
expect(xai.route.defaults.providerOptions).toEqual({
|
||||
xai: { reasoningEffort: "high", store: false },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("loads arbitrary AISDK packages through the injected AISDK loader", () =>
|
||||
Effect.gen(function* () {
|
||||
const native = yield* ModelResolver.fromCatalogModel(
|
||||
@@ -554,8 +613,8 @@ describe("ModelResolver", () => {
|
||||
}),
|
||||
)
|
||||
const resolved = yield* ModelResolver.fromCatalogModel(
|
||||
model(Provider.aisdk("@ai-sdk/google"), {
|
||||
modelID: "gemini-api-model",
|
||||
model(Provider.aisdk("@ai-sdk/mistral"), {
|
||||
modelID: "mistral-api-model",
|
||||
settings: { project: "test" },
|
||||
headers: { "x-aisdk": "header" },
|
||||
body: { custom: true },
|
||||
@@ -566,9 +625,9 @@ describe("ModelResolver", () => {
|
||||
Effect.sync(() => {
|
||||
expect(runtime).toMatchObject({
|
||||
id: "test-model",
|
||||
modelID: "gemini-api-model",
|
||||
modelID: "mistral-api-model",
|
||||
providerID: "test-provider",
|
||||
package: Provider.aisdk("@ai-sdk/google"),
|
||||
package: Provider.aisdk("@ai-sdk/mistral"),
|
||||
settings: { project: "test", apiKey: "fallback-secret" },
|
||||
headers: { "x-aisdk": "header" },
|
||||
body: { custom: true },
|
||||
@@ -582,15 +641,15 @@ describe("ModelResolver", () => {
|
||||
},
|
||||
)
|
||||
|
||||
expect(resolved).toMatchObject({ id: "gemini-api-model", provider: "test-provider" })
|
||||
expect(resolved).toMatchObject({ id: "mistral-api-model", provider: "test-provider" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects AISDK packages without an available loader", () =>
|
||||
Effect.gen(function* () {
|
||||
const failure = yield* ModelResolver.fromCatalogModel(
|
||||
model(Provider.aisdk("@ai-sdk/google"), {
|
||||
settings: { baseURL: "https://google.example/v1" },
|
||||
model(Provider.aisdk("@ai-sdk/mistral"), {
|
||||
settings: { baseURL: "https://mistral.example/v1" },
|
||||
}),
|
||||
).pipe(Effect.flip)
|
||||
|
||||
@@ -598,9 +657,9 @@ describe("ModelResolver", () => {
|
||||
_tag: "SessionRunnerModel.UnsupportedPackageError",
|
||||
providerID: "test-provider",
|
||||
modelID: "test-model",
|
||||
package: "aisdk:@ai-sdk/google",
|
||||
package: "aisdk:@ai-sdk/mistral",
|
||||
})
|
||||
expect(failure.message).toBe("Unsupported package for test-provider/test-model: aisdk:@ai-sdk/google")
|
||||
expect(failure.message).toBe("Unsupported package for test-provider/test-model: aisdk:@ai-sdk/mistral")
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -612,8 +671,8 @@ describe("ModelResolver", () => {
|
||||
}),
|
||||
)
|
||||
yield* ModelResolver.fromCatalogModel(
|
||||
model(Provider.aisdk("@ai-sdk/google"), {
|
||||
settings: { apiKey: "", baseURL: "https://google.example/v1" },
|
||||
model(Provider.aisdk("@ai-sdk/mistral"), {
|
||||
settings: { apiKey: "", baseURL: "https://mistral.example/v1" },
|
||||
}),
|
||||
undefined,
|
||||
{
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { AISDK } from "@opencode-ai/core/aisdk"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { Agent } from "@opencode-ai/schema/agent"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
import { describe, expect } from "bun:test"
|
||||
import type { LanguageModelV3 } from "@ai-sdk/provider"
|
||||
import { Effect } from "effect"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
@@ -9,6 +9,7 @@ import { Integration } from "@opencode-ai/core/integration"
|
||||
import { Model } from "@opencode-ai/core/model"
|
||||
import { Plugin } from "@opencode-ai/core/plugin"
|
||||
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
|
||||
import { OpenAIPlugin } from "@opencode-ai/core/plugin/provider/openai"
|
||||
import { Provider } from "@opencode-ai/core/provider"
|
||||
import { testEffect } from "../lib/effect"
|
||||
@@ -18,7 +19,6 @@ const it = testEffect(PluginTestLayer)
|
||||
|
||||
const addPlugin = Effect.fn(function* () {
|
||||
const plugin = yield* Plugin.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
const host = yield* PluginHost.make(plugin)
|
||||
const integrations = yield* Integration.Service
|
||||
yield* OpenAIPlugin.effect(host).pipe(Effect.provideService(Integration.Service, integrations))
|
||||
@@ -29,19 +29,6 @@ function required<T>(value: T | undefined): T {
|
||||
return value
|
||||
}
|
||||
|
||||
function fakeSelectorSdk(calls: string[]) {
|
||||
const make = (method: string) => (id: string) => {
|
||||
calls.push(`${method}:${id}`)
|
||||
return { modelId: id, provider: method, specificationVersion: "v3" } as unknown as LanguageModelV3
|
||||
}
|
||||
return {
|
||||
responses: make("responses"),
|
||||
messages: make("messages"),
|
||||
chat: make("chat"),
|
||||
languageModel: make("languageModel"),
|
||||
}
|
||||
}
|
||||
|
||||
describe("OpenAIPlugin", () => {
|
||||
it.effect("registers browser and headless ChatGPT OAuth methods", () =>
|
||||
Effect.gen(function* () {
|
||||
@@ -61,104 +48,6 @@ describe("OpenAIPlugin", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("creates an OpenAI SDK for @ai-sdk/openai using the provider ID as SDK name", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* Plugin.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
yield* addPlugin()
|
||||
const result = yield* aisdk.runSDK({
|
||||
model: Model.Info.make({
|
||||
...Model.Info.default(Provider.ID.make("custom-openai"), Model.ID.make("gpt-5")),
|
||||
modelID: Model.ID.make("gpt-5"),
|
||||
package: Provider.aisdk("test-provider"),
|
||||
}),
|
||||
package: "@ai-sdk/openai",
|
||||
options: { name: "custom-openai", apiKey: "test" },
|
||||
})
|
||||
expect(result.sdk?.responses("gpt-5").provider).toBe("custom-openai.responses")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("ignores non-OpenAI SDK packages", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* Plugin.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
yield* addPlugin()
|
||||
const result = yield* aisdk.runSDK({
|
||||
model: Model.Info.make({
|
||||
...Model.Info.default(Provider.ID.openai, Model.ID.make("gpt-5")),
|
||||
modelID: Model.ID.make("gpt-5"),
|
||||
package: Provider.aisdk("test-provider"),
|
||||
}),
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
options: { name: "openai" },
|
||||
})
|
||||
expect(result.sdk).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("uses the Responses API for language models", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* Plugin.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
const calls: string[] = []
|
||||
yield* addPlugin()
|
||||
const result = yield* aisdk.runLanguage({
|
||||
model: Model.Info.make({
|
||||
...Model.Info.default(Provider.ID.openai, Model.ID.make("alias")),
|
||||
modelID: Model.ID.make("gpt-5"),
|
||||
package: Provider.aisdk("test-provider"),
|
||||
}),
|
||||
sdk: fakeSelectorSdk(calls),
|
||||
options: {},
|
||||
})
|
||||
expect(calls).toEqual(["responses:gpt-5"])
|
||||
expect(result.language).toBeDefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("ignores non-OpenAI providers", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* Plugin.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
const calls: string[] = []
|
||||
yield* addPlugin()
|
||||
const result = yield* aisdk.runLanguage({
|
||||
model: Model.Info.make({
|
||||
...Model.Info.default(Provider.ID.anthropic, Model.ID.make("gpt-5")),
|
||||
modelID: Model.ID.make("gpt-5"),
|
||||
package: Provider.aisdk("test-provider"),
|
||||
}),
|
||||
sdk: fakeSelectorSdk(calls),
|
||||
options: {},
|
||||
})
|
||||
expect(calls).toEqual([])
|
||||
expect(result.language).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("disables gpt-5-chat-latest during catalog transforms", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* catalog.transform((catalog) => {
|
||||
const item = Provider.Info.make({
|
||||
...Provider.Info.empty(Provider.ID.openai),
|
||||
package: Provider.aisdk("@ai-sdk/openai"),
|
||||
})
|
||||
catalog.provider.update(item.id, (draft) => {
|
||||
draft.package = item.package
|
||||
})
|
||||
catalog.model.update(item.id, Model.ID.make("gpt-5"), () => {})
|
||||
catalog.model.update(item.id, Model.ID.make("gpt-5-chat-latest"), () => {})
|
||||
})
|
||||
yield* addPlugin()
|
||||
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5"))).enabled).toBe(true)
|
||||
expect(
|
||||
required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5-chat-latest"))).enabled,
|
||||
).toBe(false)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("filters the OpenAI catalog to codex-eligible models under a ChatGPT connection", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
@@ -172,6 +61,7 @@ describe("OpenAIPlugin", () => {
|
||||
draft.package = item.package
|
||||
})
|
||||
catalog.model.update(item.id, Model.ID.make("gpt-5.5"), (model) => {
|
||||
model.limit = { context: 1_050_000, input: 922_000, output: 128_000 }
|
||||
model.cost = [
|
||||
{
|
||||
input: Money.USDPerMillionTokens.make(1),
|
||||
@@ -184,12 +74,17 @@ describe("OpenAIPlugin", () => {
|
||||
]
|
||||
})
|
||||
catalog.model.update(item.id, Model.ID.make("gpt-5.5-pro"), () => {})
|
||||
catalog.model.update(item.id, Model.ID.make("gpt-5.4"), (model) => {
|
||||
model.limit = { context: 1_050_000, input: 922_000, output: 64_000 }
|
||||
})
|
||||
catalog.model.update(item.id, Model.ID.make("gpt-5.4-pro"), (model) => {
|
||||
model.modelID = Model.ID.make("gpt-5.4")
|
||||
model.body = { reasoning: { mode: "pro" } }
|
||||
})
|
||||
catalog.model.update(item.id, Model.ID.make("gpt-5.6"), () => {})
|
||||
catalog.model.update(item.id, Model.ID.make("gpt-5.6-sol"), () => {})
|
||||
catalog.model.update(item.id, Model.ID.make("gpt-5.6-sol"), (model) => {
|
||||
model.limit = { context: 1_050_000, input: 922_000, output: 128_000 }
|
||||
})
|
||||
catalog.model.update(item.id, Model.ID.make("gpt-4.1"), () => {})
|
||||
})
|
||||
yield* credentials.create({
|
||||
@@ -205,8 +100,47 @@ 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 provider = required(yield* catalog.provider.get(Provider.ID.openai))
|
||||
expect(provider.package).toBe("@opencode-ai/ai/providers/openai")
|
||||
expect(provider.settings).toMatchObject({ baseURL: "https://chatgpt.com/backend-api/codex" })
|
||||
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(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")))
|
||||
expect(eligible.package).toBe("@opencode-ai/ai/providers/openai")
|
||||
expect(eligible.cost).toEqual([])
|
||||
expect(eligible.limit).toEqual({ context: 272_000, input: 272_000, output: 128_000 })
|
||||
expect(eligible.enabled).toBe(true)
|
||||
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.5-pro"))).enabled).toBe(
|
||||
false,
|
||||
@@ -214,10 +148,15 @@ describe("OpenAIPlugin", () => {
|
||||
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.4-pro"))).enabled).toBe(
|
||||
false,
|
||||
)
|
||||
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.4"))).limit).toEqual({
|
||||
context: 272_000,
|
||||
input: 272_000,
|
||||
output: 64_000,
|
||||
})
|
||||
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.6"))).enabled).toBe(false)
|
||||
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.6-sol"))).enabled).toBe(
|
||||
true,
|
||||
)
|
||||
const gpt56 = required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.6-sol")))
|
||||
expect(gpt56.enabled).toBe(true)
|
||||
expect(gpt56.limit).toEqual({ context: 272_000, input: 272_000, output: 128_000 })
|
||||
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-4.1"))).enabled).toBe(false)
|
||||
}),
|
||||
)
|
||||
@@ -234,7 +173,9 @@ describe("OpenAIPlugin", () => {
|
||||
catalog.provider.update(item.id, (draft) => {
|
||||
draft.package = item.package
|
||||
})
|
||||
catalog.model.update(item.id, Model.ID.make("gpt-5.5"), () => {})
|
||||
catalog.model.update(item.id, Model.ID.make("gpt-5.5"), (model) => {
|
||||
model.limit = { context: 1_050_000, input: 922_000, output: 128_000 }
|
||||
})
|
||||
catalog.model.update(item.id, Model.ID.make("gpt-4.1"), () => {})
|
||||
})
|
||||
yield* credentials.create({
|
||||
@@ -243,29 +184,23 @@ describe("OpenAIPlugin", () => {
|
||||
})
|
||||
yield* addPlugin()
|
||||
|
||||
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.5"))).enabled).toBe(true)
|
||||
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 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(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-4.1"))).enabled).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not disable gpt-5-chat-latest for non-OpenAI providers", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* catalog.transform((catalog) => {
|
||||
const item = Provider.Info.make({
|
||||
...Provider.Info.empty(Provider.ID.make("custom-openai")),
|
||||
package: Provider.aisdk("test-provider"),
|
||||
})
|
||||
catalog.provider.update(item.id, (draft) => {
|
||||
draft.package = item.package
|
||||
})
|
||||
catalog.model.update(item.id, Model.ID.make("gpt-5-chat-latest"), () => {})
|
||||
})
|
||||
yield* addPlugin()
|
||||
expect(
|
||||
required(yield* catalog.model.get(Provider.ID.make("custom-openai"), Model.ID.make("gpt-5-chat-latest")))
|
||||
.enabled,
|
||||
).toBe(true)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -143,6 +143,7 @@ describe("Project.resolve", () => {
|
||||
|
||||
expect(result.id).toBe(Project.ID.make(yield* Effect.promise(() => rootCommit(tmp.path))))
|
||||
expect(result.directory).toBe(yield* real(tmp.path))
|
||||
expect(result.canonical).toBe(result.directory)
|
||||
expect(result.previous).toBeUndefined()
|
||||
expect(result.vcs?.type).toBe("git")
|
||||
}),
|
||||
|
||||
@@ -19,9 +19,11 @@ import { Session } from "@opencode-ai/core/session"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
import { App } from "@opencode-ai/core/app"
|
||||
import { Agent } from "@opencode-ai/core/agent"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { DateTime, Effect, Fiber, Layer, Stream } from "effect"
|
||||
import { DateTime, Effect, Fiber, Layer, Schema, Stream } from "effect"
|
||||
import { asc, eq } from "drizzle-orm"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
@@ -130,6 +132,52 @@ test("compaction prompt requires the checkpoint headings in order", () => {
|
||||
expect(prompt).toContain("Keep every section, even when empty.")
|
||||
})
|
||||
|
||||
it.effect("auto compaction reserves a buffer below the prompt ceiling", () =>
|
||||
Effect.gen(function* () {
|
||||
const compaction = yield* SessionCompaction.Service
|
||||
const session = Session.Info.make({
|
||||
id: Session.ID.make("ses_input_limit"),
|
||||
projectID: Project.ID.global,
|
||||
cost: Money.USD.zero,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
|
||||
location: Location.Ref.make({ directory: AbsolutePath.make("/tmp") }),
|
||||
})
|
||||
const input = (tokens: number, limits: { context: number; input?: number; output: number }) => ({
|
||||
session,
|
||||
model: Model.make({
|
||||
id: "test-model",
|
||||
provider: "test-provider",
|
||||
route: OpenAIChat.route.with({ limits }),
|
||||
}),
|
||||
cost: [],
|
||||
messages: [
|
||||
Schema.decodeUnknownSync(SessionMessage.Assistant)({
|
||||
id: SessionMessage.ID.make("msg_assistant"),
|
||||
type: "assistant",
|
||||
agent: Agent.defaultID,
|
||||
model: { id: "test-model", providerID: "test-provider" },
|
||||
content: [],
|
||||
tokens: { input: tokens, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 0, completed: 0 },
|
||||
}),
|
||||
],
|
||||
})
|
||||
|
||||
const inputLimited = { context: 400_000, input: 272_000, output: 128_000 }
|
||||
expect(compaction.required(input(251_999, inputLimited))).toBe(false)
|
||||
expect(compaction.required(input(252_000, inputLimited))).toBe(true)
|
||||
|
||||
const contextLimited = { context: 100_000, output: 10_000 }
|
||||
expect(compaction.required(input(79_999, contextLimited))).toBe(false)
|
||||
expect(compaction.required(input(80_000, contextLimited))).toBe(true)
|
||||
|
||||
const outputLimited = { context: 100_000, output: 30_000 }
|
||||
expect(compaction.required(input(69_999, outputLimited))).toBe(false)
|
||||
expect(compaction.required(input(70_000, outputLimited))).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("manual compaction summarizes short context instead of no-op", () =>
|
||||
Effect.gen(function* () {
|
||||
requests = []
|
||||
|
||||
@@ -71,6 +71,27 @@ function withTmp<A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>) {
|
||||
}
|
||||
|
||||
describe("Session.create", () => {
|
||||
it.effect("persists a missing title until one is generated or supplied", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
const { db } = yield* Database.Service
|
||||
|
||||
const created = yield* session.create({ location })
|
||||
const row = yield* db.select().from(SessionTable).where(eq(SessionTable.id, created.id)).get().pipe(Effect.orDie)
|
||||
const event = yield* db
|
||||
.select({ data: EventTable.data })
|
||||
.from(EventTable)
|
||||
.where(eq(EventTable.aggregate_id, created.id))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
expect(created.title).toBeUndefined()
|
||||
expect(row?.title).toBeNull()
|
||||
expect(event?.data).not.toHaveProperty("info.title")
|
||||
expect((yield* session.create({ location, title: "Explicit title" })).title).toBe("Explicit title")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("creates a fresh projected session when the ID is omitted", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
@@ -171,6 +192,30 @@ describe("Session.create", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("orders sessions by their latest prompt", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
const { db } = yield* Database.Service
|
||||
const active = yield* session.create({ location, title: "active" })
|
||||
const newer = yield* session.create({ location, title: "newer" })
|
||||
|
||||
yield* db
|
||||
.update(SessionTable)
|
||||
.set({ time_created: -2, time_updated: -2 })
|
||||
.where(eq(SessionTable.id, active.id))
|
||||
.run()
|
||||
yield* db
|
||||
.update(SessionTable)
|
||||
.set({ time_created: -1, time_updated: -1 })
|
||||
.where(eq(SessionTable.id, newer.id))
|
||||
.run()
|
||||
|
||||
yield* session.prompt({ sessionID: active.id, text: "continue", resume: false })
|
||||
|
||||
expect((yield* session.list()).data.map((item) => item.id)).toEqual([active.id, newer.id])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("filters direct child sessions by parent ID", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
@@ -272,6 +317,23 @@ describe("Session.create", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps a fork untitled when its parent is untitled", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
const bus = yield* Bus.Service
|
||||
const { db } = yield* Database.Service
|
||||
const parent = yield* session.create({ location })
|
||||
yield* session.prompt({ sessionID: parent.id, text: "First", resume: false })
|
||||
yield* SessionPending.promote(db, bus, parent.id, "steer")
|
||||
|
||||
const forked = yield* session.fork({ sessionID: parent.id, boundary: { type: "through" } })
|
||||
const row = yield* db.select().from(SessionTable).where(eq(SessionTable.id, forked.id)).get().pipe(Effect.orDie)
|
||||
|
||||
expect(forked.title).toBeUndefined()
|
||||
expect(row?.title).toBeNull()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects forking an empty session", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Message, ToolResultPart } from "@opencode-ai/ai"
|
||||
import { unsupportedParts } from "@opencode-ai/core/session/model-request"
|
||||
import { boundImages, unsupportedParts } from "@opencode-ai/core/session/model-request"
|
||||
|
||||
const capabilities = (input: string[]) => ({ tools: true, input, output: ["text"] })
|
||||
|
||||
@@ -62,3 +62,51 @@ describe("SessionModelRequest.unsupportedParts", () => {
|
||||
expect(unsupportedParts([message], capabilities(["text", "image"]))[0]?.content).toEqual(message.content)
|
||||
})
|
||||
})
|
||||
|
||||
describe("SessionModelRequest.boundImages", () => {
|
||||
test("preserves images below the trigger", () => {
|
||||
const messages = [Message.user({ type: "media", mediaType: "image/png", data: "aGVsbG8=" })]
|
||||
expect(boundImages(messages)).toBe(messages)
|
||||
})
|
||||
|
||||
test("replaces oldest images until the retained payload reaches the target", () => {
|
||||
const image = "a".repeat(9 * 1024 * 1024)
|
||||
const messages = [
|
||||
Message.user({ type: "media", mediaType: "image/png", data: image, filename: "first.png" }),
|
||||
Message.user({ type: "media", mediaType: "image/png", data: image, filename: "second.png" }),
|
||||
Message.user({ type: "media", mediaType: "image/png", data: image, filename: "third.png" }),
|
||||
]
|
||||
const result = boundImages(messages)
|
||||
|
||||
expect(result[0]?.content[0]).toMatchObject({ type: "text" })
|
||||
expect(result[1]?.content[0]).toMatchObject({ type: "text" })
|
||||
expect(result[2]?.content[0]).toMatchObject({ type: "media", filename: "third.png" })
|
||||
})
|
||||
|
||||
test("replaces images nested in tool results", () => {
|
||||
const image = "a".repeat(13 * 1024 * 1024)
|
||||
const result = boundImages([
|
||||
Message.tool(
|
||||
ToolResultPart.make({
|
||||
id: "call_1",
|
||||
name: "read",
|
||||
result: {
|
||||
type: "content",
|
||||
value: [
|
||||
{ type: "file", uri: `data:image/png;base64,${image}`, mime: "image/png", name: "first.png" },
|
||||
{ type: "file", uri: `data:image/png;base64,${image}`, mime: "image/png", name: "second.png" },
|
||||
],
|
||||
},
|
||||
}),
|
||||
),
|
||||
])
|
||||
|
||||
expect(result[0]?.content[0]).toMatchObject({
|
||||
type: "tool-result",
|
||||
result: {
|
||||
type: "content",
|
||||
value: [{ type: "text" }, { type: "file", name: "second.png" }],
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -26,8 +26,8 @@ import { SessionPendingTable, SessionMessageTable, SessionTable } from "@opencod
|
||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
|
||||
import type { LocationServices } from "@opencode-ai/core/location-services"
|
||||
import { Image } from "@opencode-ai/core/image"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { imagePassthrough } from "./lib/image"
|
||||
|
||||
const executionCalls: Session.ID[] = []
|
||||
const interruptCalls: Session.ID[] = []
|
||||
@@ -58,7 +58,10 @@ const locations = Layer.effect(
|
||||
() =>
|
||||
// Attachment admission only needs the location-scoped Image service.
|
||||
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
|
||||
imagePassthrough as unknown as Layer.Layer<LocationServices>,
|
||||
Layer.mock(Image.Service, {
|
||||
normalize: (_resource, content) =>
|
||||
Effect.succeed(content.content.length > 5 * 1024 * 1024 ? { ...content, content: "AA==" } : content),
|
||||
}) as unknown as Layer.Layer<LocationServices>,
|
||||
),
|
||||
)
|
||||
const it = testEffect(
|
||||
@@ -386,6 +389,35 @@ describe("Session.prompt", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("normalizes large image content before validating persisted Base64", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* Session.Service
|
||||
const pixel = Buffer.from(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
|
||||
"base64",
|
||||
)
|
||||
const bytes = Buffer.concat([pixel, Buffer.alloc(4_323_030 - pixel.length)])
|
||||
const data = bytes.toString("base64")
|
||||
expect(data).toHaveLength(5_764_040)
|
||||
|
||||
const message = yield* session.prompt({
|
||||
sessionID,
|
||||
text: "Inspect this image",
|
||||
files: [{ uri: `data:image/png;base64,${data}` }],
|
||||
resume: false,
|
||||
})
|
||||
|
||||
expect(message.data.files).toEqual([
|
||||
{
|
||||
data: "AA==",
|
||||
mime: "image/png",
|
||||
source: { type: "inline" },
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("sniffs data URL content instead of trusting its declared MIME", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
|
||||
@@ -796,6 +796,59 @@ const verifyPartialFlushOnInterruption = (kind: FragmentKind) =>
|
||||
})
|
||||
|
||||
describe("SessionRunnerLLM", () => {
|
||||
it.effect("retries title generation from the first prompt after execution and title failures", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
const agents = yield* Agent.Service
|
||||
const { db } = yield* Database.Service
|
||||
yield* db.update(SessionTable).set({ title: null }).where(eq(SessionTable.id, sessionID)).run().pipe(Effect.orDie)
|
||||
yield* agents.transform((draft) =>
|
||||
draft.update(Agent.ID.make("title"), (agent) => {
|
||||
agent.mode = "primary"
|
||||
agent.hidden = true
|
||||
agent.system = "Generate a title."
|
||||
}),
|
||||
)
|
||||
|
||||
yield* admit(session, "First prompt")
|
||||
yield* TestLLM.push(Stream.fail(invalidRequest()))
|
||||
expect((yield* session.resume(sessionID).pipe(Effect.exit))._tag).toBe("Failure")
|
||||
|
||||
yield* admit(session, "Second prompt")
|
||||
const titleFailed = yield* Deferred.make<void>()
|
||||
yield* TestLLM.push(
|
||||
TestLLM.text("Recovered", "text-recovered"),
|
||||
Stream.make(LLMEvent.providerError({ message: "Title provider unavailable" })).pipe(
|
||||
Stream.ensuring(Deferred.succeed(titleFailed, undefined)),
|
||||
),
|
||||
)
|
||||
yield* session.resume(sessionID)
|
||||
yield* Deferred.await(titleFailed)
|
||||
yield* Effect.yieldNow
|
||||
expect((yield* session.get(sessionID)).title).toBeUndefined()
|
||||
|
||||
const bus = yield* Bus.Service
|
||||
const renamed = yield* bus.subscribe(SessionEvent.Renamed).pipe(
|
||||
Stream.filter((event) => event.data.sessionID === sessionID),
|
||||
Stream.take(1),
|
||||
Stream.runCollect,
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
yield* admit(session, "Third prompt")
|
||||
yield* TestLLM.push(
|
||||
TestLLM.text("Recovered again", "text-recovered-again"),
|
||||
TestLLM.text("Generated title", "text-title"),
|
||||
)
|
||||
yield* session.resume(sessionID)
|
||||
yield* Fiber.join(renamed)
|
||||
|
||||
expect(requests).toHaveLength(5)
|
||||
expect(requests[2]?.messages).toContainEqual(Message.user("First prompt"))
|
||||
expect(requests[4]?.messages).toContainEqual(Message.user("First prompt"))
|
||||
expect((yield* session.get(sessionID)).title).toBe("Generated title")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("applies session context hooks without exposing unavailable tools", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
|
||||
@@ -20,7 +20,7 @@ import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
import { App } from "@opencode-ai/core/app"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { Effect, Layer, Stream } from "effect"
|
||||
import { Deferred, Effect, Fiber, Layer, Stream } from "effect"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
let requests: LLMRequest[] = []
|
||||
@@ -39,27 +39,30 @@ const cost = [
|
||||
},
|
||||
},
|
||||
]
|
||||
const successfulTitle = () =>
|
||||
Stream.make(
|
||||
LLMEvent.textDelta({ id: "title", text: "Generated Title\n" }),
|
||||
LLMEvent.stepFinish({
|
||||
index: 0,
|
||||
reason: { normalized: "stop" },
|
||||
usage: {
|
||||
inputTokens: 15,
|
||||
outputTokens: 6,
|
||||
nonCachedInputTokens: 10,
|
||||
cacheReadInputTokens: 3,
|
||||
cacheWriteInputTokens: 2,
|
||||
reasoningTokens: 2,
|
||||
},
|
||||
}),
|
||||
LLMEvent.finish({
|
||||
reason: { normalized: "stop" },
|
||||
}),
|
||||
)
|
||||
let titleStream: () => Stream.Stream<LLMEvent> = successfulTitle
|
||||
const client = Layer.mock(LLMClient.Service)({
|
||||
stream: (request: LLMRequest) => {
|
||||
requests.push(request)
|
||||
return Stream.make(
|
||||
LLMEvent.textDelta({ id: "title", text: "Generated Title\n" }),
|
||||
LLMEvent.stepFinish({
|
||||
index: 0,
|
||||
reason: { normalized: "stop" },
|
||||
usage: {
|
||||
inputTokens: 15,
|
||||
outputTokens: 6,
|
||||
nonCachedInputTokens: 10,
|
||||
cacheReadInputTokens: 3,
|
||||
cacheWriteInputTokens: 2,
|
||||
reasoningTokens: 2,
|
||||
},
|
||||
}),
|
||||
LLMEvent.finish({
|
||||
reason: { normalized: "stop" },
|
||||
}),
|
||||
)
|
||||
return titleStream()
|
||||
},
|
||||
generate: () => Effect.die("unused"),
|
||||
})
|
||||
@@ -89,7 +92,7 @@ const it = testEffect(
|
||||
),
|
||||
)
|
||||
|
||||
const insertSession = (id: Session.ID) =>
|
||||
const insertSession = (id: Session.ID, title?: string, created?: number) =>
|
||||
Effect.gen(function* () {
|
||||
const { db } = yield* Database.Service
|
||||
yield* db
|
||||
@@ -105,7 +108,8 @@ const insertSession = (id: Session.ID) =>
|
||||
project_id: Project.ID.global,
|
||||
slug: id,
|
||||
directory: "/project",
|
||||
title: "New session - fake",
|
||||
title,
|
||||
time_created: created,
|
||||
version: "test",
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
@@ -131,6 +135,7 @@ const prompt = (sessionID: Session.ID, text: string) =>
|
||||
it.effect("generates a title from the sole user message and renames the session", () =>
|
||||
Effect.gen(function* () {
|
||||
requests = []
|
||||
titleStream = successfulTitle
|
||||
const agentService = yield* Agent.Service
|
||||
yield* agentService.transform((editor) => {
|
||||
editor.update(Agent.ID.make("title"), (agent) => {
|
||||
@@ -144,11 +149,8 @@ it.effect("generates a title from the sole user message and renames the session"
|
||||
yield* prompt(sessionID, "Help me debug the failing build")
|
||||
|
||||
const store = yield* SessionStore.Service
|
||||
const session = yield* store
|
||||
.get(sessionID)
|
||||
.pipe(Effect.flatMap((session) => (session ? Effect.succeed(session) : Effect.die("session missing"))))
|
||||
const title = yield* SessionTitle.Service
|
||||
yield* title.generateForFirstPrompt(session)
|
||||
yield* title.generateForFirstPrompt(sessionID)
|
||||
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(requests[0]?.http?.headers).toEqual({
|
||||
@@ -167,9 +169,10 @@ it.effect("generates a title from the sole user message and renames the session"
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not generate once a second user message exists", () =>
|
||||
it.effect("generates from the first user message after later messages exist", () =>
|
||||
Effect.gen(function* () {
|
||||
requests = []
|
||||
titleStream = successfulTitle
|
||||
const agentService = yield* Agent.Service
|
||||
yield* agentService.transform((editor) => {
|
||||
editor.update(Agent.ID.make("title"), (agent) => {
|
||||
@@ -184,21 +187,46 @@ it.effect("does not generate once a second user message exists", () =>
|
||||
yield* prompt(sessionID, "Second message")
|
||||
|
||||
const store = yield* SessionStore.Service
|
||||
const session = yield* store
|
||||
.get(sessionID)
|
||||
.pipe(Effect.flatMap((session) => (session ? Effect.succeed(session) : Effect.die("session missing"))))
|
||||
const title = yield* SessionTitle.Service
|
||||
yield* title.generateForFirstPrompt(session)
|
||||
yield* title.generateForFirstPrompt(sessionID)
|
||||
|
||||
expect(requests).toHaveLength(0)
|
||||
const untouched = yield* store.get(sessionID)
|
||||
expect(untouched?.title).toBe("New session - fake")
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(JSON.stringify(requests[0]?.messages)).toContain("First message")
|
||||
expect(JSON.stringify(requests[0]?.messages)).not.toContain("Second message")
|
||||
expect((yield* store.get(sessionID))?.title).toBe("Generated Title")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("retries a legacy persisted fallback title", () =>
|
||||
Effect.gen(function* () {
|
||||
requests = []
|
||||
titleStream = successfulTitle
|
||||
const agentService = yield* Agent.Service
|
||||
yield* agentService.transform((editor) => {
|
||||
editor.update(Agent.ID.make("title"), (agent) => {
|
||||
agent.mode = "primary"
|
||||
agent.hidden = true
|
||||
agent.system = "You are a title generator."
|
||||
})
|
||||
})
|
||||
const sessionID = Session.ID.make("ses_title_legacy")
|
||||
const created = Date.parse("2026-07-30T18:45:03.662Z")
|
||||
yield* insertSession(sessionID, "New session - 2026-07-30T18:45:03.662Z", created)
|
||||
yield* prompt(sessionID, "Retry the legacy title")
|
||||
|
||||
const title = yield* SessionTitle.Service
|
||||
yield* title.generateForFirstPrompt(sessionID)
|
||||
|
||||
const store = yield* SessionStore.Service
|
||||
expect(requests).toHaveLength(1)
|
||||
expect((yield* store.get(sessionID))?.title).toBe("Generated Title")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not generate for a child session", () =>
|
||||
Effect.gen(function* () {
|
||||
requests = []
|
||||
titleStream = successfulTitle
|
||||
const agentService = yield* Agent.Service
|
||||
yield* agentService.transform((editor) => {
|
||||
editor.update(Agent.ID.make("title"), (agent) => {
|
||||
@@ -223,7 +251,6 @@ it.effect("does not generate for a child session", () =>
|
||||
parent_id: Session.ID.make("ses_title_parent"),
|
||||
slug: sessionID,
|
||||
directory: "/project",
|
||||
title: "Child session - fake",
|
||||
version: "test",
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
@@ -231,12 +258,8 @@ it.effect("does not generate for a child session", () =>
|
||||
.pipe(Effect.orDie)
|
||||
yield* prompt(sessionID, "Do this subtask")
|
||||
|
||||
const store = yield* SessionStore.Service
|
||||
const session = yield* store
|
||||
.get(sessionID)
|
||||
.pipe(Effect.flatMap((session) => (session ? Effect.succeed(session) : Effect.die("session missing"))))
|
||||
const title = yield* SessionTitle.Service
|
||||
yield* title.generateForFirstPrompt(session)
|
||||
yield* title.generateForFirstPrompt(sessionID)
|
||||
|
||||
expect(requests).toHaveLength(0)
|
||||
}),
|
||||
@@ -245,19 +268,100 @@ it.effect("does not generate for a child session", () =>
|
||||
it.effect("does not generate when the title agent is removed", () =>
|
||||
Effect.gen(function* () {
|
||||
requests = []
|
||||
titleStream = successfulTitle
|
||||
const sessionID = Session.ID.make("ses_title_no_agent")
|
||||
yield* insertSession(sessionID)
|
||||
yield* prompt(sessionID, "Help me debug the failing build")
|
||||
|
||||
const store = yield* SessionStore.Service
|
||||
const session = yield* store
|
||||
.get(sessionID)
|
||||
.pipe(Effect.flatMap((session) => (session ? Effect.succeed(session) : Effect.die("session missing"))))
|
||||
const title = yield* SessionTitle.Service
|
||||
yield* title.generateForFirstPrompt(session)
|
||||
yield* title.generateForFirstPrompt(sessionID)
|
||||
|
||||
expect(requests).toHaveLength(0)
|
||||
const untouched = yield* store.get(sessionID)
|
||||
expect(untouched?.title).toBe("New session - fake")
|
||||
expect(untouched?.title).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not overwrite an explicit title", () =>
|
||||
Effect.gen(function* () {
|
||||
requests = []
|
||||
titleStream = successfulTitle
|
||||
const sessionID = Session.ID.make("ses_title_explicit")
|
||||
yield* insertSession(sessionID)
|
||||
yield* prompt(sessionID, "Help me debug the failing build")
|
||||
const events = yield* Bus.Service
|
||||
yield* events.publish(SessionEvent.Renamed, { sessionID, title: "New session - 2099-01-01T00:00:00.000Z" })
|
||||
|
||||
const title = yield* SessionTitle.Service
|
||||
yield* title.generateForFirstPrompt(sessionID)
|
||||
|
||||
const store = yield* SessionStore.Service
|
||||
expect(requests).toHaveLength(0)
|
||||
expect((yield* store.get(sessionID))?.title).toBe("New session - 2099-01-01T00:00:00.000Z")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("retries after a failed title request", () =>
|
||||
Effect.gen(function* () {
|
||||
requests = []
|
||||
const agentService = yield* Agent.Service
|
||||
yield* agentService.transform((editor) => {
|
||||
editor.update(Agent.ID.make("title"), (agent) => {
|
||||
agent.mode = "primary"
|
||||
agent.hidden = true
|
||||
agent.system = "You are a title generator."
|
||||
})
|
||||
})
|
||||
const sessionID = Session.ID.make("ses_title_retry")
|
||||
yield* insertSession(sessionID)
|
||||
yield* prompt(sessionID, "Retry this title")
|
||||
const title = yield* SessionTitle.Service
|
||||
titleStream = () => Stream.make(LLMEvent.providerError({ message: "Provider unavailable" }))
|
||||
|
||||
yield* title.generateForFirstPrompt(sessionID)
|
||||
titleStream = successfulTitle
|
||||
yield* title.generateForFirstPrompt(sessionID)
|
||||
|
||||
const store = yield* SessionStore.Service
|
||||
expect(requests).toHaveLength(2)
|
||||
expect((yield* store.get(sessionID))?.title).toBe("Generated Title")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves a manual rename completed while generation is in flight", () =>
|
||||
Effect.gen(function* () {
|
||||
requests = []
|
||||
const agentService = yield* Agent.Service
|
||||
yield* agentService.transform((editor) => {
|
||||
editor.update(Agent.ID.make("title"), (agent) => {
|
||||
agent.mode = "primary"
|
||||
agent.hidden = true
|
||||
agent.system = "You are a title generator."
|
||||
})
|
||||
})
|
||||
const sessionID = Session.ID.make("ses_title_manual_rename")
|
||||
yield* insertSession(sessionID)
|
||||
yield* prompt(sessionID, "Generate this title")
|
||||
const started = yield* Deferred.make<void>()
|
||||
const release = yield* Deferred.make<void>()
|
||||
titleStream = () =>
|
||||
Stream.unwrap(
|
||||
Deferred.succeed(started, undefined).pipe(
|
||||
Effect.andThen(Deferred.await(release)),
|
||||
Effect.as(successfulTitle()),
|
||||
),
|
||||
)
|
||||
const title = yield* SessionTitle.Service
|
||||
const fiber = yield* title.generateForFirstPrompt(sessionID).pipe(Effect.forkScoped)
|
||||
yield* Deferred.await(started)
|
||||
const events = yield* Bus.Service
|
||||
yield* events.publish(SessionEvent.Renamed, { sessionID, title: "Manual title" })
|
||||
yield* Deferred.succeed(release, undefined)
|
||||
yield* Fiber.join(fiber)
|
||||
|
||||
const store = yield* SessionStore.Service
|
||||
expect(requests).toHaveLength(1)
|
||||
expect((yield* store.get(sessionID))?.title).toBe("Manual title")
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -2,7 +2,7 @@ import { beforeEach, describe, expect } from "bun:test"
|
||||
import path from "path"
|
||||
import { Effect, Exit, Layer, PlatformError, Stream } from "effect"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { ConfigAttachments } from "@opencode-ai/core/config/attachments"
|
||||
import { ConfigMedia } from "@opencode-ai/core/config/media"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { FileSystem } from "@opencode-ai/core/filesystem"
|
||||
@@ -432,8 +432,8 @@ describe("ReadTool", () => {
|
||||
new Config.Document({
|
||||
type: "document",
|
||||
info: new Config.Info({
|
||||
attachments: new ConfigAttachments.Info({
|
||||
image: new ConfigAttachments.Image({ auto_resize: false, max_width: 4 }),
|
||||
media: new ConfigMedia.Info({
|
||||
image: new ConfigMedia.Image({ auto_resize: false, max_width: 4 }),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
@@ -475,7 +475,7 @@ describe("ReadTool", () => {
|
||||
new Config.Document({
|
||||
type: "document",
|
||||
info: new Config.Info({
|
||||
attachments: new ConfigAttachments.Info({ image: new ConfigAttachments.Image({ max_width: 4 }) }),
|
||||
media: new ConfigMedia.Info({ image: new ConfigMedia.Image({ max_width: 4 }) }),
|
||||
}),
|
||||
}),
|
||||
])
|
||||
@@ -514,8 +514,8 @@ describe("ReadTool", () => {
|
||||
new Config.Document({
|
||||
type: "document",
|
||||
info: new Config.Info({
|
||||
attachments: new ConfigAttachments.Info({
|
||||
image: new ConfigAttachments.Image({ max_base64_bytes: 1 }),
|
||||
media: new ConfigMedia.Info({
|
||||
image: new ConfigMedia.Image({ max_base64_bytes: 1 }),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
|
||||
@@ -47,7 +47,7 @@ const executionNode = makeGlobalNode({
|
||||
const completed = new Set<Session.ID>()
|
||||
const complete = Effect.fn("SubagentTest.complete")(function* (sessionID: Session.ID) {
|
||||
if (completed.has(sessionID)) return
|
||||
if ((yield* store.get(sessionID))?.title.includes("fail")) {
|
||||
if ((yield* store.get(sessionID))?.title?.includes("fail")) {
|
||||
yield* new SessionRunnerModel.ModelNotSelectedError({ sessionID })
|
||||
return
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/session-ui": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
"aws4fetch": "^1.0.20",
|
||||
"@pierre/diffs": "catalog:",
|
||||
"@solidjs/router": "catalog:",
|
||||
|
||||
@@ -4,6 +4,7 @@ import { SessionReview } from "@opencode-ai/session-ui/session-review"
|
||||
import { DataProvider } from "@opencode-ai/session-ui/context"
|
||||
import { FileComponentProvider } from "@opencode-ai/ui/context/file"
|
||||
import { WorkerPoolProvider } from "@opencode-ai/ui/context/worker-pool"
|
||||
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
|
||||
import { createAsync, query, useParams } from "@solidjs/router"
|
||||
import { createMemo, createSignal, ErrorBoundary, For, Match, Show, Switch } from "solid-js"
|
||||
import { Share } from "~/core/share"
|
||||
@@ -158,6 +159,7 @@ export default function () {
|
||||
const match = createMemo(() => Binary.search(data().session, data().sessionID, (s) => s.id))
|
||||
if (!match().found) throw new Error(`Session ${data().sessionID} not found`)
|
||||
const info = createMemo(() => data().session[match().index])
|
||||
const title = createMemo(() => withTimestampedFallback(info()))
|
||||
const ogImage = createMemo(() => {
|
||||
const models = new Set<string>()
|
||||
const messages = data().message[data().sessionID] ?? []
|
||||
@@ -167,7 +169,7 @@ export default function () {
|
||||
}
|
||||
}
|
||||
const modelIDs = Array.from(models)
|
||||
const encodedTitle = encodeURIComponent(Base64.encode(encodeURIComponent(info().title.substring(0, 700))))
|
||||
const encodedTitle = encodeURIComponent(Base64.encode(encodeURIComponent(title().substring(0, 700))))
|
||||
let modelParam: string
|
||||
if (modelIDs.length === 1) {
|
||||
modelParam = modelIDs[0]
|
||||
@@ -184,9 +186,7 @@ export default function () {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Show when={info().title}>
|
||||
<Title>{info().title} | OpenCode</Title>
|
||||
</Show>
|
||||
<Title>{title()} | OpenCode</Title>
|
||||
<Meta name="description" content="opencode - The AI coding agent built for the terminal." />
|
||||
<Meta property="og:image" content={ogImage()} />
|
||||
<Meta name="twitter:image" content={ogImage()} />
|
||||
@@ -240,7 +240,7 @@ export default function () {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-left text-16-medium text-text-strong">{info().title}</div>
|
||||
<div class="text-left text-16-medium text-text-strong">{title()}</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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"
|
||||
@@ -15,8 +16,15 @@ export interface SessionContext {
|
||||
tools: Record<string, { description: string; input: JsonSchema.JsonSchema }>
|
||||
}
|
||||
|
||||
export interface SessionRequest extends HttpRequest {
|
||||
readonly sessionID: Session.ID
|
||||
readonly agent: Agent.ID
|
||||
readonly model: Model.Ref
|
||||
}
|
||||
|
||||
export interface SessionHooks {
|
||||
readonly context: SessionContext
|
||||
readonly request: SessionRequest
|
||||
}
|
||||
|
||||
export type SessionDomain = Pick<
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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"
|
||||
@@ -15,8 +16,15 @@ export interface SessionContext {
|
||||
tools: Record<string, { description: string; input: JsonSchema.JsonSchema }>
|
||||
}
|
||||
|
||||
export interface SessionRequest extends HttpRequest {
|
||||
readonly sessionID: Session.ID
|
||||
readonly agent: Agent.ID
|
||||
readonly model: Model.Ref
|
||||
}
|
||||
|
||||
export interface SessionHooks {
|
||||
readonly context: SessionContext
|
||||
readonly request: SessionRequest
|
||||
}
|
||||
|
||||
export type SessionDomain = Pick<
|
||||
|
||||
@@ -42,7 +42,7 @@ export const Info = Schema.Struct({
|
||||
updated: DateTimeUtcFromMillis,
|
||||
archived: DateTimeUtcFromMillis.pipe(optional),
|
||||
}),
|
||||
title: Schema.String,
|
||||
title: Schema.String.pipe(optional),
|
||||
location: Location.Ref,
|
||||
subpath: RelativePath.pipe(optional),
|
||||
revert: Revert.pipe(optional),
|
||||
|
||||
@@ -552,7 +552,7 @@ export const SessionInfo = Schema.Struct({
|
||||
cost: optional(Schema.Finite),
|
||||
tokens: optional(SessionTokens),
|
||||
share: optional(SessionShare),
|
||||
title: Schema.String,
|
||||
title: optional(Schema.String),
|
||||
agent: optional(Schema.String),
|
||||
model: optional(SessionModel),
|
||||
version: Schema.String,
|
||||
|
||||
@@ -17,7 +17,7 @@ import { Money } from "../src/money.js"
|
||||
import { Skill } from "../src/skill.js"
|
||||
import { Shell } from "../src/shell.js"
|
||||
import { PersistedRevert } from "../src/session-revert.js"
|
||||
import { optional } from "../src/schema.js"
|
||||
import { AbsolutePath, optional } from "../src/schema.js"
|
||||
|
||||
describe("contract hygiene", () => {
|
||||
test("restricts agent colors to six-digit hex values", () => {
|
||||
@@ -52,6 +52,18 @@ describe("contract hygiene", () => {
|
||||
metadata: undefined,
|
||||
}),
|
||||
).toEqual({ text: "completed" })
|
||||
|
||||
expect(
|
||||
Schema.encodeSync(Session.Info)({
|
||||
id: Session.ID.make("ses_untitled"),
|
||||
projectID: Project.ID.make("global"),
|
||||
cost: Money.USD.zero,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
|
||||
title: undefined,
|
||||
location: { directory: AbsolutePath.make("/project") },
|
||||
}),
|
||||
).not.toHaveProperty("title")
|
||||
})
|
||||
|
||||
test("pending session items omit the internal admission sequence", () => {
|
||||
|
||||
@@ -41,6 +41,7 @@ export const create = Effect.fn("SimulationRenderer.create")(function* (
|
||||
...options,
|
||||
width: cols,
|
||||
height: rows,
|
||||
kittyKeyboard: Boolean(options.useKittyKeyboard),
|
||||
...(recording
|
||||
? {
|
||||
stdout: recording as unknown as NodeJS.WriteStream,
|
||||
|
||||
@@ -43,6 +43,25 @@ test("normalizes named keys for OpenTUI", async () => {
|
||||
])
|
||||
})
|
||||
|
||||
test("headless input mirrors the configured kitty keyboard protocol", async () => {
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const renderer = yield* SimulationRenderer.create({ useKittyKeyboard: {} })
|
||||
const harness = createHarness(renderer)
|
||||
let key: { readonly name: string; readonly source: string } | undefined
|
||||
renderer.keyInput.once("keypress", (event) => {
|
||||
key = event
|
||||
})
|
||||
|
||||
yield* execute(harness, { type: "ui.press", key: "escape" })
|
||||
|
||||
expect(key).toMatchObject({ name: "escape", source: "kitty" })
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
test("clicks a target at relative coordinates through descendant text", async () => {
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
|
||||
+45
-32
@@ -66,6 +66,7 @@ import { DialogThemeList } from "./component/dialog-theme-list"
|
||||
import { DialogHelp } from "./ui/dialog-help"
|
||||
import { DialogAgent } from "./component/dialog-agent"
|
||||
import { DialogSessionList } from "./component/dialog-session-list"
|
||||
import { DialogOpen } from "./component/dialog-open"
|
||||
import { SessionTabs } from "./component/session-tabs"
|
||||
import { ThemeErrorToast } from "./component/theme-error-toast"
|
||||
import { ThemeProvider, useTheme, useThemes } from "./context/theme"
|
||||
@@ -75,7 +76,7 @@ import { PromptHistoryProvider } from "./component/prompt/history"
|
||||
import { FrecencyProvider } from "./component/prompt/frecency"
|
||||
import { PromptStashProvider } from "./component/prompt/stash"
|
||||
import { Toast, ToastProvider, useToast } from "./ui/toast"
|
||||
import { isDefaultTitle } from "./util/session"
|
||||
import { isFallbackTitle } from "@opencode-ai/util/session-title-fallback"
|
||||
import * as Model from "./util/model"
|
||||
import { ArgsProvider, useArgs, type Args } from "./context/args"
|
||||
import open from "open"
|
||||
@@ -94,16 +95,15 @@ import { StorageProvider } from "./context/storage"
|
||||
|
||||
registerOpencodeSpinner()
|
||||
|
||||
const appGlobalBindingCommands = ["session.list", "session.new"] as const
|
||||
const appGlobalBindingCommands = ["session.list", "session.new", "open.menu"] as const
|
||||
|
||||
const sessionTabBindingCommands = [
|
||||
"session.tab.next",
|
||||
"session.tab.previous",
|
||||
"session.tab.history.back",
|
||||
"session.tab.history.forward",
|
||||
"session.tab.next_unread",
|
||||
"session.tab.previous_unread",
|
||||
"session.tab.close",
|
||||
"session.tab.reopen",
|
||||
"session.tab.select.1",
|
||||
"session.tab.select.2",
|
||||
"session.tab.select.3",
|
||||
@@ -523,8 +523,11 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||
renderer.useMouse = config.data.mouse
|
||||
})
|
||||
|
||||
let active: { id: string; title?: string } | undefined
|
||||
// Update terminal window title based on current route and session
|
||||
createEffect(() => {
|
||||
const session = route.data.type === "session" ? data.session.get(route.data.sessionID) : undefined
|
||||
if (session) active = { id: session.id, title: session.title }
|
||||
if (!terminalTitleEnabled()) return
|
||||
|
||||
if (route.data.type === "home") {
|
||||
@@ -533,14 +536,13 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||
}
|
||||
|
||||
if (route.data.type === "session") {
|
||||
const session = data.session.get(route.data.sessionID)
|
||||
if (!session || isDefaultTitle(session.title)) {
|
||||
const title = session?.title
|
||||
if (!title || isFallbackTitle(title)) {
|
||||
renderer.setTerminalTitle("OpenCode")
|
||||
return
|
||||
}
|
||||
|
||||
const title = session.title.length > 40 ? session.title.slice(0, 37) + "..." : session.title
|
||||
renderer.setTerminalTitle(`OC | ${title}`)
|
||||
renderer.setTerminalTitle(`OC | ${title.length > 40 ? title.slice(0, 37) + "..." : title}`)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -644,10 +646,23 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||
run: () => {
|
||||
route.navigate({
|
||||
type: "home",
|
||||
location:
|
||||
route.data.type === "session"
|
||||
? (data.session.get(route.data.sessionID)?.location ?? location.ref)
|
||||
: undefined,
|
||||
})
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "open.menu",
|
||||
title: "Open session or project",
|
||||
category: "Session",
|
||||
slash: { name: "open", aliases: ["projects", "project"] },
|
||||
run: () => {
|
||||
dialog.replace(() => <DialogOpen />)
|
||||
},
|
||||
},
|
||||
...Array.from({ length: 9 }, (_, i) => ({
|
||||
name: `session.quick_switch.${i + 1}`,
|
||||
title: `Switch to session in quick slot ${i + 1}`,
|
||||
@@ -658,7 +673,7 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||
})),
|
||||
{
|
||||
name: "session.tab.next",
|
||||
title: "Next open session tab",
|
||||
title: "Next tab",
|
||||
category: "Session",
|
||||
palette: undefined,
|
||||
enabled: sessionTabs.enabled,
|
||||
@@ -666,31 +681,15 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||
},
|
||||
{
|
||||
name: "session.tab.previous",
|
||||
title: "Previous open session tab",
|
||||
title: "Previous tab",
|
||||
category: "Session",
|
||||
palette: undefined,
|
||||
enabled: sessionTabs.enabled,
|
||||
run: () => sessionTabs.cycle(-1),
|
||||
},
|
||||
{
|
||||
name: "session.tab.history.back",
|
||||
title: "Back in session tab history",
|
||||
category: "Session",
|
||||
palette: undefined,
|
||||
enabled: sessionTabs.enabled,
|
||||
run: () => sessionTabs.history(-1),
|
||||
},
|
||||
{
|
||||
name: "session.tab.history.forward",
|
||||
title: "Forward in session tab history",
|
||||
category: "Session",
|
||||
palette: undefined,
|
||||
enabled: sessionTabs.enabled,
|
||||
run: () => sessionTabs.history(1),
|
||||
},
|
||||
{
|
||||
name: "session.tab.next_unread",
|
||||
title: "Next unread session tab",
|
||||
title: "Next unread tab",
|
||||
category: "Session",
|
||||
palette: undefined,
|
||||
enabled: sessionTabs.enabled,
|
||||
@@ -698,7 +697,7 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||
},
|
||||
{
|
||||
name: "session.tab.previous_unread",
|
||||
title: "Previous unread session tab",
|
||||
title: "Previous unread tab",
|
||||
category: "Session",
|
||||
palette: undefined,
|
||||
enabled: sessionTabs.enabled,
|
||||
@@ -706,14 +705,21 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||
},
|
||||
{
|
||||
name: "session.tab.close",
|
||||
title: "Close current session tab",
|
||||
title: "Close tab",
|
||||
category: "Session",
|
||||
enabled: sessionTabs.enabled,
|
||||
run: () => sessionTabs.close(),
|
||||
},
|
||||
{
|
||||
name: "session.tab.reopen",
|
||||
title: "Reopen closed tab",
|
||||
category: "Session",
|
||||
enabled: sessionTabs.enabled,
|
||||
run: () => sessionTabs.reopen(),
|
||||
},
|
||||
...Array.from({ length: 9 }, (_, i) => ({
|
||||
name: `session.tab.select.${i + 1}`,
|
||||
title: `Switch to session tab ${i + 1}`,
|
||||
title: `Switch to tab ${i + 1}`,
|
||||
category: "Session",
|
||||
palette: undefined,
|
||||
enabled: sessionTabs.enabled,
|
||||
@@ -1142,10 +1148,11 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||
|
||||
event.on("session.deleted", (evt) => {
|
||||
if (route.data.type === "session" && route.data.sessionID === evt.data.sessionID) {
|
||||
const title = active?.id === evt.data.sessionID ? active.title : undefined
|
||||
route.navigate({ type: "home" })
|
||||
toast.show({
|
||||
variant: "info",
|
||||
message: "The current session was deleted",
|
||||
message: title ? `Session "${title}" was deleted` : "The current session was deleted",
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -1209,7 +1216,13 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||
<box flexGrow={1} minWidth={0} flexDirection="column">
|
||||
<Show when={plugins.ready()}>
|
||||
<box flexGrow={1} minHeight={0} flexDirection="column">
|
||||
<Show when={sessionTabs.enabled() && sessionTabs.tabs().length > 0 && route.data.type !== "plugin"}>
|
||||
<Show
|
||||
when={
|
||||
sessionTabs.enabled() &&
|
||||
(sessionTabs.tabs().length > 0 || sessionTabs.newTab()) &&
|
||||
route.data.type !== "plugin"
|
||||
}
|
||||
>
|
||||
<SessionTabs />
|
||||
</Show>
|
||||
<Switch>
|
||||
|
||||
@@ -1,436 +0,0 @@
|
||||
import { OptimizedBuffer, RGBA, TextAttributes } from "@opentui/core"
|
||||
import { go } from "../logo"
|
||||
|
||||
const PERIOD = 4600
|
||||
const RINGS = 3
|
||||
const WIDTH = 3.8
|
||||
const TAIL = 9.5
|
||||
const AMP = 0.55
|
||||
const TAIL_AMP = 0.16
|
||||
const BREATH_AMP = 0.05
|
||||
const BREATH_SPEED = 0.0008
|
||||
// Offset so the bg ring emits from the estimated GO center when the logo shimmer peaks.
|
||||
const PHASE_OFFSET = 0.29
|
||||
const LOGO_GAP = 1
|
||||
const LOGO_TOP_BIAS = -1
|
||||
const LOGO_LEFT_WIDTH = go.left[0]?.length ?? 0
|
||||
const LOGO_LINES = go.left.map((line, index) => line + " ".repeat(LOGO_GAP) + go.right[index])
|
||||
const LOGO_WIDTH = LOGO_LINES[0]?.length ?? 0
|
||||
const LOGO_HEIGHT = LOGO_LINES.length
|
||||
const SPACE = " ".codePointAt(0)!
|
||||
const TOP_HALF = "▀".codePointAt(0)!
|
||||
const FULL_BLOCK = "█".codePointAt(0)!
|
||||
const RING_SCALE = 1 / RINGS
|
||||
const TAIL_SCALE = 1 / TAIL
|
||||
const LOGO_REACH = Math.hypot(LOGO_WIDTH, LOGO_HEIGHT * 2) + 3
|
||||
|
||||
const enum LogoCellKind {
|
||||
Background,
|
||||
Top,
|
||||
ShadowTop,
|
||||
Solid,
|
||||
Char,
|
||||
}
|
||||
|
||||
type LogoTemplateCell = {
|
||||
x: number
|
||||
y: number
|
||||
kind: LogoCellKind
|
||||
charCode: number
|
||||
attributes: number
|
||||
topDist: number
|
||||
bottomDist: number
|
||||
}
|
||||
|
||||
const LOGO_TEMPLATE: LogoTemplateCell[] = LOGO_LINES.flatMap((line, y) =>
|
||||
Array.from(line)
|
||||
.map((char, x) => {
|
||||
if (char === " ") return
|
||||
const kind =
|
||||
char === "_"
|
||||
? LogoCellKind.Background
|
||||
: char === "^"
|
||||
? LogoCellKind.Top
|
||||
: char === "~"
|
||||
? LogoCellKind.ShadowTop
|
||||
: char === "█"
|
||||
? LogoCellKind.Solid
|
||||
: LogoCellKind.Char
|
||||
return {
|
||||
x,
|
||||
y,
|
||||
kind,
|
||||
charCode: char.codePointAt(0) ?? SPACE,
|
||||
attributes: x > LOGO_LEFT_WIDTH ? TextAttributes.BOLD : 0,
|
||||
topDist: Math.hypot(x + 0.5 - LOGO_WIDTH / 2, y * 2 - LOGO_HEIGHT),
|
||||
bottomDist: Math.hypot(x + 0.5 - LOGO_WIDTH / 2, y * 2 + 1 - LOGO_HEIGHT),
|
||||
}
|
||||
})
|
||||
.filter((cell): cell is LogoTemplateCell => !!cell),
|
||||
)
|
||||
|
||||
export type Rgb = [number, number, number]
|
||||
|
||||
export type GoUpsellArtRenderOptions = {
|
||||
deltaTime?: number
|
||||
rgb?: boolean
|
||||
cache?: boolean
|
||||
}
|
||||
|
||||
const CACHE_FRAME_COUNT = Math.round(PERIOD / (1000 / 30))
|
||||
const CACHE_FRAMES_PER_RENDER = 1
|
||||
|
||||
export function toRgb(color: RGBA): Rgb {
|
||||
const [r, g, b] = color.toInts()
|
||||
return [r, g, b]
|
||||
}
|
||||
|
||||
function clamp(n: number) {
|
||||
return Math.max(0, Math.min(1, n))
|
||||
}
|
||||
|
||||
function writeRgb(buffer: Uint16Array, offset: number, r: number, g: number, b: number, a = 255) {
|
||||
buffer[offset] = r
|
||||
buffer[offset + 1] = g
|
||||
buffer[offset + 2] = b
|
||||
buffer[offset + 3] = a
|
||||
}
|
||||
|
||||
function mixChannel(base: number, overlay: number, alpha: number) {
|
||||
return Math.round(base + (overlay - base) * clamp(alpha))
|
||||
}
|
||||
|
||||
function writeLogoTint(
|
||||
buffer: Uint16Array,
|
||||
offset: number,
|
||||
base: Rgb,
|
||||
primary: Rgb,
|
||||
primaryMix: number,
|
||||
peakMix: number,
|
||||
) {
|
||||
const p = clamp(primaryMix)
|
||||
const q = clamp(peakMix)
|
||||
const r = mixChannel(mixChannel(base[0], primary[0], p), 255, q)
|
||||
const g = mixChannel(mixChannel(base[1], primary[1], p), 255, q)
|
||||
const b = mixChannel(mixChannel(base[2], primary[2], p), 255, q)
|
||||
writeRgb(buffer, offset, r, g, b)
|
||||
}
|
||||
|
||||
function sameRgb(a: Rgb, b: Rgb) {
|
||||
return a[0] === b[0] && a[1] === b[1] && a[2] === b[2]
|
||||
}
|
||||
|
||||
export class GoUpsellArtPainter {
|
||||
private panelRgb: Rgb = [0, 0, 0]
|
||||
private primaryRgb: Rgb = [255, 255, 255]
|
||||
private logoBaseRgb: Rgb = [180, 180, 180]
|
||||
private elapsed = 0
|
||||
private distances = new Float32Array(0)
|
||||
private edgeFalloff = new Float32Array(0)
|
||||
private geometryWidth = 0
|
||||
private geometryHeight = 0
|
||||
private reach = 1
|
||||
private logoX = 0
|
||||
private logoY = 0
|
||||
private logoIndexes = new Int32Array(0)
|
||||
private logoRgb: boolean | undefined
|
||||
private pulsePeak = 0
|
||||
private pulsePrimary = 0
|
||||
private cacheDirty = true
|
||||
private frameCache: Array<{ fg: Uint16Array; bg: Uint16Array }> = []
|
||||
private cacheBuildIndex = 0
|
||||
|
||||
setBackgroundPanel(value: RGBA | Rgb | undefined) {
|
||||
if (!value) return false
|
||||
const next = value instanceof RGBA ? toRgb(value) : value
|
||||
if (sameRgb(this.panelRgb, next)) return false
|
||||
this.panelRgb = next
|
||||
this.invalidateCache()
|
||||
return true
|
||||
}
|
||||
|
||||
setLogoBase(value: RGBA | Rgb | undefined) {
|
||||
if (!value) return false
|
||||
const next = value instanceof RGBA ? toRgb(value) : value
|
||||
if (sameRgb(this.logoBaseRgb, next)) return false
|
||||
this.logoBaseRgb = next
|
||||
this.invalidateCache()
|
||||
return true
|
||||
}
|
||||
|
||||
setPrimary(value: RGBA | Rgb | undefined) {
|
||||
if (!value) return false
|
||||
const next = value instanceof RGBA ? toRgb(value) : value
|
||||
if (sameRgb(this.primaryRgb, next)) return false
|
||||
this.primaryRgb = next
|
||||
this.invalidateCache()
|
||||
return true
|
||||
}
|
||||
|
||||
render(frameBuffer: OptimizedBuffer, options: GoUpsellArtRenderOptions = {}) {
|
||||
const rgb = options.rgb === true
|
||||
this.elapsed = (this.elapsed + (options.deltaTime ?? 0)) % PERIOD
|
||||
this.rebuildGeometry(frameBuffer, rgb)
|
||||
if (options.cache !== false) {
|
||||
this.drawCached(frameBuffer, rgb)
|
||||
return
|
||||
}
|
||||
this.drawBackground(frameBuffer, this.elapsed)
|
||||
this.drawLogo(frameBuffer, this.elapsed, rgb)
|
||||
}
|
||||
|
||||
private invalidateCache() {
|
||||
this.cacheDirty = true
|
||||
this.cacheBuildIndex = 0
|
||||
this.frameCache = []
|
||||
}
|
||||
|
||||
private rebuildGeometry(frameBuffer: OptimizedBuffer, rgb: boolean) {
|
||||
const width = frameBuffer.width
|
||||
const height = frameBuffer.height
|
||||
const geometryChanged = width !== this.geometryWidth || height !== this.geometryHeight
|
||||
const logoTemplateChanged = this.logoRgb !== rgb
|
||||
if (!geometryChanged && !logoTemplateChanged) return
|
||||
|
||||
if (geometryChanged) {
|
||||
this.geometryWidth = width
|
||||
this.geometryHeight = height
|
||||
this.logoX = Math.max(0, Math.floor((width - LOGO_WIDTH) / 2))
|
||||
this.logoY = Math.max(
|
||||
0,
|
||||
Math.min(Math.max(0, height - LOGO_HEIGHT), Math.round((height - LOGO_HEIGHT) / 2) + LOGO_TOP_BIAS),
|
||||
)
|
||||
|
||||
const centerX = this.logoX + LOGO_WIDTH / 2
|
||||
const centerY = this.logoY + LOGO_HEIGHT / 2
|
||||
this.reach = Math.hypot(Math.max(centerX, width - centerX), Math.max(centerY, height - centerY) * 2) + TAIL
|
||||
this.distances = new Float32Array(width * height)
|
||||
this.edgeFalloff = new Float32Array(width * height)
|
||||
|
||||
for (let y = 0; y < height; y++) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
const index = y * width + x
|
||||
const dist = Math.hypot(x + 0.5 - centerX, (y + 0.5 - centerY) * 2)
|
||||
this.distances[index] = dist
|
||||
this.edgeFalloff[index] = Math.max(0, 1 - (dist / (this.reach * 0.85)) ** 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.logoRgb = rgb
|
||||
this.invalidateCache()
|
||||
this.rebuildCellTemplate(frameBuffer, rgb)
|
||||
}
|
||||
|
||||
private drawCached(frameBuffer: OptimizedBuffer, rgb: boolean) {
|
||||
if (this.cacheDirty) this.startFrameCache(frameBuffer, rgb)
|
||||
if (this.cacheBuildIndex < CACHE_FRAME_COUNT) {
|
||||
this.buildFrameCache(frameBuffer, rgb)
|
||||
this.drawBackground(frameBuffer, this.elapsed)
|
||||
this.drawLogo(frameBuffer, this.elapsed, rgb)
|
||||
return
|
||||
}
|
||||
|
||||
const frame = this.frameCache[Math.floor((this.elapsed / PERIOD) * CACHE_FRAME_COUNT) % CACHE_FRAME_COUNT]
|
||||
if (frame) {
|
||||
frameBuffer.buffers.fg.set(frame.fg)
|
||||
frameBuffer.buffers.bg.set(frame.bg)
|
||||
}
|
||||
}
|
||||
|
||||
private startFrameCache(frameBuffer: OptimizedBuffer, rgb: boolean) {
|
||||
this.frameCache = []
|
||||
this.cacheBuildIndex = 0
|
||||
this.rebuildCellTemplate(frameBuffer, rgb)
|
||||
this.cacheDirty = false
|
||||
}
|
||||
|
||||
private buildFrameCache(frameBuffer: OptimizedBuffer, rgb: boolean) {
|
||||
const end = Math.min(CACHE_FRAME_COUNT, this.cacheBuildIndex + CACHE_FRAMES_PER_RENDER)
|
||||
for (; this.cacheBuildIndex < end; this.cacheBuildIndex++) {
|
||||
const t = (this.cacheBuildIndex / CACHE_FRAME_COUNT) * PERIOD
|
||||
this.drawBackground(frameBuffer, t)
|
||||
this.drawLogo(frameBuffer, t, rgb)
|
||||
this.frameCache.push({
|
||||
fg: new Uint16Array(frameBuffer.buffers.fg),
|
||||
bg: new Uint16Array(frameBuffer.buffers.bg),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private rebuildCellTemplate(frameBuffer: OptimizedBuffer, rgb: boolean) {
|
||||
const buffers = frameBuffer.buffers
|
||||
buffers.char.fill(SPACE)
|
||||
buffers.attributes.fill(0)
|
||||
|
||||
if (this.geometryWidth < LOGO_WIDTH || this.geometryHeight < LOGO_HEIGHT) {
|
||||
this.logoIndexes = new Int32Array(0)
|
||||
return
|
||||
}
|
||||
|
||||
this.logoIndexes = new Int32Array(LOGO_TEMPLATE.length)
|
||||
for (let i = 0; i < LOGO_TEMPLATE.length; i++) {
|
||||
const cell = LOGO_TEMPLATE[i]!
|
||||
const index = (this.logoY + cell.y) * this.geometryWidth + this.logoX + cell.x
|
||||
this.logoIndexes[i] = index
|
||||
buffers.attributes[index] = cell.attributes
|
||||
buffers.char[index] =
|
||||
cell.kind === LogoCellKind.Background
|
||||
? SPACE
|
||||
: cell.kind === LogoCellKind.Top || cell.kind === LogoCellKind.ShadowTop
|
||||
? TOP_HALF
|
||||
: cell.kind === LogoCellKind.Solid
|
||||
? rgb
|
||||
? TOP_HALF
|
||||
: FULL_BLOCK
|
||||
: cell.charCode
|
||||
}
|
||||
}
|
||||
|
||||
private drawBackground(frameBuffer: OptimizedBuffer, t: number) {
|
||||
const buffers = frameBuffer.buffers
|
||||
const fg = buffers.fg
|
||||
const bg = buffers.bg
|
||||
const distances = this.distances
|
||||
const edgeFalloff = this.edgeFalloff
|
||||
const baseR = this.panelRgb[0]
|
||||
const baseG = this.panelRgb[1]
|
||||
const baseB = this.panelRgb[2]
|
||||
const deltaR = this.primaryRgb[0] - baseR
|
||||
const deltaG = this.primaryRgb[1] - baseG
|
||||
const deltaB = this.primaryRgb[2] - baseB
|
||||
const breath = (0.5 + 0.5 * Math.sin(t * BREATH_SPEED)) * BREATH_AMP
|
||||
|
||||
const phase0 = (t / PERIOD - PHASE_OFFSET + 1) % 1
|
||||
const phase1 = (t / PERIOD + 1 / RINGS - PHASE_OFFSET + 1) % 1
|
||||
const phase2 = (t / PERIOD + 2 / RINGS - PHASE_OFFSET + 1) % 1
|
||||
const envelope0 = Math.sin(phase0 * Math.PI)
|
||||
const envelope1 = Math.sin(phase1 * Math.PI)
|
||||
const envelope2 = Math.sin(phase2 * Math.PI)
|
||||
const eased0 = envelope0 * envelope0 * (3 - 2 * envelope0)
|
||||
const eased1 = envelope1 * envelope1 * (3 - 2 * envelope1)
|
||||
const eased2 = envelope2 * envelope2 * (3 - 2 * envelope2)
|
||||
const head0 = phase0 * this.reach
|
||||
const head1 = phase1 * this.reach
|
||||
const head2 = phase2 * this.reach
|
||||
|
||||
for (let index = 0; index < distances.length; index++) {
|
||||
const dist = distances[index]
|
||||
const delta0 = dist - head0
|
||||
const abs0 = delta0 < 0 ? -delta0 : delta0
|
||||
const crest0 = abs0 < WIDTH ? 0.5 + 0.5 * Math.cos((delta0 / WIDTH) * Math.PI) : 0
|
||||
const tail0 = delta0 < 0 && delta0 > -TAIL ? (1 + delta0 * TAIL_SCALE) ** 2.3 : 0
|
||||
|
||||
const delta1 = dist - head1
|
||||
const abs1 = delta1 < 0 ? -delta1 : delta1
|
||||
const crest1 = abs1 < WIDTH ? 0.5 + 0.5 * Math.cos((delta1 / WIDTH) * Math.PI) : 0
|
||||
const tail1 = delta1 < 0 && delta1 > -TAIL ? (1 + delta1 * TAIL_SCALE) ** 2.3 : 0
|
||||
|
||||
const delta2 = dist - head2
|
||||
const abs2 = delta2 < 0 ? -delta2 : delta2
|
||||
const crest2 = abs2 < WIDTH ? 0.5 + 0.5 * Math.cos((delta2 / WIDTH) * Math.PI) : 0
|
||||
const tail2 = delta2 < 0 && delta2 > -TAIL ? (1 + delta2 * TAIL_SCALE) ** 2.3 : 0
|
||||
|
||||
const level =
|
||||
(crest0 * AMP + tail0 * TAIL_AMP) * eased0 +
|
||||
(crest1 * AMP + tail1 * TAIL_AMP) * eased1 +
|
||||
(crest2 * AMP + tail2 * TAIL_AMP) * eased2
|
||||
const rawStrength = (level * RING_SCALE + breath) * edgeFalloff[index]
|
||||
const strength = (rawStrength > 1 ? 1 : rawStrength) * 0.7
|
||||
const offset = index * 4
|
||||
const r = Math.round(baseR + deltaR * strength)
|
||||
const g = Math.round(baseG + deltaG * strength)
|
||||
const b = Math.round(baseB + deltaB * strength)
|
||||
bg[offset] = fg[offset] = r
|
||||
bg[offset + 1] = fg[offset + 1] = g
|
||||
bg[offset + 2] = fg[offset + 2] = b
|
||||
bg[offset + 3] = fg[offset + 3] = 255
|
||||
}
|
||||
}
|
||||
|
||||
private setLogoPulse(dist: number, head0: number, eased0: number, head1: number, eased1: number) {
|
||||
let peak = 0.04
|
||||
let primary = 0
|
||||
|
||||
const delta0 = dist - head0
|
||||
const core0 = Math.exp(-(Math.abs(delta0 / 1.2) ** 1.8))
|
||||
const soft0 = Math.exp(-(Math.abs(delta0 / 7) ** 1.6))
|
||||
const tail0 = delta0 < 0 && delta0 > -7 ? (1 + delta0 / 7) ** 2.6 : 0
|
||||
peak += core0 * 0.65 * eased0
|
||||
primary += (soft0 * 0.16 + tail0 * 0.22) * eased0
|
||||
|
||||
const delta1 = dist - head1
|
||||
const core1 = Math.exp(-(Math.abs(delta1 / 1.2) ** 1.8))
|
||||
const soft1 = Math.exp(-(Math.abs(delta1 / 7) ** 1.6))
|
||||
const tail1 = delta1 < 0 && delta1 > -7 ? (1 + delta1 / 7) ** 2.6 : 0
|
||||
peak += core1 * 0.65 * eased1
|
||||
primary += (soft1 * 0.16 + tail1 * 0.22) * eased1
|
||||
|
||||
this.pulsePeak = peak > 1 ? 1 : peak
|
||||
this.pulsePrimary = primary > 1 ? 1 : primary
|
||||
}
|
||||
|
||||
private drawLogo(frameBuffer: OptimizedBuffer, t: number, rgb: boolean) {
|
||||
if (this.logoIndexes.length === 0) return
|
||||
|
||||
const buffers = frameBuffer.buffers
|
||||
const fg = buffers.fg
|
||||
const bg = buffers.bg
|
||||
const shadow: Rgb = [
|
||||
mixChannel(this.panelRgb[0], this.logoBaseRgb[0], 0.25),
|
||||
mixChannel(this.panelRgb[1], this.logoBaseRgb[1], 0.25),
|
||||
mixChannel(this.panelRgb[2], this.logoBaseRgb[2], 0.25),
|
||||
]
|
||||
const phase0 = (t / PERIOD) % 1
|
||||
const phase1 = (t / PERIOD + 0.5) % 1
|
||||
const envelope0 = Math.sin(phase0 * Math.PI)
|
||||
const envelope1 = Math.sin(phase1 * Math.PI)
|
||||
const eased0 = envelope0 * envelope0 * (3 - 2 * envelope0)
|
||||
const eased1 = envelope1 * envelope1 * (3 - 2 * envelope1)
|
||||
const head0 = phase0 * LOGO_REACH
|
||||
const head1 = phase1 * LOGO_REACH
|
||||
|
||||
for (let i = 0; i < LOGO_TEMPLATE.length; i++) {
|
||||
const cell = LOGO_TEMPLATE[i]!
|
||||
const index = this.logoIndexes[i]!
|
||||
const offset = index * 4
|
||||
this.setLogoPulse(cell.topDist, head0, eased0, head1, eased1)
|
||||
const topPeak = this.pulsePeak
|
||||
const topPrimary = this.pulsePrimary
|
||||
this.setLogoPulse(cell.bottomDist, head0, eased0, head1, eased1)
|
||||
const bottomPeak = this.pulsePeak
|
||||
const bottomPrimary = this.pulsePrimary
|
||||
|
||||
if (cell.kind === LogoCellKind.Background) {
|
||||
writeLogoTint(bg, offset, shadow, this.primaryRgb, 0, Math.max(topPeak, bottomPeak) * 0.18)
|
||||
continue
|
||||
}
|
||||
|
||||
if (cell.kind === LogoCellKind.Top) {
|
||||
writeLogoTint(fg, offset, this.logoBaseRgb, this.primaryRgb, topPrimary, topPeak)
|
||||
writeLogoTint(bg, offset, shadow, this.primaryRgb, 0, bottomPeak * 0.18)
|
||||
continue
|
||||
}
|
||||
|
||||
if (cell.kind === LogoCellKind.ShadowTop) {
|
||||
writeLogoTint(fg, offset, shadow, this.primaryRgb, 0, topPeak * 0.18)
|
||||
continue
|
||||
}
|
||||
|
||||
if (cell.kind === LogoCellKind.Solid && rgb) {
|
||||
writeLogoTint(fg, offset, this.logoBaseRgb, this.primaryRgb, topPrimary, topPeak)
|
||||
writeLogoTint(bg, offset, this.logoBaseRgb, this.primaryRgb, bottomPrimary, bottomPeak)
|
||||
continue
|
||||
}
|
||||
|
||||
writeLogoTint(
|
||||
fg,
|
||||
offset,
|
||||
this.logoBaseRgb,
|
||||
this.primaryRgb,
|
||||
(topPrimary + bottomPrimary) / 2,
|
||||
(topPeak + bottomPeak) / 2,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
import {
|
||||
FrameBufferRenderable,
|
||||
RGBA,
|
||||
type OptimizedBuffer,
|
||||
type RenderContext,
|
||||
type RenderableOptions,
|
||||
} from "@opentui/core"
|
||||
import { extend, useRenderer } from "@opentui/solid"
|
||||
import { onCleanup, onMount } from "solid-js"
|
||||
import { useTheme, useThemes } from "../context/theme"
|
||||
import { tint } from "../theme/color"
|
||||
import { GoUpsellArtPainter } from "./bg-pulse-render"
|
||||
|
||||
type GoUpsellArtOptions = RenderableOptions<FrameBufferRenderable> & {
|
||||
backgroundPanel?: RGBA
|
||||
primary?: RGBA
|
||||
logoBase?: RGBA
|
||||
}
|
||||
|
||||
class GoUpsellArtRenderable extends FrameBufferRenderable {
|
||||
private painter = new GoUpsellArtPainter()
|
||||
|
||||
constructor(ctx: RenderContext, options: GoUpsellArtOptions = {}) {
|
||||
const width = typeof options.width === "number" ? options.width : 1
|
||||
const height = typeof options.height === "number" ? options.height : 1
|
||||
super(ctx, {
|
||||
...options,
|
||||
width,
|
||||
height,
|
||||
live: options.live ?? true,
|
||||
respectAlpha: false,
|
||||
})
|
||||
|
||||
if (options.width !== undefined && typeof options.width !== "number") this.width = options.width
|
||||
if (options.height !== undefined && typeof options.height !== "number") this.height = options.height
|
||||
this.painter.setBackgroundPanel(options.backgroundPanel)
|
||||
this.painter.setPrimary(options.primary)
|
||||
this.painter.setLogoBase(options.logoBase)
|
||||
}
|
||||
|
||||
set backgroundPanel(value: RGBA | undefined) {
|
||||
if (this.painter.setBackgroundPanel(value)) this.requestRender()
|
||||
}
|
||||
|
||||
set logoBase(value: RGBA | undefined) {
|
||||
if (this.painter.setLogoBase(value)) this.requestRender()
|
||||
}
|
||||
|
||||
set primary(value: RGBA | undefined) {
|
||||
if (this.painter.setPrimary(value)) this.requestRender()
|
||||
}
|
||||
|
||||
protected override renderSelf(buffer: OptimizedBuffer, deltaTime = 0): void {
|
||||
if (!this.visible || this.isDestroyed) return
|
||||
|
||||
this.painter.render(this.frameBuffer, {
|
||||
deltaTime,
|
||||
rgb: this._ctx.capabilities?.rgb === true,
|
||||
})
|
||||
super.renderSelf(buffer)
|
||||
}
|
||||
}
|
||||
|
||||
declare module "@opentui/solid" {
|
||||
interface OpenTUIComponents {
|
||||
go_upsell_art: typeof GoUpsellArtRenderable
|
||||
}
|
||||
}
|
||||
|
||||
extend({ go_upsell_art: GoUpsellArtRenderable })
|
||||
|
||||
export function BgPulse() {
|
||||
const themes = useThemes()
|
||||
const theme = useTheme("elevated")
|
||||
const mode = themes.mode
|
||||
const renderer = useRenderer()
|
||||
let targetFps = renderer.targetFps
|
||||
let maxFps = renderer.maxFps
|
||||
|
||||
onMount(() => {
|
||||
targetFps = renderer.targetFps
|
||||
maxFps = renderer.maxFps
|
||||
renderer.targetFps = 30
|
||||
renderer.maxFps = 30
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
renderer.targetFps = targetFps
|
||||
renderer.maxFps = maxFps
|
||||
})
|
||||
|
||||
return (
|
||||
<go_upsell_art
|
||||
width="100%"
|
||||
height="100%"
|
||||
backgroundPanel={theme.background.default}
|
||||
primary={theme.hue.interactive[mode() === "light" ? 800 : 200]}
|
||||
logoBase={tint(theme.background.default, theme.text.default, 0.62)}
|
||||
live
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -105,7 +105,7 @@ export const settings: Setting[] = [
|
||||
title: "Scope",
|
||||
category: "Tabs",
|
||||
path: ["tabs", "scope"],
|
||||
default: "cwd",
|
||||
default: "global",
|
||||
values: ["cwd", "global"],
|
||||
labels: ["current directory", "global"],
|
||||
},
|
||||
|
||||
@@ -2,8 +2,8 @@ import { useTerminalDimensions } from "@opentui/solid"
|
||||
import { TextAttributes } from "@opentui/core"
|
||||
import { createMemo, createResource, createSignal, onMount, Show } from "solid-js"
|
||||
import path from "path"
|
||||
import { DialogSelect, type DialogSelectOption } from "../ui/dialog-select"
|
||||
import { useDialog } from "../ui/dialog"
|
||||
import { DialogSelect, dialogSelectContentWidth, type DialogSelectOption } from "../ui/dialog-select"
|
||||
import { dialogWidth, useDialog } from "../ui/dialog"
|
||||
import { useClient } from "../context/client"
|
||||
import { Keymap } from "../context/keymap"
|
||||
import { useTheme } from "../context/theme"
|
||||
@@ -159,7 +159,10 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
|
||||
if (b.location === b.root.directory) return 1
|
||||
return a.location.localeCompare(b.location)
|
||||
})
|
||||
const titleWidth = Math.max(1, Math.min(116, dimensions().width - 2) - 12)
|
||||
const titleWidth = Math.max(
|
||||
1,
|
||||
dialogSelectContentWidth(Math.min(dialogWidth("xlarge"), dimensions().width - 2)),
|
||||
)
|
||||
|
||||
return list.map((item) => {
|
||||
const title = abbreviateHome(item.location, paths.home)
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import path from "path"
|
||||
import { createMemo, createResource, createSignal, onMount } from "solid-js"
|
||||
import type { SessionInfo } from "@opencode-ai/client"
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
import { dialogWidth, useDialog } from "../ui/dialog"
|
||||
import { DialogSelect, dialogSelectContentWidth } from "../ui/dialog-select"
|
||||
import { useRoute } from "../context/route"
|
||||
import { useData } from "../context/data"
|
||||
import { useClient } from "../context/client"
|
||||
import { useLocation } from "../context/location"
|
||||
import { useSessionTabs } from "../context/session-tabs"
|
||||
import { useTheme, useThemes } from "../context/theme"
|
||||
import { Keymap } from "../context/keymap"
|
||||
import { Locale } from "../util/locale"
|
||||
import { abbreviateHome } from "../runtime"
|
||||
import { useTuiPaths } from "../context/runtime"
|
||||
import { truncateFilePath } from "../ui/file-path"
|
||||
import { stringWidth } from "../util/string-width"
|
||||
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
|
||||
import { Spinner } from "./spinner"
|
||||
|
||||
const RECENT_LIMIT = 8
|
||||
|
||||
type OpenTarget = { type: "session"; sessionID: string } | { type: "project"; directory: string }
|
||||
|
||||
export function DialogOpen() {
|
||||
const dialog = useDialog()
|
||||
const route = useRoute()
|
||||
const data = useData()
|
||||
const client = useClient()
|
||||
const location = useLocation()
|
||||
const sessionTabs = useSessionTabs()
|
||||
const themes = useThemes()
|
||||
const theme = useTheme("elevated")
|
||||
const mode = themes.mode
|
||||
const paths = useTuiPaths()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const shortcuts = Keymap.useShortcuts()
|
||||
const [filter, setFilter] = createSignal("")
|
||||
const [selectionMoved, setSelectionMoved] = createSignal(false)
|
||||
|
||||
void data.project.sync().catch(() => {})
|
||||
|
||||
// One background fetch fills in recent sessions from other projects; the menu renders
|
||||
// immediately from the local store and never blocks on the network.
|
||||
const [fetched] = createResource(
|
||||
() =>
|
||||
client.api.session
|
||||
.list({ limit: 50, order: "desc", parentID: null })
|
||||
.then((response) => response.data)
|
||||
.catch(() => [] as SessionInfo[]),
|
||||
{ initialValue: [] },
|
||||
)
|
||||
|
||||
const openTabs = createMemo(
|
||||
() => new Set(sessionTabs.enabled() ? sessionTabs.tabs().map((tab) => tab.sessionID) : []),
|
||||
)
|
||||
const currentSessionID = createMemo(() =>
|
||||
route.data.type === "session" ? data.session.root(route.data.sessionID) : undefined,
|
||||
)
|
||||
const sessions = createMemo(() => {
|
||||
const seen = new Set<string>()
|
||||
return [...data.session.list(), ...fetched()]
|
||||
.filter((session) => {
|
||||
if (session.parentID || seen.has(session.id)) return false
|
||||
seen.add(session.id)
|
||||
return true
|
||||
})
|
||||
.toSorted((a, b) => b.time.updated - a.time.updated)
|
||||
})
|
||||
|
||||
const options = createMemo(() => {
|
||||
const tabs = openTabs()
|
||||
// With an empty query the menu shows what is not already one keystroke away: open tabs are
|
||||
// visible in the strip, so recents exclude them. Typing widens the pool to every session so
|
||||
// matching a loaded tab by name still switches to it.
|
||||
const recent = filter().trim()
|
||||
? sessions()
|
||||
: sessions()
|
||||
.filter((session) => !tabs.has(session.id))
|
||||
.slice(0, RECENT_LIMIT)
|
||||
const sessionOptions = recent.map((session) => {
|
||||
const project = data.project.get(session.projectID)
|
||||
const name = project?.canonical === "/" ? undefined : project?.name || path.basename(project?.canonical ?? "")
|
||||
const running =
|
||||
data.session.status(session.id) === "running" ||
|
||||
data.session.family(session.id).some((id) => data.session.status(id) === "running")
|
||||
return {
|
||||
title: withTimestampedFallback(session),
|
||||
value: { type: "session", sessionID: session.id } as OpenTarget,
|
||||
category: "Sessions",
|
||||
footer: `${name ? `${Locale.truncate(name, 20)} · ` : ""}${timeAgo(session.time.updated)}`,
|
||||
onSelect: () => location.set(session.location),
|
||||
gutter: running
|
||||
? () => <Spinner />
|
||||
: tabs.has(session.id)
|
||||
? () => <text fg={theme.hue.accent[mode() === "light" ? 800 : 200]}>▪</text>
|
||||
: undefined,
|
||||
}
|
||||
})
|
||||
|
||||
const current = location.current?.project
|
||||
const seen = new Set<string>()
|
||||
const projectOptions = data.project
|
||||
.list()
|
||||
.filter((project) => {
|
||||
if (project.canonical === "/" || seen.has(project.canonical)) return false
|
||||
seen.add(project.canonical)
|
||||
return true
|
||||
})
|
||||
.map((project) => {
|
||||
const title = project.name ?? path.basename(project.canonical)
|
||||
const footer = abbreviateHome(project.canonical, paths.home)
|
||||
const width =
|
||||
dialogSelectContentWidth(Math.min(dialogWidth("large"), dimensions().width - 2)) - stringWidth(title)
|
||||
return {
|
||||
title,
|
||||
footer: truncateFilePath(footer, width),
|
||||
searchText: footer,
|
||||
value: { type: "project", directory: project.canonical } as OpenTarget,
|
||||
category: "Projects",
|
||||
gutter:
|
||||
project.canonical === current?.canonical
|
||||
? () => <text fg={theme.text.formfield.selected}>●</text>
|
||||
: undefined,
|
||||
}
|
||||
})
|
||||
|
||||
return [...sessionOptions, ...projectOptions]
|
||||
})
|
||||
|
||||
onMount(() => dialog.setSize("large"))
|
||||
|
||||
return (
|
||||
<DialogSelect
|
||||
title="Open"
|
||||
placeholder="Search sessions and projects…"
|
||||
options={options()}
|
||||
current={currentSessionID() ? ({ type: "session", sessionID: currentSessionID()! } as OpenTarget) : undefined}
|
||||
focusCurrent={false}
|
||||
preserveSelection={selectionMoved()}
|
||||
onMove={() => setSelectionMoved(true)}
|
||||
onFilter={setFilter}
|
||||
noMatchView={
|
||||
<box paddingLeft={4} paddingRight={4}>
|
||||
<text fg={theme.text.subdued}>
|
||||
{shortcuts.get("session.list")
|
||||
? `No matches · search all sessions with ${shortcuts.get("session.list")}`
|
||||
: "No matches"}
|
||||
</text>
|
||||
</box>
|
||||
}
|
||||
onSelect={(option) => {
|
||||
dialog.clear()
|
||||
if (option.value.type === "session") {
|
||||
route.navigate({ type: "session", sessionID: option.value.sessionID })
|
||||
return
|
||||
}
|
||||
const target = { directory: option.value.directory }
|
||||
route.navigate({ type: "home", location: target })
|
||||
location.set(target)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function timeAgo(timestamp: number) {
|
||||
const minutes = Math.floor((Date.now() - timestamp) / 60_000)
|
||||
if (minutes < 1) return "now"
|
||||
if (minutes < 60) return `${minutes}m`
|
||||
const hours = Math.floor(minutes / 60)
|
||||
if (hours < 24) return `${hours}h`
|
||||
const days = Math.floor(hours / 24)
|
||||
if (days < 30) return `${days}d`
|
||||
const months = Math.floor(days / 30)
|
||||
if (months < 12) return `${months}mo`
|
||||
return `${Math.floor(days / 365)}y`
|
||||
}
|
||||
@@ -1,164 +0,0 @@
|
||||
import { RGBA, TextAttributes } from "@opentui/core"
|
||||
import open from "open"
|
||||
import { createSignal } from "solid-js"
|
||||
import { Keymap } from "../context/keymap"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useDialog, type DialogContext } from "../ui/dialog"
|
||||
import { Link } from "../ui/link"
|
||||
import { BgPulse } from "./bg-pulse"
|
||||
|
||||
const GO_URL = "https://opencode.ai/go"
|
||||
const PAD_X = 3
|
||||
const PAD_TOP_OUTER = 1
|
||||
const FOREGROUND_ALPHA = 186
|
||||
|
||||
export type DialogRetryActionProps = {
|
||||
title: string
|
||||
message: string
|
||||
label: string
|
||||
link?: string
|
||||
onClose?: (dontShowAgain?: boolean) => void
|
||||
}
|
||||
|
||||
function runAction(props: DialogRetryActionProps, dialog: ReturnType<typeof useDialog>) {
|
||||
if (props.link) open(props.link).catch(() => {})
|
||||
props.onClose?.()
|
||||
dialog.clear()
|
||||
}
|
||||
|
||||
function dismiss(props: DialogRetryActionProps, dialog: ReturnType<typeof useDialog>) {
|
||||
props.onClose?.(true)
|
||||
dialog.clear()
|
||||
}
|
||||
|
||||
function panelOverlay(color: RGBA) {
|
||||
const [r, g, b] = color.toInts()
|
||||
return RGBA.fromInts(r, g, b, FOREGROUND_ALPHA)
|
||||
}
|
||||
|
||||
export function DialogRetryAction(props: DialogRetryActionProps) {
|
||||
const dialog = useDialog()
|
||||
const theme = useTheme("elevated")
|
||||
const showGoTreatment = () => props.link === GO_URL
|
||||
const textBg = () => (showGoTreatment() ? panelOverlay(theme.background.default) : undefined)
|
||||
const [selected, setSelected] = createSignal<"dismiss" | "action">("action")
|
||||
|
||||
Keymap.createLayer(() => ({
|
||||
mode: "modal",
|
||||
commands: [
|
||||
{
|
||||
bind: "left",
|
||||
title: "Previous retry option",
|
||||
group: "Dialog",
|
||||
run: () => setSelected((value) => (value === "action" ? "dismiss" : "action")),
|
||||
},
|
||||
{
|
||||
bind: "right",
|
||||
title: "Next retry option",
|
||||
group: "Dialog",
|
||||
run: () => setSelected((value) => (value === "action" ? "dismiss" : "action")),
|
||||
},
|
||||
{
|
||||
bind: "tab",
|
||||
title: "Next retry option",
|
||||
group: "Dialog",
|
||||
run: () => setSelected((value) => (value === "action" ? "dismiss" : "action")),
|
||||
},
|
||||
{
|
||||
bind: "return",
|
||||
title: "Confirm retry option",
|
||||
group: "Dialog",
|
||||
run: () => {
|
||||
if (selected() === "action") runAction(props, dialog)
|
||||
else dismiss(props, dialog)
|
||||
},
|
||||
},
|
||||
],
|
||||
}))
|
||||
|
||||
return (
|
||||
<box>
|
||||
{showGoTreatment() ? (
|
||||
<box position="absolute" top={-PAD_TOP_OUTER} left={0} right={0} bottom={0} zIndex={0}>
|
||||
<BgPulse />
|
||||
</box>
|
||||
) : null}
|
||||
<box zIndex={1} paddingLeft={PAD_X} paddingRight={PAD_X} paddingBottom={1} gap={1}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text attributes={TextAttributes.BOLD} fg={theme.text.default} bg={textBg()}>
|
||||
{props.title}
|
||||
</text>
|
||||
<text fg={theme.text.subdued} bg={textBg()} onMouseUp={() => dialog.clear()}>
|
||||
esc
|
||||
</text>
|
||||
</box>
|
||||
<box gap={0}>
|
||||
<text fg={theme.text.subdued} bg={textBg()}>
|
||||
{props.message}
|
||||
</text>
|
||||
</box>
|
||||
{props.link ? (
|
||||
showGoTreatment() ? (
|
||||
<box alignItems="center" justifyContent="flex-end" height={7} paddingBottom={1}>
|
||||
<Link href={props.link} fg={theme.markdown.link} bg={textBg()} wrapMode="none" />
|
||||
</box>
|
||||
) : (
|
||||
<box width="100%" flexDirection="row" justifyContent="center" paddingBottom={1}>
|
||||
<Link href={props.link} fg={theme.markdown.link} wrapMode="none" />
|
||||
</box>
|
||||
)
|
||||
) : (
|
||||
<box paddingBottom={1} />
|
||||
)}
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<box
|
||||
paddingLeft={2}
|
||||
paddingRight={2}
|
||||
backgroundColor={
|
||||
selected() === "dismiss" ? theme.background.action.primary.focused : RGBA.fromInts(0, 0, 0, 0)
|
||||
}
|
||||
onMouseOver={() => setSelected("dismiss")}
|
||||
onMouseUp={() => dismiss(props, dialog)}
|
||||
>
|
||||
<text
|
||||
fg={selected() === "dismiss" ? theme.text.action.primary.focused : theme.text.subdued}
|
||||
bg={selected() === "dismiss" ? undefined : textBg()}
|
||||
attributes={selected() === "dismiss" ? TextAttributes.BOLD : undefined}
|
||||
>
|
||||
don't show again
|
||||
</text>
|
||||
</box>
|
||||
<box
|
||||
paddingLeft={2}
|
||||
paddingRight={2}
|
||||
backgroundColor={
|
||||
selected() === "action" ? theme.background.action.primary.focused : RGBA.fromInts(0, 0, 0, 0)
|
||||
}
|
||||
onMouseOver={() => setSelected("action")}
|
||||
onMouseUp={() => runAction(props, dialog)}
|
||||
>
|
||||
<text
|
||||
fg={selected() === "action" ? theme.text.action.primary.focused : theme.text.default}
|
||||
bg={selected() === "action" ? undefined : textBg()}
|
||||
attributes={selected() === "action" ? TextAttributes.BOLD : undefined}
|
||||
>
|
||||
{props.label}
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
DialogRetryAction.show = (
|
||||
dialog: DialogContext,
|
||||
props: Pick<DialogRetryActionProps, "title" | "message" | "label" | "link">,
|
||||
) => {
|
||||
return new Promise<boolean>((resolve) => {
|
||||
dialog.replace(
|
||||
() => <DialogRetryAction {...props} onClose={(dontShow) => resolve(dontShow ?? false)} />,
|
||||
() => resolve(false),
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
import { TextAttributes } from "@opentui/core"
|
||||
import { Keymap } from "../context/keymap"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useDialog } from "../ui/dialog"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { For } from "solid-js"
|
||||
|
||||
export function DialogSessionDeleteFailed(props: {
|
||||
session: string
|
||||
workspace: string
|
||||
onDelete?: () => boolean | void | Promise<boolean | void>
|
||||
onRestore?: () => boolean | void | Promise<boolean | void>
|
||||
onDone?: () => void
|
||||
}) {
|
||||
const dialog = useDialog()
|
||||
const theme = useTheme("elevated")
|
||||
const [store, setStore] = createStore({
|
||||
active: "delete" as "delete" | "restore",
|
||||
})
|
||||
|
||||
const options = [
|
||||
{
|
||||
id: "delete" as const,
|
||||
title: "Delete workspace",
|
||||
description: "Delete the workspace and all sessions attached to it.",
|
||||
run: props.onDelete,
|
||||
},
|
||||
{
|
||||
id: "restore" as const,
|
||||
title: "Restore to new workspace",
|
||||
description: "Try to restore this session into a new workspace.",
|
||||
run: props.onRestore,
|
||||
},
|
||||
]
|
||||
|
||||
async function confirm() {
|
||||
const result = await options.find((item) => item.id === store.active)?.run?.()
|
||||
if (result === false) return
|
||||
props.onDone?.()
|
||||
if (!props.onDone) dialog.clear()
|
||||
}
|
||||
|
||||
Keymap.createLayer(() => ({
|
||||
mode: "modal",
|
||||
commands: [
|
||||
{ bind: "return", title: "Confirm recovery option", group: "Dialog", run: () => void confirm() },
|
||||
{ bind: "left", title: "Delete broken session", group: "Dialog", run: () => setStore("active", "delete") },
|
||||
{ bind: "up", title: "Delete broken session", group: "Dialog", run: () => setStore("active", "delete") },
|
||||
{
|
||||
bind: "right",
|
||||
title: "Restore broken session",
|
||||
group: "Dialog",
|
||||
run: () => setStore("active", "restore"),
|
||||
},
|
||||
{
|
||||
bind: "down",
|
||||
title: "Restore broken session",
|
||||
group: "Dialog",
|
||||
run: () => setStore("active", "restore"),
|
||||
},
|
||||
],
|
||||
}))
|
||||
|
||||
return (
|
||||
<box paddingLeft={2} paddingRight={2} gap={1}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text attributes={TextAttributes.BOLD} fg={theme.text.default}>
|
||||
Failed to Delete Session
|
||||
</text>
|
||||
<text fg={theme.text.subdued} onMouseUp={() => dialog.clear()}>
|
||||
esc
|
||||
</text>
|
||||
</box>
|
||||
<text fg={theme.text.subdued} wrapMode="word">
|
||||
{`The session "${props.session}" could not be deleted because the workspace "${props.workspace}" is not available.`}
|
||||
</text>
|
||||
<text fg={theme.text.subdued} wrapMode="word">
|
||||
Choose how you want to recover this broken workspace session.
|
||||
</text>
|
||||
<box flexDirection="column" paddingBottom={1} gap={1}>
|
||||
<For each={options}>
|
||||
{(item) => (
|
||||
<box
|
||||
flexDirection="column"
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
backgroundColor={item.id === store.active ? theme.background.action.primary.focused : undefined}
|
||||
onMouseUp={() => {
|
||||
setStore("active", item.id)
|
||||
void confirm()
|
||||
}}
|
||||
>
|
||||
<text
|
||||
attributes={TextAttributes.BOLD}
|
||||
fg={item.id === store.active ? theme.text.action.primary.focused : theme.text.default}
|
||||
>
|
||||
{item.title}
|
||||
</text>
|
||||
<text
|
||||
fg={item.id === store.active ? theme.text.action.primary.focused : theme.text.subdued}
|
||||
wrapMode="word"
|
||||
>
|
||||
{item.description}
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
@@ -17,6 +17,9 @@ import { DialogSessionRename } from "./dialog-session-rename"
|
||||
import { Spinner } from "./spinner"
|
||||
import { errorMessage } from "../util/error"
|
||||
import { useSessionTabs } from "../context/session-tabs"
|
||||
import { useStorage } from "../context/storage"
|
||||
import { useConfig } from "../config"
|
||||
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
|
||||
|
||||
export function DialogSessionList() {
|
||||
const dialog = useDialog()
|
||||
@@ -28,12 +31,16 @@ export function DialogSessionList() {
|
||||
const client = useClient()
|
||||
const local = useLocal()
|
||||
const sessionTabs = useSessionTabs()
|
||||
const config = useConfig().data
|
||||
const toast = useToast()
|
||||
const [filter, setFilter] = createSignal("")
|
||||
const shortcuts = Keymap.useShortcuts()
|
||||
const [search, setSearch] = createDebouncedSignal("", 150)
|
||||
const [toDelete, setToDelete] = createSignal<string>()
|
||||
const [allProjects, setAllProjects] = createSignal(false)
|
||||
const [prefs, updatePrefs] = useStorage().store("session-list", {
|
||||
initial: { allProjects: config.tabs?.scope !== "cwd" },
|
||||
})
|
||||
const allProjects = () => prefs.allProjects
|
||||
|
||||
const [searchResults, { mutate: setSearchResults }] = createResource(
|
||||
() => ({ query: search().trim(), allProjects: allProjects() }),
|
||||
@@ -75,12 +82,15 @@ export function DialogSessionList() {
|
||||
(session.projectID === current?.project.id && session.location.directory === current.directory),
|
||||
)
|
||||
if (!query) return sessions
|
||||
return sessions.filter((session) => !session.parentID && session.title.toLowerCase().includes(query))
|
||||
return sessions.filter(
|
||||
(session) => !session.parentID && withTimestampedFallback(session).toLowerCase().includes(query),
|
||||
)
|
||||
})
|
||||
const sessions = createMemo(() => {
|
||||
const query = filter().trim()
|
||||
const local = localSessions()
|
||||
if (query !== search().trim() || searchResults.loading) return searchResults.latest?.sessions ?? local
|
||||
if (query !== search().trim()) return searchResults.latest?.sessions ?? local
|
||||
if (searchResults.loading) return searchResults.latest?.sessions ?? []
|
||||
const result = searchResults()
|
||||
if (result?.query !== query || result.allProjects !== allProjects() || result.error) return local
|
||||
return result.sessions
|
||||
@@ -133,23 +143,25 @@ export function DialogSessionList() {
|
||||
const project = data.project.get(session.projectID)
|
||||
const footer = allProjects()
|
||||
? Locale.truncate(project?.name || path.basename(project?.canonical ?? directory), 20)
|
||||
: directory !== data.location.info()?.project.directory
|
||||
? Locale.truncate(path.basename(directory), 20)
|
||||
: ""
|
||||
: undefined
|
||||
const slot = sessionTabs.enabled() ? undefined : slotByID.get(session.id)
|
||||
const deleting = toDelete() === session.id
|
||||
return {
|
||||
title: deleting ? `Press ${shortcuts.get("session.delete")} again to confirm` : session.title,
|
||||
title: deleting
|
||||
? `Press ${shortcuts.get("session.delete")} again to confirm`
|
||||
: withTimestampedFallback(session),
|
||||
value: session.id,
|
||||
category,
|
||||
footer,
|
||||
bg: deleting ? theme.background.action.destructive.focused : undefined,
|
||||
fg: deleting ? theme.text.action.destructive.focused : undefined,
|
||||
gutter: data.session.family(session.id).some((id) => data.session.status(id) === "running")
|
||||
? () => <Spinner />
|
||||
: slot === undefined
|
||||
? undefined
|
||||
: () => <text fg={theme.hue.accent[mode() === "light" ? 800 : 200]}>{slot}</text>,
|
||||
gutter:
|
||||
data.session.status(session.id) === "running" ||
|
||||
data.session.family(session.id).some((id) => data.session.status(id) === "running")
|
||||
? () => <Spinner />
|
||||
: slot === undefined
|
||||
? undefined
|
||||
: () => <text fg={theme.hue.accent[mode() === "light" ? 800 : 200]}>{slot}</text>,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,7 +203,9 @@ export function DialogSessionList() {
|
||||
title: allProjects() ? "Show current directory sessions" : "Show all project sessions",
|
||||
group: "Dialog",
|
||||
run: () => {
|
||||
setAllProjects((value) => !value)
|
||||
void updatePrefs((draft) => {
|
||||
draft.allProjects = !draft.allProjects
|
||||
}).catch(() => {})
|
||||
},
|
||||
},
|
||||
]}
|
||||
@@ -233,7 +247,9 @@ export function DialogSessionList() {
|
||||
.remove({ sessionID: option.value })
|
||||
.then(() => {
|
||||
setSearchResults((result) =>
|
||||
result ? { ...result, sessions: result.sessions.filter((session) => session.id !== option.value) } : result,
|
||||
result
|
||||
? { ...result, sessions: result.sessions.filter((session) => session.id !== option.value) }
|
||||
: result,
|
||||
)
|
||||
})
|
||||
.catch((error) => {
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
import { createMemo, createResource } from "solid-js"
|
||||
import { DialogSelect } from "../ui/dialog-select"
|
||||
import { useDialog } from "../ui/dialog"
|
||||
import { useClient } from "../context/client"
|
||||
import { useData } from "../context/data"
|
||||
import { createStore } from "solid-js/store"
|
||||
|
||||
export function DialogTag(props: { onSelect?: (value: string) => void }) {
|
||||
const client = useClient()
|
||||
const dialog = useDialog()
|
||||
const data = useData()
|
||||
|
||||
const [store] = createStore({
|
||||
filter: "",
|
||||
})
|
||||
|
||||
const [files] = createResource(
|
||||
() => [store.filter],
|
||||
async () => {
|
||||
const result = await client.api.file
|
||||
.find({
|
||||
query: store.filter,
|
||||
type: "file",
|
||||
limit: 5,
|
||||
location: {
|
||||
directory: data.location.default().directory,
|
||||
workspace: data.location.default().workspaceID,
|
||||
},
|
||||
})
|
||||
.catch(() => undefined)
|
||||
return result?.data.map((item) => item.path) ?? []
|
||||
},
|
||||
)
|
||||
|
||||
const options = createMemo(() =>
|
||||
(files() ?? []).map((file) => ({
|
||||
value: file,
|
||||
title: file,
|
||||
})),
|
||||
)
|
||||
|
||||
return (
|
||||
<DialogSelect
|
||||
title="Autocomplete"
|
||||
options={options()}
|
||||
onSelect={(option) => {
|
||||
props.onSelect?.(option.value)
|
||||
dialog.clear()
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user