Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions packages/protocol/src/groups/chat-completion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,49 @@ export const ChatCompletionResponse = Schema.Struct({
}).annotate({ identifier: "ChatCompletionResponse" })
export type ChatCompletionResponse = typeof ChatCompletionResponse.Type

/**
* One frame of the `stream: true` response.
*
* A DIFFERENT shape from `ChatCompletionResponse`, which is why documenting the streaming
* path needed this rather than reusing the JSON one: the object discriminator is
* `chat.completion.chunk`, and each choice carries a partial `delta` instead of a complete
* `message`. A generated client that assumed the non-streaming shape would look for
* `choices[].message.content` and find nothing on every frame.
*
* Declared for documentation and codegen only — the handler is `handleRaw` and writes SSE
* frames itself, because one request answers with JSON and another with
* `text/event-stream`. This type is what makes the OpenAPI honest about the second.
*
* Every field on `delta` is optional because OpenAI's stream uses the shape sparsely: the
* first frame typically carries only `role`, middle frames only `content`, a tool call
* arrives spread across frames, and the terminator carries an empty delta with
* `finish_reason` set. A schema that required `content` would reject the terminator.
*/
export const ChatCompletionChunk = Schema.Struct({
id: Schema.String,
object: Schema.Literal("chat.completion.chunk"),
created: Schema.Int,
model: Schema.String,
choices: Schema.Array(
Schema.Struct({
index: Schema.Int,
delta: Schema.Struct({
role: Schema.optional(Schema.Literal("assistant")),
content: Schema.optional(Schema.String),
tool_calls: Schema.optional(Schema.Array(ToolCall)),
reasoning_content: Schema.optional(Schema.String),
}),
finish_reason: Schema.NullOr(Schema.String),
}),
),
/**
* Sent on the final frame by providers that report it. Optional because many do not,
* and a client must not wait for a frame that never comes.
*/
usage: Schema.optional(Usage),
}).annotate({ identifier: "ChatCompletionChunk" })
export type ChatCompletionChunk = typeof ChatCompletionChunk.Type

/**
* The OpenAI error envelope.
*
Expand Down
6 changes: 6 additions & 0 deletions packages/redrob/src/server/routes/instance/httpapi/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { WorkspaceApi } from "./groups/workspace"
import { makeApi } from "@redrob-code/protocol/api"
import { LocationMiddleware } from "@redrob-code/server/location"
import { SessionLocationMiddleware } from "@redrob-code/server/middleware/session-location"
import { ChatCompletionChunk } from "@redrob-code/protocol/groups/chat-completion"
import { GlobalApi } from "./groups/global"
import { Authorization } from "./middleware/authorization"
import { SchemaErrorMiddleware } from "./middleware/schema-error"
Expand Down Expand Up @@ -91,6 +92,11 @@ export const RedrobHttpApi = HttpApi.make("redrob")
Integration.Method,
Integration.Ref,
SkillV2.Source,
// Not referenced by any endpoint's declared success type -- the streaming shape is
// patched onto /v1/chat/completions in public.ts, because HttpApi cannot express "JSON
// or SSE depending on a request field". Registering it here is what makes that patch's
// $ref resolve instead of dangling in the generated spec.
ChatCompletionChunk,
])

export type RootHttpApiType = typeof RootHttpApi
Expand Down
22 changes: 22 additions & 0 deletions packages/redrob/src/server/routes/instance/httpapi/public.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,28 @@ function matchLegacyOpenApi(input: Record<string, unknown>) {
},
}
}
if (path === "/v1/chat/completions" && method === "post") {
// Same gap, and worse consequences here: this route answers with JSON or with
// text/event-stream depending on `stream`, and the declared success schema only
// describes the JSON. A generated client built from the unpatched spec looks for
// choices[].message.content on a streaming response and finds nothing on every
// frame, because a chunk carries choices[].delta instead.
//
// Both content types are declared on the one 200 so the spec says what actually
// happens, rather than replacing the JSON shape and lying in the other direction.
const existing = operation.responses?.["200"] as
| { description?: string; content?: Record<string, unknown> }
| undefined
operation.responses!["200"] = {
description:
"Chat completion. `application/json` when `stream` is false or absent; " +
"`text/event-stream` of `ChatCompletionChunk` frames terminated by `data: [DONE]` when true.",
content: {
...(existing?.content ?? {}),
"text/event-stream": { schema: { $ref: "#/components/schemas/ChatCompletionChunk" } },
},
}
}
const route = `${method.toUpperCase()} ${path}`
for (const param of operation.parameters ?? []) normalizeParameter(param, route)
}
Expand Down
45 changes: 45 additions & 0 deletions packages/redrob/test/server/httpapi-public-openapi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ type OpenApiSchema = {
readonly enum?: readonly unknown[]
readonly properties?: Record<string, OpenApiSchema>
readonly required?: readonly string[]
/** Element schema of an array property, e.g. a chunk's `choices`. */
readonly items?: OpenApiSchema
readonly contentSchema?: OpenApiSchema
readonly contentMediaType?: string
}
Expand Down Expand Up @@ -70,6 +72,49 @@ function isBuiltInEndpointError(name: string) {
}

