* feat(ai): enhance Windows CLI binary resolution and connection handling

- Introduced functions to handle both .cmd and .ps1 wrappers for Windows installations, improving compatibility for CLI tools like Codex and Copilot.
- Updated connection logic in `connect-agent.ts` to utilize environment variables for Codex home directory, enhancing flexibility in locating configuration files.
- Added warning messages in connection results to inform users when no models are found, guiding them to run the CLI tools for model population.
- Modified the agent settings dialog to accommodate the new warning field in connection responses, improving user feedback during the connection process.

This update significantly enhances the user experience for Windows users by ensuring better handling of CLI binaries and providing clearer connection status information.

* feat(ai): improve Windows binary resolution for CLI tools

- Added `resolveWinExtension` function to handle extensionless binaries returned by the `where` command on Windows, ensuring compatibility with `.cmd` and `.ps1` wrappers.
- Updated `connect-agent.ts` and `copilot-client.ts` to utilize the new resolution function, enhancing the reliability of binary path lookups for Codex and Copilot.
- Enhanced logging to provide clearer information on resolved paths and their existence status.

This update significantly improves the handling of CLI binaries on Windows, ensuring users have a smoother experience when connecting to AI tools.

* feat(ai): enhance binary path handling in connect-agent

- Added logic to ensure the directory of the resolved OpenCode binary is included in the PATH environment variable, improving the execution of CLI tools.
- Enhanced logging to provide feedback when the binary directory is prepended to the PATH, aiding in troubleshooting and user awareness.

This update improves the reliability of connecting to AI tools by ensuring the necessary binaries are accessible during execution.

* feat(patching): add patch for @opencode-ai/sdk and update package configurations

- Introduced a new patch for the @opencode-ai/sdk version 1.2.6 to address specific issues.
- Updated package.json and bun.lock to include the new patchedDependencies section, ensuring the patch is applied during installation.
- Removed redundant binary path handling code in connect-agent.ts to streamline the connection process.

This update enhances the SDK's functionality while simplifying the connection logic for improved performance.

* fix(ai): add orchestrator fallback and three-way intent routing

Orchestrator now falls back to a heuristic plan when the model returns
non-JSON instead of throwing an error.

Intent classification upgraded from binary (DESIGN/CHAT) to three-way
(DESIGN_NEW/DESIGN_MODIFY/CHAT). Modification requests without an
explicit selection auto-target the last top-level frame on the active
page, preventing them from being misrouted to the orchestrator.

* chore: bump version to 0.4.4 and remove deprecated patch for @opencode-ai/sdk

- Updated package version in package.json from 0.4.3 to 0.4.4.
- Removed the patchedDependencies section for @opencode-ai/sdk as the patch is no longer needed.
- Added new utility function `buildSpawnClaudeCodeProcess` to enhance agent SDK functionality across multiple files.

* chore: bump version to 0.4.4 in package.json

* chore(electron): optimize application icon assets

Reduce icon file sizes for faster builds and smaller distribution.

* chore: remove deprecated patchedDependencies for @opencode-ai/sdk in bun.lock

- Eliminated the patchedDependencies section for @opencode-ai/sdk as the patch is no longer necessary, streamlining the dependency management in the project.

* fix(ai): improve Codex CLI error extraction for auth and structured log errors

Parse Codex's structured log format (<timestamp> ERROR <module>: <message>)
to surface real errors like expired auth tokens instead of the unhelpful
"Warning: no last agent message" fallback.

* fix(ai): update error handling and remove deprecated reasoning field

- Updated error checks in chat and generate handlers to ensure proper validation of required fields.
- Removed the deprecated reasoning field from the OpenCode SDK integration, streamlining the prompt parameters.
- Enhanced logging for system prompt injection errors to improve debugging capabilities.

* fix(ai): enhance Windows compatibility for Codex CLI execution

- Updated the handling of prompts on Windows to avoid parsing errors caused by shell escaping in PowerShell and cmd.exe.
- Introduced a temporary PowerShell script to manage prompt input and command execution, ensuring special characters are processed correctly.
- Refactored the argument passing mechanism to accommodate the new script-based approach.

* fix(electron): improve process cleanup and Windows compatibility

- Simplified the application quit logic to always call app.quit() on window close.
- Enhanced the before-quit event to ensure proper cleanup of the Nitro process and port file.
- Updated the Nitro process termination logic to use SIGKILL for reliable cleanup on non-Windows platforms.
- Refactored argument handling in the Codex CLI execution to utilize PowerShell array splatting for safer argument passing.

* feat(ai): add fallback for model parsing from Codex's latest-model.md

- Implemented a new function to parse model IDs from the latest-model.md file when models_cache.json is unavailable, enhancing compatibility for fresh installations.
- Updated logging to reflect the loading of models from the fallback method and improved error handling for model loading scenarios.

* feat(ai): update prompt structure for design generation assistant

- Revised the prompt format to enhance clarity and organization for the design generation assistant.
- Introduced clear section headers for system instructions and user tasks, improving user experience and guidance.

* refactor(ai): streamline prompt handling for Codex CLI execution

- Simplified the prompt input mechanism for all platforms by utilizing stdin mode, eliminating the need for temporary scripts on Windows.
- Improved argument passing to avoid shell escaping issues and command-line length limits, enhancing compatibility and reliability across environments.
- Updated the execution logic to handle prompts more efficiently, ensuring a smoother user experience.

---------

Co-authored-by: Fini <fini.yang@gmail.com>
This commit is contained in:
Kayshen Xu 2026-03-20 22:00:22 +08:00 committed by GitHub
parent 6a1891fc6e
commit c9e844ae30
58 changed files with 22573 additions and 185 deletions

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 915 KiB

After

Width:  |  Height:  |  Size: 544 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 894 KiB

After

Width:  |  Height:  |  Size: 544 KiB

View file

