-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathhttp.ts
More file actions
229 lines (219 loc) · 8.4 KB
/
Copy pathhttp.ts
File metadata and controls
229 lines (219 loc) · 8.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
/**
* @file OpenAI-compatible HTTP backends for AI providers that expose a
* chat-completions endpoint rather than a CLI — Fireworks
* (`api.fireworks.ai`) and Synthetic (`api.synthetic.new`). The CLI path
* (`spawn.mts`) drives an interactive agent binary; this path is for a
* script/hook that needs a single completion from a model without an agent
* harness (the way the local OpenCode setup reaches GLM-5.2 / Kimi-K2.6). Why
* a separate module from `spawn.mts`: those are different surfaces. A CLI
* agent gets tools + a permission mode + a working dir; an HTTP completion
* gets a prompt + a model + (optionally) a reasoning effort and returns text.
* Conflating them would force every HTTP call to carry meaningless CLI
* lockdown fields. Lockdown equivalent: these calls send NO tools /
* function-calling surface — they're plain completions, so there's no agentic
* capability to constrain. The token is read from the env var the provider
* config names (`FIREWORKS_API_KEY` / `SYNTHETIC_API_KEY`), NEVER passed
* inline, and never logged — same token-hygiene rule as the rest of Socket. A
* missing token throws with the exact env var to set. Wire format is the
* OpenAI Chat Completions API (`POST {baseUrl}/chat/completions`), which both
* providers implement.
*/
// oxlint-disable-next-line socket/no-platform-specific-import -- the relative barrel '../http-request' has no index.ts and exports-map resolution only applies to the bare package name, so only the explicit /node path resolves here (the rule's autofix produces an unresolvable import — verified TS2307). Matches src/dlx/firewall.ts.
import { httpJson } from '../http-request/node'
import { ErrorCtor } from '../primordials/error'
import { isCredentialProvider, resolveProviderCredential } from './credentials'
import type { AiEffort } from './types'
/**
* An OpenAI-compatible HTTP provider. `id` is the slug prefix used in
* `provider/model` references; `baseUrl` is the chat-completions API root;
* `tokenEnv` names the env var holding the bearer token.
*/
export interface AiHttpProvider {
readonly id: string
readonly baseUrl: string
readonly tokenEnv: string
}
/**
* Built-in OpenAI-compatible providers. Add an entry to support a new one — no
* other call site changes. Base URLs are the documented chat-completions
* roots.
*/
export const AI_HTTP_PROVIDERS: Readonly<Record<string, AiHttpProvider>> = {
__proto__: null,
fireworks: {
id: 'fireworks',
baseUrl: 'https://api.fireworks.ai/inference/v1',
tokenEnv: 'FIREWORKS_API_KEY',
},
synthetic: {
id: 'synthetic',
baseUrl: 'https://api.synthetic.new/openai/v1',
tokenEnv: 'SYNTHETIC_API_KEY',
},
} as unknown as Readonly<Record<string, AiHttpProvider>>
/**
* Inputs to a single completion call.
*
* Required: `provider`, `model`, `prompt`. `effort` maps to the OpenAI
* `reasoning_effort` field for models that support it and is left off for the
* rest.
*/
export interface AiHttpCallOptions {
/**
* Provider id (a key of AI_HTTP_PROVIDERS) or a full AiHttpProvider.
*/
readonly provider: string | AiHttpProvider
/**
* The provider's model id (e.g. `accounts/fireworks/models/glm-5p2`,
* `hf:moonshotai/Kimi-K2.6`).
*/
readonly model: string
/**
* The user prompt.
*/
readonly prompt: string
/**
* Optional system prompt prepended as the `system` role message.
*/
readonly system?: string | undefined
/**
* Reasoning effort (`reasoning_effort` field); omitted when absent. Only set
* for a model that supports it — providers ignore or reject it otherwise.
*/
readonly effort?: AiEffort | undefined
/**
* Sampling temperature; provider default when absent.
*/
readonly temperature?: number | undefined
/**
* Per-call timeout (ms).
*/
readonly timeoutMs?: number | undefined
/**
* An explicit bearer token that wins over env + keychain. When absent the
* token resolves via `resolveProviderCredential` (env → keychain).
*/
readonly token?: string | undefined
/**
* Skip the keychain fallback when resolving the token — env var only. Set in
* headless contexts (CI, hooks) where a keychain auth prompt is
* unacceptable.
*/
readonly allowEnvOnly?: boolean | undefined
}
/**
* Result of a completion: the assistant text plus the raw provider response for
* callers that need usage / finish-reason detail.
*/
export interface AiHttpResult {
readonly text: string
readonly raw: OpenAiChatResponse
}
/**
* The slice of the OpenAI chat-completions response we read.
*/
export interface OpenAiChatResponse {
readonly choices?:
| ReadonlyArray<{ message?: { content?: string | undefined } | undefined }>
| undefined
}
/**
* Build the chat-completions request body. Kept pure for testing — the effort →
* `reasoning_effort` mapping + system-message prepend are the parts worth
* asserting without a network call.
*/
export function buildChatRequestBody(options: AiHttpCallOptions): string {
options = { __proto__: null, ...options } as typeof options
const messages: Array<{ role: string; content: string }> = []
if (options.system) {
messages.push({ content: options.system, role: 'system' })
}
messages.push({ content: options.prompt, role: 'user' })
const body: Record<string, unknown> = {
messages,
model: options.model,
}
if (options.effort) {
body['reasoning_effort'] = options.effort
}
if (typeof options.temperature === 'number') {
body['temperature'] = options.temperature
}
return JSON.stringify(body)
}
/**
* Call an OpenAI-compatible chat-completions endpoint and return the assistant
* text. The bearer token is read from the provider's `tokenEnv` env var — never
* accepted as a parameter, never logged. Throws when the token env var is unset
* — the error names the var to set — or when the response carries no message
* text.
*
* @example
* ;```ts
* const { text } = await callAiHttpModel({
* provider: 'fireworks',
* model: 'accounts/fireworks/models/glm-5p2',
* prompt: 'Summarize this diff: …',
* effort: 'high',
* })
* ```
*/
export async function callAiHttpModel(
options: AiHttpCallOptions,
): Promise<AiHttpResult> {
options = { __proto__: null, ...options } as typeof options
const provider = resolveAiHttpProvider(options.provider)
// Resolve via the layered resolver (explicit → env → keychain) when the
// provider is a known CredentialProvider; otherwise fall back to its env var
// directly, which is the case for a caller-supplied custom provider that is
// not in the credential map.
const token = isCredentialProvider(provider.id)
? await resolveProviderCredential({
allowEnvOnly: options.allowEnvOnly,
explicit: options.token,
provider: provider.id,
})
: (options.token ?? process.env[provider.tokenEnv])
if (!token) {
throw new ErrorCtor(
`Missing API token for AI HTTP provider "${provider.id}". Set the ${provider.tokenEnv} environment variable (a bearer token) or store it in the keychain — never pass it inline.`,
)
}
const url = `${provider.baseUrl}/chat/completions`
const raw = await httpJson<OpenAiChatResponse>(url, {
body: buildChatRequestBody(options),
headers: {
// The token is interpolated into the Authorization header only; it is
// never logged or echoed back to the caller.
Authorization: `Bearer ${token}`,
},
method: 'POST',
...(options.timeoutMs === undefined ? {} : { timeout: options.timeoutMs }),
})
const text = raw.choices?.[0]?.message?.content
if (typeof text !== 'string') {
throw new ErrorCtor(
`AI HTTP provider "${provider.id}" returned no message text for model "${options.model}". The response had no choices[0].message.content — check the model id and the provider's status.`,
)
}
return { raw, text }
}
/**
* Resolve a provider id / object to an AiHttpProvider. Throws with the known
* provider set when an unknown id is passed.
*/
export function resolveAiHttpProvider(
provider: string | AiHttpProvider,
): AiHttpProvider {
if (typeof provider !== 'string') {
return provider
}
const found = AI_HTTP_PROVIDERS[provider]
if (!found) {
const known = Object.keys(AI_HTTP_PROVIDERS).join(', ')
throw new ErrorCtor(
`Unknown AI HTTP provider "${provider}". Known providers: ${known}. Pass a known id or a full AiHttpProvider { id, baseUrl, tokenEnv }.`,
)
}
return found
}