@glassly/llm-providers 0.1.0-dev.52
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/package.json +24 -0
- package/src/anthropic.ts +207 -0
- package/src/gemini.ts +216 -0
- package/src/index.ts +16 -0
- package/src/openai.ts +169 -0
- package/src/provider.ts +62 -0
package/package.json
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@glassly/llm-providers",
|
|
3
|
+
"version": "0.1.0-dev.52",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "./src/index.ts",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": "./src/index.ts"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"test": "bun test"
|
|
11
|
+
},
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"@glassly/cloud-protocol": "0.1.0-dev.52"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"src",
|
|
17
|
+
"!src/**/*.test.ts"
|
|
18
|
+
],
|
|
19
|
+
"repository": {
|
|
20
|
+
"type": "git",
|
|
21
|
+
"url": "git+https://github.com/tetramo-labs/glassly.git",
|
|
22
|
+
"directory": "cloud-v2/packages/llm-providers"
|
|
23
|
+
}
|
|
24
|
+
}
|
package/src/anthropic.ts
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Anthropic Messages provider.
|
|
3
|
+
*
|
|
4
|
+
* Raw `fetch` rather than `@anthropic-ai/sdk`: this code runs both server-side
|
|
5
|
+
* (cloud runtime) and on the phone (React Native), and it needs only the plain
|
|
6
|
+
* (non-beta) Messages endpoint, so an SDK would buy nothing and cost
|
|
7
|
+
* portability. Callers that need the beta surfaces (`code_execution`, the
|
|
8
|
+
* Files API) are out of scope for the neutral contract and keep their own
|
|
9
|
+
* direct client — see the note in protocol/llm.ts.
|
|
10
|
+
*
|
|
11
|
+
* WEB SEARCH (`webSearch` on the neutral request) maps to the server-side
|
|
12
|
+
* `web_search_20250305` tool — deliberately not `_20260209`, which
|
|
13
|
+
* auto-injects a colliding code_execution tool (the same pin
|
|
14
|
+
* llm-generate.service uses). It needs no beta header, and its result blocks
|
|
15
|
+
* (`server_tool_use` / `web_search_tool_result`) fold into ordinary content,
|
|
16
|
+
* which the block filters below already skip. `usage.server_tool_use.
|
|
17
|
+
* web_search_requests` becomes `webSearchCount` for metering.
|
|
18
|
+
*/
|
|
19
|
+
import type { LlmStopReason, LlmToolCall } from "@glassly/cloud-protocol/llm";
|
|
20
|
+
import type { LlmProviderCompleteArgs, LlmProviderImpl, LlmProviderOptions } from "./provider";
|
|
21
|
+
|
|
22
|
+
const ANTHROPIC_URL = "https://api.anthropic.com/v1/messages";
|
|
23
|
+
const ANTHROPIC_VERSION = "2023-06-01";
|
|
24
|
+
const FALLBACK_MODEL = "claude-sonnet-5";
|
|
25
|
+
const DEFAULT_MAX_TOKENS = 2048;
|
|
26
|
+
/**
|
|
27
|
+
* Cap on server-side searches per call. Keeps worst-case latency and the
|
|
28
|
+
* per-search surcharge bounded; callers wanting deep research belong on the
|
|
29
|
+
* generate surface, not complete().
|
|
30
|
+
*/
|
|
31
|
+
const WEB_SEARCH_MAX_USES = 3;
|
|
32
|
+
|
|
33
|
+
/** Anthropic content blocks we care about. Other block types are ignored. */
|
|
34
|
+
interface AnthropicTextBlock {
|
|
35
|
+
type: "text";
|
|
36
|
+
text: string;
|
|
37
|
+
}
|
|
38
|
+
interface AnthropicToolUseBlock {
|
|
39
|
+
type: "tool_use";
|
|
40
|
+
id: string;
|
|
41
|
+
name: string;
|
|
42
|
+
input: Record<string, unknown>;
|
|
43
|
+
}
|
|
44
|
+
type AnthropicBlock = AnthropicTextBlock | AnthropicToolUseBlock | { type: string };
|
|
45
|
+
|
|
46
|
+
interface AnthropicResponse {
|
|
47
|
+
content?: AnthropicBlock[];
|
|
48
|
+
stop_reason?: string;
|
|
49
|
+
usage?: {
|
|
50
|
+
input_tokens?: number;
|
|
51
|
+
output_tokens?: number;
|
|
52
|
+
server_tool_use?: { web_search_requests?: number };
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Map Anthropic's stop_reason onto ours. `refusal` arrives on a 200 — it is a
|
|
58
|
+
* successful result the caller must handle, not a transport error.
|
|
59
|
+
*
|
|
60
|
+
* `pause_turn` (a long server-tool turn the API parked) falls to `end_turn`
|
|
61
|
+
* deliberately: continuing requires echoing vendor-shaped blocks the neutral
|
|
62
|
+
* contract can't carry, and with WEB_SEARCH_MAX_USES this is rare — the text
|
|
63
|
+
* so far is the answer we have.
|
|
64
|
+
*/
|
|
65
|
+
function mapStopReason(raw: string | undefined): LlmStopReason {
|
|
66
|
+
switch (raw) {
|
|
67
|
+
case "tool_use":
|
|
68
|
+
return "tool_use";
|
|
69
|
+
case "max_tokens":
|
|
70
|
+
return "max_tokens";
|
|
71
|
+
case "refusal":
|
|
72
|
+
return "refusal";
|
|
73
|
+
default:
|
|
74
|
+
return "end_turn";
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Build Anthropic's `messages` array from our neutral turns.
|
|
80
|
+
*
|
|
81
|
+
* Two shape rules Anthropic enforces that this has to respect:
|
|
82
|
+
* - an assistant turn that called tools must carry those `tool_use` blocks, so
|
|
83
|
+
* the following `tool_result` blocks have something to reference;
|
|
84
|
+
* - EVERY `tool_use` must be answered by a `tool_result` with a matching id.
|
|
85
|
+
* Callers that skip one would get a 400, so a missing answer is sent as an
|
|
86
|
+
* explicit empty result rather than being dropped.
|
|
87
|
+
*/
|
|
88
|
+
function toAnthropicMessages(args: LlmProviderCompleteArgs): unknown[] {
|
|
89
|
+
return args.messages.map((m) => {
|
|
90
|
+
if (m.role === "assistant" && m.toolCalls?.length) {
|
|
91
|
+
const blocks: unknown[] = [];
|
|
92
|
+
if (m.content) blocks.push({ type: "text", text: m.content });
|
|
93
|
+
for (const call of m.toolCalls) {
|
|
94
|
+
blocks.push({ type: "tool_use", id: call.id, name: call.name, input: call.input });
|
|
95
|
+
}
|
|
96
|
+
return { role: "assistant", content: blocks };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (m.role === "user" && m.toolResults?.length) {
|
|
100
|
+
const blocks = m.toolResults.map((r) => ({
|
|
101
|
+
type: "tool_result",
|
|
102
|
+
tool_use_id: r.toolCallId,
|
|
103
|
+
content: r.content,
|
|
104
|
+
...(r.isError ? { is_error: true } : {}),
|
|
105
|
+
}));
|
|
106
|
+
// Any prose alongside tool results follows them as a normal text block.
|
|
107
|
+
if (m.content) blocks.push({ type: "text", text: m.content } as never);
|
|
108
|
+
return { role: "user", content: blocks };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return { role: m.role, content: m.content };
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Anthropic has no JSON-mode flag (unlike Gemini's `responseMimeType`), so the
|
|
117
|
+
* instruction is appended to the system prompt. The caller still parses and
|
|
118
|
+
* validates the text — this only raises the odds of clean JSON.
|
|
119
|
+
*/
|
|
120
|
+
function buildSystem(args: LlmProviderCompleteArgs): string | undefined {
|
|
121
|
+
if (!args.jsonMode) return args.system;
|
|
122
|
+
const instruction = "Respond with a single valid JSON object and nothing else. No prose, no markdown fences.";
|
|
123
|
+
return args.system ? `${args.system}\n\n${instruction}` : instruction;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function createAnthropicProvider(opts: LlmProviderOptions = {}): LlmProviderImpl {
|
|
127
|
+
const defaultModel = opts.defaultModel?.trim() || FALLBACK_MODEL;
|
|
128
|
+
const logger = opts.logger;
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
name: "anthropic",
|
|
132
|
+
defaultModel,
|
|
133
|
+
|
|
134
|
+
async complete(args) {
|
|
135
|
+
const model = args.model || defaultModel;
|
|
136
|
+
const system = buildSystem(args);
|
|
137
|
+
|
|
138
|
+
const body: Record<string, unknown> = {
|
|
139
|
+
model,
|
|
140
|
+
max_tokens: args.maxTokens ?? DEFAULT_MAX_TOKENS,
|
|
141
|
+
messages: toAnthropicMessages(args),
|
|
142
|
+
};
|
|
143
|
+
if (system) body.system = system;
|
|
144
|
+
// `temperature` is deliberately dropped: current Claude models (Opus
|
|
145
|
+
// 4.7+, Sonnet 5) removed sampling parameters and 400 any request that
|
|
146
|
+
// sends one ("`temperature` is deprecated for this model"). Steering is
|
|
147
|
+
// prompt-side now; the neutral field still applies on Gemini.
|
|
148
|
+
const tools: unknown[] = (args.tools ?? []).map((t) => ({
|
|
149
|
+
// Anthropic takes JSON Schema in the lowercase dialect, which is the
|
|
150
|
+
// dialect our neutral LlmTool already uses — no translation needed.
|
|
151
|
+
name: t.name,
|
|
152
|
+
description: t.description,
|
|
153
|
+
input_schema: t.inputSchema,
|
|
154
|
+
}));
|
|
155
|
+
if (args.webSearch) {
|
|
156
|
+
tools.push({ type: "web_search_20250305", name: "web_search", max_uses: WEB_SEARCH_MAX_USES });
|
|
157
|
+
}
|
|
158
|
+
if (tools.length) body.tools = tools;
|
|
159
|
+
|
|
160
|
+
const res = await fetch(ANTHROPIC_URL, {
|
|
161
|
+
method: "POST",
|
|
162
|
+
headers: {
|
|
163
|
+
"Content-Type": "application/json",
|
|
164
|
+
"x-api-key": args.apiKey,
|
|
165
|
+
"anthropic-version": ANTHROPIC_VERSION,
|
|
166
|
+
},
|
|
167
|
+
body: JSON.stringify(body),
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
if (!res.ok) {
|
|
171
|
+
const detail = await res.text().catch(() => "");
|
|
172
|
+
logger?.error({ status: res.status, model }, "anthropic completion failed");
|
|
173
|
+
throw new Error(`anthropic ${res.status}: ${detail.slice(0, 300)}`);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const data = (await res.json()) as AnthropicResponse;
|
|
177
|
+
const blocks = data.content ?? [];
|
|
178
|
+
|
|
179
|
+
const text = blocks
|
|
180
|
+
.filter((b): b is AnthropicTextBlock => b.type === "text")
|
|
181
|
+
.map((b) => b.text)
|
|
182
|
+
.join("\n");
|
|
183
|
+
|
|
184
|
+
// `tool_use` blocks are CLIENT tool calls only — the server tool's own
|
|
185
|
+
// `server_tool_use` blocks have a different type and fall through the
|
|
186
|
+
// filter, so a web search never leaks into the caller's tool loop.
|
|
187
|
+
const toolCalls: LlmToolCall[] = blocks
|
|
188
|
+
.filter((b): b is AnthropicToolUseBlock => b.type === "tool_use")
|
|
189
|
+
.map((b) => ({ id: b.id, name: b.name, input: b.input ?? {} }));
|
|
190
|
+
|
|
191
|
+
const webSearchCount = data.usage?.server_tool_use?.web_search_requests ?? 0;
|
|
192
|
+
|
|
193
|
+
return {
|
|
194
|
+
text,
|
|
195
|
+
stopReason: mapStopReason(data.stop_reason),
|
|
196
|
+
...(toolCalls.length ? { toolCalls } : {}),
|
|
197
|
+
usage: {
|
|
198
|
+
inputTokens: data.usage?.input_tokens ?? 0,
|
|
199
|
+
outputTokens: data.usage?.output_tokens ?? 0,
|
|
200
|
+
},
|
|
201
|
+
provider: "anthropic" as const,
|
|
202
|
+
model,
|
|
203
|
+
...(webSearchCount > 0 ? { webSearchCount } : {}),
|
|
204
|
+
};
|
|
205
|
+
},
|
|
206
|
+
};
|
|
207
|
+
}
|
package/src/gemini.ts
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Google Gemini `generateContent` provider.
|
|
3
|
+
*
|
|
4
|
+
* Raw `fetch` — this code runs both server-side (cloud runtime) and on the
|
|
5
|
+
* phone (React Native), so no SDK and no Node APIs.
|
|
6
|
+
*
|
|
7
|
+
* Three Gemini quirks this file absorbs so callers never see them:
|
|
8
|
+
* 1. Tool schemas use the OpenAPI dialect with UPPERCASE type names
|
|
9
|
+
* ("OBJECT", "STRING") where Anthropic/JSON Schema use lowercase.
|
|
10
|
+
* 2. Function calls carry NO ids. Ours are synthesized positionally, and
|
|
11
|
+
* results are replayed in order — see `toGeminiContents`.
|
|
12
|
+
* 3. `webSearch` maps to the server-side `google_search` tool, but not every
|
|
13
|
+
* model accepts it alongside function declarations or JSON mode — those
|
|
14
|
+
* combinations 400. Rather than encode a per-model compatibility matrix,
|
|
15
|
+
* a 400 on a search-granted request retries ONCE without the search tool
|
|
16
|
+
* (search is a grant, not a guarantee — see protocol/llm.ts).
|
|
17
|
+
*/
|
|
18
|
+
import type { LlmStopReason, LlmToolCall } from "@glassly/cloud-protocol/llm";
|
|
19
|
+
import type { LlmProviderCompleteArgs, LlmProviderImpl, LlmProviderOptions } from "./provider";
|
|
20
|
+
|
|
21
|
+
const GEMINI_BASE = "https://generativelanguage.googleapis.com/v1beta/models";
|
|
22
|
+
const FALLBACK_MODEL = "gemini-3.5-flash";
|
|
23
|
+
const DEFAULT_MAX_TOKENS = 2048;
|
|
24
|
+
|
|
25
|
+
/** Prefix for synthesized tool-call ids. See the file header. */
|
|
26
|
+
const TOOL_CALL_ID_PREFIX = "call-";
|
|
27
|
+
|
|
28
|
+
interface GeminiPart {
|
|
29
|
+
text?: string;
|
|
30
|
+
functionCall?: { name?: string; args?: Record<string, unknown> };
|
|
31
|
+
}
|
|
32
|
+
interface GeminiResponse {
|
|
33
|
+
candidates?: Array<{
|
|
34
|
+
content?: { parts?: GeminiPart[] };
|
|
35
|
+
finishReason?: string;
|
|
36
|
+
/** Present when google_search grounding actually ran. */
|
|
37
|
+
groundingMetadata?: { webSearchQueries?: string[] };
|
|
38
|
+
}>;
|
|
39
|
+
usageMetadata?: { promptTokenCount?: number; candidatesTokenCount?: number };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function mapStopReason(raw: string | undefined, hasToolCalls: boolean): LlmStopReason {
|
|
43
|
+
if (hasToolCalls) return "tool_use";
|
|
44
|
+
switch (raw) {
|
|
45
|
+
case "MAX_TOKENS":
|
|
46
|
+
return "max_tokens";
|
|
47
|
+
// Gemini reports content-policy stops as SAFETY/PROHIBITED_CONTENT; both
|
|
48
|
+
// mean "declined to answer", which is our `refusal`.
|
|
49
|
+
case "SAFETY":
|
|
50
|
+
case "PROHIBITED_CONTENT":
|
|
51
|
+
case "BLOCKLIST":
|
|
52
|
+
return "refusal";
|
|
53
|
+
default:
|
|
54
|
+
return "end_turn";
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Recursively uppercase JSON Schema `type` values for Gemini's OpenAPI dialect.
|
|
60
|
+
* Callers author one lowercase schema; this is the only place the dialects differ.
|
|
61
|
+
*/
|
|
62
|
+
function toGeminiSchema(schema: unknown): unknown {
|
|
63
|
+
if (Array.isArray(schema)) return schema.map(toGeminiSchema);
|
|
64
|
+
if (!schema || typeof schema !== "object") return schema;
|
|
65
|
+
|
|
66
|
+
const out: Record<string, unknown> = {};
|
|
67
|
+
for (const [key, value] of Object.entries(schema as Record<string, unknown>)) {
|
|
68
|
+
if (key === "type" && typeof value === "string") {
|
|
69
|
+
out[key] = value.toUpperCase();
|
|
70
|
+
} else if (key === "properties" && value && typeof value === "object") {
|
|
71
|
+
const props: Record<string, unknown> = {};
|
|
72
|
+
for (const [propName, propSchema] of Object.entries(value as Record<string, unknown>)) {
|
|
73
|
+
props[propName] = toGeminiSchema(propSchema);
|
|
74
|
+
}
|
|
75
|
+
out[key] = props;
|
|
76
|
+
} else if (key === "items") {
|
|
77
|
+
out[key] = toGeminiSchema(value);
|
|
78
|
+
} else {
|
|
79
|
+
out[key] = value;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Build Gemini `contents` from our neutral turns.
|
|
87
|
+
*
|
|
88
|
+
* Gemini's roles are "user"/"model" (not "assistant"), and tool results go back
|
|
89
|
+
* as `functionResponse` parts matched BY NAME — there are no ids to match on.
|
|
90
|
+
* We recover the name from the assistant turn that issued the call, looking it
|
|
91
|
+
* up by the positional id we synthesized when decoding that turn.
|
|
92
|
+
*/
|
|
93
|
+
function toGeminiContents(args: LlmProviderCompleteArgs): unknown[] {
|
|
94
|
+
// toolCallId -> tool name, harvested from every assistant turn seen so far.
|
|
95
|
+
const nameById = new Map<string, string>();
|
|
96
|
+
for (const m of args.messages) {
|
|
97
|
+
for (const call of m.toolCalls ?? []) nameById.set(call.id, call.name);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return args.messages.map((m) => {
|
|
101
|
+
if (m.role === "assistant" && m.toolCalls?.length) {
|
|
102
|
+
const parts: GeminiPart[] = [];
|
|
103
|
+
if (m.content) parts.push({ text: m.content });
|
|
104
|
+
for (const call of m.toolCalls) {
|
|
105
|
+
parts.push({ functionCall: { name: call.name, args: call.input } });
|
|
106
|
+
}
|
|
107
|
+
return { role: "model", parts };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (m.role === "user" && m.toolResults?.length) {
|
|
111
|
+
const parts = m.toolResults.map((r) => ({
|
|
112
|
+
functionResponse: {
|
|
113
|
+
name: nameById.get(r.toolCallId) ?? r.toolCallId,
|
|
114
|
+
response: { result: r.content },
|
|
115
|
+
},
|
|
116
|
+
}));
|
|
117
|
+
return { role: "user", parts };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return { role: m.role === "assistant" ? "model" : "user", parts: [{ text: m.content }] };
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function createGeminiProvider(opts: LlmProviderOptions = {}): LlmProviderImpl {
|
|
125
|
+
const defaultModel = opts.defaultModel?.trim() || FALLBACK_MODEL;
|
|
126
|
+
const logger = opts.logger;
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
name: "gemini",
|
|
130
|
+
defaultModel,
|
|
131
|
+
|
|
132
|
+
async complete(args) {
|
|
133
|
+
const model = args.model || defaultModel;
|
|
134
|
+
|
|
135
|
+
const generationConfig: Record<string, unknown> = {
|
|
136
|
+
maxOutputTokens: args.maxTokens ?? DEFAULT_MAX_TOKENS,
|
|
137
|
+
};
|
|
138
|
+
if (args.temperature !== undefined) generationConfig.temperature = args.temperature;
|
|
139
|
+
if (args.jsonMode) generationConfig.responseMimeType = "application/json";
|
|
140
|
+
|
|
141
|
+
const buildBody = (withSearch: boolean): Record<string, unknown> => {
|
|
142
|
+
const body: Record<string, unknown> = {
|
|
143
|
+
contents: toGeminiContents(args),
|
|
144
|
+
generationConfig,
|
|
145
|
+
};
|
|
146
|
+
if (args.system) body.systemInstruction = { parts: [{ text: args.system }] };
|
|
147
|
+
const tools: unknown[] = [];
|
|
148
|
+
if (args.tools?.length) {
|
|
149
|
+
tools.push({
|
|
150
|
+
function_declarations: args.tools.map((t) => ({
|
|
151
|
+
name: t.name,
|
|
152
|
+
description: t.description,
|
|
153
|
+
parameters: toGeminiSchema(t.inputSchema),
|
|
154
|
+
})),
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
if (withSearch) tools.push({ google_search: {} });
|
|
158
|
+
if (tools.length) body.tools = tools;
|
|
159
|
+
return body;
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const post = (withSearch: boolean) =>
|
|
163
|
+
fetch(`${GEMINI_BASE}/${model}:generateContent`, {
|
|
164
|
+
method: "POST",
|
|
165
|
+
headers: { "Content-Type": "application/json", "x-goog-api-key": args.apiKey },
|
|
166
|
+
body: JSON.stringify(buildBody(withSearch)),
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
let res = await post(Boolean(args.webSearch));
|
|
170
|
+
if (!res.ok && res.status === 400 && args.webSearch) {
|
|
171
|
+
// See file header, quirk 3: this model rejects google_search combined
|
|
172
|
+
// with this request's other features. Degrade to searchless rather
|
|
173
|
+
// than failing the call.
|
|
174
|
+
logger?.error({ status: res.status, model }, "gemini rejected google_search; retrying without it");
|
|
175
|
+
res = await post(false);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (!res.ok) {
|
|
179
|
+
const detail = await res.text().catch(() => "");
|
|
180
|
+
logger?.error({ status: res.status, model }, "gemini completion failed");
|
|
181
|
+
throw new Error(`gemini ${res.status}: ${detail.slice(0, 300)}`);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const data = (await res.json()) as GeminiResponse;
|
|
185
|
+
const parts = data.candidates?.[0]?.content?.parts ?? [];
|
|
186
|
+
|
|
187
|
+
const text = parts
|
|
188
|
+
.map((p) => p.text)
|
|
189
|
+
.filter((t): t is string => typeof t === "string" && t.length > 0)
|
|
190
|
+
.join("\n");
|
|
191
|
+
|
|
192
|
+
const toolCalls: LlmToolCall[] = parts
|
|
193
|
+
.filter((p) => p.functionCall?.name)
|
|
194
|
+
.map((p, i) => ({
|
|
195
|
+
id: `${TOOL_CALL_ID_PREFIX}${i}`,
|
|
196
|
+
name: p.functionCall?.name ?? "",
|
|
197
|
+
input: p.functionCall?.args ?? {},
|
|
198
|
+
}));
|
|
199
|
+
|
|
200
|
+
const webSearchCount = data.candidates?.[0]?.groundingMetadata?.webSearchQueries?.length ?? 0;
|
|
201
|
+
|
|
202
|
+
return {
|
|
203
|
+
text,
|
|
204
|
+
stopReason: mapStopReason(data.candidates?.[0]?.finishReason, toolCalls.length > 0),
|
|
205
|
+
...(toolCalls.length ? { toolCalls } : {}),
|
|
206
|
+
usage: {
|
|
207
|
+
inputTokens: data.usageMetadata?.promptTokenCount ?? 0,
|
|
208
|
+
outputTokens: data.usageMetadata?.candidatesTokenCount ?? 0,
|
|
209
|
+
},
|
|
210
|
+
provider: "gemini" as const,
|
|
211
|
+
model,
|
|
212
|
+
...(webSearchCount > 0 ? { webSearchCount } : {}),
|
|
213
|
+
};
|
|
214
|
+
},
|
|
215
|
+
};
|
|
216
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @glassly/llm-providers — vendor LLM providers behind one neutral contract.
|
|
3
|
+
*
|
|
4
|
+
* Shared by the cloud runtime (platform-key path) and the phone host
|
|
5
|
+
* (user-key direct path), so it must stay runtime-neutral: global fetch +
|
|
6
|
+
* JSON only. See src/provider.ts for the full contract notes.
|
|
7
|
+
*/
|
|
8
|
+
export {createAnthropicProvider} from "./anthropic";
|
|
9
|
+
export {createGeminiProvider} from "./gemini";
|
|
10
|
+
export {createOpenAiProvider} from "./openai";
|
|
11
|
+
export type {
|
|
12
|
+
LlmProviderCompleteArgs,
|
|
13
|
+
LlmProviderImpl,
|
|
14
|
+
LlmProviderLogger,
|
|
15
|
+
LlmProviderOptions,
|
|
16
|
+
} from "./provider";
|
package/src/openai.ts
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview OpenAI Chat Completions provider.
|
|
3
|
+
*
|
|
4
|
+
* Raw `fetch` like the other providers — this code runs both server-side and
|
|
5
|
+
* on the phone (React Native), so no SDK and no Node APIs.
|
|
6
|
+
*
|
|
7
|
+
* Only standard platform API keys (`sk-...`) are supported. ChatGPT/Codex
|
|
8
|
+
* subscription OAuth tokens are deliberately NOT accepted: they only work
|
|
9
|
+
* against the Codex backend endpoint while impersonating the Codex CLI, which
|
|
10
|
+
* is fragile and squarely against OpenAI's terms. Users with a ChatGPT plan
|
|
11
|
+
* still need a platform API key here.
|
|
12
|
+
*
|
|
13
|
+
* Dialect notes this file absorbs so callers never see them:
|
|
14
|
+
* - tool arguments arrive as a JSON *string* (`function.arguments`), where
|
|
15
|
+
* Anthropic/Gemini hand back objects — parsed here, `{}` on garbage;
|
|
16
|
+
* - tool results are separate `role: "tool"` messages, one per call, that
|
|
17
|
+
* must directly follow the assistant turn carrying the calls;
|
|
18
|
+
* - `max_tokens` is retired on current models — `max_completion_tokens`.
|
|
19
|
+
*
|
|
20
|
+
* `webSearch` is IGNORED here: Chat Completions has no server-side search
|
|
21
|
+
* tool on the standard models (that lives in the Responses API, a different
|
|
22
|
+
* surface). The flag is a capability grant (see protocol/llm.ts), so ignoring
|
|
23
|
+
* it is the contract-correct degradation — the model answers from knowledge
|
|
24
|
+
* and the result carries no webSearchCount.
|
|
25
|
+
*/
|
|
26
|
+
import type { LlmStopReason, LlmToolCall } from "@glassly/cloud-protocol/llm";
|
|
27
|
+
import type { LlmProviderCompleteArgs, LlmProviderImpl, LlmProviderOptions } from "./provider";
|
|
28
|
+
|
|
29
|
+
const OPENAI_URL = "https://api.openai.com/v1/chat/completions";
|
|
30
|
+
const FALLBACK_MODEL = "gpt-5.1";
|
|
31
|
+
const DEFAULT_MAX_TOKENS = 2048;
|
|
32
|
+
|
|
33
|
+
interface OpenAiToolCall {
|
|
34
|
+
id?: string;
|
|
35
|
+
function?: { name?: string; arguments?: string };
|
|
36
|
+
}
|
|
37
|
+
interface OpenAiResponse {
|
|
38
|
+
choices?: Array<{
|
|
39
|
+
message?: { content?: string | null; tool_calls?: OpenAiToolCall[] };
|
|
40
|
+
finish_reason?: string;
|
|
41
|
+
}>;
|
|
42
|
+
usage?: { prompt_tokens?: number; completion_tokens?: number };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function mapStopReason(raw: string | undefined, hasToolCalls: boolean): LlmStopReason {
|
|
46
|
+
if (hasToolCalls) return "tool_use";
|
|
47
|
+
switch (raw) {
|
|
48
|
+
case "length":
|
|
49
|
+
return "max_tokens";
|
|
50
|
+
case "content_filter":
|
|
51
|
+
return "refusal";
|
|
52
|
+
default:
|
|
53
|
+
return "end_turn";
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Build OpenAI `messages` from our neutral turns. The system prompt rides as
|
|
59
|
+
* the leading `system` message; a user turn carrying tool results becomes one
|
|
60
|
+
* `role: "tool"` message per result (OpenAI's required shape), with any prose
|
|
61
|
+
* following as a normal user message.
|
|
62
|
+
*/
|
|
63
|
+
function toOpenAiMessages(args: LlmProviderCompleteArgs): unknown[] {
|
|
64
|
+
const out: unknown[] = [];
|
|
65
|
+
if (args.system) out.push({ role: "system", content: args.system });
|
|
66
|
+
|
|
67
|
+
for (const m of args.messages) {
|
|
68
|
+
if (m.role === "assistant" && m.toolCalls?.length) {
|
|
69
|
+
out.push({
|
|
70
|
+
role: "assistant",
|
|
71
|
+
content: m.content || null,
|
|
72
|
+
tool_calls: m.toolCalls.map((call) => ({
|
|
73
|
+
id: call.id,
|
|
74
|
+
type: "function",
|
|
75
|
+
function: { name: call.name, arguments: JSON.stringify(call.input) },
|
|
76
|
+
})),
|
|
77
|
+
});
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (m.role === "user" && m.toolResults?.length) {
|
|
82
|
+
for (const r of m.toolResults) {
|
|
83
|
+
// No is_error flag in this dialect; the content itself tells the model.
|
|
84
|
+
out.push({ role: "tool", tool_call_id: r.toolCallId, content: r.content });
|
|
85
|
+
}
|
|
86
|
+
if (m.content) out.push({ role: "user", content: m.content });
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
out.push({ role: m.role, content: m.content });
|
|
91
|
+
}
|
|
92
|
+
return out;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function createOpenAiProvider(opts: LlmProviderOptions = {}): LlmProviderImpl {
|
|
96
|
+
const defaultModel = opts.defaultModel?.trim() || FALLBACK_MODEL;
|
|
97
|
+
const logger = opts.logger;
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
name: "openai",
|
|
101
|
+
defaultModel,
|
|
102
|
+
|
|
103
|
+
async complete(args) {
|
|
104
|
+
const model = args.model || defaultModel;
|
|
105
|
+
|
|
106
|
+
const body: Record<string, unknown> = {
|
|
107
|
+
model,
|
|
108
|
+
max_completion_tokens: args.maxTokens ?? DEFAULT_MAX_TOKENS,
|
|
109
|
+
messages: toOpenAiMessages(args),
|
|
110
|
+
};
|
|
111
|
+
// `temperature` is deliberately dropped, like the Anthropic provider:
|
|
112
|
+
// OpenAI's reasoning models 400 on any non-default sampling value.
|
|
113
|
+
if (args.jsonMode) body.response_format = { type: "json_object" };
|
|
114
|
+
if (args.tools?.length) {
|
|
115
|
+
// OpenAI takes JSON Schema in the lowercase dialect our neutral
|
|
116
|
+
// LlmTool already uses — no translation needed.
|
|
117
|
+
body.tools = args.tools.map((t) => ({
|
|
118
|
+
type: "function",
|
|
119
|
+
function: { name: t.name, description: t.description, parameters: t.inputSchema },
|
|
120
|
+
}));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const res = await fetch(OPENAI_URL, {
|
|
124
|
+
method: "POST",
|
|
125
|
+
headers: {
|
|
126
|
+
"Content-Type": "application/json",
|
|
127
|
+
Authorization: `Bearer ${args.apiKey}`,
|
|
128
|
+
},
|
|
129
|
+
body: JSON.stringify(body),
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
if (!res.ok) {
|
|
133
|
+
const detail = await res.text().catch(() => "");
|
|
134
|
+
logger?.error({ status: res.status, model }, "openai completion failed");
|
|
135
|
+
throw new Error(`openai ${res.status}: ${detail.slice(0, 300)}`);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const data = (await res.json()) as OpenAiResponse;
|
|
139
|
+
const choice = data.choices?.[0];
|
|
140
|
+
|
|
141
|
+
const toolCalls: LlmToolCall[] = (choice?.message?.tool_calls ?? [])
|
|
142
|
+
.filter((c) => c.function?.name)
|
|
143
|
+
.map((c, i) => {
|
|
144
|
+
let input: Record<string, unknown> = {};
|
|
145
|
+
try {
|
|
146
|
+
const parsed: unknown = JSON.parse(c.function?.arguments || "{}");
|
|
147
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
148
|
+
input = parsed as Record<string, unknown>;
|
|
149
|
+
}
|
|
150
|
+
} catch {
|
|
151
|
+
/* malformed arguments degrade to {} — the tool sees an empty query */
|
|
152
|
+
}
|
|
153
|
+
return { id: c.id || `call-${i}`, name: c.function?.name ?? "", input };
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
text: choice?.message?.content ?? "",
|
|
158
|
+
stopReason: mapStopReason(choice?.finish_reason, toolCalls.length > 0),
|
|
159
|
+
...(toolCalls.length ? { toolCalls } : {}),
|
|
160
|
+
usage: {
|
|
161
|
+
inputTokens: data.usage?.prompt_tokens ?? 0,
|
|
162
|
+
outputTokens: data.usage?.completion_tokens ?? 0,
|
|
163
|
+
},
|
|
164
|
+
provider: "openai" as const,
|
|
165
|
+
model,
|
|
166
|
+
};
|
|
167
|
+
},
|
|
168
|
+
};
|
|
169
|
+
}
|
package/src/provider.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview The LLM provider contract.
|
|
3
|
+
*
|
|
4
|
+
* This interface is the line where a vendor's dialect becomes our one neutral
|
|
5
|
+
* vocabulary. It speaks ONLY in the protocol-neutral types from
|
|
6
|
+
* `protocol/llm` (LlmMessage, LlmTool, LlmCompleteResult, ...) — never a
|
|
7
|
+
* vendor's JSON shape. Each implementation owns its own endpoint, auth, request
|
|
8
|
+
* translation, tool-schema dialect, and response decoding.
|
|
9
|
+
*
|
|
10
|
+
* TWO CONSUMERS share these implementations — that is why this package exists:
|
|
11
|
+
* - the cloud runtime's llm.service (platform-key path, metered);
|
|
12
|
+
* - the phone host's LocalMiniappRuntime (user-key path: a saved
|
|
13
|
+
* Settings > API Keys credential calls the vendor directly from the phone,
|
|
14
|
+
* never transiting Glassly Cloud).
|
|
15
|
+
*
|
|
16
|
+
* Because the second consumer is React Native (Hermes), everything here must
|
|
17
|
+
* stay RUNTIME-NEUTRAL: global `fetch` + JSON only — no Node APIs, no process
|
|
18
|
+
* env reads, no pino. Configuration (default model, logging) is injected via
|
|
19
|
+
* `LlmProviderOptions`; the runtime passes its env + pino logger, the phone
|
|
20
|
+
* passes its own.
|
|
21
|
+
*
|
|
22
|
+
* `apiKey` is passed per-call rather than read from config inside the provider,
|
|
23
|
+
* because a request may be paid for either by the platform key or by the
|
|
24
|
+
* caller's own key (Settings > API Keys). The caller decides which; the
|
|
25
|
+
* provider just uses what it is handed.
|
|
26
|
+
*/
|
|
27
|
+
import type { LlmCompleteRequest, LlmCompleteResult } from "@glassly/cloud-protocol/llm";
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Minimal structured-logging surface, shaped so a pino child logger satisfies
|
|
31
|
+
* it directly (`logger.error(fields, msg)`). Omit for silence.
|
|
32
|
+
*/
|
|
33
|
+
export interface LlmProviderLogger {
|
|
34
|
+
error(fields: Record<string, unknown>, message: string): void;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface LlmProviderOptions {
|
|
38
|
+
/** Model used when a request omits `model`. Falls back to a built-in default. */
|
|
39
|
+
defaultModel?: string;
|
|
40
|
+
/** Structured logger; silent when omitted. */
|
|
41
|
+
logger?: LlmProviderLogger;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface LlmProviderCompleteArgs extends LlmCompleteRequest {
|
|
45
|
+
/** Resolved credential — platform key or the caller's own. Never empty. */
|
|
46
|
+
apiKey: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface LlmProviderImpl {
|
|
50
|
+
/** Diagnostic label, e.g. "anthropic". */
|
|
51
|
+
readonly name: string;
|
|
52
|
+
|
|
53
|
+
/** The model used when a request omits `model`. */
|
|
54
|
+
readonly defaultModel: string;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Run one completion turn. Returns neutral types including token usage.
|
|
58
|
+
* Throws on transport/auth failure; a refusal or a tool-call pause is a
|
|
59
|
+
* SUCCESSFUL result with the corresponding `stopReason`, not an error.
|
|
60
|
+
*/
|
|
61
|
+
complete(args: LlmProviderCompleteArgs): Promise<Omit<LlmCompleteResult, "billedToUserKey">>;
|
|
62
|
+
}
|