Skip to content

Commit 2ac8659

Browse files
lauripiisangclaude
andcommitted
QVAC-19778 fix[api]: finish_reason=length + unified token accounting across chat-category routes
Chat-category routes hardcoded finish_reason to 'stop' even when generation was truncated by max_tokens, and counted completion tokens three different ways (chat used a stats helper, /v1/completions used a whitespace split when blocking and an SSE-event count when streaming, responses used its own inline fallback). Introduce a single drainCompletion(result, onToken?) helper that consumes the SDK completion event stream once and returns text, tool calls, stats, the terminal stopReason, the completion-token count, and the OpenAI finish_reason (tool_calls > length > stop). chat, completions, and responses all use it, so finish_reason and token accounting are derived in one place instead of drifting per route. For the Responses API (which has no finish_reason), length truncation now maps to status 'incomplete' + incomplete_details.reason 'max_output_tokens', and the streaming path emits response.incomplete instead of response.completed. Docs: document the /v1/videos* surface in both the in-repo serve-openai.md and the marketing http-server mdx, and sync serve-openai.md's endpoint table with vector_stores and the audio voices/models routes. The two other 0.11.0 follow-ups in QVAC-19778 need no code change and were verified against the current tree: - streaming cancellation on client disconnect already ships (cancel bridge re-homed by the Fastify rewrite #2306; every inference route binds req.on('close') -> cancel({ requestId })). - the chat-category route preamble is already DRY via resolveAndCheckModel / requireModel(). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent c6984f2 commit 2ac8659

10 files changed

Lines changed: 390 additions & 60 deletions

File tree