@ -695,9 +695,7 @@ app.on('ready', async () => {
})
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit()
}
app.quit()
})
app.on('activate', () => {
@ -706,25 +704,33 @@ app.on('activate', () => {
}
})
app.on('before-quit', async () => {
app.on('before-quit', () => {
clearUpdateTimer()
await cleanupPortFile()
killNitroProcess()
cleanupPortFile().catch(() => {})
})
/** Platform-aware Nitro process termination. */
function killNitroProcess(): void {
if (!nitroProcess) return
const pid = nitroProcess.pid
if (process.platform === 'win32') {
// SIGTERM is unreliable on Windows; use taskkill for proper tree-kill
try {
const pid = nitroProcess.pid
if (pid) {
execSync(`taskkill /pid ${pid} /T /F`, { stdio: 'ignore' })
}
} catch { /* process may have already exited */ }
} else {
nitroProcess.kill('SIGTERM')
// SIGTERM may not be processed before the main process exits,
// leaving orphan Nitro processes. Kill the entire process group
// with SIGKILL for reliable cleanup.
try {
if (pid) process.kill(-pid, 'SIGKILL')
} catch { /* process may have already exited */ }
try {
nitroProcess.kill('SIGKILL')
} catch { /* ignore */ }
}
nitroProcess = null
}

View file

@ -1,6 +1,6 @@
{
"name": "openpencil",
"version": "0.4.3",
"version": "0.4.4",
"description": "The world's first open-source AI-native vector design tool and the first to feature concurrent Agent Teams. Design-as-Code. Turn prompts into UI directly on the live canvas. A modern alternative to Pencil.",
"author": {
"name": "ZSeven-W",

View file

@ -6,6 +6,7 @@ import { resolveClaudeCli } from '../../utils/resolve-claude-cli'
import { runCodexExec } from '../../utils/codex-client'
import {
buildClaudeAgentEnv,
buildSpawnClaudeCodeProcess,
getClaudeAgentDebugFilePath,
} from '../../utils/resolve-claude-agent-env'
@ -80,7 +81,7 @@ function buildClaudeExitHint(rawError: string, debugTail?: string[]): string | u
export default defineEventHandler(async (event) => {
const body = await readBody<ChatBody>(event)
if (!body?.messages || !body?.system) {
if (!body?.messages || body?.system == null) {
setResponseHeaders(event, { 'Content-Type': 'application/json' })
return { error: 'Missing required fields: system, messages' }
}
@ -236,6 +237,7 @@ function streamViaAgentSDK(body: ChatBody, model?: string) {
env,
...(debugFile ? { debugFile } : {}),
...(claudePath ? { pathToClaudeCodeExecutable: claudePath } : {}),
...(buildSpawnClaudeCodeProcess() ? { spawnClaudeCodeProcess: buildSpawnClaudeCodeProcess() } : {}),
},
})
@ -285,6 +287,7 @@ function streamViaAgentSDK(body: ChatBody, model?: string) {
env,
...(debugFile ? { debugFile } : {}),
...(claudePath ? { pathToClaudeCodeExecutable: claudePath } : {}),
...(buildSpawnClaudeCodeProcess() ? { spawnClaudeCodeProcess: buildSpawnClaudeCodeProcess() } : {}),
},
})
@ -406,32 +409,8 @@ function parseOpenCodeModel(model?: string): { providerID: string; modelID: stri
return { providerID: model.slice(0, idx), modelID: model.slice(idx + 1) }
}
function mapOpenCodeEffort(
effort?: 'low' | 'medium' | 'high' | 'max',
): 'low' | 'medium' | 'high' | undefined {
if (!effort) return undefined
if (effort === 'max') return 'high'
return effort
}
function buildOpenCodeReasoning(
body: ChatBody,
): Record<string, unknown> | undefined {
const reasoning: Record<string, unknown> = {}
const effort = mapOpenCodeEffort(body.effort)
if (effort) {
reasoning.effort = effort
}
if (body.thinkingMode === 'enabled') {
reasoning.enabled = true
} else if (body.thinkingMode === 'disabled') {
reasoning.enabled = false
}
if (typeof body.thinkingBudgetTokens === 'number' && body.thinkingBudgetTokens > 0) {
reasoning.budgetTokens = body.thinkingBudgetTokens
}
return Object.keys(reasoning).length > 0 ? reasoning : undefined
}
// Note: OpenCode SDK does not support `reasoning` in promptAsync/prompt params.
// The `reasoning` field was silently dropped by buildClientParams. Removed.
/** Wrap an async generator with a timeout — yields values until timeout fires */
async function* streamWithTimeout<T>(
@ -543,11 +522,14 @@ function streamViaOpenCode(body: ChatBody, model?: string) {
}
// Inject system prompt as context (no AI reply)
await ocClient.session.prompt({
const { error: sysPromptError } = await ocClient.session.prompt({
sessionID: session.id,
noReply: true,
parts: [{ type: 'text', text: body.system }],
})
}) as any
if (sysPromptError) {
console.error('[AI] OpenCode system prompt injection failed:', formatOpenCodeError(sysPromptError))
}
// Build prompt from the last user message
const lastUserMsg = [...body.messages].reverse().find((m) => m.role === 'user')
@ -568,7 +550,6 @@ function streamViaOpenCode(body: ChatBody, model?: string) {
{ type: 'text', text: prompt || 'Analyze these images.' },
]
console.log(`[AI] OpenCode streaming prompt: model=${model}, parsed=${JSON.stringify(parsed)}`)
// Build prompt payload with optional model and reasoning
const promptPayload: Record<string, unknown> = {
@ -576,16 +557,46 @@ function streamViaOpenCode(body: ChatBody, model?: string) {
...(parsed ? { model: parsed } : {}),
parts,
}
const reasoning = buildOpenCodeReasoning(body)
if (reasoning) {
promptPayload.reasoning = reasoning
}
// Subscribe to event stream for real-time deltas
// Subscribe to event stream for real-time deltas.
// IMPORTANT: The SSE connection is lazy — it only connects when
// iteration starts. We must start consuming BEFORE sending the
// prompt to avoid a race where events are emitted before the
// SSE connection is established.
const eventResult = await ocClient.event.subscribe()
const eventStream = eventResult.stream
// Send prompt asynchronously — response comes via events
const sessionId = session.id
const STREAM_TIMEOUT_MS = 180_000
// Start eagerly consuming the event stream into a buffer.
// This triggers the SSE HTTP connection immediately.
const eventBuffer: unknown[] = []
let streamDone = false
let notifyFn: (() => void) | null = null
const notify = () => { if (notifyFn) { const fn = notifyFn; notifyFn = null; fn() } }
// eslint-disable-next-line @typescript-eslint/no-floating-promises
void (async () => {
const timeoutPromise = new Promise<{ done: true; value: undefined }>((resolve) =>
setTimeout(() => resolve({ done: true, value: undefined }), STREAM_TIMEOUT_MS),
)
try {
for await (const event of streamWithTimeout(eventStream, timeoutPromise)) {
eventBuffer.push(event)
notify()
}
} finally {
streamDone = true
notify()
}
})()
// Give the SSE connection a moment to establish before sending prompt
await new Promise<void>(resolve => setTimeout(resolve, 100))
// Now send the prompt — SSE connection should already be active
const { error: asyncError } = await ocClient.session.promptAsync(promptPayload as any)
if (asyncError) {
const detail = formatOpenCodeError(asyncError)
@ -593,18 +604,24 @@ function streamViaOpenCode(body: ChatBody, model?: string) {
throw new Error(detail)
}
// Consume event stream, forwarding text deltas to client
// Consume buffered events + wait for new ones
let emittedText = false
const sessionId = session.id
const STREAM_TIMEOUT_MS = 180_000
const timeoutPromise = new Promise<{ done: true; value: undefined }>((resolve) =>
setTimeout(() => resolve({ done: true, value: undefined }), STREAM_TIMEOUT_MS),
)
let eventCount = 0
let shouldBreak = false
for await (const event of streamWithTimeout(eventStream, timeoutPromise)) {
if (!event || !('type' in event)) continue
while (!shouldBreak) {
// Wait for events if buffer is empty
if (eventBuffer.length === 0) {
if (streamDone) break
await new Promise<void>(resolve => { notifyFn = resolve })
continue
}
const eventType = event.type as string
const event = eventBuffer.shift()
if (!event || !('type' in (event as any))) continue
const eventType = (event as any).type as string
eventCount++
// Stream text deltas for our session
if (eventType === 'message.part.delta') {
@ -625,7 +642,9 @@ function streamViaOpenCode(body: ChatBody, model?: string) {
// Session went idle — response complete
if (eventType === 'session.idle') {
const props = (event as any).properties
if (props?.sessionID === sessionId) break
if (props?.sessionID === sessionId) {
shouldBreak = true
}
continue
}
@ -637,7 +656,7 @@ function streamViaOpenCode(body: ChatBody, model?: string) {
console.error('[AI] OpenCode session error:', errMsg)
const data = JSON.stringify({ type: 'error', content: errMsg })
controller.enqueue(encoder.encode(`data: ${data}\n\n`))
break
shouldBreak = true
}
continue
}
@ -645,8 +664,29 @@ function streamViaOpenCode(body: ChatBody, model?: string) {
clearInterval(pingTimer)
// Fallback: if no text was streamed, try reading session messages directly
if (!emittedText) {
try {
const { data: messages } = await ocClient.session.messages({ sessionID: sessionId }) as any
if (messages && Array.isArray(messages)) {
// Find the last assistant message (each item has { info, parts })
const assistantMsg = [...messages].reverse().find((m: any) => m.info?.role === 'assistant')
if (assistantMsg?.parts) {
for (const part of assistantMsg.parts) {
if (part.type === 'text' && part.text) {
const data = JSON.stringify({ type: 'text', content: part.text })
controller.enqueue(encoder.encode(`data: ${data}\n\n`))
emittedText = true
}
}
}
}
} catch {
// fallback failed — will emit error below
}
}
if (!emittedText) {
console.warn('[AI] OpenCode returned no text via streaming events')
const data = JSON.stringify({ type: 'error', content: 'OpenCode returned an empty response. The model may not have generated any output.' })
controller.enqueue(encoder.encode(`data: ${data}\n\n`))
}

View file

@ -1,13 +1,43 @@
import { defineEventHandler, readBody, setResponseHeaders } from 'h3'
import { existsSync } from 'node:fs'
import { join } from 'node:path'
import type { GroupedModel } from '../../../src/types/agent-settings'
import { resolveClaudeCli } from '../../utils/resolve-claude-cli'
import { serverLog } from '../../utils/server-logger'
import {
buildClaudeAgentEnv,
buildSpawnClaudeCodeProcess,
getClaudeAgentDebugFilePath,
} from '../../utils/resolve-claude-agent-env'
/** Windows npm global installs may create .cmd or .ps1 wrappers — try both */
function winNpmCandidates(dir: string, name: string): string[] {
return [join(dir, `${name}.cmd`), join(dir, `${name}.ps1`)]
}
/**
* On Windows, `where` may return an extensionless Unix shell script (e.g. `…/npm/opencode`).
* This file exists but can't be executed. Prefer `.cmd` or `.ps1` wrapper at the same location.
*/
function resolveWinExtension(binPath: string): string {
if (process.platform !== 'win32') return binPath
// Already has a usable extension
if (/\.(cmd|ps1|exe)$/i.test(binPath)) return binPath
// Try .cmd then .ps1
for (const ext of ['.cmd', '.ps1']) {
if (existsSync(binPath + ext)) return binPath + ext
}
return binPath
}
/** Build a shell command to invoke a resolved binary (handles .ps1 on Windows) */
function buildExecCmd(binPath: string, args: string): string {
if (binPath.endsWith('.ps1')) {
return `powershell -ExecutionPolicy Bypass -File "${binPath}" ${args}`
}
return `"${binPath}" ${args}`
}
interface ConnectBody {
agent: 'claude-code' | 'codex-cli' | 'opencode' | 'copilot'
}
@ -16,6 +46,7 @@ interface ConnectResult {
connected: boolean
models: GroupedModel[]
error?: string
warning?: string
notInstalled?: boolean
/** Human-readable connection status, e.g. "Connected via API key" */
connectionInfo?: string
@ -96,6 +127,7 @@ async function connectClaudeCode(): Promise<ConnectResult> {
env,
...(debugFile ? { debugFile } : {}),
...(claudePath ? { pathToClaudeCodeExecutable: claudePath } : {}),
...(buildSpawnClaudeCodeProcess() ? { spawnClaudeCodeProcess: buildSpawnClaudeCodeProcess() } : {}),
},
})
@ -194,7 +226,8 @@ async function buildCodexConnectionInfo(): Promise<{ connectionInfo: string; hin
}
try {
const authPath = join(homedir(), '.codex', 'auth.json')
const codexHome = process.env.CODEX_HOME || join(homedir(), '.codex')
const authPath = join(codexHome, 'auth.json')
const raw = await readFile(authPath, 'utf-8')
const auth = JSON.parse(raw) as { auth_mode?: string; tokens?: { id_token?: string } }
@ -237,6 +270,41 @@ function friendlyClaudeError(raw: string): string {
return raw
}
/**
* Fallback: parse model IDs from Codex's bundled latest-model.md when
* models_cache.json is missing (e.g. fresh Windows install).
* Only includes text/reasoning models (skips image, audio, video, embedding, moderation).
*/
async function parseCodexLatestModelMd(codexHome: string): Promise<GroupedModel[]> {
const { readFile } = await import('node:fs/promises')
const { join } = await import('node:path')
const mdPath = join(codexHome, 'skills', '.system', 'openai-docs', 'references', 'latest-model.md')
try {
const content = await readFile(mdPath, 'utf-8')
const models: GroupedModel[] = []
// Match markdown table rows: | `model-id` | description |
const rowRe = /^\|\s*`([^`]+)`\s*\|\s*(.+?)\s*\|/gm
const skipRe = /image|audio|tts|transcribe|realtime|sora|video|embedding|moderation/i
let match: RegExpExecArray | null
const seen = new Set<string>()
while ((match = rowRe.exec(content)) !== null) {
const slug = match[1]
const desc = match[2].trim()
if (skipRe.test(slug) || skipRe.test(desc) || seen.has(slug)) continue
seen.add(slug)
models.push({
value: slug,
displayName: slug,
description: desc,
provider: 'openai' as const,
})
}
return models
} catch {
return []
}
}
/** Connect to Codex CLI and fetch its supported models from the local cache */
async function connectCodexCli(): Promise<ConnectResult> {
serverLog.info('[connect-agent] connecting to Codex CLI...')
@ -258,13 +326,13 @@ async function connectCodexCli(): Promise<ConnectResult> {
encoding: 'utf-8',
timeout: 5000,
}).trim().split(/\r?\n/)[0]?.trim() ?? ''
if (result && existsSync(result)) which = result
serverLog.info(`[connect-agent] codex PATH result: "${result}" (exists=${result ? existsSync(result) : false})`)
if (result && existsSync(result)) which = resolveWinExtension(result)
serverLog.info(`[connect-agent] codex PATH result: "${result}" resolved="${which}" (exists=${result ? existsSync(result) : false})`)
} catch (err) {
serverLog.info(`[connect-agent] codex PATH lookup failed: ${err instanceof Error ? err.message : err}`)
}
// 2. npm prefix -g (Windows: npm global creates .cmd wrappers)
// 2. npm prefix -g (Windows: npm global creates .cmd or .ps1 wrappers)
if (!which && isWin) {
try {
serverLog.info('[connect-agent] codex: trying npm.cmd prefix -g')
@ -274,9 +342,10 @@ async function connectCodexCli(): Promise<ConnectResult> {
}).trim()
serverLog.info(`[connect-agent] codex npm global prefix: "${prefix}"`)
if (prefix) {
const bin = join(prefix, 'codex.cmd')
serverLog.info(`[connect-agent] codex npm global bin: "${bin}" (exists=${existsSync(bin)})`)
if (existsSync(bin)) which = bin
for (const bin of winNpmCandidates(prefix, 'codex')) {
serverLog.info(`[connect-agent] codex npm global bin: "${bin}" (exists=${existsSync(bin)})`)
if (existsSync(bin)) { which = bin; break }
}
}
} catch (err) {
serverLog.info(`[connect-agent] codex npm prefix -g failed: ${err instanceof Error ? err.message : err}`)
@ -286,9 +355,9 @@ async function connectCodexCli(): Promise<ConnectResult> {
// 3. Common install locations
if (!which && isWin) {
const candidates = [
join(process.env.APPDATA || '', 'npm', 'codex.cmd'),
join(process.env.NVM_SYMLINK || '', 'codex.cmd'),
join(process.env.FNM_MULTISHELL_PATH || '', 'codex.cmd'),
...winNpmCandidates(join(process.env.APPDATA || '', 'npm'), 'codex'),
...winNpmCandidates(join(process.env.NVM_SYMLINK || ''), 'codex'),
...winNpmCandidates(join(process.env.FNM_MULTISHELL_PATH || ''), 'codex'),
]
for (const c of candidates) {
const exists = c ? existsSync(c) : false
@ -304,8 +373,8 @@ async function connectCodexCli(): Promise<ConnectResult> {
serverLog.info(`[connect-agent] codex resolved: "${which}"`)
// Verify codex is responsive — on Windows, use the resolved path or .cmd wrapper
const versionCmd = isWin ? `"${which}" --version 2>&1` : 'codex --version 2>&1'
// Verify codex is responsive — always use the resolved path
const versionCmd = buildExecCmd(which, '--version') + ' 2>&1'
try {
const ver = execSync(versionCmd, { encoding: 'utf-8', timeout: 5000 }).trim()
serverLog.info(`[connect-agent] codex version: ${ver}`)
@ -314,11 +383,10 @@ async function connectCodexCli(): Promise<ConnectResult> {
return { connected: false, models: [], error: 'Codex CLI not responding' }
}
// Read models from Codex CLI's local models cache
// Read models from Codex CLI's local models cache (best-effort)
let models: GroupedModel[] = []
const cachePath = join(homedir(), '.codex', 'models_cache.json')
serverLog.info(`[connect-agent] codex models cache: "${cachePath}" (exists=${existsSync(cachePath)})`)
const codexHome = process.env.CODEX_HOME || join(homedir(), '.codex')
const cachePath = join(codexHome, 'models_cache.json')
try {
const raw = await readFile(cachePath, 'utf-8')
const cache = JSON.parse(raw) as {
@ -330,7 +398,6 @@ async function connectCodexCli(): Promise<ConnectResult> {
priority: number
}>
}
if (cache.models && Array.isArray(cache.models)) {
models = cache.models
.filter((m) => m.visibility === 'list')
@ -342,18 +409,22 @@ async function connectCodexCli(): Promise<ConnectResult> {
provider: 'openai' as const,
}))
}
} catch (cacheErr) {
serverLog.info(`[connect-agent] codex cache read failed: ${cacheErr instanceof Error ? cacheErr.message : cacheErr}`)
} catch {
serverLog.info(`[connect-agent] codex models cache not available`)
}
// Fallback: parse models from Codex's bundled latest-model.md reference
if (models.length === 0) {
serverLog.info('[connect-agent] codex: no models found')
return { connected: false, models: [], error: 'No models found. Try running codex once to populate the model cache.' }
models = await parseCodexLatestModelMd(codexHome)
if (models.length > 0) {
serverLog.info(`[connect-agent] codex models loaded from latest-model.md: ${models.length}`)
}
}
serverLog.info(`[connect-agent] codex connected, ${models.length} models found`)
const codexInfo = await buildCodexConnectionInfo()
return { connected: true, models, ...codexInfo }
const warning = models.length === 0 ? 'No models found. Try running codex once to populate the model cache.' : undefined
return { connected: true, models, warning, ...codexInfo }
} catch (error) {
const msg = error instanceof Error ? error.message : 'Failed to connect'
serverLog.error(`[connect-agent] codex connection error: ${msg}`)
@ -377,7 +448,7 @@ async function resolveOpencodeBinary(): Promise<string | undefined> {
serverLog.info(`[resolve-opencode] PATH lookup: ${cmd}`)
const result = execSync(cmd, { encoding: 'utf-8', timeout: 5000 }).trim().split(/\r?\n/)[0]?.trim()
serverLog.info(`[resolve-opencode] PATH result: "${result}" (exists=${result ? existsSync(result) : false})`)
if (result && existsSync(result)) return result
if (result && existsSync(result)) return resolveWinExtension(result)
} catch (err) {
serverLog.info(`[resolve-opencode] PATH lookup failed: ${err instanceof Error ? err.message : err}`)
}
@ -390,9 +461,16 @@ async function resolveOpencodeBinary(): Promise<string | undefined> {
const prefix = execSync(npmCmd, { encoding: 'utf-8', timeout: 5000 }).trim()
serverLog.info(`[resolve-opencode] npm global prefix: "${prefix}"`)
if (prefix) {
const bin = isWin ? join(prefix, 'opencode.cmd') : join(prefix, 'bin', 'opencode')
serverLog.info(`[resolve-opencode] npm global bin: "${bin}" (exists=${existsSync(bin)})`)
if (existsSync(bin)) return bin
if (isWin) {
for (const bin of winNpmCandidates(prefix, 'opencode')) {
serverLog.info(`[resolve-opencode] npm global bin: "${bin}" (exists=${existsSync(bin)})`)
if (existsSync(bin)) return bin
}
} else {
const bin = join(prefix, 'bin', 'opencode')
serverLog.info(`[resolve-opencode] npm global bin: "${bin}" (exists=${existsSync(bin)})`)
if (existsSync(bin)) return bin
}
}
} catch (err) {
serverLog.info(`[resolve-opencode] npm prefix -g failed: ${err instanceof Error ? err.message : err}`)
@ -405,12 +483,12 @@ async function resolveOpencodeBinary(): Promise<string | undefined> {
const home = homedir()
const candidates = isWin
? [
// npm global
join(process.env.APPDATA || '', 'npm', 'opencode.cmd'),
join(process.env.ProgramFiles || '', 'nodejs', 'opencode.cmd'),
// npm global (.cmd + .ps1)
...winNpmCandidates(join(process.env.APPDATA || '', 'npm'), 'opencode'),
...winNpmCandidates(join(process.env.ProgramFiles || '', 'nodejs'), 'opencode'),
// nvm-windows / fnm
join(process.env.NVM_SYMLINK || '', 'opencode.cmd'),
join(process.env.FNM_MULTISHELL_PATH || '', 'opencode.cmd'),
...winNpmCandidates(join(process.env.NVM_SYMLINK || ''), 'opencode'),
...winNpmCandidates(join(process.env.FNM_MULTISHELL_PATH || ''), 'opencode'),
// Scoop
join(home, 'scoop', 'shims', 'opencode.exe'),
join(process.env.LOCALAPPDATA || '', 'Programs', 'opencode', 'opencode.exe'),

View file

@ -3,6 +3,7 @@ import { resolveClaudeCli } from '../../utils/resolve-claude-cli'
import { runCodexExec } from '../../utils/codex-client'
import {
buildClaudeAgentEnv,
buildSpawnClaudeCodeProcess,
getClaudeAgentDebugFilePath,
} from '../../utils/resolve-claude-agent-env'
import { formatOpenCodeError } from './chat'
@ -25,7 +26,7 @@ interface GenerateBody {
export default defineEventHandler(async (event) => {
const body = await readBody<GenerateBody>(event)
if (!body?.message || !body?.system) {
if (!body?.message || body?.system == null) {
setResponseHeaders(event, { 'Content-Type': 'application/json' })
return { error: 'Missing required fields: system, message' }
}
@ -74,6 +75,7 @@ async function generateViaAgentSDK(body: GenerateBody, model?: string): Promise<
env,
...(debugFile ? { debugFile } : {}),
...(claudePath ? { pathToClaudeCodeExecutable: claudePath } : {}),
...(buildSpawnClaudeCodeProcess() ? { spawnClaudeCodeProcess: buildSpawnClaudeCodeProcess() } : {}),
},
})
@ -247,7 +249,6 @@ async function generateViaOpenCode(body: GenerateBody, model?: string): Promise<
}
if (texts.length === 0) {
console.warn('[AI] OpenCode generate returned no text parts. Response:', JSON.stringify(result).slice(0, 500))
return { error: 'OpenCode returned an empty response. The model may not have generated any output.' }
}

View file

@ -2,6 +2,7 @@ import { defineEventHandler, readBody, setResponseHeaders } from 'h3'
import { resolveClaudeCli } from '../../utils/resolve-claude-cli'
import {
buildClaudeAgentEnv,
buildSpawnClaudeCodeProcess,
getClaudeAgentDebugFilePath,
} from '../../utils/resolve-claude-agent-env'
import { writeFile, mkdtemp, rm } from 'node:fs/promises'
@ -125,6 +126,7 @@ CRITICAL: Your ENTIRE response must be a single JSON object. No markdown, no exp
env,
...(debugFile ? { debugFile } : {}),
...(claudePath ? { pathToClaudeCodeExecutable: claudePath } : {}),
...(buildSpawnClaudeCodeProcess() ? { spawnClaudeCodeProcess: buildSpawnClaudeCodeProcess() } : {}),
},
})

39
server/opencode/client.ts Normal file
View file

@ -0,0 +1,39 @@
export * from "./gen/types.gen"
import { createClient } from "./gen/client/client.gen"
import { type Config } from "./gen/client/types.gen"
import { OpencodeClient } from "./gen/sdk.gen"
export { type Config as OpencodeClientConfig, OpencodeClient }
export function createOpencodeClient(config?: Config & { directory?: string; experimental_workspaceID?: string }) {
if (!config?.fetch) {
const customFetch: any = (req: any) => {
// @ts-ignore
req.timeout = false
return fetch(req)
}
config = {
...config,
fetch: customFetch,
}
}
if (config?.directory) {
const isNonASCII = /[^\x00-\x7F]/.test(config.directory)
const encodedDirectory = isNonASCII ? encodeURIComponent(config.directory) : config.directory
config.headers = {
...config.headers,
"x-opencode-directory": encodedDirectory,
}
}
if (config?.experimental_workspaceID) {
config.headers = {
...config.headers,
"x-opencode-workspace": config.experimental_workspaceID,
}
}
const client = createClient(config)
return new OpencodeClient({ client })
}

View file

@ -0,0 +1,18 @@
// This file is auto-generated by @hey-api/openapi-ts
import { type ClientOptions, type Config, createClient, createConfig } from "./client/index"
import type { ClientOptions as ClientOptions2 } from "./types.gen"
/**
* The `createClientConfig()` function will be called on client initialization
* and the returned object will become the client's initial configuration.
*
* You may want to initialize your client this way instead of calling
* `setConfig()`. This is useful for example if you're using Next.js
* to ensure your client always has the correct values.
*/
export type CreateClientConfig<T extends ClientOptions = ClientOptions2> = (
override?: Config<ClientOptions & T>,
) => Config<Required<ClientOptions> & T>
export const client = createClient(createConfig<ClientOptions2>({ baseUrl: "http://localhost:4096" }))

View file

@ -0,0 +1,285 @@
// This file is auto-generated by @hey-api/openapi-ts
import { createSseClient } from "../core/serverSentEvents.gen"
import type { HttpMethod } from "../core/types.gen"
import { getValidRequestBody } from "../core/utils.gen"
import type { Client, Config, RequestOptions, ResolvedRequestOptions } from "./types.gen"
import {
buildUrl,
createConfig,
createInterceptors,
getParseAs,
mergeConfigs,
mergeHeaders,
setAuthParams,
} from "./utils.gen"
type ReqInit = Omit<RequestInit, "body" | "headers"> & {
body?: any
headers: ReturnType<typeof mergeHeaders>
}
export const createClient = (config: Config = {}): Client => {
let _config = mergeConfigs(createConfig(), config)
const getConfig = (): Config => ({ ..._config })
const setConfig = (config: Config): Config => {
_config = mergeConfigs(_config, config)
return getConfig()
}
const interceptors = createInterceptors<Request, Response, unknown, ResolvedRequestOptions>()
const beforeRequest = async (options: RequestOptions) => {
const opts = {
..._config,
...options,
fetch: options.fetch ?? _config.fetch ?? globalThis.fetch,
headers: mergeHeaders(_config.headers, options.headers),
serializedBody: undefined,
}
if (opts.security) {
await setAuthParams({
...opts,
security: opts.security,
})
}
if (opts.requestValidator) {
await opts.requestValidator(opts)
}
if (opts.body !== undefined && opts.bodySerializer) {
opts.serializedBody = opts.bodySerializer(opts.body)
}
// remove Content-Type header if body is empty to avoid sending invalid requests
if (opts.body === undefined || opts.serializedBody === "") {
opts.headers.delete("Content-Type")
}
const url = buildUrl(opts)
return { opts, url }
}
const request: Client["request"] = async (options) => {
// @ts-expect-error
const { opts, url } = await beforeRequest(options)
const requestInit: ReqInit = {
redirect: "follow",
...opts,
body: getValidRequestBody(opts),
}
let request = new Request(url, requestInit)
for (const fn of interceptors.request.fns) {
if (fn) {
request = await fn(request, opts)
}
}
// fetch must be assigned here, otherwise it would throw the error:
// TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation
const _fetch = opts.fetch!
let response: Response
try {
response = await _fetch(request)
} catch (error) {
// Handle fetch exceptions (AbortError, network errors, etc.)
let finalError = error
for (const fn of interceptors.error.fns) {
if (fn) {
finalError = (await fn(error, undefined as any, request, opts)) as unknown
}
}
finalError = finalError || ({} as unknown)
if (opts.throwOnError) {
throw finalError
}
// Return error response
return opts.responseStyle === "data"
? undefined
: {
error: finalError,
request,
response: undefined as any,
}
}
for (const fn of interceptors.response.fns) {
if (fn) {
response = await fn(response, request, opts)
}
}
const result = {
request,
response,
}
if (response.ok) {
const parseAs =
(opts.parseAs === "auto" ? getParseAs(response.headers.get("Content-Type")) : opts.parseAs) ?? "json"
if (response.status === 204 || response.headers.get("Content-Length") === "0") {
let emptyData: any
switch (parseAs) {
case "arrayBuffer":
case "blob":
case "text":
emptyData = await response[parseAs]()
break
case "formData":
emptyData = new FormData()
break
case "stream":
emptyData = response.body
break
case "json":
default:
emptyData = {}
break
}
return opts.responseStyle === "data"
? emptyData
: {
data: emptyData,
...result,
}
}
let data: any
switch (parseAs) {
case "arrayBuffer":
case "blob":
case "formData":
case "text":
data = await response[parseAs]()
break
case "json": {
// Some servers return 200 with no Content-Length and empty body.
// response.json() would throw; read as text and parse if non-empty.
const text = await response.text()
data = text ? JSON.parse(text) : {}
break
}
case "stream":
return opts.responseStyle === "data"
? response.body
: {
data: response.body,
...result,
}
}
if (parseAs === "json") {
if (opts.responseValidator) {
await opts.responseValidator(data)
}
if (opts.responseTransformer) {
data = await opts.responseTransformer(data)
}
}
return opts.responseStyle === "data"
? data
: {
data,
...result,
}
}
const textError = await response.text()
let jsonError: unknown
try {
jsonError = JSON.parse(textError)
} catch {
// noop
}
const error = jsonError ?? textError
let finalError = error
for (const fn of interceptors.error.fns) {
if (fn) {
finalError = (await fn(error, response, request, opts)) as string
}
}
finalError = finalError || ({} as string)
if (opts.throwOnError) {
throw finalError
}
// TODO: we probably want to return error and improve types
return opts.responseStyle === "data"
? undefined
: {
error: finalError,
...result,
}
}
const makeMethodFn = (method: Uppercase<HttpMethod>) => (options: RequestOptions) => request({ ...options, method })
const makeSseFn = (method: Uppercase<HttpMethod>) => async (options: RequestOptions) => {
const { opts, url } = await beforeRequest(options)
return createSseClient({
...opts,
body: opts.body as BodyInit | null | undefined,
headers: opts.headers as unknown as Record<string, string>,
method,
onRequest: async (url, init) => {
let request = new Request(url, init)
for (const fn of interceptors.request.fns) {
if (fn) {
request = await fn(request, opts)
}
}
return request
},
serializedBody: getValidRequestBody(opts) as BodyInit | null | undefined,
url,
})
}
return {
buildUrl,
connect: makeMethodFn("CONNECT"),
delete: makeMethodFn("DELETE"),
get: makeMethodFn("GET"),
getConfig,
head: makeMethodFn("HEAD"),
interceptors,
options: makeMethodFn("OPTIONS"),
patch: makeMethodFn("PATCH"),
post: makeMethodFn("POST"),
put: makeMethodFn("PUT"),
request,
setConfig,
sse: {
connect: makeSseFn("CONNECT"),
delete: makeSseFn("DELETE"),
get: makeSseFn("GET"),
head: makeSseFn("HEAD"),
options: makeSseFn("OPTIONS"),
patch: makeSseFn("PATCH"),
post: makeSseFn("POST"),
put: makeSseFn("PUT"),
trace: makeSseFn("TRACE"),
},
trace: makeMethodFn("TRACE"),
} as Client
}

View file

@ -0,0 +1,25 @@
// This file is auto-generated by @hey-api/openapi-ts
export type { Auth } from "../core/auth.gen"
export type { QuerySerializerOptions } from "../core/bodySerializer.gen"
export {
formDataBodySerializer,
jsonBodySerializer,
urlSearchParamsBodySerializer,
} from "../core/bodySerializer.gen"
export { buildClientParams } from "../core/params.gen"
export { serializeQueryKeyValue } from "../core/queryKeySerializer.gen"
export { createClient } from "./client.gen"
export type {
Client,
ClientOptions,
Config,
CreateClientConfig,
Options,
RequestOptions,
RequestResult,
ResolvedRequestOptions,
ResponseStyle,
TDataShape,
} from "./types.gen"
export { createConfig, mergeHeaders } from "./utils.gen"

View file

@ -0,0 +1,202 @@
// This file is auto-generated by @hey-api/openapi-ts
import type { Auth } from "../core/auth.gen"
import type { ServerSentEventsOptions, ServerSentEventsResult } from "../core/serverSentEvents.gen"
import type { Client as CoreClient, Config as CoreConfig } from "../core/types.gen"
import type { Middleware } from "./utils.gen"
export type ResponseStyle = "data" | "fields"
export interface Config<T extends ClientOptions = ClientOptions>
extends Omit<RequestInit, "body" | "headers" | "method">,
CoreConfig {
/**
* Base URL for all requests made by this client.
*/
baseUrl?: T["baseUrl"]
/**
* Fetch API implementation. You can use this option to provide a custom
* fetch instance.
*
* @default globalThis.fetch
*/
fetch?: typeof fetch
/**
* Please don't use the Fetch client for Next.js applications. The `next`
* options won't have any effect.
*
* Install {@link https://www.npmjs.com/package/@hey-api/client-next `@hey-api/client-next`} instead.
*/
next?: never
/**
* Return the response data parsed in a specified format. By default, `auto`
* will infer the appropriate method from the `Content-Type` response header.
* You can override this behavior with any of the {@link Body} methods.
* Select `stream` if you don't want to parse response data at all.
*
* @default 'auto'
*/
parseAs?: "arrayBuffer" | "auto" | "blob" | "formData" | "json" | "stream" | "text"
/**
* Should we return only data or multiple fields (data, error, response, etc.)?
*
* @default 'fields'
*/
responseStyle?: ResponseStyle
/**
* Throw an error instead of returning it in the response?
*
* @default false
*/
throwOnError?: T["throwOnError"]
}
export interface RequestOptions<
TData = unknown,
TResponseStyle extends ResponseStyle = "fields",
ThrowOnError extends boolean = boolean,
Url extends string = string,
> extends Config<{
responseStyle: TResponseStyle
throwOnError: ThrowOnError
}>,
Pick<
ServerSentEventsOptions<TData>,
"onSseError" | "onSseEvent" | "sseDefaultRetryDelay" | "sseMaxRetryAttempts" | "sseMaxRetryDelay"
> {
/**
* Any body that you want to add to your request.
*
* {@link https://developer.mozilla.org/docs/Web/API/fetch#body}
*/
body?: unknown
path?: Record<string, unknown>
query?: Record<string, unknown>
/**
* Security mechanism(s) to use for the request.
*/
security?: ReadonlyArray<Auth>
url: Url
}
export interface ResolvedRequestOptions<
TResponseStyle extends ResponseStyle = "fields",
ThrowOnError extends boolean = boolean,
Url extends string = string,
> extends RequestOptions<unknown, TResponseStyle, ThrowOnError, Url> {
serializedBody?: string
}
export type RequestResult<
TData = unknown,
TError = unknown,
ThrowOnError extends boolean = boolean,
TResponseStyle extends ResponseStyle = "fields",
> = ThrowOnError extends true
? Promise<
TResponseStyle extends "data"
? TData extends Record<string, unknown>
? TData[keyof TData]
: TData
: {
data: TData extends Record<string, unknown> ? TData[keyof TData] : TData
request: Request
response: Response
}
>
: Promise<
TResponseStyle extends "data"
? (TData extends Record<string, unknown> ? TData[keyof TData] : TData) | undefined
: (
| {
data: TData extends Record<string, unknown> ? TData[keyof TData] : TData
error: undefined
}
| {
data: undefined
error: TError extends Record<string, unknown> ? TError[keyof TError] : TError
}
) & {
request: Request
response: Response
}
>
export interface ClientOptions {
baseUrl?: string
responseStyle?: ResponseStyle
throwOnError?: boolean
}
type MethodFn = <
TData = unknown,
TError = unknown,
ThrowOnError extends boolean = false,
TResponseStyle extends ResponseStyle = "fields",
>(
options: Omit<RequestOptions<TData, TResponseStyle, ThrowOnError>, "method">,
) => RequestResult<TData, TError, ThrowOnError, TResponseStyle>
type SseFn = <
TData = unknown,
TError = unknown,
ThrowOnError extends boolean = false,
TResponseStyle extends ResponseStyle = "fields",
>(
options: Omit<RequestOptions<TData, TResponseStyle, ThrowOnError>, "method">,
) => Promise<ServerSentEventsResult<TData, TError>>
type RequestFn = <
TData = unknown,
TError = unknown,
ThrowOnError extends boolean = false,
TResponseStyle extends ResponseStyle = "fields",
>(
options: Omit<RequestOptions<TData, TResponseStyle, ThrowOnError>, "method"> &
Pick<Required<RequestOptions<TData, TResponseStyle, ThrowOnError>>, "method">,
) => RequestResult<TData, TError, ThrowOnError, TResponseStyle>
type BuildUrlFn = <
TData extends {
body?: unknown
path?: Record<string, unknown>
query?: Record<string, unknown>
url: string
},
>(
options: TData & Options<TData>,
) => string
export type Client = CoreClient<RequestFn, Config, MethodFn, BuildUrlFn, SseFn> & {
interceptors: Middleware<Request, Response, unknown, ResolvedRequestOptions>
}
/**
* The `createClientConfig()` function will be called on client initialization
* and the returned object will become the client's initial configuration.
*
* You may want to initialize your client this way instead of calling
* `setConfig()`. This is useful for example if you're using Next.js
* to ensure your client always has the correct values.
*/
export type CreateClientConfig<T extends ClientOptions = ClientOptions> = (
override?: Config<ClientOptions & T>,
) => Config<Required<ClientOptions> & T>
export interface TDataShape {
body?: unknown
headers?: unknown
path?: unknown
query?: unknown
url: string
}
type OmitKeys<T, K> = Pick<T, Exclude<keyof T, K>>
export type Options<
TData extends TDataShape = TDataShape,
ThrowOnError extends boolean = boolean,
TResponse = unknown,
TResponseStyle extends ResponseStyle = "fields",
> = OmitKeys<RequestOptions<TResponse, TResponseStyle, ThrowOnError>, "body" | "path" | "query" | "url"> &
([TData] extends [never] ? unknown : Omit<TData, "url">)

View file

@ -0,0 +1,289 @@
// This file is auto-generated by @hey-api/openapi-ts
import { getAuthToken } from "../core/auth.gen"
import type { QuerySerializerOptions } from "../core/bodySerializer.gen"
import { jsonBodySerializer } from "../core/bodySerializer.gen"
import { serializeArrayParam, serializeObjectParam, serializePrimitiveParam } from "../core/pathSerializer.gen"
import { getUrl } from "../core/utils.gen"
import type { Client, ClientOptions, Config, RequestOptions } from "./types.gen"
export const createQuerySerializer = <T = unknown>({ parameters = {}, ...args }: QuerySerializerOptions = {}) => {
const querySerializer = (queryParams: T) => {
const search: string[] = []
if (queryParams && typeof queryParams === "object") {
for (const name in queryParams) {
const value = queryParams[name]
if (value === undefined || value === null) {
continue
}
const options = parameters[name] || args
if (Array.isArray(value)) {
const serializedArray = serializeArrayParam({
allowReserved: options.allowReserved,
explode: true,
name,
style: "form",
value,
...options.array,
})
if (serializedArray) search.push(serializedArray)
} else if (typeof value === "object") {
const serializedObject = serializeObjectParam({
allowReserved: options.allowReserved,
explode: true,
name,
style: "deepObject",
value: value as Record<string, unknown>,
...options.object,
})
if (serializedObject) search.push(serializedObject)
} else {
const serializedPrimitive = serializePrimitiveParam({
allowReserved: options.allowReserved,
name,
value: value as string,
})
if (serializedPrimitive) search.push(serializedPrimitive)
}
}
}
return search.join("&")
}
return querySerializer
}
/**
* Infers parseAs value from provided Content-Type header.
*/
export const getParseAs = (contentType: string | null): Exclude<Config["parseAs"], "auto"> => {
if (!contentType) {
// If no Content-Type header is provided, the best we can do is return the raw response body,
// which is effectively the same as the 'stream' option.
return "stream"
}
const cleanContent = contentType.split(";")[0]?.trim()
if (!cleanContent) {
return
}
if (cleanContent.startsWith("application/json") || cleanContent.endsWith("+json")) {
return "json"
}
if (cleanContent === "multipart/form-data") {
return "formData"
}
if (["application/", "audio/", "image/", "video/"].some((type) => cleanContent.startsWith(type))) {
return "blob"
}
if (cleanContent.startsWith("text/")) {
return "text"
}
return
}
const checkForExistence = (
options: Pick<RequestOptions, "auth" | "query"> & {
headers: Headers
},
name?: string,
): boolean => {
if (!name) {
return false
}
if (options.headers.has(name) || options.query?.[name] || options.headers.get("Cookie")?.includes(`${name}=`)) {
return true
}
return false
}
export const setAuthParams = async ({
security,
...options
}: Pick<Required<RequestOptions>, "security"> &
Pick<RequestOptions, "auth" | "query"> & {
headers: Headers
}) => {
for (const auth of security) {
if (checkForExistence(options, auth.name)) {
continue
}
const token = await getAuthToken(auth, options.auth)
if (!token) {
continue
}
const name = auth.name ?? "Authorization"
switch (auth.in) {
case "query":
if (!options.query) {
options.query = {}
}
options.query[name] = token
break
case "cookie":
options.headers.append("Cookie", `${name}=${token}`)
break
case "header":
default:
options.headers.set(name, token)
break
}
}
}
export const buildUrl: Client["buildUrl"] = (options) =>
getUrl({
baseUrl: options.baseUrl as string,
path: options.path,
query: options.query,
querySerializer:
typeof options.querySerializer === "function"
? options.querySerializer
: createQuerySerializer(options.querySerializer),
url: options.url,
})
export const mergeConfigs = (a: Config, b: Config): Config => {
const config = { ...a, ...b }
if (config.baseUrl?.endsWith("/")) {
config.baseUrl = config.baseUrl.substring(0, config.baseUrl.length - 1)
}
config.headers = mergeHeaders(a.headers, b.headers)
return config
}
const headersEntries = (headers: Headers): Array<[string, string]> => {
const entries: Array<[string, string]> = []
headers.forEach((value, key) => {
entries.push([key, value])
})
return entries
}
export const mergeHeaders = (...headers: Array<Required<Config>["headers"] | undefined>): Headers => {
const mergedHeaders = new Headers()
for (const header of headers) {
if (!header) {
continue
}
const iterator = header instanceof Headers ? headersEntries(header) : Object.entries(header)
for (const [key, value] of iterator) {
if (value === null) {
mergedHeaders.delete(key)
} else if (Array.isArray(value)) {
for (const v of value) {
mergedHeaders.append(key, v as string)
}
} else if (value !== undefined) {
// assume object headers are meant to be JSON stringified, i.e. their
// content value in OpenAPI specification is 'application/json'
mergedHeaders.set(key, typeof value === "object" ? JSON.stringify(value) : (value as string))
}
}
}
return mergedHeaders
}
type ErrInterceptor<Err, Res, Req, Options> = (
error: Err,
response: Res,
request: Req,
options: Options,
) => Err | Promise<Err>
type ReqInterceptor<Req, Options> = (request: Req, options: Options) => Req | Promise<Req>
type ResInterceptor<Res, Req, Options> = (response: Res, request: Req, options: Options) => Res | Promise<Res>
class Interceptors<Interceptor> {
fns: Array<Interceptor | null> = []
clear(): void {
this.fns = []
}
eject(id: number | Interceptor): void {
const index = this.getInterceptorIndex(id)
if (this.fns[index]) {
this.fns[index] = null
}
}
exists(id: number | Interceptor): boolean {
const index = this.getInterceptorIndex(id)
return Boolean(this.fns[index])
}
getInterceptorIndex(id: number | Interceptor): number {
if (typeof id === "number") {
return this.fns[id] ? id : -1
}
return this.fns.indexOf(id)
}
update(id: number | Interceptor, fn: Interceptor): number | Interceptor | false {
const index = this.getInterceptorIndex(id)
if (this.fns[index]) {
this.fns[index] = fn
return id
}
return false
}
use(fn: Interceptor): number {
this.fns.push(fn)
return this.fns.length - 1
}
}
export interface Middleware<Req, Res, Err, Options> {
error: Interceptors<ErrInterceptor<Err, Res, Req, Options>>
request: Interceptors<ReqInterceptor<Req, Options>>
response: Interceptors<ResInterceptor<Res, Req, Options>>
}
export const createInterceptors = <Req, Res, Err, Options>(): Middleware<Req, Res, Err, Options> => ({
error: new Interceptors<ErrInterceptor<Err, Res, Req, Options>>(),
request: new Interceptors<ReqInterceptor<Req, Options>>(),
response: new Interceptors<ResInterceptor<Res, Req, Options>>(),
})
const defaultQuerySerializer = createQuerySerializer({
allowReserved: false,
array: {
explode: true,
style: "form",
},
object: {
explode: true,
style: "deepObject",
},
})
const defaultHeaders = {
"Content-Type": "application/json",
}
export const createConfig = <T extends ClientOptions = ClientOptions>(
override: Config<Omit<ClientOptions, keyof T> & T> = {},
): Config<Omit<ClientOptions, keyof T> & T> => ({
...jsonBodySerializer,
headers: defaultHeaders,
parseAs: "auto",
querySerializer: defaultQuerySerializer,
...override,
})

View file

@ -0,0 +1,41 @@
// This file is auto-generated by @hey-api/openapi-ts
export type AuthToken = string | undefined
export interface Auth {
/**
* Which part of the request do we use to send the auth?
*
* @default 'header'
*/
in?: "header" | "query" | "cookie"
/**
* Header or query parameter name.
*
* @default 'Authorization'
*/
name?: string
scheme?: "basic" | "bearer"
type: "apiKey" | "http"
}
export const getAuthToken = async (
auth: Auth,
callback: ((auth: Auth) => Promise<AuthToken> | AuthToken) | AuthToken,
): Promise<string | undefined> => {
const token = typeof callback === "function" ? await callback(auth) : callback
if (!token) {
return
}
if (auth.scheme === "bearer") {
return `Bearer ${token}`
}
if (auth.scheme === "basic") {
return `Basic ${btoa(token)}`
}
return token
}

View file

@ -0,0 +1,82 @@
// This file is auto-generated by @hey-api/openapi-ts
import type { ArrayStyle, ObjectStyle, SerializerOptions } from "./pathSerializer.gen"
export type QuerySerializer = (query: Record<string, unknown>) => string
export type BodySerializer = (body: any) => any
type QuerySerializerOptionsObject = {
allowReserved?: boolean
array?: Partial<SerializerOptions<ArrayStyle>>
object?: Partial<SerializerOptions<ObjectStyle>>
}
export type QuerySerializerOptions = QuerySerializerOptionsObject & {
/**
* Per-parameter serialization overrides. When provided, these settings
* override the global array/object settings for specific parameter names.
*/
parameters?: Record<string, QuerySerializerOptionsObject>
}
const serializeFormDataPair = (data: FormData, key: string, value: unknown): void => {
if (typeof value === "string" || value instanceof Blob) {
data.append(key, value)
} else if (value instanceof Date) {
data.append(key, value.toISOString())
} else {
data.append(key, JSON.stringify(value))
}
}
const serializeUrlSearchParamsPair = (data: URLSearchParams, key: string, value: unknown): void => {
if (typeof value === "string") {
data.append(key, value)
} else {
data.append(key, JSON.stringify(value))
}
}
export const formDataBodySerializer = {
bodySerializer: <T extends Record<string, any> | Array<Record<string, any>>>(body: T): FormData => {
const data = new FormData()
Object.entries(body).forEach(([key, value]) => {
if (value === undefined || value === null) {
return
}
if (Array.isArray(value)) {
value.forEach((v) => serializeFormDataPair(data, key, v))
} else {
serializeFormDataPair(data, key, value)
}
})
return data
},
}
export const jsonBodySerializer = {
bodySerializer: <T>(body: T): string =>
JSON.stringify(body, (_key, value) => (typeof value === "bigint" ? value.toString() : value)),
}
export const urlSearchParamsBodySerializer = {
bodySerializer: <T extends Record<string, any> | Array<Record<string, any>>>(body: T): string => {
const data = new URLSearchParams()
Object.entries(body).forEach(([key, value]) => {
if (value === undefined || value === null) {
return
}
if (Array.isArray(value)) {
value.forEach((v) => serializeUrlSearchParamsPair(data, key, v))
} else {
serializeUrlSearchParamsPair(data, key, value)
}
})
return data.toString()
},
}

View file

@ -0,0 +1,169 @@
// This file is auto-generated by @hey-api/openapi-ts
type Slot = "body" | "headers" | "path" | "query"
export type Field =
| {
in: Exclude<Slot, "body">
/**
* Field name. This is the name we want the user to see and use.
*/
key: string
/**
* Field mapped name. This is the name we want to use in the request.
* If omitted, we use the same value as `key`.
*/
map?: string
}
| {
in: Extract<Slot, "body">
/**
* Key isn't required for bodies.
*/
key?: string
map?: string
}
| {
/**
* Field name. This is the name we want the user to see and use.
*/
key: string
/**
* Field mapped name. This is the name we want to use in the request.
* If `in` is omitted, `map` aliases `key` to the transport layer.
*/
map: Slot
}
export interface Fields {
allowExtra?: Partial<Record<Slot, boolean>>
args?: ReadonlyArray<Field>
}
export type FieldsConfig = ReadonlyArray<Field | Fields>
const extraPrefixesMap: Record<string, Slot> = {
$body_: "body",
$headers_: "headers",
$path_: "path",
$query_: "query",
}
const extraPrefixes = Object.entries(extraPrefixesMap)
type KeyMap = Map<
string,
| {
in: Slot
map?: string
}
| {
in?: never
map: Slot
}
>
const buildKeyMap = (fields: FieldsConfig, map?: KeyMap): KeyMap => {
if (!map) {
map = new Map()
}
for (const config of fields) {
if ("in" in config) {
if (config.key) {
map.set(config.key, {
in: config.in,
map: config.map,
})
}
} else if ("key" in config) {
map.set(config.key, {
map: config.map,
})
} else if (config.args) {
buildKeyMap(config.args, map)
}
}
return map
}
interface Params {
body: unknown
headers: Record<string, unknown>
path: Record<string, unknown>
query: Record<string, unknown>
}
const stripEmptySlots = (params: Params) => {
for (const [slot, value] of Object.entries(params)) {
if (value && typeof value === "object" && !Object.keys(value).length) {
delete params[slot as Slot]
}
}
}
export const buildClientParams = (args: ReadonlyArray<unknown>, fields: FieldsConfig) => {
const params: Params = {
body: {},
headers: {},
path: {},
query: {},
}
const map = buildKeyMap(fields)
let config: FieldsConfig[number] | undefined
for (const [index, arg] of args.entries()) {
if (fields[index]) {
config = fields[index]
}
if (!config) {
continue
}
if ("in" in config) {
if (config.key) {
const field = map.get(config.key)!
const name = field.map || config.key
if (field.in) {
;(params[field.in] as Record<string, unknown>)[name] = arg
}
} else {
params.body = arg
}
} else {
for (const [key, value] of Object.entries(arg ?? {})) {
const field = map.get(key)
if (field) {
if (field.in) {
const name = field.map || key
;(params[field.in] as Record<string, unknown>)[name] = value
} else {
params[field.map] = value
}
} else {
const extra = extraPrefixes.find(([prefix]) => key.startsWith(prefix))
if (extra) {
const [prefix, slot] = extra
;(params[slot] as Record<string, unknown>)[key.slice(prefix.length)] = value
} else if ("allowExtra" in config && config.allowExtra) {
for (const [slot, allowed] of Object.entries(config.allowExtra)) {
if (allowed) {
;(params[slot as Slot] as Record<string, unknown>)[key] = value
break
}
}
}
}
}
}
}
stripEmptySlots(params)
return params
}

View file

@ -0,0 +1,167 @@
// This file is auto-generated by @hey-api/openapi-ts
interface SerializeOptions<T> extends SerializePrimitiveOptions, SerializerOptions<T> {}
interface SerializePrimitiveOptions {
allowReserved?: boolean
name: string
}
export interface SerializerOptions<T> {
/**
* @default true
*/
explode: boolean
style: T
}
export type ArrayStyle = "form" | "spaceDelimited" | "pipeDelimited"
export type ArraySeparatorStyle = ArrayStyle | MatrixStyle
type MatrixStyle = "label" | "matrix" | "simple"
export type ObjectStyle = "form" | "deepObject"
type ObjectSeparatorStyle = ObjectStyle | MatrixStyle
interface SerializePrimitiveParam extends SerializePrimitiveOptions {
value: string
}
export const separatorArrayExplode = (style: ArraySeparatorStyle) => {
switch (style) {
case "label":
return "."
case "matrix":
return ";"
case "simple":
return ","
default:
return "&"
}
}
export const separatorArrayNoExplode = (style: ArraySeparatorStyle) => {
switch (style) {
case "form":
return ","
case "pipeDelimited":
return "|"
case "spaceDelimited":
return "%20"
default:
return ","
}
}
export const separatorObjectExplode = (style: ObjectSeparatorStyle) => {
switch (style) {
case "label":
return "."
case "matrix":
return ";"
case "simple":
return ","
default:
return "&"
}
}
export const serializeArrayParam = ({
allowReserved,
explode,
name,
style,
value,
}: SerializeOptions<ArraySeparatorStyle> & {
value: unknown[]
}) => {
if (!explode) {
const joinedValues = (allowReserved ? value : value.map((v) => encodeURIComponent(v as string))).join(
separatorArrayNoExplode(style),
)
switch (style) {
case "label":
return `.${joinedValues}`
case "matrix":
return `;${name}=${joinedValues}`
case "simple":
return joinedValues
default:
return `${name}=${joinedValues}`
}
}
const separator = separatorArrayExplode(style)
const joinedValues = value
.map((v) => {
if (style === "label" || style === "simple") {
return allowReserved ? v : encodeURIComponent(v as string)
}
return serializePrimitiveParam({
allowReserved,
name,
value: v as string,
})
})
.join(separator)
return style === "label" || style === "matrix" ? separator + joinedValues : joinedValues
}
export const serializePrimitiveParam = ({ allowReserved, name, value }: SerializePrimitiveParam) => {
if (value === undefined || value === null) {
return ""
}
if (typeof value === "object") {
throw new Error(
"Deeply-nested arrays/objects arent supported. Provide your own `querySerializer()` to handle these.",
)
}
return `${name}=${allowReserved ? value : encodeURIComponent(value)}`
}
export const serializeObjectParam = ({
allowReserved,
explode,
name,
style,
value,
valueOnly,
}: SerializeOptions<ObjectSeparatorStyle> & {
value: Record<string, unknown> | Date
valueOnly?: boolean
}) => {
if (value instanceof Date) {
return valueOnly ? value.toISOString() : `${name}=${value.toISOString()}`
}
if (style !== "deepObject" && !explode) {
let values: string[] = []
Object.entries(value).forEach(([key, v]) => {
values = [...values, key, allowReserved ? (v as string) : encodeURIComponent(v as string)]
})
const joinedValues = values.join(",")
switch (style) {
case "form":
return `${name}=${joinedValues}`
case "label":
return `.${joinedValues}`
case "matrix":
return `;${name}=${joinedValues}`
default:
return joinedValues
}
}
const separator = separatorObjectExplode(style)
const joinedValues = Object.entries(value)
.map(([key, v]) =>
serializePrimitiveParam({
allowReserved,
name: style === "deepObject" ? `${name}[${key}]` : key,
value: v as string,
}),
)
.join(separator)
return style === "label" || style === "matrix" ? separator + joinedValues : joinedValues
}

View file

@ -0,0 +1,111 @@
// This file is auto-generated by @hey-api/openapi-ts
/**
* JSON-friendly union that mirrors what Pinia Colada can hash.
*/
export type JsonValue = null | string | number | boolean | JsonValue[] | { [key: string]: JsonValue }
/**
* Replacer that converts non-JSON values (bigint, Date, etc.) to safe substitutes.
*/
export const queryKeyJsonReplacer = (_key: string, value: unknown) => {
if (value === undefined || typeof value === "function" || typeof value === "symbol") {
return undefined
}
if (typeof value === "bigint") {
return value.toString()
}
if (value instanceof Date) {
return value.toISOString()
}
return value
}
/**
* Safely stringifies a value and parses it back into a JsonValue.
*/
export const stringifyToJsonValue = (input: unknown): JsonValue | undefined => {
try {
const json = JSON.stringify(input, queryKeyJsonReplacer)
if (json === undefined) {
return undefined
}
return JSON.parse(json) as JsonValue
} catch {
return undefined
}
}
/**
* Detects plain objects (including objects with a null prototype).
*/
const isPlainObject = (value: unknown): value is Record<string, unknown> => {
if (value === null || typeof value !== "object") {
return false
}
const prototype = Object.getPrototypeOf(value as object)
return prototype === Object.prototype || prototype === null
}
/**
* Turns URLSearchParams into a sorted JSON object for deterministic keys.
*/
const serializeSearchParams = (params: URLSearchParams): JsonValue => {
const entries = Array.from(params.entries()).sort(([a], [b]) => a.localeCompare(b))
const result: Record<string, JsonValue> = {}
for (const [key, value] of entries) {
const existing = result[key]
if (existing === undefined) {
result[key] = value
continue
}
if (Array.isArray(existing)) {
;(existing as string[]).push(value)
} else {
result[key] = [existing, value]
}
}
return result
}
/**
* Normalizes any accepted value into a JSON-friendly shape for query keys.
*/
export const serializeQueryKeyValue = (value: unknown): JsonValue | undefined => {
if (value === null) {
return null
}
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
return value
}
if (value === undefined || typeof value === "function" || typeof value === "symbol") {
return undefined
}
if (typeof value === "bigint") {
return value.toString()
}
if (value instanceof Date) {
return value.toISOString()
}
if (Array.isArray(value)) {
return stringifyToJsonValue(value)
}
if (typeof URLSearchParams !== "undefined" && value instanceof URLSearchParams) {
return serializeSearchParams(value)
}
if (isPlainObject(value)) {
return stringifyToJsonValue(value)
}
return undefined
}

View file

@ -0,0 +1,239 @@
// This file is auto-generated by @hey-api/openapi-ts
import type { Config } from "./types.gen"
export type ServerSentEventsOptions<TData = unknown> = Omit<RequestInit, "method"> &
Pick<Config, "method" | "responseTransformer" | "responseValidator"> & {
/**
* Fetch API implementation. You can use this option to provide a custom
* fetch instance.
*
* @default globalThis.fetch
*/
fetch?: typeof fetch
/**
* Implementing clients can call request interceptors inside this hook.
*/
onRequest?: (url: string, init: RequestInit) => Promise<Request>
/**
* Callback invoked when a network or parsing error occurs during streaming.
*
* This option applies only if the endpoint returns a stream of events.
*
* @param error The error that occurred.
*/
onSseError?: (error: unknown) => void
/**
* Callback invoked when an event is streamed from the server.
*
* This option applies only if the endpoint returns a stream of events.
*
* @param event Event streamed from the server.
* @returns Nothing (void).
*/
onSseEvent?: (event: StreamEvent<TData>) => void
serializedBody?: RequestInit["body"]
/**
* Default retry delay in milliseconds.
*
* This option applies only if the endpoint returns a stream of events.
*
* @default 3000
*/
sseDefaultRetryDelay?: number
/**
* Maximum number of retry attempts before giving up.
*/
sseMaxRetryAttempts?: number
/**
* Maximum retry delay in milliseconds.
*
* Applies only when exponential backoff is used.
*
* This option applies only if the endpoint returns a stream of events.
*
* @default 30000
*/
sseMaxRetryDelay?: number
/**
* Optional sleep function for retry backoff.
*
* Defaults to using `setTimeout`.
*/
sseSleepFn?: (ms: number) => Promise<void>
url: string
}
export interface StreamEvent<TData = unknown> {
data: TData
event?: string
id?: string
retry?: number
}
export type ServerSentEventsResult<TData = unknown, TReturn = void, TNext = unknown> = {
stream: AsyncGenerator<TData extends Record<string, unknown> ? TData[keyof TData] : TData, TReturn, TNext>
}
export const createSseClient = <TData = unknown>({
onRequest,
onSseError,
onSseEvent,
responseTransformer,
responseValidator,
sseDefaultRetryDelay,
sseMaxRetryAttempts,
sseMaxRetryDelay,
sseSleepFn,
url,
...options
}: ServerSentEventsOptions): ServerSentEventsResult<TData> => {
let lastEventId: string | undefined
const sleep = sseSleepFn ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms)))
const createStream = async function* () {
let retryDelay: number = sseDefaultRetryDelay ?? 3000
let attempt = 0
const signal = options.signal ?? new AbortController().signal
while (true) {
if (signal.aborted) break
attempt++
const headers =
options.headers instanceof Headers
? options.headers
: new Headers(options.headers as Record<string, string> | undefined)
if (lastEventId !== undefined) {
headers.set("Last-Event-ID", lastEventId)
}
try {
const requestInit: RequestInit = {
redirect: "follow",
...options,
body: options.serializedBody,
headers,
signal,
}
let request = new Request(url, requestInit)
if (onRequest) {
request = await onRequest(url, requestInit)
}
// fetch must be assigned here, otherwise it would throw the error:
// TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation
const _fetch = options.fetch ?? globalThis.fetch
const response = await _fetch(request)
if (!response.ok) throw new Error(`SSE failed: ${response.status} ${response.statusText}`)
if (!response.body) throw new Error("No body in SSE response")
const reader = response.body.pipeThrough(new TextDecoderStream()).getReader()
let buffer = ""
const abortHandler = () => {
try {
reader.cancel()
} catch {
// noop
}
}
signal.addEventListener("abort", abortHandler)
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += value
// Normalize line endings: CRLF -> LF, then CR -> LF
buffer = buffer.replace(/\r\n/g, "\n").replace(/\r/g, "\n")
const chunks = buffer.split("\n\n")
buffer = chunks.pop() ?? ""
for (const chunk of chunks) {
const lines = chunk.split("\n")
const dataLines: Array<string> = []
let eventName: string | undefined
for (const line of lines) {
if (line.startsWith("data:")) {
dataLines.push(line.replace(/^data:\s*/, ""))
} else if (line.startsWith("event:")) {
eventName = line.replace(/^event:\s*/, "")
} else if (line.startsWith("id:")) {
lastEventId = line.replace(/^id:\s*/, "")
} else if (line.startsWith("retry:")) {
const parsed = Number.parseInt(line.replace(/^retry:\s*/, ""), 10)
if (!Number.isNaN(parsed)) {
retryDelay = parsed
}
}
}
let data: unknown
let parsedJson = false
if (dataLines.length) {
const rawData = dataLines.join("\n")
try {
data = JSON.parse(rawData)
parsedJson = true
} catch {
data = rawData
}
}
if (parsedJson) {
if (responseValidator) {
await responseValidator(data)
}
if (responseTransformer) {
data = await responseTransformer(data)
}
}
onSseEvent?.({
data,
event: eventName,
id: lastEventId,
retry: retryDelay,
})
if (dataLines.length) {
yield data as any
}
}
}
} finally {
signal.removeEventListener("abort", abortHandler)
reader.releaseLock()
}
break // exit loop on normal completion
} catch (error) {
// connection failed or aborted; retry after delay
onSseError?.(error)
if (sseMaxRetryAttempts !== undefined && attempt >= sseMaxRetryAttempts) {
break // stop after firing error
}
// exponential backoff: double retry each attempt, cap at 30s
const backoff = Math.min(retryDelay * 2 ** (attempt - 1), sseMaxRetryDelay ?? 30000)
await sleep(backoff)
}
}
}
const stream = createStream()
return { stream }
}

View file

@ -0,0 +1,86 @@
// This file is auto-generated by @hey-api/openapi-ts
import type { Auth, AuthToken } from "./auth.gen"
import type { BodySerializer, QuerySerializer, QuerySerializerOptions } from "./bodySerializer.gen"
export type HttpMethod = "connect" | "delete" | "get" | "head" | "options" | "patch" | "post" | "put" | "trace"
export type Client<RequestFn = never, Config = unknown, MethodFn = never, BuildUrlFn = never, SseFn = never> = {
/**
* Returns the final request URL.
*/
buildUrl: BuildUrlFn
getConfig: () => Config
request: RequestFn
setConfig: (config: Config) => Config
} & {
[K in HttpMethod]: MethodFn
} & ([SseFn] extends [never] ? { sse?: never } : { sse: { [K in HttpMethod]: SseFn } })
export interface Config {
/**
* Auth token or a function returning auth token. The resolved value will be
* added to the request payload as defined by its `security` array.
*/
auth?: ((auth: Auth) => Promise<AuthToken> | AuthToken) | AuthToken
/**
* A function for serializing request body parameter. By default,
* {@link JSON.stringify()} will be used.
*/
bodySerializer?: BodySerializer | null
/**
* An object containing any HTTP headers that you want to pre-populate your
* `Headers` object with.
*
* {@link https://developer.mozilla.org/docs/Web/API/Headers/Headers#init See more}
*/
headers?:
| RequestInit["headers"]
| Record<string, string | number | boolean | (string | number | boolean)[] | null | undefined | unknown>
/**
* The request method.
*
* {@link https://developer.mozilla.org/docs/Web/API/fetch#method See more}
*/
method?: Uppercase<HttpMethod>
/**
* A function for serializing request query parameters. By default, arrays
* will be exploded in form style, objects will be exploded in deepObject
* style, and reserved characters are percent-encoded.
*
* This method will have no effect if the native `paramsSerializer()` Axios
* API function is used.
*
* {@link https://swagger.io/docs/specification/serialization/#query View examples}
*/
querySerializer?: QuerySerializer | QuerySerializerOptions
/**
* A function validating request data. This is useful if you want to ensure
* the request conforms to the desired shape, so it can be safely sent to
* the server.
*/
requestValidator?: (data: unknown) => Promise<unknown>
/**
* A function transforming response data before it's returned. This is useful
* for post-processing data, e.g. converting ISO strings into Date objects.
*/
responseTransformer?: (data: unknown) => Promise<unknown>
/**
* A function validating response data. This is useful if you want to ensure
* the response conforms to the desired shape, so it can be safely passed to
* the transformers and returned to the user.
*/
responseValidator?: (data: unknown) => Promise<unknown>
}
type IsExactlyNeverOrNeverUndefined<T> = [T] extends [never]
? true
: [T] extends [never | undefined]
? [undefined] extends [T]
? false
: true
: false
export type OmitNever<T extends Record<string, unknown>> = {
[K in keyof T as IsExactlyNeverOrNeverUndefined<T[K]> extends true ? never : K]: T[K]
}

View file

@ -0,0 +1,137 @@
// This file is auto-generated by @hey-api/openapi-ts
import type { BodySerializer, QuerySerializer } from "./bodySerializer.gen"
import {
type ArraySeparatorStyle,
serializeArrayParam,
serializeObjectParam,
serializePrimitiveParam,
} from "./pathSerializer.gen"
export interface PathSerializer {
path: Record<string, unknown>
url: string
}
export const PATH_PARAM_RE = /\{[^{}]+\}/g
export const defaultPathSerializer = ({ path, url: _url }: PathSerializer) => {
let url = _url
const matches = _url.match(PATH_PARAM_RE)
if (matches) {
for (const match of matches) {
let explode = false
let name = match.substring(1, match.length - 1)
let style: ArraySeparatorStyle = "simple"
if (name.endsWith("*")) {
explode = true
name = name.substring(0, name.length - 1)
}
if (name.startsWith(".")) {
name = name.substring(1)
style = "label"
} else if (name.startsWith(";")) {
name = name.substring(1)
style = "matrix"
}
const value = path[name]
if (value === undefined || value === null) {
continue
}
if (Array.isArray(value)) {
url = url.replace(match, serializeArrayParam({ explode, name, style, value }))
continue
}
if (typeof value === "object") {
url = url.replace(
match,
serializeObjectParam({
explode,
name,
style,
value: value as Record<string, unknown>,
valueOnly: true,
}),
)
continue
}
if (style === "matrix") {
url = url.replace(
match,
`;${serializePrimitiveParam({
name,
value: value as string,
})}`,
)
continue
}
const replaceValue = encodeURIComponent(style === "label" ? `.${value as string}` : (value as string))
url = url.replace(match, replaceValue)
}
}
return url
}
export const getUrl = ({
baseUrl,
path,
query,
querySerializer,
url: _url,
}: {
baseUrl?: string
path?: Record<string, unknown>
query?: Record<string, unknown>
querySerializer: QuerySerializer
url: string
}) => {
const pathUrl = _url.startsWith("/") ? _url : `/${_url}`
let url = (baseUrl ?? "") + pathUrl
if (path) {
url = defaultPathSerializer({ path, url })
}
let search = query ? querySerializer(query) : ""
if (search.startsWith("?")) {
search = search.substring(1)
}
if (search) {
url += `?${search}`
}
return url
}
export function getValidRequestBody(options: {
body?: unknown
bodySerializer?: BodySerializer | null
serializedBody?: unknown
}) {
const hasBody = options.body !== undefined
const isSerializedBody = hasBody && options.bodySerializer
if (isSerializedBody) {
if ("serializedBody" in options) {
const hasSerializedBody = options.serializedBody !== undefined && options.serializedBody !== ""
return hasSerializedBody ? options.serializedBody : null
}
// not all clients implement a serializedBody property (i.e. client-axios)
return options.body !== "" ? options.body : null
}
// plain/text body
if (hasBody) {
return options.body
}
// no body was provided
return undefined
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

21
server/opencode/index.ts Normal file
View file

@ -0,0 +1,21 @@
export * from "./client"
export * from "./server"
import { createOpencodeClient } from "./client"
import { createOpencodeServer } from "./server"
import type { ServerOptions } from "./server"
export async function createOpencode(options?: ServerOptions) {
const server = await createOpencodeServer({
...options,
})
const client = createOpencodeClient({
baseUrl: server.url,
})
return {
client,
server,
}
}

125
server/opencode/server.ts Normal file
View file

@ -0,0 +1,125 @@
import { spawn } from "node:child_process"
import { type Config } from "./gen/types.gen"
export type ServerOptions = {
hostname?: string
port?: number
signal?: AbortSignal
timeout?: number
config?: Config
}
export type TuiOptions = {
project?: string
model?: string
session?: string
agent?: string
signal?: AbortSignal
config?: Config
}
export async function createOpencodeServer(options?: ServerOptions) {
options = Object.assign(
{
hostname: "127.0.0.1",
port: 4096,
timeout: 5000,
},
options ?? {},
)
const args = [`serve`, `--hostname=${options.hostname}`, `--port=${options.port}`]
if (options.config?.logLevel) args.push(`--log-level=${options.config.logLevel}`)
const proc = spawn(`opencode`, args, {
shell: process.platform === 'win32',
signal: options.signal,
env: {
...process.env,
OPENCODE_CONFIG_CONTENT: JSON.stringify(options.config ?? {}),
},
})
const url = await new Promise<string>((resolve, reject) => {
const id = setTimeout(() => {
reject(new Error(`Timeout waiting for server to start after ${options.timeout}ms`))
}, options.timeout)
let output = ""
proc.stdout?.on("data", (chunk) => {
output += chunk.toString()
const lines = output.split("\n")
for (const line of lines) {
if (line.startsWith("opencode server listening")) {
const match = line.match(/on\s+(https?:\/\/[^\s]+)/)
if (!match) {
throw new Error(`Failed to parse server url from output: ${line}`)
}
clearTimeout(id)
resolve(match[1]!)
return
}
}
})
proc.stderr?.on("data", (chunk) => {
output += chunk.toString()
})
proc.on("exit", (code) => {
clearTimeout(id)
let msg = `Server exited with code ${code}`
if (output.trim()) {
msg += `\nServer output: ${output}`
}
reject(new Error(msg))
})
proc.on("error", (error) => {
clearTimeout(id)
reject(error)
})
if (options.signal) {
options.signal.addEventListener("abort", () => {
clearTimeout(id)
reject(new Error("Aborted"))
})
}
})
return {
url,
close() {
proc.kill()
},
}
}
export function createOpencodeTui(options?: TuiOptions) {
const args = []
if (options?.project) {
args.push(`--project=${options.project}`)
}
if (options?.model) {
args.push(`--model=${options.model}`)
}
if (options?.session) {
args.push(`--session=${options.session}`)
}
if (options?.agent) {
args.push(`--agent=${options.agent}`)
}
const proc = spawn(`opencode`, args, {
shell: process.platform === 'win32',
signal: options?.signal,
stdio: "inherit",
env: {
...process.env,
OPENCODE_CONFIG_CONTENT: JSON.stringify(options?.config ?? {}),
},
})
return {
close() {
proc.kill()
},
}
}

View file

@ -0,0 +1,39 @@
export * from "./gen/types.gen.js"
import { createClient } from "./gen/client/client.gen.js"
import { type Config } from "./gen/client/types.gen.js"
import { OpencodeClient } from "./gen/sdk.gen.js"
export { type Config as OpencodeClientConfig, OpencodeClient }
export function createOpencodeClient(config?: Config & { directory?: string; experimental_workspaceID?: string }) {
if (!config?.fetch) {
const customFetch: any = (req: any) => {
// @ts-ignore
req.timeout = false
return fetch(req)
}
config = {
...config,
fetch: customFetch,
}
}
if (config?.directory) {
const isNonASCII = /[^\x00-\x7F]/.test(config.directory)
const encodedDirectory = isNonASCII ? encodeURIComponent(config.directory) : config.directory
config.headers = {
...config.headers,
"x-opencode-directory": encodedDirectory,
}
}
if (config?.experimental_workspaceID) {
config.headers = {
...config.headers,
"x-opencode-workspace": config.experimental_workspaceID,
}
}
const client = createClient(config)
return new OpencodeClient({ client })
}

View file

@ -0,0 +1,18 @@
// This file is auto-generated by @hey-api/openapi-ts
import { type ClientOptions, type Config, createClient, createConfig } from "./client/index.js"
import type { ClientOptions as ClientOptions2 } from "./types.gen.js"
/**
* The `createClientConfig()` function will be called on client initialization
* and the returned object will become the client's initial configuration.
*
* You may want to initialize your client this way instead of calling
* `setConfig()`. This is useful for example if you're using Next.js
* to ensure your client always has the correct values.
*/
export type CreateClientConfig<T extends ClientOptions = ClientOptions2> = (
override?: Config<ClientOptions & T>,
) => Config<Required<ClientOptions> & T>
export const client = createClient(createConfig<ClientOptions2>({ baseUrl: "http://localhost:4096" }))

View file

@ -0,0 +1,285 @@
// This file is auto-generated by @hey-api/openapi-ts
import { createSseClient } from "../core/serverSentEvents.gen.js"
import type { HttpMethod } from "../core/types.gen.js"
import { getValidRequestBody } from "../core/utils.gen.js"
import type { Client, Config, RequestOptions, ResolvedRequestOptions } from "./types.gen.js"
import {
buildUrl,
createConfig,
createInterceptors,
getParseAs,
mergeConfigs,
mergeHeaders,
setAuthParams,
} from "./utils.gen.js"
type ReqInit = Omit<RequestInit, "body" | "headers"> & {
body?: any
headers: ReturnType<typeof mergeHeaders>
}
export const createClient = (config: Config = {}): Client => {
let _config = mergeConfigs(createConfig(), config)
const getConfig = (): Config => ({ ..._config })
const setConfig = (config: Config): Config => {
_config = mergeConfigs(_config, config)
return getConfig()
}
const interceptors = createInterceptors<Request, Response, unknown, ResolvedRequestOptions>()
const beforeRequest = async (options: RequestOptions) => {
const opts = {
..._config,
...options,
fetch: options.fetch ?? _config.fetch ?? globalThis.fetch,
headers: mergeHeaders(_config.headers, options.headers),
serializedBody: undefined,
}
if (opts.security) {
await setAuthParams({
...opts,
security: opts.security,
})
}
if (opts.requestValidator) {
await opts.requestValidator(opts)
}
if (opts.body !== undefined && opts.bodySerializer) {
opts.serializedBody = opts.bodySerializer(opts.body)
}
// remove Content-Type header if body is empty to avoid sending invalid requests
if (opts.body === undefined || opts.serializedBody === "") {
opts.headers.delete("Content-Type")
}
const url = buildUrl(opts)
return { opts, url }
}
const request: Client["request"] = async (options) => {
// @ts-expect-error
const { opts, url } = await beforeRequest(options)
const requestInit: ReqInit = {
redirect: "follow",
...opts,
body: getValidRequestBody(opts),
}
let request = new Request(url, requestInit)
for (const fn of interceptors.request.fns) {
if (fn) {
request = await fn(request, opts)
}
}
// fetch must be assigned here, otherwise it would throw the error:
// TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation
const _fetch = opts.fetch!
let response: Response
try {
response = await _fetch(request)
} catch (error) {
// Handle fetch exceptions (AbortError, network errors, etc.)
let finalError = error
for (const fn of interceptors.error.fns) {
if (fn) {
finalError = (await fn(error, undefined as any, request, opts)) as unknown
}
}
finalError = finalError || ({} as unknown)
if (opts.throwOnError) {
throw finalError
}
// Return error response
return opts.responseStyle === "data"
? undefined
: {
error: finalError,
request,
response: undefined as any,
}
}
for (const fn of interceptors.response.fns) {
if (fn) {
response = await fn(response, request, opts)
}
}
const result = {
request,
response,
}
if (response.ok) {
const parseAs =
(opts.parseAs === "auto" ? getParseAs(response.headers.get("Content-Type")) : opts.parseAs) ?? "json"
if (response.status === 204 || response.headers.get("Content-Length") === "0") {
let emptyData: any
switch (parseAs) {
case "arrayBuffer":
case "blob":
case "text":
emptyData = await response[parseAs]()
break
case "formData":
emptyData = new FormData()
break
case "stream":
emptyData = response.body
break
case "json":
default:
emptyData = {}
break
}
return opts.responseStyle === "data"
? emptyData
: {
data: emptyData,
...result,
}
}
let data: any
switch (parseAs) {
case "arrayBuffer":
case "blob":
case "formData":
case "text":
data = await response[parseAs]()
break
case "json": {
// Some servers return 200 with no Content-Length and empty body.
// response.json() would throw; read as text and parse if non-empty.
const text = await response.text()
data = text ? JSON.parse(text) : {}
break
}
case "stream":
return opts.responseStyle === "data"
? response.body
: {
data: response.body,
...result,
}
}
if (parseAs === "json") {
if (opts.responseValidator) {
await opts.responseValidator(data)
}
if (opts.responseTransformer) {
data = await opts.responseTransformer(data)
}
}
return opts.responseStyle === "data"
? data
: {
data,
...result,
}
}
const textError = await response.text()
let jsonError: unknown
try {
jsonError = JSON.parse(textError)
} catch {
// noop
}
const error = jsonError ?? textError
let finalError = error
for (const fn of interceptors.error.fns) {
if (fn) {
finalError = (await fn(error, response, request, opts)) as string
}
}
finalError = finalError || ({} as string)
if (opts.throwOnError) {
throw finalError
}
// TODO: we probably want to return error and improve types
return opts.responseStyle === "data"
? undefined
: {
error: finalError,
...result,
}
}
const makeMethodFn = (method: Uppercase<HttpMethod>) => (options: RequestOptions) => request({ ...options, method })
const makeSseFn = (method: Uppercase<HttpMethod>) => async (options: RequestOptions) => {
const { opts, url } = await beforeRequest(options)
return createSseClient({
...opts,
body: opts.body as BodyInit | null | undefined,
headers: opts.headers as unknown as Record<string, string>,
method,
onRequest: async (url, init) => {
let request = new Request(url, init)
for (const fn of interceptors.request.fns) {
if (fn) {
request = await fn(request, opts)
}
}
return request
},
serializedBody: getValidRequestBody(opts) as BodyInit | null | undefined,
url,
})
}
return {
buildUrl,
connect: makeMethodFn("CONNECT"),
delete: makeMethodFn("DELETE"),
get: makeMethodFn("GET"),
getConfig,
head: makeMethodFn("HEAD"),
interceptors,
options: makeMethodFn("OPTIONS"),
patch: makeMethodFn("PATCH"),
post: makeMethodFn("POST"),
put: makeMethodFn("PUT"),
request,
setConfig,
sse: {
connect: makeSseFn("CONNECT"),
delete: makeSseFn("DELETE"),
get: makeSseFn("GET"),
head: makeSseFn("HEAD"),
options: makeSseFn("OPTIONS"),
patch: makeSseFn("PATCH"),
post: makeSseFn("POST"),
put: makeSseFn("PUT"),
trace: makeSseFn("TRACE"),
},
trace: makeMethodFn("TRACE"),
} as Client
}

View file

@ -0,0 +1,25 @@
// This file is auto-generated by @hey-api/openapi-ts
export type { Auth } from "../core/auth.gen.js"
export type { QuerySerializerOptions } from "../core/bodySerializer.gen.js"
export {
formDataBodySerializer,
jsonBodySerializer,
urlSearchParamsBodySerializer,
} from "../core/bodySerializer.gen.js"
export { buildClientParams } from "../core/params.gen.js"
export { serializeQueryKeyValue } from "../core/queryKeySerializer.gen.js"
export { createClient } from "./client.gen.js"
export type {
Client,
ClientOptions,
Config,
CreateClientConfig,
Options,
RequestOptions,
RequestResult,
ResolvedRequestOptions,
ResponseStyle,
TDataShape,
} from "./types.gen.js"
export { createConfig, mergeHeaders } from "./utils.gen.js"

View file

@ -0,0 +1,202 @@
// This file is auto-generated by @hey-api/openapi-ts
import type { Auth } from "../core/auth.gen.js"
import type { ServerSentEventsOptions, ServerSentEventsResult } from "../core/serverSentEvents.gen.js"
import type { Client as CoreClient, Config as CoreConfig } from "../core/types.gen.js"
import type { Middleware } from "./utils.gen.js"
export type ResponseStyle = "data" | "fields"
export interface Config<T extends ClientOptions = ClientOptions>
extends Omit<RequestInit, "body" | "headers" | "method">,
CoreConfig {
/**
* Base URL for all requests made by this client.
*/
baseUrl?: T["baseUrl"]
/**
* Fetch API implementation. You can use this option to provide a custom
* fetch instance.
*
* @default globalThis.fetch
*/
fetch?: typeof fetch
/**
* Please don't use the Fetch client for Next.js applications. The `next`
* options won't have any effect.
*
* Install {@link https://www.npmjs.com/package/@hey-api/client-next `@hey-api/client-next`} instead.
*/
next?: never
/**
* Return the response data parsed in a specified format. By default, `auto`
* will infer the appropriate method from the `Content-Type` response header.
* You can override this behavior with any of the {@link Body} methods.
* Select `stream` if you don't want to parse response data at all.
*
* @default 'auto'
*/
parseAs?: "arrayBuffer" | "auto" | "blob" | "formData" | "json" | "stream" | "text"
/**
* Should we return only data or multiple fields (data, error, response, etc.)?
*
* @default 'fields'
*/
responseStyle?: ResponseStyle
/**
* Throw an error instead of returning it in the response?
*
* @default false
*/
throwOnError?: T["throwOnError"]
}
export interface RequestOptions<
TData = unknown,
TResponseStyle extends ResponseStyle = "fields",
ThrowOnError extends boolean = boolean,
Url extends string = string,
> extends Config<{
responseStyle: TResponseStyle
throwOnError: ThrowOnError
}>,
Pick<
ServerSentEventsOptions<TData>,
"onSseError" | "onSseEvent" | "sseDefaultRetryDelay" | "sseMaxRetryAttempts" | "sseMaxRetryDelay"
> {
/**
* Any body that you want to add to your request.
*
* {@link https://developer.mozilla.org/docs/Web/API/fetch#body}
*/
body?: unknown
path?: Record<string, unknown>
query?: Record<string, unknown>
/**
* Security mechanism(s) to use for the request.
*/
security?: ReadonlyArray<Auth>
url: Url
}
export interface ResolvedRequestOptions<
TResponseStyle extends ResponseStyle = "fields",
ThrowOnError extends boolean = boolean,
Url extends string = string,
> extends RequestOptions<unknown, TResponseStyle, ThrowOnError, Url> {
serializedBody?: string
}
export type RequestResult<
TData = unknown,
TError = unknown,
ThrowOnError extends boolean = boolean,
TResponseStyle extends ResponseStyle = "fields",
> = ThrowOnError extends true
? Promise<
TResponseStyle extends "data"
? TData extends Record<string, unknown>
? TData[keyof TData]
: TData
: {
data: TData extends Record<string, unknown> ? TData[keyof TData] : TData
request: Request
response: Response
}
>
: Promise<
TResponseStyle extends "data"
? (TData extends Record<string, unknown> ? TData[keyof TData] : TData) | undefined
: (
| {
data: TData extends Record<string, unknown> ? TData[keyof TData] : TData
error: undefined
}
| {
data: undefined
error: TError extends Record<string, unknown> ? TError[keyof TError] : TError
}
) & {
request: Request
response: Response
}
>
export interface ClientOptions {
baseUrl?: string
responseStyle?: ResponseStyle
throwOnError?: boolean
}
type MethodFn = <
TData = unknown,
TError = unknown,
ThrowOnError extends boolean = false,
TResponseStyle extends ResponseStyle = "fields",
>(
options: Omit<RequestOptions<TData, TResponseStyle, ThrowOnError>, "method">,
) => RequestResult<TData, TError, ThrowOnError, TResponseStyle>
type SseFn = <
TData = unknown,
TError = unknown,
ThrowOnError extends boolean = false,
TResponseStyle extends ResponseStyle = "fields",
>(
options: Omit<RequestOptions<TData, TResponseStyle, ThrowOnError>, "method">,
) => Promise<ServerSentEventsResult<TData, TError>>
type RequestFn = <
TData = unknown,
TError = unknown,
ThrowOnError extends boolean = false,
TResponseStyle extends ResponseStyle = "fields",
>(
options: Omit<RequestOptions<TData, TResponseStyle, ThrowOnError>, "method"> &
Pick<Required<RequestOptions<TData, TResponseStyle, ThrowOnError>>, "method">,
) => RequestResult<TData, TError, ThrowOnError, TResponseStyle>
type BuildUrlFn = <
TData extends {
body?: unknown
path?: Record<string, unknown>
query?: Record<string, unknown>
url: string
},
>(
options: TData & Options<TData>,
) => string
export type Client = CoreClient<RequestFn, Config, MethodFn, BuildUrlFn, SseFn> & {
interceptors: Middleware<Request, Response, unknown, ResolvedRequestOptions>
}
/**
* The `createClientConfig()` function will be called on client initialization
* and the returned object will become the client's initial configuration.
*
* You may want to initialize your client this way instead of calling
* `setConfig()`. This is useful for example if you're using Next.js
* to ensure your client always has the correct values.
*/
export type CreateClientConfig<T extends ClientOptions = ClientOptions> = (
override?: Config<ClientOptions & T>,
) => Config<Required<ClientOptions> & T>
export interface TDataShape {
body?: unknown
headers?: unknown
path?: unknown
query?: unknown
url: string
}
type OmitKeys<T, K> = Pick<T, Exclude<keyof T, K>>
export type Options<
TData extends TDataShape = TDataShape,
ThrowOnError extends boolean = boolean,
TResponse = unknown,
TResponseStyle extends ResponseStyle = "fields",
> = OmitKeys<RequestOptions<TResponse, TResponseStyle, ThrowOnError>, "body" | "path" | "query" | "url"> &
([TData] extends [never] ? unknown : Omit<TData, "url">)

View file

@ -0,0 +1,289 @@
// This file is auto-generated by @hey-api/openapi-ts
import { getAuthToken } from "../core/auth.gen.js"
import type { QuerySerializerOptions } from "../core/bodySerializer.gen.js"
import { jsonBodySerializer } from "../core/bodySerializer.gen.js"
import { serializeArrayParam, serializeObjectParam, serializePrimitiveParam } from "../core/pathSerializer.gen.js"
import { getUrl } from "../core/utils.gen.js"
import type { Client, ClientOptions, Config, RequestOptions } from "./types.gen.js"
export const createQuerySerializer = <T = unknown>({ parameters = {}, ...args }: QuerySerializerOptions = {}) => {
const querySerializer = (queryParams: T) => {
const search: string[] = []
if (queryParams && typeof queryParams === "object") {
for (const name in queryParams) {
const value = queryParams[name]
if (value === undefined || value === null) {
continue
}
const options = parameters[name] || args
if (Array.isArray(value)) {
const serializedArray = serializeArrayParam({
allowReserved: options.allowReserved,
explode: true,
name,
style: "form",
value,
...options.array,
})
if (serializedArray) search.push(serializedArray)
} else if (typeof value === "object") {
const serializedObject = serializeObjectParam({
allowReserved: options.allowReserved,
explode: true,
name,
style: "deepObject",
value: value as Record<string, unknown>,
...options.object,
})
if (serializedObject) search.push(serializedObject)
} else {
const serializedPrimitive = serializePrimitiveParam({
allowReserved: options.allowReserved,
name,
value: value as string,
})
if (serializedPrimitive) search.push(serializedPrimitive)
}
}
}
return search.join("&")
}
return querySerializer
}
/**
* Infers parseAs value from provided Content-Type header.
*/
export const getParseAs = (contentType: string | null): Exclude<Config["parseAs"], "auto"> => {
if (!contentType) {
// If no Content-Type header is provided, the best we can do is return the raw response body,
// which is effectively the same as the 'stream' option.
return "stream"
}
const cleanContent = contentType.split(";")[0]?.trim()
if (!cleanContent) {
return
}
if (cleanContent.startsWith("application/json") || cleanContent.endsWith("+json")) {
return "json"
}
if (cleanContent === "multipart/form-data") {
return "formData"
}
if (["application/", "audio/", "image/", "video/"].some((type) => cleanContent.startsWith(type))) {
return "blob"
}
if (cleanContent.startsWith("text/")) {
return "text"
}
return
}
const checkForExistence = (
options: Pick<RequestOptions, "auth" | "query"> & {
headers: Headers
},
name?: string,
): boolean => {
if (!name) {
return false
}
if (options.headers.has(name) || options.query?.[name] || options.headers.get("Cookie")?.includes(`${name}=`)) {
return true
}
return false
}
export const setAuthParams = async ({
security,
...options
}: Pick<Required<RequestOptions>, "security"> &
Pick<RequestOptions, "auth" | "query"> & {
headers: Headers
}) => {
for (const auth of security) {
if (checkForExistence(options, auth.name)) {
continue
}
const token = await getAuthToken(auth, options.auth)
if (!token) {
continue
}
const name = auth.name ?? "Authorization"
switch (auth.in) {
case "query":
if (!options.query) {
options.query = {}
}
options.query[name] = token
break
case "cookie":
options.headers.append("Cookie", `${name}=${token}`)
break
case "header":
default:
options.headers.set(name, token)
break
}
}
}
export const buildUrl: Client["buildUrl"] = (options) =>
getUrl({
baseUrl: options.baseUrl as string,
path: options.path,
query: options.query,
querySerializer:
typeof options.querySerializer === "function"
? options.querySerializer
: createQuerySerializer(options.querySerializer),
url: options.url,
})
export const mergeConfigs = (a: Config, b: Config): Config => {
const config = { ...a, ...b }
if (config.baseUrl?.endsWith("/")) {
config.baseUrl = config.baseUrl.substring(0, config.baseUrl.length - 1)
}
config.headers = mergeHeaders(a.headers, b.headers)
return config
}
const headersEntries = (headers: Headers): Array<[string, string]> => {
const entries: Array<[string, string]> = []
headers.forEach((value, key) => {
entries.push([key, value])
})
return entries
}
export const mergeHeaders = (...headers: Array<Required<Config>["headers"] | undefined>): Headers => {
const mergedHeaders = new Headers()
for (const header of headers) {
if (!header) {
continue
}
const iterator = header instanceof Headers ? headersEntries(header) : Object.entries(header)
for (const [key, value] of iterator) {
if (value === null) {
mergedHeaders.delete(key)
} else if (Array.isArray(value)) {
for (const v of value) {
mergedHeaders.append(key, v as string)
}
} else if (value !== undefined) {
// assume object headers are meant to be JSON stringified, i.e. their
// content value in OpenAPI specification is 'application/json'
mergedHeaders.set(key, typeof value === "object" ? JSON.stringify(value) : (value as string))
}
}
}
return mergedHeaders
}
type ErrInterceptor<Err, Res, Req, Options> = (
error: Err,
response: Res,
request: Req,
options: Options,
) => Err | Promise<Err>
type ReqInterceptor<Req, Options> = (request: Req, options: Options) => Req | Promise<Req>
type ResInterceptor<Res, Req, Options> = (response: Res, request: Req, options: Options) => Res | Promise<Res>
class Interceptors<Interceptor> {
fns: Array<Interceptor | null> = []
clear(): void {
this.fns = []
}
eject(id: number | Interceptor): void {
const index = this.getInterceptorIndex(id)
if (this.fns[index]) {
this.fns[index] = null
}
}
exists(id: number | Interceptor): boolean {
const index = this.getInterceptorIndex(id)
return Boolean(this.fns[index])
}
getInterceptorIndex(id: number | Interceptor): number {
if (typeof id === "number") {
return this.fns[id] ? id : -1
}
return this.fns.indexOf(id)
}
update(id: number | Interceptor, fn: Interceptor): number | Interceptor | false {
const index = this.getInterceptorIndex(id)
if (this.fns[index]) {
this.fns[index] = fn
return id
}
return false
}
use(fn: Interceptor): number {
this.fns.push(fn)
return this.fns.length - 1
}
}
export interface Middleware<Req, Res, Err, Options> {
error: Interceptors<ErrInterceptor<Err, Res, Req, Options>>
request: Interceptors<ReqInterceptor<Req, Options>>
response: Interceptors<ResInterceptor<Res, Req, Options>>
}
export const createInterceptors = <Req, Res, Err, Options>(): Middleware<Req, Res, Err, Options> => ({
error: new Interceptors<ErrInterceptor<Err, Res, Req, Options>>(),
request: new Interceptors<ReqInterceptor<Req, Options>>(),
response: new Interceptors<ResInterceptor<Res, Req, Options>>(),
})
const defaultQuerySerializer = createQuerySerializer({
allowReserved: false,
array: {
explode: true,
style: "form",
},
object: {
explode: true,
style: "deepObject",
},
})
const defaultHeaders = {
"Content-Type": "application/json",
}
export const createConfig = <T extends ClientOptions = ClientOptions>(
override: Config<Omit<ClientOptions, keyof T> & T> = {},
): Config<Omit<ClientOptions, keyof T> & T> => ({
...jsonBodySerializer,
headers: defaultHeaders,
parseAs: "auto",
querySerializer: defaultQuerySerializer,
...override,
})

View file

@ -0,0 +1,41 @@
// This file is auto-generated by @hey-api/openapi-ts
export type AuthToken = string | undefined
export interface Auth {
/**
* Which part of the request do we use to send the auth?
*
* @default 'header'
*/
in?: "header" | "query" | "cookie"
/**
* Header or query parameter name.
*
* @default 'Authorization'
*/
name?: string
scheme?: "basic" | "bearer"
type: "apiKey" | "http"
}
export const getAuthToken = async (
auth: Auth,
callback: ((auth: Auth) => Promise<AuthToken> | AuthToken) | AuthToken,
): Promise<string | undefined> => {
const token = typeof callback === "function" ? await callback(auth) : callback
if (!token) {
return
}
if (auth.scheme === "bearer") {
return `Bearer ${token}`
}
if (auth.scheme === "basic") {
return `Basic ${btoa(token)}`
}
return token
}

View file

@ -0,0 +1,82 @@
// This file is auto-generated by @hey-api/openapi-ts
import type { ArrayStyle, ObjectStyle, SerializerOptions } from "./pathSerializer.gen.js"
export type QuerySerializer = (query: Record<string, unknown>) => string
export type BodySerializer = (body: any) => any
type QuerySerializerOptionsObject = {
allowReserved?: boolean
array?: Partial<SerializerOptions<ArrayStyle>>
object?: Partial<SerializerOptions<ObjectStyle>>
}
export type QuerySerializerOptions = QuerySerializerOptionsObject & {
/**
* Per-parameter serialization overrides. When provided, these settings
* override the global array/object settings for specific parameter names.
*/
parameters?: Record<string, QuerySerializerOptionsObject>
}
const serializeFormDataPair = (data: FormData, key: string, value: unknown): void => {
if (typeof value === "string" || value instanceof Blob) {
data.append(key, value)
} else if (value instanceof Date) {
data.append(key, value.toISOString())
} else {
data.append(key, JSON.stringify(value))
}
}
const serializeUrlSearchParamsPair = (data: URLSearchParams, key: string, value: unknown): void => {
if (typeof value === "string") {
data.append(key, value)
} else {
data.append(key, JSON.stringify(value))
}
}
export const formDataBodySerializer = {
bodySerializer: <T extends Record<string, any> | Array<Record<string, any>>>(body: T): FormData => {
const data = new FormData()
Object.entries(body).forEach(([key, value]) => {
if (value === undefined || value === null) {
return
}
if (Array.isArray(value)) {
value.forEach((v) => serializeFormDataPair(data, key, v))
} else {
serializeFormDataPair(data, key, value)
}
})
return data
},
}
export const jsonBodySerializer = {
bodySerializer: <T>(body: T): string =>
JSON.stringify(body, (_key, value) => (typeof value === "bigint" ? value.toString() : value)),
}
export const urlSearchParamsBodySerializer = {
bodySerializer: <T extends Record<string, any> | Array<Record<string, any>>>(body: T): string => {
const data = new URLSearchParams()
Object.entries(body).forEach(([key, value]) => {
if (value === undefined || value === null) {
return
}
if (Array.isArray(value)) {
value.forEach((v) => serializeUrlSearchParamsPair(data, key, v))
} else {
serializeUrlSearchParamsPair(data, key, value)
}
})
return data.toString()
},
}

View file

@ -0,0 +1,169 @@
// This file is auto-generated by @hey-api/openapi-ts
type Slot = "body" | "headers" | "path" | "query"
export type Field =
| {
in: Exclude<Slot, "body">
/**
* Field name. This is the name we want the user to see and use.
*/
key: string
/**
* Field mapped name. This is the name we want to use in the request.
* If omitted, we use the same value as `key`.
*/
map?: string
}
| {
in: Extract<Slot, "body">
/**
* Key isn't required for bodies.
*/
key?: string
map?: string
}
| {
/**
* Field name. This is the name we want the user to see and use.
*/
key: string
/**
* Field mapped name. This is the name we want to use in the request.
* If `in` is omitted, `map` aliases `key` to the transport layer.
*/
map: Slot
}
export interface Fields {
allowExtra?: Partial<Record<Slot, boolean>>
args?: ReadonlyArray<Field>
}
export type FieldsConfig = ReadonlyArray<Field | Fields>
const extraPrefixesMap: Record<string, Slot> = {
$body_: "body",
$headers_: "headers",
$path_: "path",
$query_: "query",
}
const extraPrefixes = Object.entries(extraPrefixesMap)
type KeyMap = Map<
string,
| {
in: Slot
map?: string
}
| {
in?: never
map: Slot
}
>
const buildKeyMap = (fields: FieldsConfig, map?: KeyMap): KeyMap => {
if (!map) {
map = new Map()
}
for (const config of fields) {
if ("in" in config) {
if (config.key) {
map.set(config.key, {
in: config.in,
map: config.map,
})
}
} else if ("key" in config) {
map.set(config.key, {
map: config.map,
})
} else if (config.args) {
buildKeyMap(config.args, map)
}
}
return map
}
interface Params {
body: unknown
headers: Record<string, unknown>
path: Record<string, unknown>
query: Record<string, unknown>
}
const stripEmptySlots = (params: Params) => {
for (const [slot, value] of Object.entries(params)) {
if (value && typeof value === "object" && !Object.keys(value).length) {
delete params[slot as Slot]
}
}
}
export const buildClientParams = (args: ReadonlyArray<unknown>, fields: FieldsConfig) => {
const params: Params = {
body: {},
headers: {},
path: {},
query: {},
}
const map = buildKeyMap(fields)
let config: FieldsConfig[number] | undefined
for (const [index, arg] of args.entries()) {
if (fields[index]) {
config = fields[index]
}
if (!config) {
continue
}
if ("in" in config) {
if (config.key) {
const field = map.get(config.key)!
const name = field.map || config.key
if (field.in) {
;(params[field.in] as Record<string, unknown>)[name] = arg
}
} else {
params.body = arg
}
} else {
for (const [key, value] of Object.entries(arg ?? {})) {
const field = map.get(key)
if (field) {
if (field.in) {
const name = field.map || key
;(params[field.in] as Record<string, unknown>)[name] = value
} else {
params[field.map] = value
}
} else {
const extra = extraPrefixes.find(([prefix]) => key.startsWith(prefix))
if (extra) {
const [prefix, slot] = extra
;(params[slot] as Record<string, unknown>)[key.slice(prefix.length)] = value
} else if ("allowExtra" in config && config.allowExtra) {
for (const [slot, allowed] of Object.entries(config.allowExtra)) {
if (allowed) {
;(params[slot as Slot] as Record<string, unknown>)[key] = value
break
}
}
}
}
}
}
}
stripEmptySlots(params)
return params
}

View file

@ -0,0 +1,167 @@
// This file is auto-generated by @hey-api/openapi-ts
interface SerializeOptions<T> extends SerializePrimitiveOptions, SerializerOptions<T> {}
interface SerializePrimitiveOptions {
allowReserved?: boolean
name: string
}
export interface SerializerOptions<T> {
/**
* @default true
*/
explode: boolean
style: T
}
export type ArrayStyle = "form" | "spaceDelimited" | "pipeDelimited"
export type ArraySeparatorStyle = ArrayStyle | MatrixStyle
type MatrixStyle = "label" | "matrix" | "simple"
export type ObjectStyle = "form" | "deepObject"
type ObjectSeparatorStyle = ObjectStyle | MatrixStyle
interface SerializePrimitiveParam extends SerializePrimitiveOptions {
value: string
}
export const separatorArrayExplode = (style: ArraySeparatorStyle) => {
switch (style) {
case "label":
return "."
case "matrix":
return ";"
case "simple":
return ","
default:
return "&"
}
}
export const separatorArrayNoExplode = (style: ArraySeparatorStyle) => {
switch (style) {
case "form":
return ","
case "pipeDelimited":
return "|"
case "spaceDelimited":
return "%20"
default:
return ","
}
}
export const separatorObjectExplode = (style: ObjectSeparatorStyle) => {
switch (style) {
case "label":
return "."
case "matrix":
return ";"
case "simple":
return ","
default:
return "&"
}
}
export const serializeArrayParam = ({
allowReserved,
explode,
name,
style,
value,
}: SerializeOptions<ArraySeparatorStyle> & {
value: unknown[]
}) => {
if (!explode) {
const joinedValues = (allowReserved ? value : value.map((v) => encodeURIComponent(v as string))).join(
separatorArrayNoExplode(style),
)
switch (style) {
case "label":
return `.${joinedValues}`
case "matrix":
return `;${name}=${joinedValues}`
case "simple":
return joinedValues
default:
return `${name}=${joinedValues}`
}
}
const separator = separatorArrayExplode(style)
const joinedValues = value
.map((v) => {
if (style === "label" || style === "simple") {
return allowReserved ? v : encodeURIComponent(v as string)
}
return serializePrimitiveParam({
allowReserved,
name,
value: v as string,
})
})
.join(separator)
return style === "label" || style === "matrix" ? separator + joinedValues : joinedValues
}
export const serializePrimitiveParam = ({ allowReserved, name, value }: SerializePrimitiveParam) => {
if (value === undefined || value === null) {
return ""
}
if (typeof value === "object") {
throw new Error(
"Deeply-nested arrays/objects arent supported. Provide your own `querySerializer()` to handle these.",
)
}
return `${name}=${allowReserved ? value : encodeURIComponent(value)}`
}
export const serializeObjectParam = ({
allowReserved,
explode,
name,
style,
value,
valueOnly,
}: SerializeOptions<ObjectSeparatorStyle> & {
value: Record<string, unknown> | Date
valueOnly?: boolean
}) => {
if (value instanceof Date) {
return valueOnly ? value.toISOString() : `${name}=${value.toISOString()}`
}
if (style !== "deepObject" && !explode) {
let values: string[] = []
Object.entries(value).forEach(([key, v]) => {
values = [...values, key, allowReserved ? (v as string) : encodeURIComponent(v as string)]
})
const joinedValues = values.join(",")
switch (style) {
case "form":
return `${name}=${joinedValues}`
case "label":
return `.${joinedValues}`
case "matrix":
return `;${name}=${joinedValues}`
default:
return joinedValues
}
}
const separator = separatorObjectExplode(style)
const joinedValues = Object.entries(value)
.map(([key, v]) =>
serializePrimitiveParam({
allowReserved,
name: style === "deepObject" ? `${name}[${key}]` : key,
value: v as string,
}),
)
.join(separator)
return style === "label" || style === "matrix" ? separator + joinedValues : joinedValues
}

View file

@ -0,0 +1,111 @@
// This file is auto-generated by @hey-api/openapi-ts
/**
* JSON-friendly union that mirrors what Pinia Colada can hash.
*/
export type JsonValue = null | string | number | boolean | JsonValue[] | { [key: string]: JsonValue }
/**
* Replacer that converts non-JSON values (bigint, Date, etc.) to safe substitutes.
*/
export const queryKeyJsonReplacer = (_key: string, value: unknown) => {
if (value === undefined || typeof value === "function" || typeof value === "symbol") {
return undefined
}
if (typeof value === "bigint") {
return value.toString()
}
if (value instanceof Date) {
return value.toISOString()
}
return value
}
/**
* Safely stringifies a value and parses it back into a JsonValue.
*/
export const stringifyToJsonValue = (input: unknown): JsonValue | undefined => {
try {
const json = JSON.stringify(input, queryKeyJsonReplacer)
if (json === undefined) {
return undefined
}
return JSON.parse(json) as JsonValue
} catch {
return undefined
}
}
/**
* Detects plain objects (including objects with a null prototype).
*/
const isPlainObject = (value: unknown): value is Record<string, unknown> => {
if (value === null || typeof value !== "object") {
return false
}
const prototype = Object.getPrototypeOf(value as object)
return prototype === Object.prototype || prototype === null
}
/**
* Turns URLSearchParams into a sorted JSON object for deterministic keys.
*/
const serializeSearchParams = (params: URLSearchParams): JsonValue => {
const entries = Array.from(params.entries()).sort(([a], [b]) => a.localeCompare(b))
const result: Record<string, JsonValue> = {}
for (const [key, value] of entries) {
const existing = result[key]
if (existing === undefined) {
result[key] = value
continue
}
if (Array.isArray(existing)) {
;(existing as string[]).push(value)
} else {
result[key] = [existing, value]
}
}
return result
}
/**
* Normalizes any accepted value into a JSON-friendly shape for query keys.
*/
export const serializeQueryKeyValue = (value: unknown): JsonValue | undefined => {
if (value === null) {
return null
}
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
return value
}
if (value === undefined || typeof value === "function" || typeof value === "symbol") {
return undefined
}
if (typeof value === "bigint") {
return value.toString()
}
if (value instanceof Date) {
return value.toISOString()
}
if (Array.isArray(value)) {
return stringifyToJsonValue(value)
}
if (typeof URLSearchParams !== "undefined" && value instanceof URLSearchParams) {
return serializeSearchParams(value)
}
if (isPlainObject(value)) {
return stringifyToJsonValue(value)
}
return undefined
}

View file

@ -0,0 +1,239 @@
// This file is auto-generated by @hey-api/openapi-ts
import type { Config } from "./types.gen.js"
export type ServerSentEventsOptions<TData = unknown> = Omit<RequestInit, "method"> &
Pick<Config, "method" | "responseTransformer" | "responseValidator"> & {
/**
* Fetch API implementation. You can use this option to provide a custom
* fetch instance.
*
* @default globalThis.fetch
*/
fetch?: typeof fetch
/**
* Implementing clients can call request interceptors inside this hook.
*/
onRequest?: (url: string, init: RequestInit) => Promise<Request>
/**
* Callback invoked when a network or parsing error occurs during streaming.
*
* This option applies only if the endpoint returns a stream of events.
*
* @param error The error that occurred.
*/
onSseError?: (error: unknown) => void
/**
* Callback invoked when an event is streamed from the server.
*
* This option applies only if the endpoint returns a stream of events.
*
* @param event Event streamed from the server.
* @returns Nothing (void).
*/
onSseEvent?: (event: StreamEvent<TData>) => void
serializedBody?: RequestInit["body"]
/**
* Default retry delay in milliseconds.
*
* This option applies only if the endpoint returns a stream of events.
*
* @default 3000
*/
sseDefaultRetryDelay?: number
/**
* Maximum number of retry attempts before giving up.
*/
sseMaxRetryAttempts?: number
/**
* Maximum retry delay in milliseconds.
*
* Applies only when exponential backoff is used.
*
* This option applies only if the endpoint returns a stream of events.
*
* @default 30000
*/
sseMaxRetryDelay?: number
/**
* Optional sleep function for retry backoff.
*
* Defaults to using `setTimeout`.
*/
sseSleepFn?: (ms: number) => Promise<void>
url: string
}
export interface StreamEvent<TData = unknown> {
data: TData
event?: string
id?: string
retry?: number
}
export type ServerSentEventsResult<TData = unknown, TReturn = void, TNext = unknown> = {
stream: AsyncGenerator<TData extends Record<string, unknown> ? TData[keyof TData] : TData, TReturn, TNext>
}
export const createSseClient = <TData = unknown>({
onRequest,
onSseError,
onSseEvent,
responseTransformer,
responseValidator,
sseDefaultRetryDelay,
sseMaxRetryAttempts,
sseMaxRetryDelay,
sseSleepFn,
url,
...options
}: ServerSentEventsOptions): ServerSentEventsResult<TData> => {
let lastEventId: string | undefined
const sleep = sseSleepFn ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms)))
const createStream = async function* () {
let retryDelay: number = sseDefaultRetryDelay ?? 3000
let attempt = 0
const signal = options.signal ?? new AbortController().signal
while (true) {
if (signal.aborted) break
attempt++
const headers =
options.headers instanceof Headers
? options.headers
: new Headers(options.headers as Record<string, string> | undefined)
if (lastEventId !== undefined) {
headers.set("Last-Event-ID", lastEventId)
}
try {
const requestInit: RequestInit = {
redirect: "follow",
...options,
body: options.serializedBody,
headers,
signal,
}
let request = new Request(url, requestInit)
if (onRequest) {
request = await onRequest(url, requestInit)
}
// fetch must be assigned here, otherwise it would throw the error:
// TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation
const _fetch = options.fetch ?? globalThis.fetch
const response = await _fetch(request)
if (!response.ok) throw new Error(`SSE failed: ${response.status} ${response.statusText}`)
if (!response.body) throw new Error("No body in SSE response")
const reader = response.body.pipeThrough(new TextDecoderStream()).getReader()
let buffer = ""
const abortHandler = () => {
try {
reader.cancel()
} catch {
// noop
}
}
signal.addEventListener("abort", abortHandler)
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += value
// Normalize line endings: CRLF -> LF, then CR -> LF
buffer = buffer.replace(/\r\n/g, "\n").replace(/\r/g, "\n")
const chunks = buffer.split("\n\n")
buffer = chunks.pop() ?? ""
for (const chunk of chunks) {
const lines = chunk.split("\n")
const dataLines: Array<string> = []
let eventName: string | undefined
for (const line of lines) {
if (line.startsWith("data:")) {
dataLines.push(line.replace(/^data:\s*/, ""))
} else if (line.startsWith("event:")) {
eventName = line.replace(/^event:\s*/, "")
} else if (line.startsWith("id:")) {
lastEventId = line.replace(/^id:\s*/, "")
} else if (line.startsWith("retry:")) {
const parsed = Number.parseInt(line.replace(/^retry:\s*/, ""), 10)
if (!Number.isNaN(parsed)) {
retryDelay = parsed
}
}
}
let data: unknown
let parsedJson = false
if (dataLines.length) {
const rawData = dataLines.join("\n")
try {
data = JSON.parse(rawData)
parsedJson = true
} catch {
data = rawData
}
}
if (parsedJson) {
if (responseValidator) {
await responseValidator(data)
}
if (responseTransformer) {
data = await responseTransformer(data)
}
}
onSseEvent?.({
data,
event: eventName,
id: lastEventId,
retry: retryDelay,
})
if (dataLines.length) {
yield data as any
}
}
}
} finally {
signal.removeEventListener("abort", abortHandler)
reader.releaseLock()
}
break // exit loop on normal completion
} catch (error) {
// connection failed or aborted; retry after delay
onSseError?.(error)
if (sseMaxRetryAttempts !== undefined && attempt >= sseMaxRetryAttempts) {
break // stop after firing error
}
// exponential backoff: double retry each attempt, cap at 30s
const backoff = Math.min(retryDelay * 2 ** (attempt - 1), sseMaxRetryDelay ?? 30000)
await sleep(backoff)
}
}
}
const stream = createStream()
return { stream }
}

View file

@ -0,0 +1,86 @@
// This file is auto-generated by @hey-api/openapi-ts
import type { Auth, AuthToken } from "./auth.gen.js"
import type { BodySerializer, QuerySerializer, QuerySerializerOptions } from "./bodySerializer.gen.js"
export type HttpMethod = "connect" | "delete" | "get" | "head" | "options" | "patch" | "post" | "put" | "trace"
export type Client<RequestFn = never, Config = unknown, MethodFn = never, BuildUrlFn = never, SseFn = never> = {
/**
* Returns the final request URL.
*/
buildUrl: BuildUrlFn
getConfig: () => Config
request: RequestFn
setConfig: (config: Config) => Config
} & {
[K in HttpMethod]: MethodFn
} & ([SseFn] extends [never] ? { sse?: never } : { sse: { [K in HttpMethod]: SseFn } })
export interface Config {
/**
* Auth token or a function returning auth token. The resolved value will be
* added to the request payload as defined by its `security` array.
*/
auth?: ((auth: Auth) => Promise<AuthToken> | AuthToken) | AuthToken
/**
* A function for serializing request body parameter. By default,
* {@link JSON.stringify()} will be used.
*/
bodySerializer?: BodySerializer | null
/**
* An object containing any HTTP headers that you want to pre-populate your
* `Headers` object with.
*
* {@link https://developer.mozilla.org/docs/Web/API/Headers/Headers#init See more}
*/
headers?:
| RequestInit["headers"]
| Record<string, string | number | boolean | (string | number | boolean)[] | null | undefined | unknown>
/**
* The request method.
*
* {@link https://developer.mozilla.org/docs/Web/API/fetch#method See more}
*/
method?: Uppercase<HttpMethod>
/**
* A function for serializing request query parameters. By default, arrays
* will be exploded in form style, objects will be exploded in deepObject
* style, and reserved characters are percent-encoded.
*
* This method will have no effect if the native `paramsSerializer()` Axios
* API function is used.
*
* {@link https://swagger.io/docs/specification/serialization/#query View examples}
*/
querySerializer?: QuerySerializer | QuerySerializerOptions
/**
* A function validating request data. This is useful if you want to ensure
* the request conforms to the desired shape, so it can be safely sent to
* the server.
*/
requestValidator?: (data: unknown) => Promise<unknown>
/**
* A function transforming response data before it's returned. This is useful
* for post-processing data, e.g. converting ISO strings into Date objects.
*/
responseTransformer?: (data: unknown) => Promise<unknown>
/**
* A function validating response data. This is useful if you want to ensure
* the response conforms to the desired shape, so it can be safely passed to
* the transformers and returned to the user.
*/
responseValidator?: (data: unknown) => Promise<unknown>
}
type IsExactlyNeverOrNeverUndefined<T> = [T] extends [never]
? true
: [T] extends [never | undefined]
? [undefined] extends [T]
? false
: true
: false
export type OmitNever<T extends Record<string, unknown>> = {
[K in keyof T as IsExactlyNeverOrNeverUndefined<T[K]> extends true ? never : K]: T[K]
}

View file

@ -0,0 +1,137 @@
// This file is auto-generated by @hey-api/openapi-ts
import type { BodySerializer, QuerySerializer } from "./bodySerializer.gen.js"
import {
type ArraySeparatorStyle,
serializeArrayParam,
serializeObjectParam,
serializePrimitiveParam,
} from "./pathSerializer.gen.js"
export interface PathSerializer {
path: Record<string, unknown>
url: string
}
export const PATH_PARAM_RE = /\{[^{}]+\}/g
export const defaultPathSerializer = ({ path, url: _url }: PathSerializer) => {
let url = _url
const matches = _url.match(PATH_PARAM_RE)
if (matches) {
for (const match of matches) {
let explode = false
let name = match.substring(1, match.length - 1)
let style: ArraySeparatorStyle = "simple"
if (name.endsWith("*")) {
explode = true
name = name.substring(0, name.length - 1)
}
if (name.startsWith(".")) {
name = name.substring(1)
style = "label"
} else if (name.startsWith(";")) {
name = name.substring(1)
style = "matrix"
}
const value = path[name]
if (value === undefined || value === null) {
continue
}
if (Array.isArray(value)) {
url = url.replace(match, serializeArrayParam({ explode, name, style, value }))
continue
}
if (typeof value === "object") {
url = url.replace(
match,
serializeObjectParam({
explode,
name,
style,
value: value as Record<string, unknown>,
valueOnly: true,
}),
)
continue
}
if (style === "matrix") {
url = url.replace(
match,
`;${serializePrimitiveParam({
name,
value: value as string,
})}`,
)
continue
}
const replaceValue = encodeURIComponent(style === "label" ? `.${value as string}` : (value as string))
url = url.replace(match, replaceValue)
}
}
return url
}
export const getUrl = ({
baseUrl,
path,
query,
querySerializer,
url: _url,
}: {
baseUrl?: string
path?: Record<string, unknown>
query?: Record<string, unknown>
querySerializer: QuerySerializer
url: string
}) => {
const pathUrl = _url.startsWith("/") ? _url : `/${_url}`
let url = (baseUrl ?? "") + pathUrl
if (path) {
url = defaultPathSerializer({ path, url })
}
let search = query ? querySerializer(query) : ""
if (search.startsWith("?")) {
search = search.substring(1)
}
if (search) {
url += `?${search}`
}
return url
}
export function getValidRequestBody(options: {
body?: unknown
bodySerializer?: BodySerializer | null
serializedBody?: unknown
}) {
const hasBody = options.body !== undefined
const isSerializedBody = hasBody && options.bodySerializer
if (isSerializedBody) {
if ("serializedBody" in options) {
const hasSerializedBody = options.serializedBody !== undefined && options.serializedBody !== ""
return hasSerializedBody ? options.serializedBody : null
}
// not all clients implement a serializedBody property (i.e. client-axios)
return options.body !== "" ? options.body : null
}
// plain/text body
if (hasBody) {
return options.body
}
// no body was provided
return undefined
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,21 @@
export * from "./client.js"
export * from "./server.js"
import { createOpencodeClient } from "./client.js"
import { createOpencodeServer } from "./server.js"
import type { ServerOptions } from "./server.js"
export async function createOpencode(options?: ServerOptions) {
const server = await createOpencodeServer({
...options,
})
const client = createOpencodeClient({
baseUrl: server.url,
})
return {
client,
server,
}
}

View file

@ -0,0 +1,125 @@
import { spawn } from "node:child_process"
import { type Config } from "./gen/types.gen.js"
export type ServerOptions = {
hostname?: string
port?: number
signal?: AbortSignal
timeout?: number
config?: Config
}
export type TuiOptions = {
project?: string
model?: string
session?: string
agent?: string
signal?: AbortSignal
config?: Config
}
export async function createOpencodeServer(options?: ServerOptions) {
options = Object.assign(
{
hostname: "127.0.0.1",
port: 4096,
timeout: 5000,
},
options ?? {},
)
const args = [`serve`, `--hostname=${options.hostname}`, `--port=${options.port}`]
if (options.config?.logLevel) args.push(`--log-level=${options.config.logLevel}`)
const proc = spawn(`opencode`, args, {
signal: options.signal,
shell: process.platform === 'win32',
env: {
...process.env,
OPENCODE_CONFIG_CONTENT: JSON.stringify(options.config ?? {}),
},
})
const url = await new Promise<string>((resolve, reject) => {
const id = setTimeout(() => {
reject(new Error(`Timeout waiting for server to start after ${options.timeout}ms`))
}, options.timeout)
let output = ""
proc.stdout?.on("data", (chunk) => {
output += chunk.toString()
const lines = output.split("\n")
for (const line of lines) {
if (line.startsWith("opencode server listening")) {
const match = line.match(/on\s+(https?:\/\/[^\s]+)/)
if (!match) {
throw new Error(`Failed to parse server url from output: ${line}`)
}
clearTimeout(id)
resolve(match[1]!)
return
}
}
})
proc.stderr?.on("data", (chunk) => {
output += chunk.toString()
})
proc.on("exit", (code) => {
clearTimeout(id)
let msg = `Server exited with code ${code}`
if (output.trim()) {
msg += `\nServer output: ${output}`
}
reject(new Error(msg))
})
proc.on("error", (error) => {
clearTimeout(id)
reject(error)
})
if (options.signal) {
options.signal.addEventListener("abort", () => {
clearTimeout(id)
reject(new Error("Aborted"))
})
}
})
return {
url,
close() {
proc.kill()
},
}
}
export function createOpencodeTui(options?: TuiOptions) {
const args = []
if (options?.project) {
args.push(`--project=${options.project}`)
}
if (options?.model) {
args.push(`--model=${options.model}`)
}
if (options?.session) {
args.push(`--session=${options.session}`)
}
if (options?.agent) {
args.push(`--agent=${options.agent}`)
}
const proc = spawn(`opencode`, args, {
signal: options?.signal,
stdio: "inherit",
shell: process.platform === 'win32',
env: {
...process.env,
OPENCODE_CONFIG_CONTENT: JSON.stringify(options?.config ?? {}),
},
})
return {
close() {
proc.kill()
},
}
}

View file

@ -72,15 +72,20 @@ export async function runCodexExec(
}
if (codexEffort) {
args.push('--config', `model_reasoning_effort="${codexEffort}"`)
args.push('--config', `model_reasoning_effort=${codexEffort}`)
}
args.push(prompt)
// On Windows, passing long prompts as command-line arguments causes
// shell escaping issues (PowerShell MissingExpression, special chars).
// Use codex's stdin mode (`-` as prompt arg) on all platforms — simpler
// and avoids command-line length limits.
args.push('-')
try {
const runResult = await executeCodexCommand(
args,
options.timeoutMs ?? DEFAULT_CODEX_TIMEOUT_MS,
prompt,
)
const finalText = await readFile(outputPath, 'utf-8').catch(() => '')
const normalizedText = finalText.trim() || runResult.text.trim()
@ -112,10 +117,12 @@ function buildPrompt(systemPrompt: string | undefined, userPrompt: string, image
}
return [
'SYSTEM INSTRUCTIONS:',
'You are a design generation assistant. Follow the guidelines below to produce the requested output.',
'',
'--- GUIDELINES ---',
systemPrompt.trim(),
'',
'USER REQUEST:',
'--- TASK ---',
userText + imageSection,
].join('\n')
}
@ -146,15 +153,22 @@ function resolveCodexEffort(
async function executeCodexCommand(
args: string[],
timeoutMs: number,
stdinText?: string,
): Promise<{ text: string; errors: string[] }> {
return await new Promise((resolve, reject) => {
const child = spawn('codex', args, {
env: filterCodexEnv(process.env as Record<string, string | undefined>),
stdio: ['ignore', 'pipe', 'pipe'],
// On Windows, npm-installed CLIs are .cmd scripts — need shell to resolve them
stdio: [stdinText ? 'pipe' : 'ignore', 'pipe', 'pipe'],
// On Windows, npm-installed CLIs are .cmd scripts — need shell to resolve.
...(process.platform === 'win32' && { shell: true }),
})
// Pipe prompt via stdin (codex reads from stdin when `-` is the prompt arg)
if (stdinText && child.stdin) {
child.stdin.write(stdinText)
child.stdin.end()
}
let stdoutBuffer = ''
let stderrBuffer = ''
let textAccumulator = ''
@ -176,7 +190,7 @@ async function executeCodexCommand(
reject(new Error(`Codex request timed out after ${Math.round(timeoutMs / 1000)}s.`))
}, timeoutMs)
child.stdout.on('data', (chunk: Buffer) => {
child.stdout!.on('data', (chunk: Buffer) => {
stdoutBuffer += chunk.toString('utf-8')
let idx = stdoutBuffer.indexOf('\n')
while (idx >= 0) {
@ -187,7 +201,7 @@ async function executeCodexCommand(
}
})
child.stderr.on('data', (chunk: Buffer) => {
child.stderr!.on('data', (chunk: Buffer) => {
stderrBuffer += chunk.toString('utf-8')
})
@ -266,6 +280,8 @@ function extractCodexCliError(stderr: string): string | null {
if (!trimmed) return null
const lines = trimmed.split('\n').map((line) => line.trim()).filter(Boolean)
// 1. Look for "error: ..." lines (simple CLI errors)
for (let i = lines.length - 1; i >= 0; i--) {
const line = lines[i]
if (line.toLowerCase().startsWith('error:')) {
@ -273,5 +289,25 @@ function extractCodexCliError(stderr: string): string | null {
}
}
return lines[lines.length - 1] ?? null
// 2. Look for Codex structured log errors: "<timestamp> ERROR <module>: <message>"
// These contain the real error (auth failures, API errors, etc.)
for (let i = lines.length - 1; i >= 0; i--) {
const match = lines[i].match(/\bERROR\s+\S+:\s*(.+)/)
if (match) {
const msg = match[1].trim()
// For auth errors, provide actionable guidance
if (/refresh token|sign in again|token.*expired|401 Unauthorized/i.test(msg)) {
return 'Codex authentication expired. Run "codex logout && codex login" to re-authenticate.'
}
return msg
}
}
// 3. Skip unhelpful "Warning: no last agent message" — surface it only as fallback
const lastLine = lines[lines.length - 1] ?? null
if (lastLine && /^warning:\s*no last agent message/i.test(lastLine)) {
return 'Codex returned no output. Check "codex login" status or try a different model.'
}
return lastLine
}

View file

@ -5,6 +5,21 @@ import { serverLog } from './server-logger'
const isWindows = process.platform === 'win32'
/** Windows npm global installs may create .cmd or .ps1 wrappers — try both */
function winNpmCandidates(dir: string, name: string): string[] {
return [join(dir, `${name}.cmd`), join(dir, `${name}.ps1`)]
}
/** On Windows, `where` may return an extensionless shell script — prefer .cmd/.ps1 */
function resolveWinExtension(binPath: string): string {
if (!isWindows) return binPath
if (/\.(cmd|ps1|exe)$/i.test(binPath)) return binPath
for (const ext of ['.cmd', '.ps1']) {
if (existsSync(binPath + ext)) return binPath + ext
}
return binPath
}
/** Resolve the standalone copilot CLI binary path to avoid Bun's node:sqlite issue */
export function resolveCopilotCli(): string | undefined {
serverLog.info(`[resolve-copilot] platform=${process.platform}, isWindows=${isWindows}`)
@ -17,7 +32,7 @@ export function resolveCopilotCli(): string | undefined {
// `where` on Windows may return multiple lines
const path = result.split(/\r?\n/)[0]?.trim()
serverLog.info(`[resolve-copilot] PATH result: "${path}" (exists=${path ? existsSync(path) : false})`)
if (path && existsSync(path)) return path
if (path && existsSync(path)) return resolveWinExtension(path)
} catch (err) {
serverLog.info(`[resolve-copilot] PATH lookup failed: ${err instanceof Error ? err.message : err}`)
}
@ -32,9 +47,10 @@ export function resolveCopilotCli(): string | undefined {
}).trim()
serverLog.info(`[resolve-copilot] npm global prefix: "${prefix}"`)
if (prefix) {
const bin = join(prefix, 'copilot.cmd')
serverLog.info(`[resolve-copilot] npm global bin: "${bin}" (exists=${existsSync(bin)})`)
if (existsSync(bin)) return bin
for (const bin of winNpmCandidates(prefix, 'copilot')) {
serverLog.info(`[resolve-copilot] npm global bin: "${bin}" (exists=${existsSync(bin)})`)
if (existsSync(bin)) return bin
}
}
} catch (err) {
serverLog.info(`[resolve-copilot] npm prefix -g failed: ${err instanceof Error ? err.message : err}`)
@ -44,11 +60,11 @@ export function resolveCopilotCli(): string | undefined {
// 3. Common install locations
if (isWindows) {
const candidates = [
// npm global
join(process.env.APPDATA || '', 'npm', 'copilot.cmd'),
// npm global (.cmd + .ps1)
...winNpmCandidates(join(process.env.APPDATA || '', 'npm'), 'copilot'),
// nvm-windows / fnm
join(process.env.NVM_SYMLINK || '', 'copilot.cmd'),
join(process.env.FNM_MULTISHELL_PATH || '', 'copilot.cmd'),
...winNpmCandidates(join(process.env.NVM_SYMLINK || ''), 'copilot'),
...winNpmCandidates(join(process.env.FNM_MULTISHELL_PATH || ''), 'copilot'),
// winget / native
join(process.env.LOCALAPPDATA || '', 'Microsoft', 'WinGet', 'Links', 'copilot.exe'),
]

View file

@ -19,7 +19,7 @@ process.on('SIGTERM', cleanup)
process.on('SIGINT', cleanup)
export async function getOpencodeClient() {
const { createOpencodeClient, createOpencode } = await import('@opencode-ai/sdk/v2')
const { createOpencodeClient, createOpencode } = await import('../opencode/index')
// Try connecting to an existing server first
try {

View file

@ -1,3 +1,4 @@
import { spawn } from 'node:child_process'
import { mkdirSync, readFileSync } from 'node:fs'
import { homedir, tmpdir } from 'node:os'
import { join } from 'node:path'
@ -117,3 +118,21 @@ export function getClaudeAgentDebugFilePath(): string | undefined {
return undefined
}
}
/**
* Custom spawnClaudeCodeProcess for Windows.
* On Windows, npm-installed CLIs are .cmd/.ps1 scripts that can't be spawned
* directly without a shell. Uses PowerShell to avoid cmd.exe's 8191-char limit.
*/
export function buildSpawnClaudeCodeProcess() {
if (process.platform !== 'win32') return undefined
return (options: { command: string; args: string[]; cwd?: string; env: Record<string, string | undefined>; signal: AbortSignal }) => {
return spawn(options.command, options.args, {
cwd: options.cwd,
env: options.env as NodeJS.ProcessEnv,
signal: options.signal,
stdio: ['pipe', 'pipe', 'pipe'],
shell: 'powershell.exe',
})
}
}

View file

@ -6,6 +6,21 @@ import { serverLog } from './server-logger'
const isWindows = platform() === 'win32'
/** Windows npm global installs may create .cmd or .ps1 wrappers — try both */
function winNpmCandidates(dir: string, name: string): string[] {
return [join(dir, `${name}.cmd`), join(dir, `${name}.ps1`)]
}
/** On Windows, `where` may return an extensionless shell script — prefer .cmd/.ps1/.exe */
function resolveWinExtension(binPath: string): string {
if (!isWindows) return binPath
if (/\.(cmd|ps1|exe)$/i.test(binPath)) return binPath
for (const ext of ['.cmd', '.ps1', '.exe']) {
if (existsSync(binPath + ext)) return binPath + ext
}
return binPath
}
/**
* Resolve the absolute path to the standalone `claude` binary.
*
@ -28,7 +43,7 @@ export function resolveClaudeCli(): string | undefined {
}).trim()
const p = raw.split(/\r?\n/)[0] // `where` on Windows may return multiple lines
serverLog.info(`[resolve-claude-cli] PATH lookup result: "${p}" (exists=${p ? existsSync(p) : false})`)
if (p && existsSync(p)) return p
if (p && existsSync(p)) return resolveWinExtension(p)
} catch (err) {
serverLog.info(`[resolve-claude-cli] PATH lookup failed: ${err instanceof Error ? err.message : err}`)
}
@ -44,9 +59,10 @@ export function resolveClaudeCli(): string | undefined {
}).trim()
serverLog.info(`[resolve-claude-cli] npm global prefix: "${prefix}"`)
if (prefix) {
const bin = join(prefix, 'claude.cmd')
serverLog.info(`[resolve-claude-cli] checking npm global bin: "${bin}" (exists=${existsSync(bin)})`)
if (existsSync(bin)) return bin
for (const bin of winNpmCandidates(prefix, 'claude')) {
serverLog.info(`[resolve-claude-cli] checking npm global bin: "${bin}" (exists=${existsSync(bin)})`)
if (existsSync(bin)) return bin
}
}
} catch (err) {
serverLog.info(`[resolve-claude-cli] npm prefix -g failed: ${err instanceof Error ? err.message : err}`)
@ -56,11 +72,11 @@ export function resolveClaudeCli(): string | undefined {
// 3. Common install locations
const candidates = isWindows
? [
// npm global (npm install -g creates .cmd wrappers here)
join(process.env.APPDATA || '', 'npm', 'claude.cmd'),
// npm global (.cmd + .ps1)
...winNpmCandidates(join(process.env.APPDATA || '', 'npm'), 'claude'),
// nvm-windows / fnm
join(process.env.NVM_SYMLINK || '', 'claude.cmd'),
join(process.env.FNM_MULTISHELL_PATH || '', 'claude.cmd'),
...winNpmCandidates(join(process.env.NVM_SYMLINK || ''), 'claude'),
...winNpmCandidates(join(process.env.FNM_MULTISHELL_PATH || ''), 'claude'),
// Native .exe install locations
join(process.env.LOCALAPPDATA || '', 'Programs', 'claude-code', 'claude.exe'),
join(process.env.LOCALAPPDATA || '', 'Microsoft', 'WinGet', 'Links', 'claude.exe'),

View file

@ -3,6 +3,7 @@ import { nanoid } from 'nanoid'
import { useAIStore } from '@/stores/ai-store'
import { useCanvasStore } from '@/stores/canvas-store'
import { useDocumentStore } from '@/stores/document-store'
import { getActivePageChildren } from '@/stores/document-tree-utils'
import { streamChat } from '@/services/ai/ai-service'
import { CHAT_SYSTEM_PROMPT } from '@/services/ai/ai-prompts'
import {
@ -18,15 +19,18 @@ import { CHAT_STREAM_THINKING_CONFIG } from '@/services/ai/ai-runtime-config'
/** Intent classification prompt — lightweight LLM call to determine message routing */
const CLASSIFY_PROMPT = `You are a UI design tool assistant. Classify the user's message intent.
Reply with EXACTLY one of these tags, nothing else:
- DESIGN user wants to create, generate, or modify any UI element, component, screen, or page
- DESIGN_NEW user wants to create or generate a NEW design, screen, page, or component from scratch
- DESIGN_MODIFY user wants to modify, adjust, refine, or iterate on an EXISTING design (e.g. change colors, resize, restyle, add/remove elements)
- CHAT user is asking a question, seeking help, or having a conversation`
type DesignIntent = 'new' | 'modify' | 'chat'
/** Classify user intent via a lightweight LLM call instead of hardcoded keyword matching */
async function classifyIntent(
text: string,
model: string,
provider?: string,
): Promise<{ isDesign: boolean }> {
): Promise<{ intent: DesignIntent }> {
try {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 8_000)
@ -48,10 +52,15 @@ async function classifyIntent(
const data = await response.json()
const upper = (data.text ?? '').trim().toUpperCase()
return { isDesign: upper.includes('DESIGN') }
if (upper.includes('DESIGN_MODIFY')) return { intent: 'modify' }
if (upper.includes('DESIGN_NEW') || upper.includes('DESIGN')) return { intent: 'new' }
if (upper.includes('CHAT')) return { intent: 'chat' }
// Fallback: in a design tool, default to new design mode
return { intent: 'new' }
} catch {
// Fallback: in a design tool, default to design mode
return { isDesign: true }
// Fallback: in a design tool, default to new design mode
return { intent: 'new' }
}
}
@ -169,24 +178,44 @@ export function useChatHandlers() {
useAIStore.getState().setAbortController(abortController)
try {
// Classify intent via lightweight LLM call
// Classify intent via lightweight LLM call (three-way: new / modify / chat)
const classified = await classifyIntent(
messageText, model, currentProvider,
)
isDesign = classified.isDesign
const isModification = isDesign && hasSelection
let intent = classified.intent
// When LLM says "modify" but canvas is empty, degrade to new design
const { document: currentDoc } = useDocumentStore.getState()
const activePageId = useCanvasStore.getState().activePageId
const pageChildren = getActivePageChildren(currentDoc, activePageId)
if (intent === 'modify' && pageChildren.length === 0) {
intent = 'new'
}
isDesign = intent === 'new' || intent === 'modify'
// Determine modification target: explicit selection or auto-selected frame
const isModification = intent === 'modify' && (hasSelection || pageChildren.length > 0)
if (isDesign) {
if (isModification) {
// --- MODIFICATION MODE ---
const { getNodeById, document: modDoc } = useDocumentStore.getState()
const selectedNodes = selectedIds.map(id => getNodeById(id)).filter(Boolean) as any[]
let modTargets: any[]
if (hasSelection) {
// User explicitly selected nodes
modTargets = selectedIds.map(id => getNodeById(id)).filter(Boolean)
} else {
// Auto-select: last top-level frame on the active page
const frames = pageChildren.filter(n => n.type === 'frame')
modTargets = frames.length > 0 ? [frames[frames.length - 1]] : [pageChildren[pageChildren.length - 1]]
}
// We update the UI to show we are working
accumulated = '<step title="Checking guidelines">Analyzing modification request...</step>'
updateLastMessage(accumulated)
const { rawResponse, nodes } = await generateDesignModification(selectedNodes, messageText, {
const { rawResponse, nodes } = await generateDesignModification(modTargets, messageText, {
variables: modDoc.variables,
themes: modDoc.themes,
model,

View file

@ -50,7 +50,7 @@ type SettingsTab = 'agents' | 'mcp' | 'system'
async function connectAgent(
agent: 'claude-code' | 'codex-cli' | 'opencode' | 'copilot',
): Promise<{ connected: boolean; models: GroupedModel[]; error?: string; notInstalled?: boolean; connectionInfo?: string; hintPath?: string }> {
): Promise<{ connected: boolean; models: GroupedModel[]; error?: string; warning?: string; notInstalled?: boolean; connectionInfo?: string; hintPath?: string }> {
try {
const res = await fetch('/api/ai/connect-agent', {
method: 'POST',
@ -104,6 +104,7 @@ function ProviderCard({ type }: { type: AIProviderType }) {
const [isConnecting, setIsConnecting] = useState(false)
const [error, setError] = useState<string | null>(null)
const [warning, setWarning] = useState<string | null>(null)
const [notInstalled, setNotInstalled] = useState(false)
const [isInstalling, setIsInstalling] = useState(false)
const [installInfo, setInstallInfo] = useState<{ command: string; docsUrl: string } | null>(null)
@ -113,12 +114,14 @@ function ProviderCard({ type }: { type: AIProviderType }) {
const handleConnect = useCallback(async () => {
setIsConnecting(true)
setError(null)
setWarning(null)
setNotInstalled(false)
setInstallInfo(null)
const result = await connectAgent(meta.agent)
if (result.connected) {
connect(type, meta.agent, result.models, result.connectionInfo, result.hintPath)
persist()
if (result.warning) setWarning(result.warning)
} else if (result.notInstalled) {
setNotInstalled(true)
} else {
@ -257,6 +260,9 @@ function ProviderCard({ type }: { type: AIProviderType }) {
{error && (
<span className="text-[11px] text-destructive leading-tight mt-0.5 block">{error}</span>
)}
{warning && !error && (
<span className="text-[11px] text-amber-500 leading-tight mt-0.5 block">{warning}</span>
)}
</div>
{/* Action */}

View file

@ -136,6 +136,22 @@ export async function* streamChat(
return
}
// Server returned JSON instead of SSE stream — read body as JSON error
const contentType = response.headers.get('content-type') ?? ''
if (contentType.includes('application/json')) {
const body = await response.text()
try {
const jsonBody = JSON.parse(body)
yield { type: 'error', content: jsonBody.error || jsonBody.message || `Unexpected JSON response: ${body.slice(0, 200)}` }
} catch {
yield { type: 'error', content: `Unexpected server response: ${body.slice(0, 200)}` }
}
clearTimeout(hardTimeout)
clearNoTextTimeout()
clearFirstTextTimeout()
return
}
const reader = response.body?.getReader()
if (!reader) {
yield { type: 'error', content: 'No response stream available' }
@ -150,7 +166,20 @@ export async function* streamChat(
while (true) {
const { done, value } = await reader.read()
if (done) break
if (done) {
if (buffer.trim().length > 0) {
// Remaining buffer may be a non-SSE response (e.g. JSON error)
try {
const jsonErr = JSON.parse(buffer.trim())
if (jsonErr.error) {
yield { type: 'error', content: jsonErr.error } as AIStreamChunk
}
} catch {
// Not JSON, ignore remaining buffer
}
}
break
}
buffer += decoder.decode(value, { stream: true })
@ -164,6 +193,7 @@ export async function* streamChat(
if (!data) continue
try {
const chunk = JSON.parse(data) as AIStreamChunk
if (chunk.type === 'done') {
clearTimeout(hardTimeout)
clearNoTextTimeout()

View file

@ -7,7 +7,7 @@ import { DESIGN_MODIFIER_PROMPT } from './ai-prompts'
import { executeOrchestration } from './orchestrator'
import { DESIGN_STREAM_TIMEOUTS } from './ai-runtime-config'
import { extractJsonFromResponse } from './design-parser'
import { resolveModelProfile, applyProfileToTimeouts, needsSimplifiedPrompt } from './model-profiles'
import { resolveModelProfile, applyProfileToTimeouts } from './model-profiles'
// ---------------------------------------------------------------------------
// Re-exports for backward compatibility — consumers that import from
@ -119,16 +119,8 @@ export async function generateDesignModification(
const profile = resolveModelProfile(options?.model)
const timeouts = applyProfileToTimeouts({ ...DESIGN_STREAM_TIMEOUTS }, profile)
// Basic-tier models (MiniMax, GLM, etc.) ignore system prompts via routers —
// inline the system prompt into the user message so the model sees it.
const inlineSystem = needsSimplifiedPrompt(profile)
const effectiveSystem = inlineSystem ? '' : DESIGN_MODIFIER_PROMPT
const effectiveUserContent = inlineSystem
? DESIGN_MODIFIER_PROMPT + '\n\n---\n\n' + userMessage
: userMessage
for await (const chunk of streamChat(effectiveSystem, [
{ role: 'user', content: effectiveUserContent },
for await (const chunk of streamChat(DESIGN_MODIFIER_PROMPT, [
{ role: 'user', content: userMessage },
], options?.model, timeouts, options?.provider, abortSignal)) {
if (chunk.type === 'thinking') {
// Ignore thinking chunks for modification -- caller already shows progress

View file

@ -37,16 +37,16 @@ const MODEL_PROFILES: ModelProfile[] = [
{ match: 'deepseek', tier: 'standard', thinkingMode: 'disabled', label: 'DeepSeek' },
// Basic tier — disable thinking, use simplified prompt
{ match: 'claude-haiku', tier: 'basic', thinkingMode: 'disabled', simplifiedPrompt: true, label: 'Claude Haiku' },
{ match: 'gpt-4o-mini', tier: 'basic', thinkingMode: 'disabled', simplifiedPrompt: true, label: 'GPT-4o Mini' },
{ match: 'gpt-4.1-mini', tier: 'basic', thinkingMode: 'disabled', simplifiedPrompt: true, label: 'GPT-4.1 Mini' },
{ match: 'gpt-4.1-nano', tier: 'basic', thinkingMode: 'disabled', simplifiedPrompt: true, label: 'GPT-4.1 Nano' },
{ match: 'minimax', tier: 'basic', thinkingMode: 'disabled', simplifiedPrompt: true, label: 'MiniMax' },
{ match: 'qwen', tier: 'basic', thinkingMode: 'disabled', simplifiedPrompt: true, label: 'Qwen' },
{ match: 'llama', tier: 'basic', thinkingMode: 'disabled', simplifiedPrompt: true, label: 'Llama' },
{ match: 'mistral', tier: 'basic', thinkingMode: 'disabled', simplifiedPrompt: true, label: 'Mistral' },
{ match: 'gemma', tier: 'basic', thinkingMode: 'disabled', simplifiedPrompt: true, label: 'Gemma' },
{ match: 'glm', tier: 'basic', thinkingMode: 'disabled', simplifiedPrompt: true, label: 'GLM' },
{ match: 'claude-haiku', tier: 'basic', thinkingMode: 'disabled', label: 'Claude Haiku' },
{ match: 'gpt-4o-mini', tier: 'basic', thinkingMode: 'disabled', label: 'GPT-4o Mini' },
{ match: 'gpt-4.1-mini', tier: 'basic', thinkingMode: 'disabled', label: 'GPT-4.1 Mini' },
{ match: 'gpt-4.1-nano', tier: 'basic', thinkingMode: 'disabled', label: 'GPT-4.1 Nano' },
{ match: 'minimax', tier: 'basic', thinkingMode: 'disabled', label: 'MiniMax' },
{ match: 'qwen', tier: 'basic', thinkingMode: 'disabled', label: 'Qwen' },
{ match: 'llama', tier: 'basic', thinkingMode: 'disabled', label: 'Llama' },
{ match: 'mistral', tier: 'basic', thinkingMode: 'disabled', label: 'Mistral' },
{ match: 'gemma', tier: 'basic', thinkingMode: 'disabled', label: 'Gemma' },
{ match: 'glm', tier: 'basic', thinkingMode: 'disabled', label: 'GLM' },
]
const DEFAULT_PROFILE: ModelProfile = {

View file

@ -18,12 +18,11 @@ import type {
SubAgentResult,
} from './ai-types'
import { streamChat } from './ai-service'
import { SUB_AGENT_PROMPT, SUB_AGENT_PROMPT_SIMPLIFIED } from './orchestrator-prompts'
import { SUB_AGENT_PROMPT } from './orchestrator-prompts'
import {
type PreparedDesignPrompt,
getSubAgentTimeouts,
} from './orchestrator-prompt-optimizer'
import { resolveModelProfile, needsSimplifiedPrompt } from './model-profiles'
import {
expandRootFrameHeight,
extractStreamingNodes,
@ -243,22 +242,11 @@ async function executeSubAgent(
request.context?.themes,
)
// Select prompt variant based on model profile
const profile = resolveModelProfile(request.model)
const basePrompt = needsSimplifiedPrompt(profile) ? SUB_AGENT_PROMPT_SIMPLIFIED : SUB_AGENT_PROMPT
const systemPrompt = preparedPrompt.designPrinciples && !needsSimplifiedPrompt(profile)
const basePrompt = SUB_AGENT_PROMPT
const systemPrompt = preparedPrompt.designPrinciples
? `${basePrompt}\n\n${preparedPrompt.designPrinciples}`
: basePrompt
// For basic-tier models (MiniMax, GLM, Qwen, etc.) via third-party routers,
// system prompts may be ignored or lost. Inline the system prompt into the
// user message to ensure the model sees the instructions.
const inlineSystem = needsSimplifiedPrompt(profile)
const effectiveSystem = inlineSystem ? '' : systemPrompt
const effectiveUserContent = inlineSystem
? `${systemPrompt}\n\n---\n\n${userPrompt}`
: userPrompt
let rawResponse = ''
const nodes: PenNode[] = []
let streamOffset = 0
@ -266,8 +254,8 @@ async function executeSubAgent(
try {
for await (const chunk of streamChat(
effectiveSystem,
[{ role: 'user', content: effectiveUserContent }],
systemPrompt,
[{ role: 'user', content: userPrompt }],
request.model,
timeoutOptions,
request.provider,

View file

@ -23,8 +23,8 @@ import { ORCHESTRATOR_PROMPT } from './orchestrator-prompts'
import {
getOrchestratorTimeouts,
prepareDesignPrompt,
buildFallbackPlanFromPrompt,
} from './orchestrator-prompt-optimizer'
import { resolveModelProfile, needsSimplifiedPrompt } from './model-profiles'
import {
adjustRootFrameHeightToContent,
insertStreamingNode,
@ -437,18 +437,9 @@ async function callOrchestrator(
let rawResponse = ''
let thinkingContent = ''
// For basic-tier models, inline system prompt into user message
// since third-party routers may drop or ignore system prompts.
const profile = resolveModelProfile(model)
const inlineSystem = needsSimplifiedPrompt(profile)
const effectiveSystem = inlineSystem ? '' : ORCHESTRATOR_PROMPT
const effectiveUserContent = inlineSystem
? `${ORCHESTRATOR_PROMPT}\n\n---\n\nUSER REQUEST:\n${prompt}`
: prompt
for await (const chunk of streamChat(
effectiveSystem,
[{ role: 'user', content: effectiveUserContent }],
ORCHESTRATOR_PROMPT,
[{ role: 'user', content: prompt }],
model,
getOrchestratorTimeouts(timeoutHintLength, model),
provider,
@ -465,15 +456,15 @@ async function callOrchestrator(
}
const plan = parseOrchestratorResponse(rawResponse)
if (!plan) {
const preview = rawResponse.trim().slice(0, 150)
const hint = rawResponse.trim().length === 0
? 'The model returned an empty response.'
: `Model output: "${preview}${rawResponse.length > 150 ? '…' : ''}"`
throw new Error(`Could not parse design plan from model response. ${hint}`)
}
if (plan) return plan
return plan
// Fallback: model returned non-JSON (e.g. markdown text). Use a heuristic
// plan derived from the user's prompt so generation can still proceed.
console.warn(
'[Orchestrator] Could not parse model response, using fallback plan. Preview:',
rawResponse.trim().slice(0, 150),
)
return buildFallbackPlanFromPrompt(prompt)
}
function parseOrchestratorResponse(raw: string): OrchestratorPlan | null {