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
16 changes: 14 additions & 2 deletions hives/openai-server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,14 @@ follows the thread's latest; there is no `/v1/conversations` here.
Responses are kept in memory, the latest 2048, so none outlives a
restart. A thread is kept only while its latest response is: once that
response is deleted or among those forgotten, the thread's next response
starts it over. `background` is refused.
starts it over. `background` is refused, and so is an input item that
holds its words elsewhere, an `item_reference` or a `compaction`, since
dropping it would lose them; a record of a tool only OpenAI runs is left
out. A failed response's `error.code` is one of OpenAI's, and hum's own
code is its `type`. A response says every field both OpenAI and Open
Responses ask of one, an `mcp_call` aside: that item is OpenAI's, kept
for its clients, though Open Responses would want it named with a
prefix of hum's.

**Completions.** Each prompt is a bloom of its own, carried as a graft of
plain text with no words of its own, so the nurse grows on from where the
Expand Down Expand Up @@ -245,7 +252,12 @@ bun test
The end-to-end tests run the official OpenAI SDK against this server
under Node, a humd of their own built from this checkout, a scripted
upstream, and an attendant serving one tool; `test/holds.test.ts` adds a
nurse that grows nothing and hears every cancel. `test/claude.live.test.ts`
nurse that grows nothing and hears every cancel. Every request they send
and every answer and stream event they get back is checked against
OpenAI's OpenAPI spec and the Open Responses spec, kept in `spec/` at a
pinned commit (`bun spec/fetch.ts` takes them again), and they send the
HTTP cases of the Open Responses compliance suite as it writes them. Its
WebSocket cases and `/responses/compact` are not served here. `test/claude.live.test.ts`
runs only with `HUM_LIVE_CLAUDE=1`: real claude behind this server,
through the claude-cli nurse (build it first with `cargo build` in
`hives/claude-cli`), signed in and spending as the person's own Claude
Expand Down
1 change: 1 addition & 0 deletions hives/openai-server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
},
"devDependencies": {
"@types/node": "^22.19.17",
"ajv": "8.17.1",
"openai": "^6.49.0",
"tsup": "^8.5.1",
"typescript": "^5.7.0"
Expand Down
34 changes: 34 additions & 0 deletions hives/openai-server/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

87 changes: 87 additions & 0 deletions hives/openai-server/spec/fetch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
// The specs this hive is held to, fetched at a pinned commit.
//
// openai.json is OpenAI's OpenAPI cut down to what this hive serves: the
// operations of its routes and every schema they reach, since the whole
// is five megabytes. openresponses.json is the Open Responses spec whole.
// Run with `bun spec/fetch.ts`; move a commit below to take a newer spec.

import { writeFileSync } from "node:fs";
import { join } from "node:path";

const OPENAI = { repo: "openai/openai-openapi", commit: "26a299159673d20d5cf0faf10e691beed3fed52d", path: "openapi.json" };
const OPEN_RESPONSES = { repo: "openresponses/openresponses", commit: "8907bc3b7c4300ff5c21fd647cfde9929243afa1", path: "public/openapi/openapi.json" };

const SERVED: Array<[string, string]> = [
["/chat/completions", "post"],
["/completions", "post"],
["/embeddings", "post"],
["/models", "get"],
["/models/{model}", "get"],
["/responses", "post"],
["/responses/{response_id}", "get"],
["/responses/{response_id}", "delete"],
];

const STREAMED = ["CreateChatCompletionStreamResponse", "CreateCompletionResponse", "ResponseStreamEvent", "ErrorResponse"];

type Json = Record<string, unknown>;

async function fetched(source: { repo: string; commit: string; path: string }): Promise<Json> {
const url = `https://raw.githubusercontent.com/${source.repo}/${source.commit}/${source.path}`;
const res = await fetch(url);
if (!res.ok) throw new Error(`${url}: ${res.status}`);
return (await res.json()) as Json;
}

function refsIn(node: unknown, into: Set<string>): void {
if (Array.isArray(node)) {
for (const n of node) refsIn(n, into);
return;
}
if (node === null || typeof node !== "object") return;
for (const [k, v] of Object.entries(node)) {
if (k === "$ref" && typeof v === "string" && v.startsWith("#/components/schemas/")) into.add(v.slice("#/components/schemas/".length));
else refsIn(v, into);
}
}

function jsonOnly(op: Json): Json {
const body = (op.requestBody as Json | undefined)?.content as Json | undefined;
const responses: Json = {};
for (const [code, r] of Object.entries((op.responses ?? {}) as Record<string, Json>)) {
const content = r.content as Record<string, Json> | undefined;
const schema = content?.["application/json"]?.schema ?? content?.["text/event-stream"]?.schema;
if (schema) responses[code] = { content: { "application/json": { schema } } };
}
return {
...(body?.["application/json"] ? { requestBody: { content: { "application/json": { schema: (body["application/json"] as Json).schema } } } } : {}),
responses,
};
}

function cut(spec: Json): Json {
const paths: Record<string, Json> = {};
for (const [path, method] of SERVED) {
const op = ((spec.paths as Record<string, Json>)[path] as Record<string, Json>)[method];
paths[path] = { ...(paths[path] ?? {}), [method]: jsonOnly(op) };
}
const all = (spec.components as Json).schemas as Record<string, unknown>;
const reached = new Set<string>(STREAMED);
refsIn(paths, reached);
const stack = [...reached];
while (stack.length > 0) {
const name = stack.pop()!;
const found = new Set<string>();
refsIn(all[name], found);
for (const n of found) if (!reached.has(n)) {
reached.add(n);
stack.push(n);
}
}
const schemas = Object.fromEntries([...reached].sort().map((n) => [n, all[n]]));
return { openapi: spec.openapi, info: spec.info, "x-hum-source": OPENAI, paths, components: { schemas } };
}

const here = import.meta.dir;
writeFileSync(join(here, "openai.json"), JSON.stringify(cut(await fetched(OPENAI))) + "\n");
writeFileSync(join(here, "openresponses.json"), JSON.stringify({ ...(await fetched(OPEN_RESPONSES)), "x-hum-source": OPEN_RESPONSES }) + "\n");
1 change: 1 addition & 0 deletions hives/openai-server/spec/openai.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions hives/openai-server/spec/openresponses.json

Large diffs are not rendered by default.

5 changes: 2 additions & 3 deletions hives/openai-server/src/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ export async function chat(ctx: Ctx, who: Caller, req: IncomingMessage, res: Ser
const stream = body.stream === true;
const sse = stream ? new Sse(res) : undefined;
const chunk = (delta: Record<string, unknown>, finish: string | null = null) => ({
id, object: "chat.completion.chunk", created, model: echoed, system_fingerprint: null,
id, object: "chat.completion.chunk", created, model: echoed,
choices: [{ index: 0, delta, logprobs: null, finish_reason: finish }],
});
let spoke = false;
Expand Down Expand Up @@ -142,7 +142,7 @@ export async function chat(ctx: Ctx, who: Caller, req: IncomingMessage, res: Ser
if (!spoke) sse.data(chunk({ role: "assistant", content: "" }));
sse.data(chunk({}, grown.finish));
if (body.stream_options?.include_usage === true) {
sse.data({ id, object: "chat.completion.chunk", created, model: echoed, system_fingerprint: null, choices: [], usage });
sse.data({ id, object: "chat.completion.chunk", created, model: echoed, choices: [], usage });
}
sse.data("[DONE]");
sse.end();
Expand All @@ -153,7 +153,6 @@ export async function chat(ctx: Ctx, who: Caller, req: IncomingMessage, res: Ser
object: "chat.completion",
created,
model: echoed,
system_fingerprint: null,
choices: [{
index: 0,
message: {
Expand Down
8 changes: 7 additions & 1 deletion hives/openai-server/src/conversation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,10 +112,16 @@ function partsOf(content: unknown): Petal[] {
return out;
}

function failureOf(error: unknown): unknown {
if (!error || typeof error !== "object") return error;
const e = error as { message?: unknown; content?: unknown };
return e.message ?? e.content ?? error;
}

function servedPetals(id: string, name: string, args: unknown, output: unknown, error: unknown): Petal[] {
return [
{ kind: PetalKind.tendril, callId: id, name, args: argsOf(args) },
{ kind: PetalKind.grasp, callId: id, result: resultOf(output ?? error), isError: Boolean(error) },
{ kind: PetalKind.grasp, callId: id, result: resultOf(output ?? failureOf(error)), isError: Boolean(error) },
];
}

Expand Down
11 changes: 6 additions & 5 deletions hives/openai-server/src/growth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,10 +69,11 @@ export interface Opening {
answers?: Answer[];
}

export function usageOf(nectar: Nectar | undefined): { prompt: number; completion: number; cached: number } {
export function usageOf(nectar: Nectar | undefined): { prompt: number; completion: number; cached: number; written: number } {
const cached = nectar?.cache_read_input_tokens ?? 0;
const prompt = (nectar?.input_tokens ?? 0) + cached + (nectar?.cache_creation_input_tokens ?? 0);
return { prompt, completion: nectar?.output_tokens ?? 0, cached };
const written = nectar?.cache_creation_input_tokens ?? 0;
const prompt = (nectar?.input_tokens ?? 0) + cached + written;
return { prompt, completion: nectar?.output_tokens ?? 0, cached, written };
}

export function finishOf(reason: string | undefined): Finish {
Expand Down Expand Up @@ -226,7 +227,7 @@ export function mcpCallOf(s: Served): Record<string, unknown> {
server_label: "hum",
name: s.name,
arguments: s.args,
output: s.result,
error: s.isError ? s.result : null,
output: s.isError ? null : s.result,
error: s.isError ? { type: "mcp_tool_execution_error", content: [{ type: "text", text: s.result }] } : null,
};
}
37 changes: 31 additions & 6 deletions hives/openai-server/src/responses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,13 +42,24 @@ interface Body {
max_output_tokens?: number;
temperature?: number;
top_p?: number;
top_logprobs?: number;
max_tool_calls?: number;
metadata?: Record<string, string>;
truncation?: string;
user?: string;
safety_identifier?: string;
prompt_cache_key?: string;
service_tier?: string;
frequency_penalty?: number;
presence_penalty?: number;
}

function echoedTool(tool: unknown): unknown {
const t = tool as Record<string, unknown> | null;
if (!t || t.type !== "function") return tool;
return { ...t, description: t.description ?? null, parameters: t.parameters ?? null, strict: t.strict ?? null };
}

export interface Kept {
label: string;
sid: string;
Expand Down Expand Up @@ -121,6 +132,8 @@ export async function responses(ctx: Ctx, who: Caller, req: IncomingMessage, res
} else if (conversation) {
before = kept.latest(rq.label, conversation);
}
const unread = (Array.isArray(body.input) ? body.input : []).map((i) => (i as { type?: string })?.type).find((t) => t === "item_reference" || t === "compaction");
if (unread) throw bad(`an input item of type ${unread} is not read here, since its words are kept elsewhere; send the items themselves, or previous_response_id`, "input");
const said = fromResponses(body.input, body.instructions, before?.said);
if (said.tail === "none") throw bad("input holds nothing to answer", "input");

Expand Down Expand Up @@ -170,18 +183,28 @@ export async function responses(ctx: Ctx, who: Caller, req: IncomingMessage, res
output,
instructions: body.instructions ?? null,
previous_response_id: body.previous_response_id ?? null,
tools: body.tools ?? [],
tools: (body.tools ?? []).map(echoedTool),
tool_choice: body.tool_choice ?? "auto",
parallel_tool_calls: body.parallel_tool_calls ?? true,
temperature: body.temperature ?? null,
top_p: body.top_p ?? null,
temperature: body.temperature ?? 1,
top_p: body.top_p ?? 1,
top_logprobs: body.top_logprobs ?? 0,
frequency_penalty: body.frequency_penalty ?? 0,
presence_penalty: body.presence_penalty ?? 0,
max_output_tokens: body.max_output_tokens ?? null,
max_tool_calls: body.max_tool_calls ?? null,
reasoning: { effort: body.reasoning?.effort ?? null, summary: body.reasoning?.summary ?? null },
text: { format: format ?? { type: "text" } },
truncation: body.truncation ?? "disabled",
metadata: body.metadata ?? {},
store: body.store !== false,
background: false,
service_tier: body.service_tier ?? "default",
user: body.user ?? null,
safety_identifier: body.safety_identifier ?? null,
prompt_cache_key: body.prompt_cache_key ?? null,
access_programs: null,
completed_at: null,
error: null,
incomplete_details: null,
usage: null,
Expand Down Expand Up @@ -268,7 +291,8 @@ export async function responses(ctx: Ctx, who: Caller, req: IncomingMessage, res
ctx.sigils.end(sid);
const err = e instanceof HttpError ? e : new HttpError(500, String(e));
if (sse?.begun) {
emit("response.failed", { response: shape("failed", output, { error: { code: err.code ?? "server_error", message: err.message } }) });
const code = err.status === 429 ? "rate_limit_exceeded" : err.status === 400 ? "invalid_prompt" : "server_error";
emit("response.failed", { response: shape("failed", output, { error: { code, message: err.message, type: err.code ?? err.type, param: err.param } }) });
sse.end();
} else if (sse) {
sse.fail(err);
Expand All @@ -289,14 +313,15 @@ export async function responses(ctx: Ctx, who: Caller, req: IncomingMessage, res
ctx.usage.track(who.key, used.prompt, used.completion);
const usage = {
input_tokens: used.prompt,
input_tokens_details: { cached_tokens: used.cached },
input_tokens_details: { cached_tokens: used.cached, cache_write_tokens: used.written },
output_tokens: used.completion,
output_tokens_details: { reasoning_tokens: 0 },
total_tokens: used.prompt + used.completion,
};
const incomplete = grown.finish === "length";
const final = shape(incomplete ? "incomplete" : "completed", output, {
usage,
completed_at: Math.floor(Date.now() / 1000),
...(incomplete ? { incomplete_details: { reason: "max_output_tokens" } } : {}),
});
if (body.store !== false) kept.put(id, { label: rq.label, sid, said: { ...said, blooms: after, tail: "assistant" }, response: final }, conversation);
Expand All @@ -317,5 +342,5 @@ export function readResponse(who: Caller, id: string, res: ServerResponse): void

export function dropResponse(who: Caller, id: string, res: ServerResponse): void {
if (!kept.drop(who.rq.label, id)) throw new HttpError(404, `No response found with id '${id}'.`, "response_not_found");
sendJson(res, 200, { id, object: "response", deleted: true });
sendJson(res, 200, { id, object: "response.deleted", deleted: true });
}
Loading
Loading