describe("PublicApi OpenAPI v2 errors", () => {
test("documents both content types on the chat-completions 200", () => {
// The route answers with JSON or with text/event-stream depending on `stream`, and
// HttpApi can only declare one success schema. Without the patch the spec describes
// only the JSON, so a generated client looks for choices[].message.content on a
// streaming response and finds nothing on every frame -- a chunk carries
// choices[].delta instead.
const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec
const response = spec.paths["/v1/chat/completions"]?.post?.responses?.["200"]

expect(Object.keys(response?.content ?? {}).sort()).toEqual(["application/json", "text/event-stream"])
// Both, not one replacing the other: dropping the JSON would lie in the other direction.
expect(response?.content?.["application/json"]).toBeDefined()
expect(response?.description).toContain("[DONE]")
})

test("the chat-completions SSE ref resolves to a registered component", () => {
// A $ref to a schema no endpoint references dangles unless it is registered through
// HttpApi.AdditionalSchemas. A dangling ref generates a client with a missing type
// rather than failing loudly, so this is the assertion that keeps the patch honest.
const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec
const ref = spec.paths["/v1/chat/completions"]?.post?.responses?.["200"]?.content?.[
"text/event-stream"
]?.schema?.$ref

expect(ref).toBe("#/components/schemas/ChatCompletionChunk")
expect(Object.keys(spec.components.schemas)).toContain("ChatCompletionChunk")
})

test("a chunk is shaped as a delta, not as a complete message", () => {
const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec
const chunk = spec.components.schemas.ChatCompletionChunk
const choice = chunk?.properties?.choices?.items as OpenApiSchema | undefined

expect(choice?.properties?.delta).toBeDefined()
// The discriminator differs from the non-streaming object, which is the whole reason a
// separate schema was needed.
expect(chunk?.properties?.object?.enum).toEqual(["chat.completion.chunk"])
// Every delta field is optional: the first frame carries only role, middle frames only
// content, and the terminator an empty delta. Requiring content would reject the
// terminator.
expect(choice?.properties?.delta?.required ?? []).toEqual([])
})

test("includes plugin-facing core schemas", () => {
const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec

Expand Down
41 changes: 41 additions & 0 deletions packages/sdk/js/src/v2/gen/sdk.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import type {
AuthRemoveResponses,
AuthSetErrors,
AuthSetResponses,
ChatCompletionRequest,
CommandListErrors,
CommandListResponses,
Config as Config3,
Expand Down Expand Up @@ -263,6 +264,8 @@ import type {
TuiShowToastResponses,
TuiSubmitPromptErrors,
TuiSubmitPromptResponses,
V1ChatCompletionsErrors,
V1ChatCompletionsResponses,
V2AgentListErrors,
V2AgentListResponses,
V2CommandListErrors,
Expand Down Expand Up @@ -7137,6 +7140,39 @@ export class V2 extends HeyApiClient {
}
}

export class Chat extends HeyApiClient {
/**
* Create a chat completion
*
* OpenAI-compatible inference against the engine's own credential and provider registry. Declaring `tools` makes the caller responsible for executing them: the turn ends with finish_reason tool_calls and the results come back as role:tool messages. `stream: true` returns text/event-stream instead of this JSON body. Failures use OpenAI's error envelope -- {error:{message,type,code,param}} -- with 400 invalid_request_error, 401 authentication_error (code engine_not_authenticated, the only status a client should offer a sign-in action for), 429 rate_limit_error or insufficient_quota, and 502 api_error. They are written by the handler rather than declared as endpoint errors, because this is a raw handler and the envelope carries no discriminator field for codegen to branch on.
*/
public completions<ThrowOnError extends boolean = false>(
parameters?: {
chatCompletionRequest?: ChatCompletionRequest
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams([parameters], [{ args: [{ key: "chatCompletionRequest", map: "body" }] }])
return (options?.client ?? this.client).post<V1ChatCompletionsResponses, V1ChatCompletionsErrors, ThrowOnError>({
url: "/v1/chat/completions",
...options,
...params,
headers: {
"Content-Type": "application/json",
...options?.headers,
...params.headers,
},
})
}
}

export class V1 extends HeyApiClient {
private _chat?: Chat
get chat(): Chat {
return (this._chat ??= new Chat({ client: this.client }))
}
}

export class RedrobClient extends HeyApiClient {
public static readonly __registry = new HeyApiRegistry<RedrobClient>()

Expand Down Expand Up @@ -7279,4 +7315,9 @@ export class RedrobClient extends HeyApiClient {
get v2(): V2 {
return (this._v2 ??= new V2({ client: this.client }))
}

private _v1?: V1
get v1(): V1 {
return (this._v1 ??= new V1({ client: this.client }))
}
}
143 changes: 143 additions & 0 deletions packages/sdk/js/src/v2/gen/types.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,35 @@ export type QuestionRejected = {
requestID: string
}

export type ChatCompletionChunk = {
id: string
object: "chat.completion.chunk"
created: number
model: string
choices: Array<{
index: number
delta: {
role?: "assistant"
content?: string
tool_calls?: Array<{
id: string
type: "function"
function: {
name: string
arguments: string
}
}>
reasoning_content?: string
}
finish_reason: string
}>
usage?: {
prompt_tokens: number
completion_tokens: number
total_tokens: number
}
}

export type OAuth = {
type: "oauth"
refresh: string
Expand Down Expand Up @@ -2142,8 +2171,15 @@ export type Provider = {
}
}

export type ChatCompletionsCapability = {
version: number
callerTools: boolean
stream: boolean
}

export type ExperimentalCapabilities = {
backgroundSubagents: boolean
chatCompletions: ChatCompletionsCapability
}

export type ConsoleState = {
Expand Down Expand Up @@ -2973,6 +3009,88 @@ export type ProjectCopyError = {
}
}

export type ChatCompletionRequest = {
model: string
messages: Array<{
role: "system" | "developer" | "user" | "assistant" | "tool"
content?:
| string
| Array<{
[key: string]: unknown
}>
tool_calls?: Array<{
id: string
type: "function"
function: {
name: string
arguments: string
}
}>
tool_call_id?: string
name?: string
}>
tools?: Array<{
type: "function"
function: {
name: string
description?: string
parameters?: {
[key: string]: unknown
}
}
}>
tool_choice?:
| "auto"
| "none"
| "required"
| {
type: "function"
function: {
name: string
}
}
stream?: boolean
max_tokens?: number
max_completion_tokens?: number
temperature?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
top_p?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
stop?: string | Array<string>
seed?: number
frequency_penalty?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
presence_penalty?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
reasoning_effort?: string
user?: string
}

export type ChatCompletionResponse = {
id: string
object: "chat.completion"
created: number
model: string
choices: Array<{
index: number
message: {
role: "assistant"
content: string
tool_calls?: Array<{
id: string
type: "function"
function: {
name: string
arguments: string
}
}>
reasoning_content?: string
}
finish_reason: string
}>
usage?: {
prompt_tokens: number
completion_tokens: number
total_tokens: number
}
}

export type EffectHttpApiErrorForbidden = {
_tag: "Forbidden"
}
Expand Down Expand Up @@ -13720,6 +13838,31 @@ export type V2ProjectCopyRefreshResponses = {

export type V2ProjectCopyRefreshResponse = V2ProjectCopyRefreshResponses[keyof V2ProjectCopyRefreshResponses]

export type V1ChatCompletionsData = {
body?: ChatCompletionRequest
path?: never
query?: never
url: "/v1/chat/completions"
}

export type V1ChatCompletionsErrors = {
/**
* Bad request
*/
400: BadRequestError
}

export type V1ChatCompletionsError = V1ChatCompletionsErrors[keyof V1ChatCompletionsErrors]

export type V1ChatCompletionsResponses = {
/**
* Chat completion. `application/json` when `stream` is false or absent; `text/event-stream` of `ChatCompletionChunk` frames terminated by `data: [DONE]` when true.
*/
200: ChatCompletionResponse
}

export type V1ChatCompletionsResponse = V1ChatCompletionsResponses[keyof V1ChatCompletionsResponses]

export type PtyConnectData = {
body?: never
path: {
Expand Down
Loading
Loading