docs/website/content/docs/cli/http-server/index.mdx

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,11 @@ All endpoints follow the [OpenAI API](https://platform.openai.com/docs/api-refer
189189
| | `DELETE` | [`/v1/vector_stores/:id`](#delete-v1vector_storesid) |
190190
| | `POST` | [`/v1/vector_stores/:id/search`](#post-v1vector_storesidsearch) |
191191
| | `POST` | [`/v1/vector_stores/:id/files`](#post-v1vector_storesidfiles) |
192+
| [Videos](#videos) | `POST` | [`/v1/videos`](#post-v1videos) |
193+
| | `GET` | [`/v1/videos`](#get-v1videos) |
194+
| | `GET` | [`/v1/videos/:id`](#get-v1videosid) |
195+
| | `GET` | [`/v1/videos/:id/content`](#get-v1videosidcontent) |
196+
| | `DELETE` | [`/v1/videos/:id`](#delete-v1videosid) |
192197

193198
<Callout type="info">
194199
All multipart endpoints (`/v1/audio/*`, `/v1/images/edits`, `/v1/files`) cap the request body at **100 MB**.
@@ -987,6 +992,50 @@ Once a vector store has been ingested with a particular embedding model, subsequ
987992

988993
Search returns OpenAI-shaped `vector_store.search_results.page` objects. Each chunk's `attributes` include the originating `file_id` and `filename` when they were attached through the file flow.
989994

995+
### Videos
996+
997+
OpenAI-compatible **async** text-to-video, backed by the SDK's `video({ mode: "txt2vid" })`. Creating a job returns immediately; the generation runs in the background. Poll for status, then download the bytes.
998+
999+
Requires an alias whose endpoint category is `video` (SDK addon `sdcpp-video`). Register it in `serve.models` and add a `serve.openai.videos.models` aliasing block so OpenAI SDK clients can use a hard-coded model name. **Text-to-video only**`input_reference`, `/edits`, `/remix`, `/extensions`, and `/characters` are not implemented.
1000+
1001+
#### `POST /v1/videos`
1002+
1003+
Create a job. Returns the job resource with `status: "queued"`.
1004+
1005+
#### `GET /v1/videos`
1006+
1007+
List jobs (`limit` / `order` / `after`). **In-memory only** — a restart clears the list.
1008+
1009+
#### `GET /v1/videos/:id`
1010+
1011+
Poll job status (`queued``in_progress``completed` / `failed`).
1012+
1013+
#### `GET /v1/videos/:id/content`
1014+
1015+
Download the generated video. Defaults to `video/mp4` (lazy ffmpeg transcode + cache); `?format=avi` returns the native MJPG-AVI. A `?variant` other than `video` returns `501 unsupported_variant`.
1016+
1017+
#### `DELETE /v1/videos/:id`
1018+
1019+
Abort the running job and drop its assets.
1020+
1021+
**End-to-end create + poll + download:**
1022+
1023+
```bash
1024+
curl http://localhost:11434/v1/videos \
1025+
-H "Content-Type: application/json" \
1026+
-d '{"model":"my-video","prompt":"a timelapse of clouds","size":"512x512"}'
1027+
1028+
curl http://localhost:11434/v1/videos/video_abc123
1029+
1030+
curl http://localhost:11434/v1/videos/video_abc123/content --output out.mp4
1031+
```
1032+
1033+
#### Deviations from the OpenAI spec
1034+
1035+
- `input_reference` is rejected with `400 unsupported_param` (no img2vid in the SDK).
1036+
- `size` accepts any `WxH` (multiples of 8) in addition to OpenAI's 4-value enum.
1037+
- `Content-Type: video/mp4` is produced by a server-side ffmpeg transcode; `?format=avi` returns the native container.
1038+
9901039
### Authentication
9911040

9921041
By default, the server accepts unauthenticated requests on `127.0.0.1`. To require a Bearer token, run the server with the `--api-key` flag:

packages/cli/docs/serve-openai.md

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,27 @@ This document describes the supported routes and how to configure `serve.models`
2020
| `POST` | `/v1/embeddings` | Embeddings |
2121
| `POST` | `/v1/audio/transcriptions` | Speech-to-text (source language) |
2222
| `POST` | `/v1/audio/translations` | Speech-to-text **into English** (Whisper translate task) |
23-
| `POST` | `/v1/audio/speech` | Text-to-speech (Chatterbox / Supertonic, `wav` + `pcm` only) |
23+
| `POST` | `/v1/audio/speech` | Text-to-speech (Chatterbox / Supertonic) |
24+
| `GET` | `/v1/audio/voices` | List configured TTS voices (from `serve.openai.audio.speech.voices`) |
25+
| `GET` | `/v1/audio/models` | List loaded `speech`-category models |
2426
| `POST` | `/v1/images/generations` | Diffusion txt2img (blocking + SSE) |
2527
| `POST` | `/v1/images/edits` | Diffusion img2img (multipart; blocking + SSE) |
2628
| `POST` | `/v1/files` | Upload a file into the in-memory store (used by image URL responses + vector stores) |
2729
| `GET` | `/v1/files` | List in-memory files |
2830
| `GET` | `/v1/files/{id}` | File metadata |
2931
| `GET` | `/v1/files/{id}/content` | Stream the bytes (used by image `response_format=url`) |
32+
| `GET` | `/v1/vector_stores` | List vector stores |
33+
| `POST` | `/v1/vector_stores` | Create a vector store |
34+
| `GET` | `/v1/vector_stores/{id}` | Retrieve a vector store |
35+
| `POST` | `/v1/vector_stores/{id}` | Update a vector store |
36+
| `DELETE` | `/v1/vector_stores/{id}` | Delete a vector store |
37+
| `POST` | `/v1/vector_stores/{id}/search` | Semantic search over a store (needs a loaded `embedding` model) |
38+
| `POST` | `/v1/vector_stores/{id}/files` | Attach + embed a previously-uploaded file |
39+
| `POST` | `/v1/videos` | Create a text-to-video job (async; backed by the SDK's `video({ mode: "txt2vid" })`) |
40+
| `GET` | `/v1/videos` | List video jobs (in-memory only) |
41+
| `GET` | `/v1/videos/{id}` | Poll job status |
42+
| `GET` | `/v1/videos/{id}/content` | Download bytes (`video/mp4` via ffmpeg transcode; `?format=avi` for native MJPG-AVI) |
43+
| `DELETE` | `/v1/videos/{id}` | Abort the job and drop its assets |
3044

3145
Other OpenAI routes may be added over time; this file is updated when they ship.
3246

@@ -532,3 +546,47 @@ ffplay -f s16le -ar 24000 -ac 1 speech.pcm # rate/channels come from the respon
532546
| 502 | `speech_empty` | The SDK returned zero samples — surfaced loudly so callers can distinguish "no audio" from "audio body" |
533547
| 503 | `model_not_ready` | Model not loaded yet |
534548
| 500 | `speech_error` | SDK / engine failure (message goes to server logs only) |
549+
550+
## `POST /v1/videos` (and job lifecycle)
551+
552+
OpenAI-compatible **async** video surface, backed by the SDK's
553+
`video({ mode: "txt2vid" })`. `POST` creates a job and returns immediately with
554+
`status: "queued"`; the generation runs in the background. Poll `GET
555+
/v1/videos/{id}` until `status` is `completed` (or `failed`), then fetch the
556+
bytes from `GET /v1/videos/{id}/content`.
557+
558+
Requires an alias whose **endpoint category** is `video` (SDK addon
559+
`sdcpp-video`). Register it in `serve.models` and add a
560+
`serve.openai.videos.models` aliasing block so OpenAI SDK clients can use a
561+
hard-coded model name.
562+
563+
**Scope: text-to-video only.** `input_reference` is rejected with `400
564+
unsupported_param` (no img2vid in the SDK); `/edits`, `/remix`, `/extensions`,
565+
and `/characters` are not implemented.
566+
567+
### Endpoints
568+
569+
| Method | Path | Notes |
570+
|--------|------|-------|
571+
| `POST` | `/v1/videos` | Create job → `{ status: "queued" }` |
572+
| `GET` | `/v1/videos/{id}` | Poll status |
573+
| `GET` | `/v1/videos/{id}/content` | Download; defaults to `video/mp4` (lazy ffmpeg transcode + cache). `?format=avi` returns the native MJPG-AVI. `?variant` other than `video` → `501 unsupported_variant` |
574+
| `GET` | `/v1/videos` | Paginated list (`limit` / `order` / `after`) |
575+
| `DELETE` | `/v1/videos/{id}` | Abort the running job and drop its assets |
576+
577+
### Deviations from the OpenAI spec
578+
579+
- `input_reference` → `400 unsupported_param`.
580+
- `size` accepts any `WxH` (multiples of 8) in addition to OpenAI's 4-value enum.
581+
- `Content-Type: video/mp4` is produced by a server-side ffmpeg transcode; `?format=avi` returns the native container.
582+
- The list endpoint is **in-memory only** — a restart clears it.
583+
584+
### Errors
585+
586+
| HTTP | `error.code` | When |
587+
|------|--------------|------|
588+
| 400 | `unsupported_param` | `input_reference` sent (no img2vid) |
589+
| 400 | `invalid_model_type` | Alias is not a `video` model |
590+
| 404 | `video_not_found` | Unknown job id |
591+
| 501 | `unsupported_variant` | `GET …/content?variant=` other than `video` |
592+
| 503 | `model_not_ready` | Model not loaded yet |
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import type { CompletionRun, CompletionStats, ToolCall } from '@qvac/sdk'
2+
3+
export type OpenAiFinishReason = 'stop' | 'length' | 'tool_calls'
4+
5+
export interface DrainedCompletion {
6+
text: string
7+
toolCalls: ToolCall[]
8+
stats: CompletionStats | undefined
9+
/**
10+
* Terminal reason from the SDK `completionDone` event (`eos` / `length` /
11+
* `stopSequence` / `cancelled`), or undefined if the stream ended without
12+
* one. `error` is never surfaced here — an error completionDone makes
13+
* `result.events` throw, which propagates out of `drainCompletion`.
14+
*/
15+
stopReason: string | undefined
16+
/** `stats.generatedTokens` when the SDK reports it, else a whitespace word count. */
17+
completionTokens: number
18+
/** OpenAI `finish_reason`: `tool_calls` wins, then `length` on truncation, else `stop`. */
19+
finishReason: OpenAiFinishReason
20+
}
21+
22+
/**
23+
* Single-pass consumer of an SDK completion run, shared by every
24+
* chat-category route (chat / completions / responses). Draining
25+
* `result.events` once yields content text, tool calls, stats and the
26+
* terminal `stopReason` together, so the OpenAI `finish_reason` and token
27+
* accounting are derived in one place instead of drifting per route.
28+
*
29+
* Pass `onToken` to stream content deltas as they arrive (SSE paths); omit
30+
* it for blocking responses.
31+
*/
32+
export async function drainCompletion (
33+
result: CompletionRun,
34+
onToken?: (token: string) => void
35+
): Promise<DrainedCompletion> {
36+
let text = ''
37+
const toolCalls: ToolCall[] = []
38+
let stats: CompletionStats | undefined
39+
let stopReason: string | undefined
40+
41+
for await (const event of result.events) {
42+
if (event.type === 'contentDelta') {
43+
text += event.text
44+
onToken?.(event.text)
45+
} else if (event.type === 'toolCall') {
46+
toolCalls.push(event.call)
47+
} else if (event.type === 'completionStats') {
48+
stats = event.stats
49+
} else if (event.type === 'completionDone') {
50+
if (event.stopReason !== undefined && event.stopReason !== 'error') {
51+
stopReason = event.stopReason
52+
}
53+
}
54+
}
55+
56+
const completionTokens = completionTokensFromStats(text, stats)
57+
const finishReason: OpenAiFinishReason =
58+
toolCalls.length > 0 ? 'tool_calls' : stopReason === 'length' ? 'length' : 'stop'
59+
60+
return { text, toolCalls, stats, stopReason, completionTokens, finishReason }
61+
}
62+
63+
export function completionTokensFromStats (text: string, stats: CompletionStats | undefined): number {
64+
if (typeof stats?.generatedTokens === 'number' && Number.isFinite(stats.generatedTokens)) {
65+
return stats.generatedTokens
66+
}
67+
return text ? text.split(/\s+/).filter(Boolean).length : 0
68+
}

packages/cli/src/serve/adapters/openai/response-writers.ts

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { ServerResponse } from 'node:http'
22
import type { CompletionRun, Tool } from '@qvac/sdk'
33
import { sendSSE, endSSE } from '../../lib/sse.js'
4+
import { drainCompletion } from './completion-result.js'
45
import { sdkToolCallsToOpenai } from './tool-calls.js'
56
import type { GenerationParams, ResponseFormat } from '../../schemas/common.js'
67
import { buildResponseObject, functionCallOutputItemId, messageId } from './responses-shape.js'
@@ -40,9 +41,7 @@ export async function writeBlockingResponse (
4041
p: ResponsesHandlerParams,
4142
result: CompletionRun
4243
): Promise<Record<string, unknown>> {
43-
const text = await result.text
44-
const toolCalls = await result.toolCalls
45-
const stats = await result.stats
44+
const { text, toolCalls, stats, stopReason } = await drainCompletion(result)
4645

4746
const responseObject = buildResponseObject({
4847
id: p.rid,
@@ -57,6 +56,7 @@ export async function writeBlockingResponse (
5756
parallelToolCalls: p.parallelToolCalls,
5857
previousResponseId: p.previousResponseId,
5958
store: p.storeEnabled,
59+
...(stopReason !== undefined ? { stopReason } : {}),
6060
...(stats !== undefined ? { stats } : {})
6161
})
6262

@@ -114,7 +114,7 @@ export async function writeStreamingResponse (
114114
response_id: p.rid
115115
})
116116

117-
for await (const token of result.tokenStream) {
117+
const { toolCalls, stats, stopReason } = await drainCompletion(result, (token) => {
118118
fullText += token
119119
sendSSE(res, {
120120
type: 'response.output_text.delta',
@@ -124,9 +124,7 @@ export async function writeStreamingResponse (
124124
delta: token,
125125
response_id: p.rid
126126
})
127-
}
128-
129-
const toolCalls = await result.toolCalls
127+
})
130128
const hasToolCalls = toolCalls.length > 0
131129

132130
sendSSE(res, {
@@ -196,8 +194,6 @@ export async function writeStreamingResponse (
196194
}
197195
}
198196

199-
const stats = await result.stats
200-
201197
const responseObject = buildResponseObject({
202198
id: p.rid,
203199
modelAlias: p.modelAlias,
@@ -213,6 +209,7 @@ export async function writeStreamingResponse (
213209
store: p.storeEnabled,
214210
messageItemId: msgId,
215211
...(hasToolCalls ? { functionCallItemIds: fcItemIds } : {}),
212+
...(stopReason !== undefined ? { stopReason } : {}),
216213
...(stats !== undefined ? { stats } : {})
217214
})
218215

@@ -228,7 +225,8 @@ export async function writeStreamingResponse (
228225
p.ctx.responsesStore.put(rec)
229226
}
230227

231-
sendSSE(res, { type: 'response.completed', response: responseObject })
228+
const terminalType = responseObject['status'] === 'incomplete' ? 'response.incomplete' : 'response.completed'
229+
sendSSE(res, { type: terminalType, response: responseObject })
232230
endSSE(res, { sentinel: false })
233231
p.ctx.logger.info(` responses stream done id=${p.rid} stored=${p.storeEnabled}`)
234232
return responseObject

packages/cli/src/serve/adapters/openai/responses-shape.ts

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import crypto from 'node:crypto'
22
import type { ToolCall, CompletionStats } from '@qvac/sdk'
33
import { sdkToolCallsToOpenai } from './tool-calls.js'
4+
import { completionTokensFromStats } from './completion-result.js'
45

56
export function responseId (): string {
67
return `resp_${randomId()}`
@@ -37,10 +38,13 @@ export interface BuildResponseObjectParams {
3738
functionCallItemIds?: string[]
3839
/** From SDK completion stats; `generatedTokens` maps to `usage.output_tokens`. */
3940
stats?: CompletionStats
40-
}
41-
42-
function wordCountFallback (text: string): number {
43-
return text ? text.split(/\s+/).filter(Boolean).length : 0
41+
/**
42+
* Terminal `stopReason` from the SDK. `length` maps to OpenAI's
43+
* `status: 'incomplete'` + `incomplete_details.reason: 'max_output_tokens'`
44+
* (the Responses-API analogue of chat's `finish_reason: 'length'`), unless
45+
* tool calls take precedence with `requires_action`.
46+
*/
47+
stopReason?: string
4448
}
4549

4650
export function buildResponseObject (params: BuildResponseObjectParams): Record<string, unknown> {
@@ -74,10 +78,7 @@ export function buildResponseObject (params: BuildResponseObjectParams): Record<
7478
}
7579
}
7680

77-
const outputTokens =
78-
typeof params.stats?.generatedTokens === 'number' && Number.isFinite(params.stats.generatedTokens)
79-
? params.stats.generatedTokens
80-
: wordCountFallback(params.text || '')
81+
const outputTokens = completionTokensFromStats(params.text || '', params.stats)
8182
// SDK does not expose prompt token count today; `cacheTokens` is KV-cache hit count, not full prompt size.
8283
const inputTokens = 0
8384
const usage = {
@@ -86,11 +87,14 @@ export function buildResponseObject (params: BuildResponseObjectParams): Record<
8687
total_tokens: inputTokens + outputTokens
8788
}
8889

90+
const truncated = !hasToolCalls && params.stopReason === 'length'
91+
const status = hasToolCalls ? 'requires_action' : truncated ? 'incomplete' : 'completed'
92+
8993
const base: Record<string, unknown> = {
9094
id: params.id,
9195
object: 'response',
9296
created_at: params.createdAtSec,
93-
status: hasToolCalls ? 'requires_action' : 'completed',
97+
status,
9498
model: params.modelAlias,
9599
output,
96100
output_text: params.text || '',
@@ -99,6 +103,10 @@ export function buildResponseObject (params: BuildResponseObjectParams): Record<
99103
store: params.store
100104
}
101105

106+
if (truncated) {
107+
base['incomplete_details'] = { reason: 'max_output_tokens' }
108+
}
109+
102110
if (hasToolCalls) {
103111
base['required_action'] = {
104112
type: 'submit_tool_outputs',

0 commit comments

Comments
 (0)