@volter/twin-openai 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +168 -0
- package/package.json +62 -0
- package/src/cli.ts +26 -0
- package/src/index.ts +108 -0
- package/src/openai-budget.ts +173 -0
- package/src/openai-capabilities.ts +1556 -0
- package/src/openai-conformance.ts +112 -0
- package/src/openai-connector.ts +337 -0
- package/src/openai-models.ts +36 -0
- package/src/openai-scenario.ts +136 -0
- package/src/openai-server.ts +167 -0
- package/src/openai-stub.ts +247 -0
- package/src/openai-twin.ts +2100 -0
- package/src/openai-types.ts +126 -0
- package/src/openai-webhooks.ts +128 -0
- package/test-fixtures/openai-openapi-operations.SOURCE.md +19 -0
- package/test-fixtures/openai-openapi-operations.json +2959 -0
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// Shared wire-shape types for the OpenAI API surface. These mirror the real vendor JSON
|
|
2
|
+
// shapes (not the SDK's internal types — the twin never imports the SDK at runtime; the SDK
|
|
3
|
+
// is exercised only in *.test.ts). Kept minimal but faithful.
|
|
4
|
+
|
|
5
|
+
// ── Chat Completions ────────────────────────────────────────────────────────────────────
|
|
6
|
+
/** A chat message param as the caller sends it (content is a string OR a content-part array). */
|
|
7
|
+
export type ChatMessageParam = {
|
|
8
|
+
role: 'system' | 'user' | 'assistant' | 'tool' | 'developer';
|
|
9
|
+
content?: string | Array<Record<string, unknown>> | null;
|
|
10
|
+
name?: string;
|
|
11
|
+
tool_calls?: ChatToolCall[];
|
|
12
|
+
tool_call_id?: string;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/** A function tool_call inside an assistant message (faithful shape). */
|
|
16
|
+
export type ChatToolCall = {
|
|
17
|
+
id: string;
|
|
18
|
+
type: 'function';
|
|
19
|
+
function: { name: string; arguments: string };
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/** The chat.completion `usage` object — deterministic token counts. */
|
|
23
|
+
export type ChatUsage = {
|
|
24
|
+
prompt_tokens: number;
|
|
25
|
+
completion_tokens: number;
|
|
26
|
+
total_tokens: number;
|
|
27
|
+
completion_tokens_details?: { accepted_prediction_tokens: number; rejected_prediction_tokens: number };
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/** A per-token logprob entry (faithful `choices[].logprobs.content[]` shape). */
|
|
31
|
+
export type ChatLogprobToken = {
|
|
32
|
+
token: string;
|
|
33
|
+
logprob: number;
|
|
34
|
+
bytes: number[];
|
|
35
|
+
top_logprobs: Array<{ token: string; logprob: number; bytes: number[] }>;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/** An audio output object on an assistant message (when `modalities` includes 'audio'). The twin
|
|
39
|
+
* can't synthesize speech, so `data` is a clearly-labeled stub base64 string; the SHAPE
|
|
40
|
+
* (id/data/transcript/expires_at) is vendor-faithful. */
|
|
41
|
+
export type ChatAudioOutput = { id: string; data: string; transcript: string; expires_at: number };
|
|
42
|
+
|
|
43
|
+
export type ChatChoice = {
|
|
44
|
+
index: number;
|
|
45
|
+
message: { role: 'assistant'; content: string | null; tool_calls?: ChatToolCall[]; refusal?: null; audio?: ChatAudioOutput };
|
|
46
|
+
logprobs: { content: ChatLogprobToken[] } | null;
|
|
47
|
+
finish_reason: 'stop' | 'length' | 'tool_calls' | 'content_filter';
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/** The unary chat.completion response envelope (faithful shape). */
|
|
51
|
+
export type ChatCompletion = {
|
|
52
|
+
id: string;
|
|
53
|
+
object: 'chat.completion';
|
|
54
|
+
created: number;
|
|
55
|
+
model: string;
|
|
56
|
+
choices: ChatChoice[];
|
|
57
|
+
usage: ChatUsage;
|
|
58
|
+
system_fingerprint: string;
|
|
59
|
+
metadata?: Record<string, unknown> | null;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
// ── Responses API ───────────────────────────────────────────────────────────────────────
|
|
63
|
+
/** A message output item (the assistant's text turn). */
|
|
64
|
+
export type ResponseMessageItem = {
|
|
65
|
+
type: 'message';
|
|
66
|
+
id: string;
|
|
67
|
+
status: 'completed';
|
|
68
|
+
role: 'assistant';
|
|
69
|
+
content: Array<{ type: 'output_text'; text: string; annotations: unknown[] }>;
|
|
70
|
+
};
|
|
71
|
+
/** A reasoning output item (emitted for reasoning models / when `reasoning.effort` is set). The
|
|
72
|
+
* twin can't run the model, so the reasoning `summary` is a clearly-labeled stub; the item SHAPE
|
|
73
|
+
* (type/id/summary[]) is vendor-faithful. */
|
|
74
|
+
export type ResponseReasoningItem = {
|
|
75
|
+
type: 'reasoning';
|
|
76
|
+
id: string;
|
|
77
|
+
summary: Array<{ type: 'summary_text'; text: string }>;
|
|
78
|
+
};
|
|
79
|
+
export type ResponseOutputItem = ResponseMessageItem | ResponseReasoningItem;
|
|
80
|
+
|
|
81
|
+
/** The Responses API usage object — `output_tokens_details.reasoning_tokens` reports tokens spent
|
|
82
|
+
* on the (stubbed) reasoning item, faithful to the vendor shape. */
|
|
83
|
+
export type ResponseUsage = {
|
|
84
|
+
input_tokens: number;
|
|
85
|
+
output_tokens: number;
|
|
86
|
+
total_tokens: number;
|
|
87
|
+
output_tokens_details?: { reasoning_tokens: number };
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
/** The Responses API response envelope (faithful shape). */
|
|
91
|
+
export type OpenAIResponse = {
|
|
92
|
+
id: string;
|
|
93
|
+
object: 'response';
|
|
94
|
+
created_at: number;
|
|
95
|
+
status: 'completed';
|
|
96
|
+
model: string;
|
|
97
|
+
output: ResponseOutputItem[];
|
|
98
|
+
output_text?: string;
|
|
99
|
+
usage: ResponseUsage;
|
|
100
|
+
reasoning?: { effort: string; summary: string | null };
|
|
101
|
+
previous_response_id?: string;
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
// ── Embeddings ──────────────────────────────────────────────────────────────────────────
|
|
105
|
+
export type Embedding = { object: 'embedding'; index: number; embedding: number[] };
|
|
106
|
+
export type EmbeddingResponse = {
|
|
107
|
+
object: 'list';
|
|
108
|
+
data: Embedding[];
|
|
109
|
+
model: string;
|
|
110
|
+
usage: { prompt_tokens: number; total_tokens: number };
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
// ── Errors ──────────────────────────────────────────────────────────────────────────────
|
|
114
|
+
/** A vendor-shaped error envelope: { error: { message, type, param, code } }. */
|
|
115
|
+
export type OpenAIError = {
|
|
116
|
+
error: { message: string; type: string; param: string | null; code: string | null };
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
// ── Streaming ───────────────────────────────────────────────────────────────────────────
|
|
120
|
+
/** A single Server-Sent Event the streaming path emits (collected, never socketed in tests).
|
|
121
|
+
* `data` is the JSON payload; `[DONE]` is signalled with `done: true` (no data object). */
|
|
122
|
+
export type SseEvent = { data?: Record<string, unknown>; done?: boolean };
|
|
123
|
+
|
|
124
|
+
/** A sink the streaming path writes events into (an injected collector in tests / a real
|
|
125
|
+
* HTTP SSE writer in the server). NO real sockets or setTimeout in the handler. */
|
|
126
|
+
export type SseSink = (event: SseEvent) => void;
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// OpenAI webhooks — REAL HMAC-signed delivery, Standard-Webhooks scheme.
|
|
2
|
+
//
|
|
3
|
+
// Real OpenAI delivers asynchronous job-completion notifications (batch / fine-tuning /
|
|
4
|
+
// background responses / evals) to a registered endpoint, signed with the Standard Webhooks
|
|
5
|
+
// scheme (the same scheme svix popularized): the signing secret is `whsec_<base64>`, the HMAC
|
|
6
|
+
// key is the base64-decoded portion, and the delivery carries `webhook-id`, `webhook-timestamp`,
|
|
7
|
+
// and `webhook-signature` headers where the signature is `v1,<base64 HMAC-SHA256(key,
|
|
8
|
+
// `${id}.${timestamp}.${payload}`)>`. The SDK verifies with `client.webhooks.unwrap()`. We
|
|
9
|
+
// reproduce the scheme BYTE-FOR-BYTE so a real consumer (or the SDK's unwrap) verifies twin-
|
|
10
|
+
// delivered webhooks unchanged — the signature is REAL, not a stub.
|
|
11
|
+
//
|
|
12
|
+
// `node:crypto` is required LAZILY (server-only) so this module is browser-bundle safe.
|
|
13
|
+
type NodeCrypto = typeof import('node:crypto');
|
|
14
|
+
function nodeCrypto(): NodeCrypto {
|
|
15
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
16
|
+
return require('node:crypto') as NodeCrypto;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** The thin webhook envelope OpenAI POSTs: { object:'event', id, type, created_at, data:{id} }. */
|
|
20
|
+
export type OpenAIWebhookEvent = {
|
|
21
|
+
object: 'event';
|
|
22
|
+
id: string;
|
|
23
|
+
type: string;
|
|
24
|
+
created_at: number;
|
|
25
|
+
data: { id: string; [k: string]: unknown };
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/** Decode a `whsec_<base64>` signing secret to its raw HMAC key bytes. */
|
|
29
|
+
function secretKeyBytes(secret: string): Buffer {
|
|
30
|
+
const b64 = secret.startsWith('whsec_') ? secret.slice('whsec_'.length) : secret;
|
|
31
|
+
return Buffer.from(b64, 'base64');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Compute the `webhook-signature` header value for a delivery: `v1,<base64 sig>` over
|
|
36
|
+
* `${id}.${timestamp}.${payload}` keyed by the (base64-decoded) signing secret — the Standard
|
|
37
|
+
* Webhooks scheme. A real Standard-Webhooks / svix verifier accepts this unchanged.
|
|
38
|
+
*/
|
|
39
|
+
export function computeOpenAIWebhookSignature(id: string, timestamp: number, payload: string, secret: string): string {
|
|
40
|
+
const key = secretKeyBytes(secret);
|
|
41
|
+
const signed = `${id}.${timestamp}.${payload}`;
|
|
42
|
+
const sig = nodeCrypto().createHmac('sha256', key).update(signed, 'utf8').digest('base64');
|
|
43
|
+
return `v1,${sig}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export class OpenAIWebhookVerificationError extends Error {
|
|
47
|
+
constructor(message: string) {
|
|
48
|
+
super(message);
|
|
49
|
+
this.name = 'OpenAIWebhookVerificationError';
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Verify a webhook payload + Standard-Webhooks headers against the signing secret and return the
|
|
55
|
+
* parsed event (mirrors the SDK's `webhooks.unwrap`). Throws on a missing/malformed header, a
|
|
56
|
+
* signature mismatch, or (when `tolerance` is given) a stale timestamp.
|
|
57
|
+
*/
|
|
58
|
+
export function verifyOpenAIWebhook(payload: string, headers: Record<string, string>, secret: string, opts: { tolerance?: number; now?: number } = {}): OpenAIWebhookEvent {
|
|
59
|
+
const lower: Record<string, string> = {};
|
|
60
|
+
for (const [k, v] of Object.entries(headers)) lower[k.toLowerCase()] = v;
|
|
61
|
+
const id = lower['webhook-id'];
|
|
62
|
+
const ts = Number(lower['webhook-timestamp']);
|
|
63
|
+
const sigHeader = lower['webhook-signature'];
|
|
64
|
+
if (!id || !sigHeader || !Number.isFinite(ts)) throw new OpenAIWebhookVerificationError('Missing required webhook headers (webhook-id / webhook-timestamp / webhook-signature).');
|
|
65
|
+
if (opts.tolerance !== undefined) {
|
|
66
|
+
const now = opts.now ?? Math.floor(Date.now() / 1000);
|
|
67
|
+
if (Math.abs(now - ts) > opts.tolerance) throw new OpenAIWebhookVerificationError('Webhook timestamp outside tolerance.');
|
|
68
|
+
}
|
|
69
|
+
const expected = computeOpenAIWebhookSignature(id, ts, payload, secret).slice('v1,'.length);
|
|
70
|
+
const expectedBuf = Buffer.from(expected, 'utf8');
|
|
71
|
+
// The header may carry multiple space-separated `v<n>,<sig>` pairs.
|
|
72
|
+
const matched = sigHeader.split(' ').some((part) => {
|
|
73
|
+
const comma = part.indexOf(',');
|
|
74
|
+
if (comma === -1) return false;
|
|
75
|
+
const sig = part.slice(comma + 1);
|
|
76
|
+
const sigBuf = Buffer.from(sig, 'utf8');
|
|
77
|
+
return sigBuf.length === expectedBuf.length && nodeCrypto().timingSafeEqual(sigBuf, expectedBuf);
|
|
78
|
+
});
|
|
79
|
+
if (!matched) throw new OpenAIWebhookVerificationError('No matching signature found.');
|
|
80
|
+
return JSON.parse(payload) as OpenAIWebhookEvent;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The webhook event types OpenAI emits (a faithful slice of the published set). */
|
|
84
|
+
export const OPENAI_WEBHOOK_EVENT_TYPES = [
|
|
85
|
+
'response.completed',
|
|
86
|
+
'response.cancelled',
|
|
87
|
+
'response.failed',
|
|
88
|
+
'response.incomplete',
|
|
89
|
+
'batch.completed',
|
|
90
|
+
'batch.cancelled',
|
|
91
|
+
'batch.expired',
|
|
92
|
+
'batch.failed',
|
|
93
|
+
'fine_tuning.job.succeeded',
|
|
94
|
+
'fine_tuning.job.cancelled',
|
|
95
|
+
'fine_tuning.job.failed',
|
|
96
|
+
'eval.run.succeeded',
|
|
97
|
+
'eval.run.canceled',
|
|
98
|
+
'eval.run.failed',
|
|
99
|
+
] as const;
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Build a SIGNED webhook delivery for an async job completion: the thin event envelope + the
|
|
103
|
+
* Standard-Webhooks headers carrying a REAL HMAC signature. Deterministic + offline.
|
|
104
|
+
*/
|
|
105
|
+
export function buildSignedOpenAIWebhook(args: {
|
|
106
|
+
type: string;
|
|
107
|
+
resourceId: string;
|
|
108
|
+
secret: string;
|
|
109
|
+
occurredAt: string;
|
|
110
|
+
webhookId: string;
|
|
111
|
+
extra?: Record<string, unknown>;
|
|
112
|
+
}): { headers: Record<string, string>; body: string; event: OpenAIWebhookEvent } {
|
|
113
|
+
const ts = Math.floor(Date.parse(args.occurredAt) / 1000);
|
|
114
|
+
const event: OpenAIWebhookEvent = {
|
|
115
|
+
object: 'event',
|
|
116
|
+
id: args.webhookId,
|
|
117
|
+
type: args.type,
|
|
118
|
+
created_at: ts,
|
|
119
|
+
data: { id: args.resourceId, ...(args.extra ?? {}) },
|
|
120
|
+
};
|
|
121
|
+
const body = JSON.stringify(event);
|
|
122
|
+
const headers = {
|
|
123
|
+
'webhook-id': args.webhookId,
|
|
124
|
+
'webhook-timestamp': String(ts),
|
|
125
|
+
'webhook-signature': computeOpenAIWebhookSignature(args.webhookId, ts, body, args.secret),
|
|
126
|
+
};
|
|
127
|
+
return { headers, body, event };
|
|
128
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# openai-openapi-operations.json — provenance
|
|
2
|
+
|
|
3
|
+
The operation-inventory denominator for `../openai-spec-census.json`: a scope-filtered subset of
|
|
4
|
+
the vendor's published spec, enumerated by `deriveFromOpenAPI` (`packages/twin/tooling/src/derive.ts`).
|
|
5
|
+
|
|
6
|
+
- **Upstream:** https://github.com/openai/openai-openapi — `openapi.yaml`, fetched 2026-09-02 from
|
|
7
|
+
`https://raw.githubusercontent.com/openai/openai-openapi/master/openapi.yaml` at commit
|
|
8
|
+
`18a43ed13461a01fce5b9cc93c86843c550734a3` (2026-09-01), spec `info.version` 2.3.0.
|
|
9
|
+
- **How it was built:** `bun scripts/spec-census-bootstrap.ts --vendor openai --from-file <openapi.yaml> --scope-paths /assistants,/audio,/batches,/chat/,/containers,/embeddings,/evals,/files,/fine_tuning,/images,/models,/moderations,/organization,/responses,/threads,/uploads,/vector_stores --apply`
|
|
10
|
+
over a copy of the upstream file fetched 2026-09-02; operation metadata only (operationId, summary, tags,
|
|
11
|
+
first response), schemas dropped. The gate never fetches — refreshing the fixture is
|
|
12
|
+
`bun scripts/spec-refresh.ts --vendor openai --from-file <newer spec> --apply` over a newly fetched copy.
|
|
13
|
+
- **Scope (222 of 281 upstream operations):** every operation whose path starts with one of
|
|
14
|
+
`/assistants`, `/audio`, `/batches`, `/chat/`, `/containers`, `/embeddings`, `/evals`, `/files`, `/fine_tuning`, `/images`, `/models`, `/moderations`, `/organization`, `/responses`, `/threads`, `/uploads`, `/vector_stores`.
|
|
15
|
+
- **Mapping:** 128 operations mapped to capability ids by segment match at birth, then reviewed by
|
|
16
|
+
hand against the router (`routeOpenAI` in `../src/openai-twin.ts`) and each capability's `verify()`:
|
|
17
|
+
60 entries were re-ruled, landing at **97 capability / 0 outOfScope / 125 unmapped** (the ratchet
|
|
18
|
+
baseline). Every later ruling (an `outOfScope` reason, a remap) is an edit to the census, never to
|
|
19
|
+
this fixture.
|