diff --git a/README.md b/README.md index 8b0cce8..2acfacc 100644 --- a/README.md +++ b/README.md @@ -1,29 +1,35 @@ # Wingman -Hosted single-page playground for testing a [Manifest](https://manifest.build) gateway. Impersonate any of the agents Manifest tracks — **OpenClaw**, **Hermes**, **OpenAI SDK**, **Vercel AI SDK**, **LangChain**, plain **cURL**, or a raw fetch — and inspect the exact request / response that lands on your backend. +Hosted single-page playground for testing LLM APIs straight from the browser. Pick a **wire format** — OpenAI **Chat Completions**, OpenAI **Responses**, or **Anthropic Messages** — paste a base URL + key, and call any provider that speaks it (Manifest, OpenAI, Anthropic, Together, Fireworks, Groq, DeepSeek, Z.AI, MiniMax, …). Stream the reply token by token or fetch it whole, and inspect the exact request / response on the wire. + +It started as a [Manifest](https://manifest.build) gateway tester, so it also impersonates the agents Manifest tracks — **OpenClaw**, **Hermes**, **OpenAI SDK**, **Vercel AI SDK**, **LangChain**, plain **cURL**, or a raw fetch — to show how the proxy classifies each client. Live at ****. ## What it's for -- Verifying routing decisions (which tier did Manifest pick for this prompt?). -- Inspecting how the proxy classifies different SDKs from their User-Agent / `X-Stainless-*` headers. +- Calling any LLM provider by URL + key + format — no SDK install, no terminal. +- Comparing the same prompt across formats (Chat Completions vs Responses) or across providers. +- Watching a streamed response arrive token by token, with time-to-first-token. +- Verifying Manifest routing decisions (which tier did it pick for this prompt?). +- Inspecting how a proxy classifies different SDKs from their User-Agent / `X-Stainless-*` headers. - Reproducing a customer report end-to-end without touching the real CLI. -- Onboarding contributors who want to see what an OpenClaw or Hermes request actually looks like. ## How it works (and what it doesn't do) -Wingman is a **static SPA**. It has no backend of its own — every request goes straight from your browser to whatever Manifest gateway you configure in the connection bar. There is no telemetry, no server-side logging, no proxy in between. +Wingman is a **static SPA**. It has no backend of its own — every request goes straight from your browser to whatever endpoint you configure in the connection bar. There is no telemetry, no server-side logging, no proxy in between. Streaming responses are read directly from the `fetch` body as Server-Sent Events. Your API key is held in `sessionStorage` (cleared when you close the tab). Everything else — base URL, model, history, system prompts — is in `localStorage` and never leaves the browser. The full source is in this repo if you want to audit it. ## Connecting to a gateway -Open , paste: +Open , then: -- **Base URL** — e.g. `https://your-manifest.example.com` or `http://localhost:3001` (more on cross-origin below). -- **API key** — your `mnfst_*` token. +- **Format** — the wire protocol: OpenAI Chat Completions (`/v1/chat/completions`), OpenAI Responses (`/v1/responses`), or Anthropic Messages (`/v1/messages`). This sets the endpoint path, auth scheme, body shape, and how the response is parsed. +- **Base URL** — e.g. `https://your-manifest.example.com`, `https://api.openai.com`, `https://api.anthropic.com`, or `http://localhost:3001` (more on cross-origin below). Wingman appends the format's path. +- **API key** — `Authorization: Bearer` for OpenAI-style formats, `x-api-key` for Anthropic (attached automatically per format). - **Model** — `auto` or a specific model id. +- **Stream** — toggle in the composer toolbar to read the reply as it's generated (Server-Sent Events). You can pre-fill via query string: `?baseUrl=https://your.gateway&apiKey=mnfst_...`. The Manifest dashboard's Wingman drawer does this automatically. @@ -42,6 +48,8 @@ The `X-Stainless-*` headers matter: the OpenClaw, Hermes, and OpenAI SDK profile If the gateway is on a loopback address (`localhost` / `127.0.0.1`) and you load Wingman over HTTPS, Chrome's Private Network Access also wants `Access-Control-Allow-Private-Network: true` on the preflight. +Calling **Anthropic** directly: its API blocks browser origins by default, so Wingman sends the `anthropic-dangerous-direct-browser-access: true` header (alongside `anthropic-version`) automatically when you pick the Anthropic Messages format. + `Access-Control-Allow-Credentials` can stay **false** — Wingman uses bearer keys, never cookies. If you're behind a corporate firewall or running a fully air-gapped Manifest, clone this repo and `npm run dev` — Wingman runs entirely client-side. @@ -69,9 +77,12 @@ Scripts: ## How it's wired -- **`src/profiles.ts`** — catalog of every supported agent/SDK shape. Adding a new one means adding one entry: headers, system prompt, body builder, code snippet builder. The UI picks up the new tile automatically. -- **`src/send.ts`** — single fetch wrapper that captures status, latency, request/response headers, and parses JSON when possible. Filters out forbidden headers (`User-Agent`, `Sec-*`, `Cookie`, etc.) that browsers refuse to set on fetch and surfaces them in the UI. -- **`src/App.tsx`** — composes the layout: connection bar → profile tiles → form → header editor → SDK code preview → response panel. +- **`src/formats/`** — one module per wire format (`openai-chat`, `openai-responses`, `anthropic-messages`). Each owns its endpoint path, auth scheme, body builder, response parsers, and streaming parser. Adding a provider format means adding one file and listing it in `index.ts`. +- **`src/profiles.ts`** — catalog of agent/SDK fingerprints layered on a format: headers, system prompt, optional body extras, code snippet. Each profile declares which formats it's compatible with; the UI filters the list to the selected format. +- **`src/snippets.ts`** — format-aware SDK / cURL code-snippet builders for the preview panel. +- **`src/send.ts`** — fetch wrapper that captures status, latency, request/response headers, and parses JSON. `sendRequestStreaming` reads the SSE body and assembles the text via the format's stream parser. Filters out forbidden headers (`User-Agent`, `Sec-*`, `Cookie`, …) that browsers refuse to set on fetch and surfaces them in the UI. +- **`src/services/sse.ts`** — generic Server-Sent Events reader (decodes the stream, splits events). +- **`src/App.tsx`** — composes the layout: format + client pickers → connection bar → form → header editor → SDK code preview → response panel. ## Caveats diff --git a/index.html b/index.html index 58dafcf..cf10a18 100644 --- a/index.html +++ b/index.html @@ -3,12 +3,12 @@ - Manifest Wingman — Gateway Tester + Manifest Wingman — LLM API Tester diff --git a/public/icons/providers/anthropic.svg b/public/icons/providers/anthropic.svg new file mode 100644 index 0000000..cf9494d --- /dev/null +++ b/public/icons/providers/anthropic.svg @@ -0,0 +1 @@ +Anthropic diff --git a/src/App.tsx b/src/App.tsx index 600e691..95bcc75 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,8 +4,21 @@ import Sidebar from './components/Sidebar.jsx'; import Conversation from './components/Conversation.jsx'; import Composer from './components/Composer.jsx'; import { type HeaderEntry } from './components/HeaderEditor.jsx'; -import { PROFILES, PROFILE_BY_ID, type Profile, type ProfileLang } from './profiles'; -import { partitionHeaders, sendRequest, type SendResult } from './send'; +import { + PROFILES, + PROFILE_BY_ID, + profilesForFormat, + type Profile, + type ProfileLang, +} from './profiles'; +import { + FORMATS, + FORMAT_BY_ID, + DEFAULT_FORMAT_ID, + type ApiFormat, + type ApiFormatId, +} from './formats'; +import { partitionHeaders, sendRequest, sendRequestStreaming, type SendResult } from './send'; import { appendHistory, clearHistory, @@ -23,6 +36,8 @@ const STORAGE = { apiKey: 'wingman:apiKey', model: 'wingman:model', profile: 'wingman:profile', + format: 'wingman:format', + stream: 'wingman:stream', }; // API keys are stored in sessionStorage (cleared on tab close) instead of @@ -87,16 +102,18 @@ function defaultBaseUrl(): string { return ''; } -function extractAssistantText(json: unknown): string | null { - if (!json || typeof json !== 'object') return null; - const root = json as Record; - const choices = root.choices; - if (Array.isArray(choices) && choices.length > 0) { - const first = choices[0] as { message?: { content?: unknown }; text?: unknown } | undefined; - if (first?.message && typeof first.message.content === 'string') return first.message.content; - if (typeof first?.text === 'string') return first.text; - } - return null; +function resolveInitialFormat(): ApiFormatId { + const param = readQueryParam('format'); + if (param && FORMAT_BY_ID[param]) return param as ApiFormatId; + const stored = readStorage(STORAGE.format, DEFAULT_FORMAT_ID); + return FORMAT_BY_ID[stored] ? (stored as ApiFormatId) : DEFAULT_FORMAT_ID; +} + +function resolveInitialProfile(formatId: ApiFormatId): string { + const compatible = profilesForFormat(formatId); + const stored = readStorage(STORAGE.profile, ''); + if (compatible.some((p) => p.id === stored)) return stored; + return compatible[0]?.id ?? PROFILES[0]!.id; } const App: Component = () => { @@ -106,15 +123,20 @@ const App: Component = () => { const initialApiKey = apiKeyParam ?? readStorage(STORAGE.apiKey, ''); if (baseUrlParam) writeStorage(STORAGE.baseUrl, baseUrlParam); if (apiKeyParam) writeStorage(STORAGE.apiKey, apiKeyParam); + const initialFormatId = resolveInitialFormat(); const [baseUrl, setBaseUrl] = createSignal(initialBaseUrl); const [apiKey, setApiKey] = createSignal(initialApiKey); const [model, setModel] = createSignal(readStorage(STORAGE.model, 'auto')); - const [profileId, setProfileId] = createSignal( - readStorage(STORAGE.profile, PROFILES[0]?.id ?? 'openclaw'), + const [formatId, setFormatId] = createSignal(initialFormatId); + const [profileId, setProfileId] = createSignal(resolveInitialProfile(initialFormatId)); + const [stream, setStream] = createSignal(readStorage(STORAGE.stream, '0') === '1'); + + const format = createMemo(() => FORMAT_BY_ID[formatId()] ?? FORMATS[0]!); + const availableProfiles = createMemo(() => profilesForFormat(formatId())); + const profile = createMemo( + () => PROFILE_BY_ID[profileId()] ?? availableProfiles()[0] ?? PROFILES[0]!, ); - const profile = createMemo(() => PROFILE_BY_ID[profileId()] ?? PROFILES[0]!); - const [systemPrompts, setSystemPrompts] = createSignal>( Object.fromEntries(PROFILES.map((p) => [p.id, p.defaultSystemPrompt ?? ''])), ); @@ -123,6 +145,7 @@ const App: Component = () => { const [lang, setLang] = createSignal(profile().defaultLang); const [result, setResult] = createSignal(null); const [loading, setLoading] = createSignal(false); + const [streamingText, setStreamingText] = createSignal(''); const [hasSent, setHasSent] = createSignal(false); const [sentMessage, setSentMessage] = createSignal(''); const [history, setHistory] = createSignal(listHistory()); @@ -130,25 +153,26 @@ const App: Component = () => { const [saveStatus, setSaveStatus] = createSignal<'idle' | 'saving' | 'saved' | 'error'>('idle'); const [gistMarkdown, setGistMarkdown] = createSignal(''); const [gistModalOpen, setGistModalOpen] = createSignal(false); - // Edited code per `${profileId}:${lang}` — when present, it overrides the - // generated snippet AND becomes the source of truth for Send (provided the - // profile is executable in this language). + // Edited code per `${formatId}:${profileId}:${lang}` — when present, it + // overrides the generated snippet AND becomes the source of truth for Send + // (provided the profile is executable in this language). const [scratchCode, setScratchCode] = createSignal>({}); const [healthStatus, setHealthStatus] = createSignal({ kind: 'idle' }); - // Pre-flight health check — pings `${baseUrl}/api/v1/health` whenever the - // base URL changes. Surfaces CORS / network / HTTP errors in the connection - // bar so users can tell a missing allow-list entry from a typo or 401. + // Pre-flight health check — only formats that expose a health path (the + // Manifest gateway's `/api/v1/health`) get probed; public provider APIs have + // no such endpoint, so we skip rather than show a misleading "unreachable". createEffect(() => { const url = baseUrl().trim(); - if (!url) { + const path = format().healthPath; + if (!url || !path) { setHealthStatus({ kind: 'idle' }); return; } const controller = new AbortController(); const timer = window.setTimeout(() => { setHealthStatus({ kind: 'checking' }); - checkHealth(url, controller.signal).then(setHealthStatus); + checkHealth(url, path, controller.signal).then(setHealthStatus); }, 400); onCleanup(() => { window.clearTimeout(timer); @@ -156,20 +180,19 @@ const App: Component = () => { }); }); - const generatedSdkCode = createMemo(() => - profile().code( - { - baseUrl: baseUrl().replace(/\/+$/, ''), - apiKey: apiKey(), - model: model(), - systemPrompt: systemPrompts()[profile().id] ?? '', - userMessage: userMessage(), - }, - lang(), - ), - ); + const params = () => ({ + baseUrl: baseUrl().replace(/\/+$/, ''), + apiKey: apiKey(), + model: model(), + systemPrompt: systemPrompts()[profile().id] ?? '', + userMessage: userMessage(), + }); + + const requestUrl = () => `${baseUrl().replace(/\/+$/, '')}${format().path}`; + + const generatedSdkCode = createMemo(() => profile().code(params(), lang(), format())); - const scratchKey = () => `${profile().id}:${lang()}`; + const scratchKey = () => `${formatId()}:${profile().id}:${lang()}`; const sdkCodeIsEdited = () => { const v = scratchCode()[scratchKey()]; return v !== undefined && v !== generatedSdkCode(); @@ -187,30 +210,25 @@ const App: Component = () => { setScratchCode(next); }; - const params = () => ({ - baseUrl: baseUrl().replace(/\/+$/, ''), - apiKey: apiKey(), - model: model(), - systemPrompt: systemPrompts()[profile().id] ?? '', - userMessage: userMessage(), - }); - + // Default headers come from the format (e.g. anthropic-version); the profile + // layers its fingerprint headers on top. + const overrideKey = () => `${formatId()}:${profile().id}:${lang()}`; const headerEntries = createMemo(() => { - const overrideKey = `${profile().id}:${lang()}`; - const cached = headerOverrides()[overrideKey]; + const cached = headerOverrides()[overrideKey()]; if (cached) return cached; - return entriesFromRecord(profile().headers(params())); + return entriesFromRecord({ + ...(format().defaultHeaders ?? {}), + ...profile().headers(params()), + }); }); const updateHeaderEntries = (next: HeaderEntry[]) => { - const overrideKey = `${profile().id}:${lang()}`; - setHeaderOverrides({ ...headerOverrides(), [overrideKey]: next }); + setHeaderOverrides({ ...headerOverrides(), [overrideKey()]: next }); }; const resetHeaders = () => { - const overrideKey = `${profile().id}:${lang()}`; const next = { ...headerOverrides() }; - delete next[overrideKey]; + delete next[overrideKey()]; setHeaderOverrides(next); }; @@ -228,6 +246,18 @@ const App: Component = () => { } }; + const setFormatSafely = (id: string) => { + if (!FORMAT_BY_ID[id]) return; + setFormatId(id as ApiFormatId); + writeStorage(STORAGE.format, id); + // Keep the selected profile compatible with the new format. + const compatible = profilesForFormat(id as ApiFormatId); + if (!compatible.some((p) => p.id === profileId())) { + const next = compatible[0]; + if (next) setProfileSafely(next.id); + } + }; + const persistAndSetBase = (value: string) => { setBaseUrl(value); writeStorage(STORAGE.baseUrl, value); @@ -240,26 +270,46 @@ const App: Component = () => { setModel(value); writeStorage(STORAGE.model, value); }; + const persistAndSetStream = (value: boolean) => { + setStream(value); + writeStorage(STORAGE.stream, value ? '1' : '0'); + }; const updateSystemPrompt = (value: string) => { setSystemPrompts({ ...systemPrompts(), [profile().id]: value }); }; + const errorResult = (message: string): SendResult => ({ + url: requestUrl(), + status: 0, + statusText: 'Code error', + ok: false, + durationMs: 0, + requestHeaders: {}, + requestBody: '', + responseHeaders: {}, + responseBody: '', + responseJson: null, + error: message, + }); + const handleSend = async () => { setResult(null); + setStreamingText(''); setActiveHistoryId(null); setSaveStatus('idle'); setLoading(true); setHasSent(true); setSentMessage(userMessage()); + const fmt = format(); let next: SendResult; let sentHeaders: Record; if (willRunCode()) { - // The user edited the SDK preview, and the profile/lang combination - // can actually execute it in-browser. Run the code through the stub - // SDK; whatever fetch the code triggers becomes the SendResult. + // The user edited the SDK preview, and the profile/lang combination can + // execute it in-browser. Run the code through the stub SDK; whatever + // fetch the code triggers becomes the SendResult. try { const out = await runUserCode({ profileId: profile().id, @@ -269,28 +319,24 @@ const App: Component = () => { }); next = out.result; } catch (err) { - const message = err instanceof Error ? err.message : String(err); - const url = `${baseUrl().replace(/\/+$/, '')}/v1/chat/completions`; - next = { - url, - status: 0, - statusText: 'Code error', - ok: false, - durationMs: 0, - requestHeaders: {}, - requestBody: '', - responseHeaders: {}, - responseBody: '', - responseJson: null, - error: message, - }; + next = errorResult(err instanceof Error ? err.message : String(err)); } sentHeaders = next.requestHeaders; } else { sentHeaders = recordFromEntries(headerEntries()); - const body = profile().body(params()); - const url = `${baseUrl().replace(/\/+$/, '')}/v1/chat/completions`; - next = await sendRequest({ url, apiKey: apiKey(), headers: sentHeaders, body }); + const p = params(); + const body = { + ...fmt.buildBody(p, { stream: stream() }), + ...(profile().bodyExtras?.(p) ?? {}), + }; + const url = requestUrl(); + const input = { url, apiKey: apiKey(), auth: fmt.auth, headers: sentHeaders, body }; + next = stream() + ? await sendRequestStreaming(input, { + createParser: fmt.createStreamParser, + onDelta: (t) => setStreamingText((prev) => prev + t), + }) + : await sendRequest(input); } setResult(next); @@ -299,6 +345,10 @@ const App: Component = () => { const stored = appendHistory({ profileId: profile().id, profileLabel: profile().label, + formatId: fmt.id, + formatLabel: fmt.label, + streamed: next.isStream ?? false, + url: next.url, baseUrl: baseUrl(), model: model(), systemPrompt: systemPrompts()[profile().id] ?? '', @@ -309,7 +359,7 @@ const App: Component = () => { statusText: next.statusText, ok: next.ok, durationMs: next.durationMs, - assistantText: extractAssistantText(next.responseJson), + assistantText: fmt.extractText(next.responseJson) ?? next.streamedText ?? null, requestBody: next.requestBody, requestHeaders: next.requestHeaders, responseBody: next.responseBody, @@ -322,29 +372,39 @@ const App: Component = () => { }; const restoreFromHistory = (entry: HistoryEntry) => { + const restoredFormatId: ApiFormatId = + entry.formatId && FORMAT_BY_ID[entry.formatId] + ? (entry.formatId as ApiFormatId) + : DEFAULT_FORMAT_ID; + setFormatId(restoredFormatId); + writeStorage(STORAGE.format, restoredFormatId); setProfileId(entry.profileId); writeStorage(STORAGE.profile, entry.profileId); persistAndSetBase(entry.baseUrl); persistAndSetModel(entry.model); + if (entry.streamed !== undefined) persistAndSetStream(entry.streamed); setSystemPrompts({ ...systemPrompts(), [entry.profileId]: entry.systemPrompt }); setUserMessage(entry.userMessage); setSentMessage(entry.userMessage); setHasSent(true); + setStreamingText(''); const next = PROFILE_BY_ID[entry.profileId]; if (next) { const restoredLang = ( next.langs.includes(entry.lang as ProfileLang) ? entry.lang : next.defaultLang ) as ProfileLang; setLang(restoredLang); - const overrideKey = `${entry.profileId}:${restoredLang}`; setHeaderOverrides({ ...headerOverrides(), - [overrideKey]: entriesFromRecord(entry.headers), + [`${restoredFormatId}:${entry.profileId}:${restoredLang}`]: entriesFromRecord( + entry.headers, + ), }); } setActiveHistoryId(entry.id); + const fmt = FORMAT_BY_ID[restoredFormatId] ?? FORMATS[0]!; setResult({ - url: `${entry.baseUrl.replace(/\/+$/, '')}/v1/chat/completions`, + url: entry.url ?? `${entry.baseUrl.replace(/\/+$/, '')}${fmt.path}`, status: entry.status, statusText: entry.statusText, ok: entry.ok, @@ -355,6 +415,7 @@ const App: Component = () => { responseBody: entry.responseBody, responseJson: entry.responseJson, error: entry.errorMessage, + isStream: entry.streamed, }); }; @@ -374,6 +435,7 @@ const App: Component = () => { const handleNewRequest = () => { setResult(null); + setStreamingText(''); setActiveHistoryId(null); setHasSent(false); setSentMessage(''); @@ -387,6 +449,8 @@ const App: Component = () => { { profileLabel: profile().label, profileCategory: profile().category, + formatLabel: format().label, + streamed: r.isStream ?? false, systemPrompt: systemPrompts()[profile().id] ?? '', userMessage: sentMessage() || userMessage(), baseUrl: baseUrl(), @@ -394,6 +458,7 @@ const App: Component = () => { apiKey: apiKey(), }, r, + format(), ); setGistMarkdown(markdown); setGistModalOpen(true); @@ -419,13 +484,20 @@ const App: Component = () => { result={result()} loading={loading()} hasSent={hasSent()} + format={format()} + streamingText={streamingText()} />
{ onResetHeaders={resetHeaders} blockedHeaders={blockedHeaderNames()} baseUrl={baseUrl()} + baseUrlPlaceholder={format().placeholderBaseUrl ?? 'https://your-manifest.example.com'} apiKey={apiKey()} model={model()} onBaseUrlChange={persistAndSetBase} diff --git a/src/components/AssistantMessage.tsx b/src/components/AssistantMessage.tsx index 61dfb99..6da1c8a 100644 --- a/src/components/AssistantMessage.tsx +++ b/src/components/AssistantMessage.tsx @@ -1,10 +1,14 @@ import { createSignal, For, Show, type Component } from 'solid-js'; import type { SendResult } from '../send'; +import type { ApiFormat } from '../formats'; import CodeView from './CodeView.jsx'; interface Props { result: SendResult | null; loading: boolean; + format: ApiFormat; + /** Live assistant text accumulating during a streamed request. */ + streamingText: string; } type Tab = 'output' | 'response-body' | 'response-headers' | 'request-body' | 'request-headers'; @@ -28,37 +32,6 @@ function prettyBody(body: string, json: unknown | null): string { return body || '(empty body)'; } -function extractAssistantText(json: unknown): string | null { - if (!json || typeof json !== 'object') return null; - const root = json as Record; - const choices = root.choices; - if (Array.isArray(choices) && choices.length > 0) { - const first = choices[0] as { message?: { content?: unknown }; text?: unknown } | undefined; - if (first?.message && typeof first.message.content === 'string') return first.message.content; - if (typeof first?.text === 'string') return first.text; - } - return null; -} - -function extractUsage(json: unknown): { in?: number; out?: number; total?: number } | null { - if (!json || typeof json !== 'object') return null; - const usage = (json as Record).usage; - if (!usage || typeof usage !== 'object') return null; - const u = usage as Record; - const num = (v: unknown) => (typeof v === 'number' ? v : undefined); - return { - in: num(u.prompt_tokens) ?? num(u.input_tokens), - out: num(u.completion_tokens) ?? num(u.output_tokens), - total: num(u.total_tokens), - }; -} - -function extractModel(json: unknown): string | null { - if (!json || typeof json !== 'object') return null; - const m = (json as Record).model; - return typeof m === 'string' ? m : null; -} - const StatusPill: Component<{ status: number; ok: boolean; statusText: string }> = (props) => { const tone = () => { if (props.status === 0) return 'error'; @@ -78,23 +51,69 @@ const StatusPill: Component<{ status: number; ok: boolean; statusText: string }> ); }; +const ClockIcon: Component = () => ( + +); + +const TokenIcon: Component = () => ( + +); + const AssistantMessage: Component = (props) => { const [tab, setTab] = createSignal('output'); + // Prefer the parsed/assembled response text; fall back to streamed text. const assistantText = () => { - if (!props.result) return null; - return props.result.responseJson ? extractAssistantText(props.result.responseJson) : null; + const r = props.result; + if (!r) return null; + return props.format.extractText(r.responseJson) ?? r.streamedText ?? null; }; - const usage = () => (props.result?.responseJson ? extractUsage(props.result.responseJson) : null); - const model = () => (props.result?.responseJson ? extractModel(props.result.responseJson) : null); + const usage = () => (props.result ? props.format.extractUsage(props.result.responseJson) : null); + const model = () => (props.result ? props.format.extractModel(props.result.responseJson) : null); return (
-
- - Thinking… -
+ + + Thinking… +
+ } + > +
+ {props.streamingText} +
+ @@ -103,39 +122,19 @@ const AssistantMessage: Component = (props) => {
- + {r.durationMs.toFixed(0)} ms + + + + {r.ttftMs!.toFixed(0)} ms TTFT + + {(total) => ( - + {total()} tok @@ -190,7 +189,14 @@ const AssistantMessage: Component = (props) => { - + + } + > + + diff --git a/src/components/Composer.tsx b/src/components/Composer.tsx index ee48ed8..c5794bf 100644 --- a/src/components/Composer.tsx +++ b/src/components/Composer.tsx @@ -2,13 +2,20 @@ import { createSignal, For, Show, type Component } from 'solid-js'; import HeaderEditor, { type HeaderEntry } from './HeaderEditor.jsx'; import CodeView from './CodeView.jsx'; import ProfileDropdown from './ProfileDropdown.jsx'; +import FormatDropdown from './FormatDropdown.jsx'; import type { Profile, ProfileLang } from '../profiles'; +import type { ApiFormat } from '../formats'; import type { HealthStatus } from '../services/healthCheck'; interface Props { + formats: readonly ApiFormat[]; + activeFormatId: string; + onSelectFormat: (id: string) => void; profiles: readonly Profile[]; activeProfileId: string; onSelectProfile: (id: string) => void; + stream: boolean; + onStreamChange: (value: boolean) => void; systemPrompt: string; onSystemPromptChange: (value: string) => void; showSystemPrompt: boolean; @@ -21,6 +28,7 @@ interface Props { baseUrl: string; apiKey: string; model: string; + baseUrlPlaceholder: string; onBaseUrlChange: (value: string) => void; onApiKeyChange: (value: string) => void; onModelChange: (value: string) => void; @@ -205,13 +213,22 @@ const Composer: Component = (props) => { return (
- {/* Profile picker — single dropdown above the wrapper. */} + {/* Client + format pickers above the wrapper, read left→right: the + request fingerprint (client) flows to the endpoint format. */}
+ +
@@ -228,7 +245,7 @@ const Composer: Component = (props) => { type="text" value={props.baseUrl} onInput={(e) => props.onBaseUrlChange(e.currentTarget.value)} - placeholder="https://your-manifest.example.com" + placeholder={props.baseUrlPlaceholder} spellcheck={false} autocomplete="off" /> @@ -294,11 +311,7 @@ const Composer: Component = (props) => {

Your API key never leaves the browser — Wingman is a static SPA with no backend.{' '} - + View source . @@ -554,6 +567,23 @@ const Composer: Component = (props) => { {sdkOpen() ? '−' : '+'} SDK code + ⌘/Ctrl + Enter to send

diff --git a/src/components/Conversation.tsx b/src/components/Conversation.tsx index 30e0bb8..94af905 100644 --- a/src/components/Conversation.tsx +++ b/src/components/Conversation.tsx @@ -1,5 +1,6 @@ import { Show, type Component } from 'solid-js'; import type { SendResult } from '../send'; +import type { ApiFormat } from '../formats'; import AssistantMessage from './AssistantMessage.jsx'; interface Props { @@ -7,6 +8,8 @@ interface Props { result: SendResult | null; loading: boolean; hasSent: boolean; + format: ApiFormat; + streamingText: string; } const PaperPlane: Component = () => ( @@ -49,7 +52,12 @@ const Conversation: Component = (props) => {
{props.userMessage}
- + ); diff --git a/src/components/FormatDropdown.tsx b/src/components/FormatDropdown.tsx new file mode 100644 index 0000000..13fec94 --- /dev/null +++ b/src/components/FormatDropdown.tsx @@ -0,0 +1,123 @@ +import { createSignal, For, onCleanup, Show, type Component } from 'solid-js'; +import type { ApiFormat } from '../formats'; + +interface Props { + formats: readonly ApiFormat[]; + activeId: string; + onSelect: (id: string) => void; +} + +const ChevronIcon: Component = () => ( + +); + +const CheckIcon: Component = () => ( + +); + +const FormatDropdown: Component = (props) => { + const [open, setOpen] = createSignal(false); + + const active = () => props.formats.find((f) => f.id === props.activeId) ?? props.formats[0]!; + + const close = () => setOpen(false); + + const onDocumentClick = (e: MouseEvent) => { + const target = e.target as HTMLElement; + if (!target.closest('.format-dd')) close(); + }; + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') close(); + }; + + const toggle = () => { + const next = !open(); + setOpen(next); + if (next) { + document.addEventListener('click', onDocumentClick); + document.addEventListener('keydown', onKeyDown); + onCleanup(() => { + document.removeEventListener('click', onDocumentClick); + document.removeEventListener('keydown', onKeyDown); + }); + } + }; + + const handleSelect = (id: string) => { + props.onSelect(id); + close(); + }; + + return ( +
+ + +
+ + {(f) => ( + + )} + +
+
+
+ ); +}; + +export default FormatDropdown; diff --git a/src/formats/anthropic-messages.ts b/src/formats/anthropic-messages.ts new file mode 100644 index 0000000..8a6d6e6 --- /dev/null +++ b/src/formats/anthropic-messages.ts @@ -0,0 +1,120 @@ +import type { ApiFormat, StreamDelta, StreamParser, Usage } from './types'; +import { asArray, asNumber, asRecord, asString, parseData } from './helpers'; + +const DEFAULT_MAX_TOKENS = 1024; + +// Assistant text is the concatenation of `content[]` parts of type `text`. +function extractText(json: unknown): string | null { + const content = asArray(asRecord(json)?.content); + if (!content) return null; + const parts: string[] = []; + for (const part of content) { + const rec = asRecord(part); + if (rec?.type === 'text') { + const t = asString(rec.text); + if (t) parts.push(t); + } + } + return parts.length ? parts.join('') : null; +} + +// Anthropic reports input/output tokens but no total — we compute it. +function extractUsage(json: unknown): Usage | null { + const u = asRecord(asRecord(json)?.usage); + if (!u) return null; + const input = asNumber(u.input_tokens); + const output = asNumber(u.output_tokens); + const total = + input !== undefined || output !== undefined ? (input ?? 0) + (output ?? 0) : undefined; + return { in: input, out: output, total }; +} + +function extractModel(json: unknown): string | null { + return asString(asRecord(json)?.model); +} + +// Anthropic splits the message across events: `message_start` carries the model +// and input tokens, `content_block_delta` streams text, `message_delta` carries +// output tokens, and `message_stop` ends it. We accumulate everything and emit +// one assembled message object (matching the non-streamed shape) at the end. +function createStreamParser(): StreamParser { + let text = ''; + let model: string | null = null; + let inputTokens: number | undefined; + let outputTokens: number | undefined; + + const assembled = () => ({ + type: 'message', + role: 'assistant', + model, + content: [{ type: 'text', text }], + usage: { input_tokens: inputTokens, output_tokens: outputTokens }, + }); + + return { + push(evt): StreamDelta { + const parsed = parseData(evt.data); + if (!parsed) return {}; + const type = evt.event ?? asString(parsed.type); + switch (type) { + case 'message_start': { + const msg = asRecord(parsed.message); + model = asString(msg?.model); + inputTokens = asNumber(asRecord(msg?.usage)?.input_tokens); + return {}; + } + case 'content_block_delta': { + const delta = asString(asRecord(parsed.delta)?.text); + if (delta) { + text += delta; + return { text: delta }; + } + return {}; + } + case 'message_delta': { + const out = asNumber(asRecord(parsed.usage)?.output_tokens); + if (out !== undefined) outputTokens = out; + return {}; + } + case 'message_stop': + return { final: assembled(), done: true }; + case 'error': + return { final: parsed, done: true }; + default: + return {}; + } + }, + }; +} + +export const anthropicMessages: ApiFormat = { + id: 'anthropic-messages', + label: 'Anthropic Messages', + blurb: 'POST /v1/messages — Anthropic’s native API (x-api-key, requires max_tokens).', + icon: '/icons/providers/anthropic.svg', + docsUrl: 'https://docs.anthropic.com/en/api/messages', + path: '/v1/messages', + placeholderBaseUrl: 'https://api.anthropic.com', + auth: { kind: 'header', name: 'x-api-key' }, + defaultHeaders: { + 'anthropic-version': '2023-06-01', + // Required for Anthropic to accept requests straight from a browser origin. + 'anthropic-dangerous-direct-browser-access': 'true', + }, + requiresMaxTokens: true, + buildBody(p, { stream }) { + const body: Record = { + model: p.model, + max_tokens: p.maxTokens ?? DEFAULT_MAX_TOKENS, + messages: [{ role: 'user', content: p.userMessage }], + }; + if (p.systemPrompt.trim()) body.system = p.systemPrompt; + if (p.temperature !== undefined) body.temperature = p.temperature; + if (stream) body.stream = true; + return body; + }, + extractText, + extractUsage, + extractModel, + createStreamParser, +}; diff --git a/src/formats/helpers.ts b/src/formats/helpers.ts new file mode 100644 index 0000000..6d6634f --- /dev/null +++ b/src/formats/helpers.ts @@ -0,0 +1,29 @@ +// Tiny safe-access helpers so each format can narrow `unknown` response JSON +// without repeating the same guards. + +export function asRecord(v: unknown): Record | null { + return v && typeof v === 'object' ? (v as Record) : null; +} + +export function asString(v: unknown): string | null { + return typeof v === 'string' ? v : null; +} + +export function asNumber(v: unknown): number | undefined { + return typeof v === 'number' ? v : undefined; +} + +export function asArray(v: unknown): unknown[] | null { + return Array.isArray(v) ? v : null; +} + +/** Parse SSE `data` JSON, returning null for `[DONE]` and malformed payloads. */ +export function parseData(data: string): Record | null { + const trimmed = data.trim(); + if (!trimmed || trimmed === '[DONE]') return null; + try { + return asRecord(JSON.parse(trimmed)); + } catch { + return null; + } +} diff --git a/src/formats/index.ts b/src/formats/index.ts new file mode 100644 index 0000000..1df3dbd --- /dev/null +++ b/src/formats/index.ts @@ -0,0 +1,22 @@ +import type { ApiFormat, ApiFormatId } from './types'; +import { openaiChat } from './openai-chat'; +import { openaiResponses } from './openai-responses'; +import { anthropicMessages } from './anthropic-messages'; + +export type { + ApiFormat, + ApiFormatId, + AuthScheme, + RequestParams, + StreamDelta, + StreamParser, + Usage, +} from './types'; + +export const FORMATS: ApiFormat[] = [openaiChat, openaiResponses, anthropicMessages]; + +export const FORMAT_BY_ID: Record = Object.fromEntries( + FORMATS.map((f) => [f.id, f]), +); + +export const DEFAULT_FORMAT_ID: ApiFormatId = 'openai-chat'; diff --git a/src/formats/openai-chat.ts b/src/formats/openai-chat.ts new file mode 100644 index 0000000..5f44d02 --- /dev/null +++ b/src/formats/openai-chat.ts @@ -0,0 +1,93 @@ +import type { ApiFormat, RequestParams, StreamDelta, StreamParser, Usage } from './types'; +import { asArray, asNumber, asRecord, asString, parseData } from './helpers'; + +function messages(p: RequestParams) { + const list: Array<{ role: string; content: string }> = []; + if (p.systemPrompt.trim()) list.push({ role: 'system', content: p.systemPrompt }); + list.push({ role: 'user', content: p.userMessage }); + return list; +} + +function extractText(json: unknown): string | null { + const choices = asArray(asRecord(json)?.choices); + const first = asRecord(choices?.[0]); + if (!first) return null; + const content = asString(asRecord(first.message)?.content); + if (content !== null) return content; + return asString(first.text); +} + +function extractUsage(json: unknown): Usage | null { + const u = asRecord(asRecord(json)?.usage); + if (!u) return null; + return { + in: asNumber(u.prompt_tokens), + out: asNumber(u.completion_tokens), + total: asNumber(u.total_tokens), + }; +} + +function extractModel(json: unknown): string | null { + return asString(asRecord(json)?.model); +} + +// Reassembles streamed `choices[0].delta.content` chunks into a single +// chat-completion-shaped object so the usage/model chips and the assembled-JSON +// view work the same as a non-streamed response. `[DONE]` ends the stream; +// usage arrives in a trailing chunk when `stream_options.include_usage` is set. +function createStreamParser(): StreamParser { + let text = ''; + let model: string | null = null; + let usage: unknown = null; + return { + push(evt): StreamDelta { + if (evt.data.trim() === '[DONE]') { + return { + done: true, + final: { + model, + choices: [{ message: { role: 'assistant', content: text } }], + usage, + }, + }; + } + const parsed = parseData(evt.data); + if (!parsed) return {}; + if (model === null) model = asString(parsed.model); + if (parsed.usage) usage = parsed.usage; + const firstChoice = asRecord(asArray(parsed.choices)?.[0]); + const delta = asString(asRecord(firstChoice?.delta)?.content); + if (delta) { + text += delta; + return { text: delta }; + } + return {}; + }, + }; +} + +export const openaiChat: ApiFormat = { + id: 'openai-chat', + label: 'OpenAI Chat Completions', + blurb: 'POST /v1/chat/completions — the OpenAI-compatible standard (Groq, Together, DeepSeek…).', + icon: '/icons/providers/openai.svg', + docsUrl: 'https://platform.openai.com/docs/api-reference/chat', + path: '/v1/chat/completions', + placeholderBaseUrl: 'https://your-manifest.example.com', + auth: { kind: 'bearer' }, + healthPath: '/api/v1/health', + buildBody(p, { stream }) { + const body: Record = { model: p.model, messages: messages(p) }; + if (p.temperature !== undefined) body.temperature = p.temperature; + if (p.maxTokens !== undefined) body.max_tokens = p.maxTokens; + if (stream) { + body.stream = true; + body.stream_options = { include_usage: true }; + } + return body; + }, + extractText, + extractUsage, + extractModel, + createStreamParser, +}; diff --git a/src/formats/openai-responses.ts b/src/formats/openai-responses.ts new file mode 100644 index 0000000..96a02a8 --- /dev/null +++ b/src/formats/openai-responses.ts @@ -0,0 +1,88 @@ +import type { ApiFormat, StreamDelta, StreamParser, Usage } from './types'; +import { asArray, asNumber, asRecord, asString, parseData } from './helpers'; + +// Responses returns an `output` array of items; assistant text lives in +// `output[].content[]` parts of type `output_text`. The SDK also exposes an +// aggregated `output_text` string, which we honour when present. +function extractText(json: unknown): string | null { + const root = asRecord(json); + if (!root) return null; + const aggregated = asString(root.output_text); + if (aggregated !== null) return aggregated; + const output = asArray(root.output); + if (!output) return null; + const parts: string[] = []; + for (const item of output) { + const content = asArray(asRecord(item)?.content); + if (!content) continue; + for (const part of content) { + const rec = asRecord(part); + if (rec?.type === 'output_text') { + const t = asString(rec.text); + if (t) parts.push(t); + } + } + } + return parts.length ? parts.join('') : null; +} + +function extractUsage(json: unknown): Usage | null { + const u = asRecord(asRecord(json)?.usage); + if (!u) return null; + return { + in: asNumber(u.input_tokens), + out: asNumber(u.output_tokens), + total: asNumber(u.total_tokens), + }; +} + +function extractModel(json: unknown): string | null { + return asString(asRecord(json)?.model); +} + +// Responses streams typed events; the SSE `event:` line names the type. Text +// arrives as `response.output_text.delta`; the full object (with usage) lands +// in `response.completed`. +function createStreamParser(): StreamParser { + return { + push(evt): StreamDelta { + const parsed = parseData(evt.data); + if (!parsed) return {}; + const type = evt.event ?? asString(parsed.type); + if (type === 'response.output_text.delta') { + const delta = asString(parsed.delta); + return delta ? { text: delta } : {}; + } + if (type === 'response.completed' || type === 'response.incomplete') { + return { final: parsed.response ?? parsed, done: true }; + } + if (type === 'response.failed' || type === 'error') { + return { final: parsed.response ?? parsed, done: true }; + } + return {}; + }, + }; +} + +export const openaiResponses: ApiFormat = { + id: 'openai-responses', + label: 'OpenAI Responses', + blurb: 'POST /v1/responses — OpenAI’s newer stateful API with typed streaming events.', + icon: '/icons/providers/openai.svg', + docsUrl: 'https://platform.openai.com/docs/api-reference/responses', + path: '/v1/responses', + placeholderBaseUrl: 'https://api.openai.com', + auth: { kind: 'bearer' }, + buildBody(p, { stream }) { + const body: Record = { model: p.model, input: p.userMessage }; + if (p.systemPrompt.trim()) body.instructions = p.systemPrompt; + if (p.temperature !== undefined) body.temperature = p.temperature; + if (p.maxTokens !== undefined) body.max_output_tokens = p.maxTokens; + if (stream) body.stream = true; + return body; + }, + extractText, + extractUsage, + extractModel, + createStreamParser, +}; diff --git a/src/formats/types.ts b/src/formats/types.ts new file mode 100644 index 0000000..b713044 --- /dev/null +++ b/src/formats/types.ts @@ -0,0 +1,76 @@ +// The API format is the wire protocol Wingman speaks to a provider: the +// endpoint path, how the key is attached, the request body shape, how to read +// the response, and how to parse a streamed (SSE) response. It's orthogonal to +// the agent/SDK `Profile` (which only contributes fingerprint headers, a +// system-prompt default, and a code snippet). One format → every provider that +// speaks it (OpenAI-compatible, Anthropic, …). + +export type ApiFormatId = 'openai-chat' | 'openai-responses' | 'anthropic-messages'; + +/** How the API key is attached to the request. */ +export type AuthScheme = + | { kind: 'bearer' } // Authorization: Bearer + | { kind: 'header'; name: string } // e.g. x-api-key: + | { kind: 'none' }; + +/** Canonical request parameters, shared by every format and profile. */ +export interface RequestParams { + baseUrl: string; + apiKey: string; + model: string; + systemPrompt: string; + userMessage: string; + maxTokens?: number; + temperature?: number; +} + +export interface Usage { + in?: number; + out?: number; + total?: number; +} + +/** One step of a streamed response, produced by a StreamParser. */ +export interface StreamDelta { + /** Incremental assistant text to append to the live output. */ + text?: string; + /** A complete response object (usage/model/text live here once known). */ + final?: unknown; + /** The stream has ended. */ + done?: boolean; +} + +/** + * Stateful per-request SSE interpreter. Stateful (not a pure function) because + * some formats — Anthropic especially — split the final usage across several + * events (input tokens in `message_start`, output tokens in `message_delta`), + * so the parser accumulates and emits a single assembled `final` at the end. + */ +export interface StreamParser { + push(evt: { event?: string; data: string }): StreamDelta; +} + +export interface ApiFormat { + id: ApiFormatId; + label: string; + blurb: string; + /** Provider icon shown in the format dropdown. */ + icon: string; + docsUrl: string; + /** Endpoint path appended to the base URL, e.g. `/v1/chat/completions`. */ + path: string; + /** Hint shown in the Base URL field for this format. */ + placeholderBaseUrl?: string; + auth: AuthScheme; + /** Headers every request in this format needs (e.g. anthropic-version). */ + defaultHeaders?: Record; + /** When set, the connection bar probes this path for a health badge. */ + healthPath?: string; + /** True when the format mandates max_tokens (Anthropic). Drives UI hints. */ + requiresMaxTokens?: boolean; + buildBody(params: RequestParams, opts: { stream: boolean }): Record; + extractText(json: unknown): string | null; + extractUsage(json: unknown): Usage | null; + extractModel(json: unknown): string | null; + createStreamParser(): StreamParser; +} diff --git a/src/profiles.ts b/src/profiles.ts index 11ff30d..dfb6bbb 100644 --- a/src/profiles.ts +++ b/src/profiles.ts @@ -1,24 +1,32 @@ -// Verbatim system prompts captured from the real OpenClaw and Hermes CLIs -// (see mnfst/team-skills `agent-request/templates/`). Several KB each — -// kept in their own modules so profiles.ts stays scannable. Personal paths -// and the host name in the OpenClaw capture are redacted to generic values; -// everything else is byte-for-byte identical to what the gateway receives -// from a real client. +// Profiles are agent/SDK *fingerprints* layered on top of an API format. A +// profile contributes request headers (to mimic a real client), a default +// system prompt, an optional body fragment, and an SDK code snippet — but the +// wire shape (path, auth, body, response parsing) belongs to the ApiFormat. +// Each profile declares which formats it's compatible with; the UI filters the +// list to the selected format. +// +// Verbatim system prompts captured from the real OpenClaw and Hermes CLIs are +// kept in their own modules so this catalog stays scannable. import { OPENCLAW_SYSTEM } from './templates/openclaw-system'; import { HERMES_SYSTEM } from './templates/hermes-system'; +import type { ApiFormat, ApiFormatId, RequestParams } from './formats'; +import { + anthropicSdkSnippet, + curlSnippet, + hermesSnippet, + langchainSnippet, + openaiResponsesSnippet, + openaiSdkSnippet, + openclawSnippet, + rawSnippet, + vercelSnippet, +} from './snippets'; export type ProfileMode = 'agent' | 'sdk' | 'raw'; export type ProfileLang = 'typescript' | 'python' | 'bash'; -export interface ProfileParams { - baseUrl: string; - apiKey: string; - model: string; - systemPrompt: string; - userMessage: string; - maxTokens?: number; - temperature?: number; -} +/** @deprecated use RequestParams from ./formats — kept as an alias. */ +export type ProfileParams = RequestParams; export interface Profile { id: string; @@ -29,6 +37,8 @@ export interface Profile { icon: string; langs: ProfileLang[]; defaultLang: ProfileLang; + /** API formats this profile is compatible with. */ + formats: ApiFormatId[]; defaultSystemPrompt?: string; /** * When true, the Headers panel is hidden — the profile simulates a real @@ -43,22 +53,13 @@ export interface Profile { * the TypeScript SDK profiles can do this — Python needs Pyodide. */ executable?: boolean; - headers: (params: ProfileParams) => Record; - body: (params: ProfileParams) => Record; - code: (params: ProfileParams, lang: ProfileLang) => string; -} - -function messages(params: ProfileParams) { - const list: Array<{ role: string; content: string }> = []; - if (params.systemPrompt.trim()) { - list.push({ role: 'system', content: params.systemPrompt }); - } - list.push({ role: 'user', content: params.userMessage }); - return list; -} - -function jsonBody(body: unknown, indent = 2): string { - return JSON.stringify(body, null, indent); + headers: (params: RequestParams) => Record; + /** + * Fingerprint-only body fields merged on top of the format's body (e.g. + * OpenClaw's `store:false`). Optional — most profiles add nothing. + */ + bodyExtras?: (params: RequestParams) => Record; + code: (params: RequestParams, lang: ProfileLang, format: ApiFormat) => string; } const stainlessJs = { @@ -97,21 +98,12 @@ export const PROFILES: Profile[] = [ icon: '/icons/openclaw.png', langs: ['bash'], defaultLang: 'bash', + formats: ['openai-chat'], defaultSystemPrompt: OPENCLAW_SYSTEM, headersLocked: true, headers: () => ({ ...stainlessJs }), - body: (p) => ({ - model: p.model, - messages: messages(p), - stream: false, - store: false, - max_completion_tokens: p.maxTokens ?? 8192, - }), - code: (p) => `# OpenClaw routes through its built-in OpenAI-compatible client. -# Configure once with the CLI: -openclaw config set models.providers.manifest '{"baseUrl":"${p.baseUrl}/v1","api":"openai-completions","apiKey":"${p.apiKey || 'mnfst_YOUR_KEY'}","models":[{"id":"${p.model}","name":"Manifest Auto"}]}' -openclaw config set agents.defaults.model.primary manifest/${p.model} -openclaw gateway restart`, + bodyExtras: () => ({ store: false, max_completion_tokens: 8192 }), + code: (p) => openclawSnippet(p), }, { id: 'hermes', @@ -122,100 +114,41 @@ openclaw gateway restart`, icon: '/icons/hermes.png', langs: ['bash'], defaultLang: 'bash', + formats: ['openai-chat'], defaultSystemPrompt: HERMES_SYSTEM, headersLocked: true, headers: () => ({ ...stainlessPython }), - body: (p) => ({ - model: p.model, - messages: messages(p), - stream: false, - }), - code: (p) => `# Hermes reads its provider from ~/.hermes/config.yaml: -cat < ~/.hermes/config.yaml -model: - provider: custom - base_url: ${p.baseUrl}/v1 - api_key: ${p.apiKey || 'mnfst_YOUR_KEY'} - default: ${p.model} -EOF -hermes chat -q '${p.userMessage.replace(/'/g, "'\\''")}'`, + code: (p) => hermesSnippet(p), }, { id: 'openai-sdk', label: 'OpenAI SDK', mode: 'sdk', category: 'app', - blurb: 'Official OpenAI client (TypeScript or Python).', + blurb: 'Official OpenAI client (Chat Completions).', icon: '/icons/providers/openai.svg', langs: ['typescript', 'python'], defaultLang: 'typescript', + formats: ['openai-chat'], headersLocked: true, executable: true, headers: () => ({ ...stainlessJs }), - body: (p) => ({ - model: p.model, - messages: messages(p), - }), - code: (p, lang) => { - if (lang === 'python') { - return `from openai import OpenAI - -client = OpenAI( - base_url="${p.baseUrl}/v1", - api_key="${p.apiKey || 'mnfst_YOUR_KEY'}", -) - -response = client.chat.completions.create( - model="${p.model}", - messages=${jsonBody(messages(p), 4).replace(/\n/g, '\n ')}, -) -print(response.choices[0].message.content)`; - } - return `import OpenAI from "openai"; - -const client = new OpenAI({ - baseURL: "${p.baseUrl}/v1", - apiKey: "${p.apiKey || 'mnfst_YOUR_KEY'}", -}); - -const response = await client.chat.completions.create({ - model: "${p.model}", - messages: ${jsonBody(messages(p), 2).replace(/\n/g, '\n ')}, -}); -console.log(response.choices[0].message.content);`; - }, + code: (p, lang) => openaiSdkSnippet(p, lang), }, { id: 'vercel-ai-sdk', label: 'Vercel AI SDK', mode: 'sdk', category: 'app', - blurb: 'Vercel AI SDK with the OpenAI provider pointed at Manifest.', + blurb: 'Vercel AI SDK with the OpenAI provider.', icon: '/icons/vercel.svg', langs: ['typescript'], defaultLang: 'typescript', + formats: ['openai-chat'], headersLocked: true, executable: true, - headers: () => ({ - 'User-Agent': 'ai-sdk/5.0.0 (Node.js v22.17.1)', - }), - body: (p) => ({ - model: p.model, - messages: messages(p), - }), - code: (p) => `import { createOpenAI } from "@ai-sdk/openai"; -import { generateText } from "ai"; - -const manifest = createOpenAI({ - baseURL: "${p.baseUrl}/v1", - apiKey: "${p.apiKey || 'mnfst_YOUR_KEY'}", -}); - -const { text } = await generateText({ - model: manifest("${p.model}"), - ${p.systemPrompt ? `system: ${JSON.stringify(p.systemPrompt)},\n ` : ''}prompt: ${JSON.stringify(p.userMessage)}, -}); -console.log(text);`, + headers: () => ({ 'User-Agent': 'ai-sdk/5.0.0 (Node.js v22.17.1)' }), + code: (p) => vercelSnippet(p), }, { id: 'langchain', @@ -226,39 +159,37 @@ console.log(text);`, icon: '/icons/langchain.png', langs: ['python', 'typescript'], defaultLang: 'python', + formats: ['openai-chat'], headersLocked: true, executable: true, - headers: () => ({ - 'User-Agent': 'langchain-python/0.3.0', - }), - body: (p) => ({ - model: p.model, - messages: messages(p), - }), - code: (p, lang) => { - if (lang === 'typescript') { - return `import { ChatOpenAI } from "@langchain/openai"; - -const llm = new ChatOpenAI({ - model: "${p.model}", - apiKey: "${p.apiKey || 'mnfst_YOUR_KEY'}", - configuration: { baseURL: "${p.baseUrl}/v1" }, -}); - -const response = await llm.invoke(${JSON.stringify(messages(p), null, 2).replace(/\n/g, '\n ')}); -console.log(response.content);`; - } - return `from langchain_openai import ChatOpenAI - -llm = ChatOpenAI( - base_url="${p.baseUrl}/v1", - api_key="${p.apiKey || 'mnfst_YOUR_KEY'}", - model="${p.model}", -) - -response = llm.invoke(${jsonBody(messages(p), 4).replace(/\n/g, '\n ')}) -print(response.content)`; - }, + headers: () => ({ 'User-Agent': 'langchain-python/0.3.0' }), + code: (p, lang) => langchainSnippet(p, lang), + }, + { + id: 'openai-responses', + label: 'OpenAI SDK', + mode: 'sdk', + category: 'app', + blurb: 'Official OpenAI client via the Responses API.', + icon: '/icons/providers/openai.svg', + langs: ['typescript', 'python'], + defaultLang: 'typescript', + formats: ['openai-responses'], + headers: () => ({ ...stainlessJs }), + code: (p, lang) => openaiResponsesSnippet(p, lang), + }, + { + id: 'anthropic-sdk', + label: 'Anthropic SDK', + mode: 'sdk', + category: 'app', + blurb: 'Official Anthropic client (Messages API).', + icon: '/icons/providers/anthropic.svg', + langs: ['typescript', 'python'], + defaultLang: 'typescript', + formats: ['anthropic-messages'], + headers: () => ({}), + code: (p, lang) => anthropicSdkSnippet(p, lang), }, { id: 'curl', @@ -269,23 +200,9 @@ print(response.content)`; icon: '/icons/other.svg', langs: ['bash'], defaultLang: 'bash', - headers: () => ({ - 'User-Agent': 'curl/8.6.0', - }), - body: (p) => ({ - model: p.model, - messages: messages(p), - }), - code: (p) => `curl -sS -X POST ${p.baseUrl}/v1/chat/completions \\ - -H "Authorization: Bearer ${p.apiKey || 'mnfst_YOUR_KEY'}" \\ - -H "Content-Type: application/json" \\ - -d '${jsonBody( - { - model: p.model, - messages: messages(p), - }, - 2, - ).replace(/'/g, "'\\''")}'`, + formats: ['openai-chat', 'openai-responses', 'anthropic-messages'], + headers: () => ({ 'User-Agent': 'curl/8.6.0' }), + code: (p, _lang, format) => curlSnippet(p, format), }, { id: 'raw', @@ -296,23 +213,17 @@ print(response.content)`; icon: '/icons/other-agent.svg', langs: ['bash'], defaultLang: 'bash', + formats: ['openai-chat', 'openai-responses', 'anthropic-messages'], headers: () => ({}), - body: (p) => ({ - model: p.model, - messages: messages(p), - }), - code: (p) => `# Plain fetch — no User-Agent override. -fetch("${p.baseUrl}/v1/chat/completions", { - method: "POST", - headers: { - "Authorization": "Bearer ${p.apiKey || 'mnfst_YOUR_KEY'}", - "Content-Type": "application/json", - }, - body: JSON.stringify(${jsonBody({ model: p.model, messages: messages(p) }, 2).replace(/\n/g, '\n ')}), -});`, + code: (p, _lang, format) => rawSnippet(p, format), }, ]; export const PROFILE_BY_ID: Record = Object.fromEntries( PROFILES.map((p) => [p.id, p]), ); + +/** Profiles compatible with a given format, in catalog order. */ +export function profilesForFormat(formatId: ApiFormatId): Profile[] { + return PROFILES.filter((p) => p.formats.includes(formatId)); +} diff --git a/src/send.ts b/src/send.ts index 55d4abb..fabc1a7 100644 --- a/src/send.ts +++ b/src/send.ts @@ -1,6 +1,11 @@ +import type { AuthScheme, StreamParser } from './formats/types'; +import { readSse } from './services/sse'; + export interface SendInput { url: string; apiKey: string; + /** How to attach the key. Defaults to bearer (used by the SDK runners). */ + auth?: AuthScheme; headers: Record; body: Record; } @@ -18,6 +23,17 @@ export interface SendResult { responseJson: unknown | null; error?: string; errorKind?: 'network' | 'cors' | 'mixed-content' | 'aborted' | 'unknown'; + /** True when the request was sent with stream:true and read as SSE. */ + isStream?: boolean; + /** Assistant text assembled from the stream (streamed requests only). */ + streamedText?: string; + /** Time to first token, ms (streamed requests only). */ + ttftMs?: number; +} + +export interface StreamOptions { + createParser: () => StreamParser; + onDelta: (text: string) => void; } const FORBIDDEN_HEADER_PREFIXES = ['proxy-', 'sec-']; @@ -70,15 +86,40 @@ export function partitionHeaders(headers: Record): { return { allowed, blocked }; } -export async function sendRequest(input: SendInput): Promise { +/** Final request headers: Content-Type + caller headers + key per auth scheme. */ +function buildHeaders(input: SendInput, extra?: Record): Record { const { allowed } = partitionHeaders(input.headers); - const finalHeaders: Record = { + const headers: Record = { 'Content-Type': 'application/json', ...allowed, + ...extra, }; + const auth = input.auth ?? { kind: 'bearer' }; if (input.apiKey) { - finalHeaders.Authorization = `Bearer ${input.apiKey}`; + if (auth.kind === 'bearer') headers.Authorization = `Bearer ${input.apiKey}`; + else if (auth.kind === 'header') headers[auth.name] = input.apiKey; + } + return headers; +} + +function collectHeaders(response: Response): Record { + const out: Record = {}; + response.headers.forEach((value, key) => { + out[key] = value; + }); + return out; +} + +function tryParseJson(text: string): unknown | null { + try { + return JSON.parse(text); + } catch { + return null; } +} + +export async function sendRequest(input: SendInput): Promise { + const finalHeaders = buildHeaders(input); const requestBody = JSON.stringify(input.body, null, 2); const start = performance.now(); @@ -89,47 +130,119 @@ export async function sendRequest(input: SendInput): Promise { body: requestBody, }); const text = await response.text(); - const durationMs = performance.now() - start; - const responseHeaders: Record = {}; - response.headers.forEach((value, key) => { - responseHeaders[key] = value; - }); - let json: unknown | null = null; - try { - json = JSON.parse(text); - } catch { - json = null; - } return { url: input.url, status: response.status, statusText: response.statusText, ok: response.ok, - durationMs, + durationMs: performance.now() - start, requestHeaders: finalHeaders, requestBody, - responseHeaders, + responseHeaders: collectHeaders(response), responseBody: text, - responseJson: json, + responseJson: tryParseJson(text), }; } catch (err) { - const durationMs = performance.now() - start; - const message = err instanceof Error ? err.message : String(err); + return errorResult(input, finalHeaders, requestBody, err, performance.now() - start); + } +} + +/** + * Send with stream:true and read the response as SSE, pushing each event + * through the format's StreamParser. Non-OK responses (and bodyless ones) fall + * back to a buffered read — provider error payloads are JSON, not a stream. + */ +export async function sendRequestStreaming( + input: SendInput, + opts: StreamOptions, +): Promise { + const finalHeaders = buildHeaders(input, { Accept: 'text/event-stream' }); + const requestBody = JSON.stringify(input.body, null, 2); + const start = performance.now(); + + let response: Response; + try { + response = await fetch(input.url, { method: 'POST', headers: finalHeaders, body: requestBody }); + } catch (err) { + return errorResult(input, finalHeaders, requestBody, err, performance.now() - start); + } + + const base = { + url: input.url, + status: response.status, + statusText: response.statusText, + ok: response.ok, + requestHeaders: finalHeaders, + requestBody, + responseHeaders: collectHeaders(response), + isStream: true as const, + }; + + if (!response.ok || !response.body) { + const text = await response.text(); return { - url: input.url, - status: 0, - statusText: 'Network error', - ok: false, - durationMs, - requestHeaders: finalHeaders, - requestBody, - responseHeaders: {}, - responseBody: '', - responseJson: null, - error: message, - errorKind: classifyError(err, input.url), + ...base, + durationMs: performance.now() - start, + responseBody: text, + responseJson: tryParseJson(text), }; } + + const parser = opts.createParser(); + const rawChunks: string[] = []; + let assembled = ''; + let finalJson: unknown | null = null; + let ttftMs: number | undefined; + let error: string | undefined; + + try { + for await (const evt of readSse(response.body)) { + rawChunks.push(evt.event ? `event: ${evt.event}\ndata: ${evt.data}` : `data: ${evt.data}`); + const delta = parser.push(evt); + if (delta.text) { + if (ttftMs === undefined) ttftMs = performance.now() - start; + assembled += delta.text; + opts.onDelta(delta.text); + } + if (delta.final !== undefined && delta.final !== null) finalJson = delta.final; + if (delta.done) break; + } + } catch (err) { + error = err instanceof Error ? err.message : String(err); + } + + return { + ...base, + durationMs: performance.now() - start, + responseBody: rawChunks.join('\n\n'), + responseJson: finalJson, + streamedText: assembled, + ttftMs, + error, + }; +} + +function errorResult( + input: SendInput, + finalHeaders: Record, + requestBody: string, + err: unknown, + durationMs: number, +): SendResult { + return { + url: input.url, + status: 0, + statusText: 'Network error', + ok: false, + durationMs, + requestHeaders: finalHeaders, + requestBody, + responseHeaders: {}, + responseBody: '', + responseJson: null, + error: err instanceof Error ? err.message : String(err), + errorKind: classifyError(err, input.url), + }; } function classifyError(err: unknown, url: string): SendResult['errorKind'] { diff --git a/src/services/gist.ts b/src/services/gist.ts index 1f3a988..1645333 100644 --- a/src/services/gist.ts +++ b/src/services/gist.ts @@ -1,8 +1,11 @@ import type { SendResult } from '../send'; +import type { ApiFormat } from '../formats'; export interface GistContext { profileLabel: string; profileCategory: string; + formatLabel: string; + streamed: boolean; systemPrompt: string; userMessage: string; baseUrl: string; @@ -22,15 +25,19 @@ function redactAuthHeader(value: string): string { return value.replace(/(Bearer\s+)([^\s]+)/i, (_, p1, token: string) => p1 + redactApiKey(token)); } +// Auth lands in Authorization (Bearer) or x-api-key (Anthropic) depending on +// the format — redact both so a shared gist never leaks a key. +function redactHeaderValue(key: string, value: string): string { + const lower = key.toLowerCase(); + if (lower === 'authorization') return redactAuthHeader(value); + if (lower === 'x-api-key') return redactApiKey(value); + return value; +} + function formatHeaders(headers: Record): string { const entries = Object.entries(headers); if (entries.length === 0) return '(none)'; - return entries - .map(([k, v]) => { - const value = k.toLowerCase() === 'authorization' ? redactAuthHeader(v) : v; - return `${k}: ${value}`; - }) - .join('\n'); + return entries.map(([k, v]) => `${k}: ${redactHeaderValue(k, v)}`).join('\n'); } function prettyJson(raw: string, parsed: unknown): string { @@ -40,36 +47,6 @@ function prettyJson(raw: string, parsed: unknown): string { return raw || '(empty)'; } -function extractAssistantText(json: unknown): string | null { - if (!json || typeof json !== 'object') return null; - const choices = (json as Record).choices; - if (Array.isArray(choices) && choices.length > 0) { - const first = choices[0] as { message?: { content?: unknown }; text?: unknown } | undefined; - if (first?.message && typeof first.message.content === 'string') return first.message.content; - if (typeof first?.text === 'string') return first.text; - } - return null; -} - -function extractUsage(json: unknown): { in?: number; out?: number; total?: number } | null { - if (!json || typeof json !== 'object') return null; - const usage = (json as Record).usage; - if (!usage || typeof usage !== 'object') return null; - const u = usage as Record; - const num = (v: unknown) => (typeof v === 'number' ? v : undefined); - return { - in: num(u.prompt_tokens) ?? num(u.input_tokens), - out: num(u.completion_tokens) ?? num(u.output_tokens), - total: num(u.total_tokens), - }; -} - -function extractModel(json: unknown): string | null { - if (!json || typeof json !== 'object') return null; - const m = (json as Record).model; - return typeof m === 'string' ? m : null; -} - function statusEmoji(result: SendResult): string { if (result.status === 0) return '🌐'; if (result.ok) return '✅'; @@ -77,10 +54,14 @@ function statusEmoji(result: SendResult): string { return '⚠️'; } -export function buildMarkdownReport(ctx: GistContext, result: SendResult): string { - const usage = extractUsage(result.responseJson); - const model = extractModel(result.responseJson); - const assistant = extractAssistantText(result.responseJson); +export function buildMarkdownReport( + ctx: GistContext, + result: SendResult, + format: ApiFormat, +): string { + const usage = format.extractUsage(result.responseJson); + const model = format.extractModel(result.responseJson); + const assistant = format.extractText(result.responseJson) ?? result.streamedText ?? null; const statusLine = result.status === 0 ? '`NETWORK` — request did not reach the server' @@ -89,10 +70,6 @@ export function buildMarkdownReport(ctx: GistContext, result: SendResult): strin usage && (usage.in !== undefined || usage.out !== undefined || usage.total !== undefined) ? `${usage.total ?? '—'} total · ${usage.in ?? '—'} in / ${usage.out ?? '—'} out` : '—'; - const requestHeadersRedacted: Record = {}; - for (const [k, v] of Object.entries(result.requestHeaders)) { - requestHeadersRedacted[k] = k.toLowerCase() === 'authorization' ? redactAuthHeader(v) : v; - } const lines: string[] = []; lines.push(`# Manifest Wingman — request report`); @@ -101,9 +78,13 @@ export function buildMarkdownReport(ctx: GistContext, result: SendResult): strin lines.push(''); lines.push('| | |'); lines.push('|---|---|'); + lines.push(`| **Format** | ${ctx.formatLabel}${ctx.streamed ? ' · streamed' : ''} |`); lines.push(`| **Profile** | ${ctx.profileLabel} _(${ctx.profileCategory})_ |`); lines.push(`| **Status** | ${statusLine} |`); lines.push(`| **Latency** | ${result.durationMs.toFixed(0)} ms |`); + if (result.ttftMs !== undefined) { + lines.push(`| **Time to first token** | ${result.ttftMs.toFixed(0)} ms |`); + } lines.push(`| **Model returned** | ${model ? `\`${model}\`` : '—'} |`); lines.push(`| **Tokens** | ${tokens} |`); lines.push(`| **Base URL** | \`${ctx.baseUrl}\` |`); @@ -154,7 +135,7 @@ export function buildMarkdownReport(ctx: GistContext, result: SendResult): strin lines.push('### Headers'); lines.push(''); lines.push('```http'); - lines.push(formatHeaders(requestHeadersRedacted)); + lines.push(formatHeaders(result.requestHeaders)); lines.push('```'); lines.push(''); lines.push('### Body'); @@ -172,10 +153,14 @@ export function buildMarkdownReport(ctx: GistContext, result: SendResult): strin lines.push(formatHeaders(result.responseHeaders)); lines.push('```'); lines.push(''); - lines.push('### Body'); + lines.push(`### Body${ctx.streamed ? ' (raw SSE)' : ''}`); lines.push(''); - lines.push('```json'); - lines.push(prettyJson(result.responseBody, result.responseJson)); + lines.push(ctx.streamed ? '```' : '```json'); + lines.push( + ctx.streamed + ? result.responseBody || '(empty)' + : prettyJson(result.responseBody, result.responseJson), + ); lines.push('```'); lines.push(''); diff --git a/src/services/healthCheck.ts b/src/services/healthCheck.ts index 1a5231d..a782f57 100644 --- a/src/services/healthCheck.ts +++ b/src/services/healthCheck.ts @@ -6,15 +6,17 @@ export type HealthStatus = | { kind: 'network'; message: string } | { kind: 'http-error'; status: number; statusText: string }; -const HEALTH_PATH = '/api/v1/health'; - -export async function checkHealth(baseUrl: string, signal?: AbortSignal): Promise { +export async function checkHealth( + baseUrl: string, + path: string, + signal?: AbortSignal, +): Promise { const trimmed = baseUrl.trim().replace(/\/+$/, ''); if (!trimmed) return { kind: 'idle' }; let url: string; try { - url = new URL(HEALTH_PATH, trimmed).toString(); + url = new URL(path, trimmed).toString(); } catch { return { kind: 'network', message: 'Invalid URL' }; } diff --git a/src/services/history.ts b/src/services/history.ts index ee895f4..99a2b3a 100644 --- a/src/services/history.ts +++ b/src/services/history.ts @@ -6,6 +6,12 @@ export interface HistoryEntry { timestamp: number; profileId: string; profileLabel: string; + // Optional so entries saved before the multi-format feature still read back. + formatId?: string; + formatLabel?: string; + streamed?: boolean; + /** Full endpoint URL the request hit (path varies by format). */ + url?: string; baseUrl: string; model: string; systemPrompt: string; diff --git a/src/services/sse.ts b/src/services/sse.ts new file mode 100644 index 0000000..32884a0 --- /dev/null +++ b/src/services/sse.ts @@ -0,0 +1,50 @@ +// Generic Server-Sent Events reader. Decodes a fetch response body stream, +// buffers across network chunks (an event can be split mid-line), and yields +// one `{ event?, data }` per SSE event. Format-agnostic — each ApiFormat's +// StreamParser interprets the `data` payload. + +export interface SseEvent { + event?: string; + data: string; +} + +function parseEventBlock(block: string): SseEvent | null { + let event: string | undefined; + const dataLines: string[] = []; + for (const line of block.split('\n')) { + if (!line || line.startsWith(':')) continue; // blank or comment line + const colon = line.indexOf(':'); + const field = colon === -1 ? line : line.slice(0, colon); + let value = colon === -1 ? '' : line.slice(colon + 1); + if (value.startsWith(' ')) value = value.slice(1); + if (field === 'data') dataLines.push(value); + else if (field === 'event') event = value; + } + if (dataLines.length === 0) return null; + return { event, data: dataLines.join('\n') }; +} + +export async function* readSse(body: ReadableStream): AsyncGenerator { + const reader = body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + // Normalise CRLF/CR so we can split events on a single blank line. + buffer += decoder.decode(value, { stream: true }).replace(/\r\n?/g, '\n'); + let sep: number; + while ((sep = buffer.indexOf('\n\n')) !== -1) { + const block = buffer.slice(0, sep); + buffer = buffer.slice(sep + 2); + const evt = parseEventBlock(block); + if (evt) yield evt; + } + } + const tail = parseEventBlock(buffer); + if (tail) yield tail; + } finally { + reader.releaseLock(); + } +} diff --git a/src/snippets.ts b/src/snippets.ts new file mode 100644 index 0000000..340d025 --- /dev/null +++ b/src/snippets.ts @@ -0,0 +1,216 @@ +// Code-snippet builders for the SDK preview panel. Kept separate from the +// profile catalog so profiles.ts stays scannable and under the file-size limit. +// Snippets are illustrative (non-streaming) — they mirror the form, they don't +// drive the request (the format does). +import type { ApiFormat, RequestParams } from './formats'; +import type { ProfileLang } from './profiles'; + +const MANIFEST_KEY = 'mnfst_YOUR_KEY'; +const GENERIC_KEY = 'YOUR_API_KEY'; + +function jsonBody(body: unknown, indent = 2): string { + return JSON.stringify(body, null, indent); +} + +function messages(p: RequestParams) { + const list: Array<{ role: string; content: string }> = []; + if (p.systemPrompt.trim()) list.push({ role: 'system', content: p.systemPrompt }); + list.push({ role: 'user', content: p.userMessage }); + return list; +} + +function indentLines(text: string, spaces: number): string { + return text.replace(/\n/g, '\n' + ' '.repeat(spaces)); +} + +// ── Manifest agent / OpenAI-chat profiles ──────────────────────────────── + +export function openclawSnippet(p: RequestParams): string { + return `# OpenClaw routes through its built-in OpenAI-compatible client. +# Configure once with the CLI: +openclaw config set models.providers.manifest '{"baseUrl":"${p.baseUrl}/v1","api":"openai-completions","apiKey":"${p.apiKey || MANIFEST_KEY}","models":[{"id":"${p.model}","name":"Manifest Auto"}]}' +openclaw config set agents.defaults.model.primary manifest/${p.model} +openclaw gateway restart`; +} + +export function hermesSnippet(p: RequestParams): string { + return `# Hermes reads its provider from ~/.hermes/config.yaml: +cat < ~/.hermes/config.yaml +model: + provider: custom + base_url: ${p.baseUrl}/v1 + api_key: ${p.apiKey || MANIFEST_KEY} + default: ${p.model} +EOF +hermes chat -q '${p.userMessage.replace(/'/g, "'\\''")}'`; +} + +export function openaiSdkSnippet(p: RequestParams, lang: ProfileLang): string { + if (lang === 'python') { + return `from openai import OpenAI + +client = OpenAI( + base_url="${p.baseUrl}/v1", + api_key="${p.apiKey || MANIFEST_KEY}", +) + +response = client.chat.completions.create( + model="${p.model}", + messages=${indentLines(jsonBody(messages(p), 4), 4)}, +) +print(response.choices[0].message.content)`; + } + return `import OpenAI from "openai"; + +const client = new OpenAI({ + baseURL: "${p.baseUrl}/v1", + apiKey: "${p.apiKey || MANIFEST_KEY}", +}); + +const response = await client.chat.completions.create({ + model: "${p.model}", + messages: ${indentLines(jsonBody(messages(p), 2), 2)}, +}); +console.log(response.choices[0].message.content);`; +} + +export function vercelSnippet(p: RequestParams): string { + return `import { createOpenAI } from "@ai-sdk/openai"; +import { generateText } from "ai"; + +const manifest = createOpenAI({ + baseURL: "${p.baseUrl}/v1", + apiKey: "${p.apiKey || MANIFEST_KEY}", +}); + +const { text } = await generateText({ + model: manifest("${p.model}"), + ${p.systemPrompt ? `system: ${JSON.stringify(p.systemPrompt)},\n ` : ''}prompt: ${JSON.stringify(p.userMessage)}, +}); +console.log(text);`; +} + +export function langchainSnippet(p: RequestParams, lang: ProfileLang): string { + if (lang === 'typescript') { + return `import { ChatOpenAI } from "@langchain/openai"; + +const llm = new ChatOpenAI({ + model: "${p.model}", + apiKey: "${p.apiKey || MANIFEST_KEY}", + configuration: { baseURL: "${p.baseUrl}/v1" }, +}); + +const response = await llm.invoke(${indentLines(jsonBody(messages(p), 2), 2)}); +console.log(response.content);`; + } + return `from langchain_openai import ChatOpenAI + +llm = ChatOpenAI( + base_url="${p.baseUrl}/v1", + api_key="${p.apiKey || MANIFEST_KEY}", + model="${p.model}", +) + +response = llm.invoke(${indentLines(jsonBody(messages(p), 4), 4)}) +print(response.content)`; +} + +// ── Format-native SDK profiles ──────────────────────────────────────────── + +export function openaiResponsesSnippet(p: RequestParams, lang: ProfileLang): string { + if (lang === 'python') { + return `from openai import OpenAI + +client = OpenAI( + base_url="${p.baseUrl}/v1", + api_key="${p.apiKey || GENERIC_KEY}", +) + +response = client.responses.create( + model="${p.model}", + ${p.systemPrompt.trim() ? `instructions=${JSON.stringify(p.systemPrompt)},\n ` : ''}input=${JSON.stringify(p.userMessage)}, +) +print(response.output_text)`; + } + return `import OpenAI from "openai"; + +const client = new OpenAI({ + baseURL: "${p.baseUrl}/v1", + apiKey: "${p.apiKey || GENERIC_KEY}", +}); + +const response = await client.responses.create({ + model: "${p.model}", + ${p.systemPrompt.trim() ? `instructions: ${JSON.stringify(p.systemPrompt)},\n ` : ''}input: ${JSON.stringify(p.userMessage)}, +}); +console.log(response.output_text);`; +} + +export function anthropicSdkSnippet(p: RequestParams, lang: ProfileLang): string { + const maxTokens = p.maxTokens ?? 1024; + if (lang === 'python') { + return `import anthropic + +client = anthropic.Anthropic( + base_url="${p.baseUrl}", + api_key="${p.apiKey || GENERIC_KEY}", +) + +message = client.messages.create( + model="${p.model}", + max_tokens=${maxTokens}, + ${p.systemPrompt.trim() ? `system=${JSON.stringify(p.systemPrompt)},\n ` : ''}messages=[{"role": "user", "content": ${JSON.stringify(p.userMessage)}}], +) +print(message.content[0].text)`; + } + return `import Anthropic from "@anthropic-ai/sdk"; + +const client = new Anthropic({ + baseURL: "${p.baseUrl}", + apiKey: "${p.apiKey || GENERIC_KEY}", +}); + +const message = await client.messages.create({ + model: "${p.model}", + max_tokens: ${maxTokens}, + ${p.systemPrompt.trim() ? `system: ${JSON.stringify(p.systemPrompt)},\n ` : ''}messages: [{ role: "user", content: ${JSON.stringify(p.userMessage)} }], +}); +console.log(message.content[0].type === "text" ? message.content[0].text : "");`; +} + +// ── Generic, format-aware cURL + fetch ──────────────────────────────────── + +function authHeaderPair(format: ApiFormat, apiKey: string): [string, string] | null { + const key = apiKey || GENERIC_KEY; + if (format.auth.kind === 'bearer') return ['Authorization', `Bearer ${key}`]; + if (format.auth.kind === 'header') return [format.auth.name, key]; + return null; +} + +export function curlSnippet(p: RequestParams, format: ApiFormat): string { + const url = `${p.baseUrl}${format.path}`; + const headerLines = ['-H "Content-Type: application/json"']; + const auth = authHeaderPair(format, p.apiKey); + if (auth) headerLines.unshift(`-H "${auth[0]}: ${auth[1]}"`); + for (const [k, v] of Object.entries(format.defaultHeaders ?? {})) { + headerLines.push(`-H "${k}: ${v}"`); + } + const body = jsonBody(format.buildBody(p, { stream: false }), 2).replace(/'/g, "'\\''"); + return `curl -sS -X POST ${url} \\ + ${headerLines.join(' \\\n ')} \\ + -d '${body}'`; +} + +export function rawSnippet(p: RequestParams, format: ApiFormat): string { + const url = `${p.baseUrl}${format.path}`; + const headers: Record = { 'Content-Type': 'application/json' }; + const auth = authHeaderPair(format, p.apiKey); + if (auth) headers[auth[0]] = auth[1]; + Object.assign(headers, format.defaultHeaders ?? {}); + return `# Plain fetch — no User-Agent override. +fetch("${url}", { + method: "POST", + headers: ${indentLines(jsonBody(headers, 2), 2)}, + body: JSON.stringify(${indentLines(jsonBody(format.buildBody(p, { stream: false }), 2), 2)}), +});`; +} diff --git a/src/styles.css b/src/styles.css index bdb6ab5..d238880 100644 --- a/src/styles.css +++ b/src/styles.css @@ -678,6 +678,24 @@ textarea { color: var(--text); } +/* Blinking caret shown at the tail of the live-streaming output. */ +.assistant-msg__cursor { + display: inline-block; + width: 7px; + height: 1.05em; + margin-left: 2px; + vertical-align: text-bottom; + background: var(--accent); + border-radius: 1px; + animation: cursor-blink 1s steps(2, start) infinite; +} + +@keyframes cursor-blink { + to { + opacity: 0; + } +} + /* ── Status pill + metric chips (in assistant head) ─── */ .status-pill { @@ -792,9 +810,26 @@ textarea { .composer__profile-row { display: flex; align-items: center; + gap: 8px; + flex-wrap: wrap; padding: 0 2px; } +.composer__profile-sep { + color: var(--text-faint); + font-size: 13px; + user-select: none; +} + +/* Format dropdown reuses the .profile-dd skin; its chip shows the endpoint + path in monospace so the wire target is always visible at a glance. */ +.format-dd__path { + font-family: 'JetBrains Mono', monospace; + text-transform: none; + letter-spacing: 0; + font-size: 10px; +} + .profile-dd { position: relative; display: inline-block;