@startsimpli/llm 0.4.13 → 0.4.15
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 +1 -1
- package/src/__tests__/agent.test.ts +74 -1
- package/src/__tests__/anthropic-provider.test.ts +171 -0
- package/src/agent/agent.ts +68 -0
- package/src/index.ts +9 -1
- package/src/providers/anthropic.ts +138 -42
- package/src/providers/interface.ts +21 -1
- package/src/providers/openai.ts +162 -1
- package/src/types.ts +46 -2
package/package.json
CHANGED
|
@@ -6,7 +6,7 @@ import { describe, it, expect, vi } from 'vitest';
|
|
|
6
6
|
import { runAgentTurn, buildToolRegistry } from '../agent/agent';
|
|
7
7
|
import type { ToolProvider } from '../agent/types';
|
|
8
8
|
import type { ILLMProvider } from '../providers/interface';
|
|
9
|
-
import type { LLMResponse } from '../types';
|
|
9
|
+
import type { LLMResponse, NativeToolResponse } from '../types';
|
|
10
10
|
|
|
11
11
|
/** Returns the queued responses in order; repeats the last once exhausted. */
|
|
12
12
|
class QueuedProvider implements ILLMProvider {
|
|
@@ -111,3 +111,76 @@ describe('runAgentTurn', () => {
|
|
|
111
111
|
expect(() => buildToolRegistry([a, b])).toThrow(/duplicate tool name 'dup'/);
|
|
112
112
|
});
|
|
113
113
|
});
|
|
114
|
+
|
|
115
|
+
/** Provider that supports NATIVE tool-calling: returns queued {content, toolCalls}. */
|
|
116
|
+
class NativeQueuedProvider implements ILLMProvider {
|
|
117
|
+
readonly name = 'native';
|
|
118
|
+
readonly availableModels = ['n'] as const;
|
|
119
|
+
readonly defaultModel = 'n';
|
|
120
|
+
private i = 0;
|
|
121
|
+
constructor(private readonly turns: NativeToolResponse[]) {}
|
|
122
|
+
async generate(): Promise<LLMResponse> {
|
|
123
|
+
return { content: '', usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 }, model: 'n' };
|
|
124
|
+
}
|
|
125
|
+
async generateWithTools(): Promise<NativeToolResponse> {
|
|
126
|
+
const t = this.turns[Math.min(this.i, this.turns.length - 1)];
|
|
127
|
+
this.i += 1;
|
|
128
|
+
return t;
|
|
129
|
+
}
|
|
130
|
+
isAvailable() {
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
describe('runAgentTurn (native tool-calling)', () => {
|
|
136
|
+
it('uses the native loop, dispatches the structured call, returns plain-text final', async () => {
|
|
137
|
+
const handler = vi.fn(async (input: { types: string[] }) => ({ saved: input.types.length }));
|
|
138
|
+
const provider = new NativeQueuedProvider([
|
|
139
|
+
{ content: null, toolCalls: [{ id: 'c1', name: 'save_schema', arguments: { types: ['Creator', 'Campaign'] } }] },
|
|
140
|
+
{ content: 'All set — I created Creator and Campaign.', toolCalls: [] },
|
|
141
|
+
]);
|
|
142
|
+
const foundry: ToolProvider = {
|
|
143
|
+
name: 'foundry',
|
|
144
|
+
tools: [{ name: 'save_schema', description: 'persist', inputSchema: { type: 'object' }, handler }],
|
|
145
|
+
};
|
|
146
|
+
const res = await runAgentTurn({
|
|
147
|
+
provider,
|
|
148
|
+
toolProviders: [foundry],
|
|
149
|
+
messages: msgs('track creators and campaigns'),
|
|
150
|
+
context: { tenant: 'x' },
|
|
151
|
+
});
|
|
152
|
+
expect(handler).toHaveBeenCalledWith({ types: ['Creator', 'Campaign'] }, { tenant: 'x' });
|
|
153
|
+
expect(res.toolCalls).toEqual([
|
|
154
|
+
{ tool: 'save_schema', input: { types: ['Creator', 'Campaign'] }, output: { saved: 2 } },
|
|
155
|
+
]);
|
|
156
|
+
// The final is the model's REAL text — no JSON echo, no synthesized fallback needed.
|
|
157
|
+
expect(res.finalText).toBe('All set — I created Creator and Campaign.');
|
|
158
|
+
expect(res.steps).toBe(2);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('does NOT retry a satisfied call (unlike the forced-json string protocol)', async () => {
|
|
162
|
+
const handler = vi.fn(async () => ({ ok: true }));
|
|
163
|
+
const provider = new NativeQueuedProvider([
|
|
164
|
+
{ content: null, toolCalls: [{ id: 'c1', name: 'save', arguments: {} }] },
|
|
165
|
+
{ content: 'Done.', toolCalls: [] },
|
|
166
|
+
]);
|
|
167
|
+
const p: ToolProvider = { name: 'p', tools: [{ name: 'save', description: 'd', handler }] };
|
|
168
|
+
const res = await runAgentTurn({ provider, toolProviders: [p], messages: msgs('go') });
|
|
169
|
+
expect(handler).toHaveBeenCalledTimes(1); // one clean call, not N retries
|
|
170
|
+
expect(res.finalText).toBe('Done.');
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('captures a handler error in the native loop', async () => {
|
|
174
|
+
const provider = new NativeQueuedProvider([
|
|
175
|
+
{ content: null, toolCalls: [{ id: 'c1', name: 'boom', arguments: {} }] },
|
|
176
|
+
{ content: 'handled', toolCalls: [] },
|
|
177
|
+
]);
|
|
178
|
+
const p: ToolProvider = {
|
|
179
|
+
name: 'p',
|
|
180
|
+
tools: [{ name: 'boom', description: 'd', handler: async () => { throw new Error('db down'); } }],
|
|
181
|
+
};
|
|
182
|
+
const res = await runAgentTurn({ provider, toolProviders: [p], messages: msgs('go') });
|
|
183
|
+
expect(res.toolCalls[0].error).toBe('db down');
|
|
184
|
+
expect(res.finalText).toBe('handled');
|
|
185
|
+
});
|
|
186
|
+
});
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the Anthropic provider actually PUTS ON THE WIRE (bd startsim-768w.38).
|
|
3
|
+
*
|
|
4
|
+
* These assert the request body, not the response, because the bug they exist
|
|
5
|
+
* for was a 400 from a parameter we sent — `temperature` is rejected outright by
|
|
6
|
+
* the Claude 5 family, so a provider that always sends it can never call the
|
|
7
|
+
* models we want to be on.
|
|
8
|
+
*
|
|
9
|
+
* The acceptance boundary below is not invented. It was measured against
|
|
10
|
+
* api.anthropic.com with a real key on 2026-08-10:
|
|
11
|
+
*
|
|
12
|
+
* claude-sonnet-5 temperature -> 400 `temperature` is deprecated for this model.
|
|
13
|
+
* claude-opus-5 temperature -> 400 (same; also top_p, top_k)
|
|
14
|
+
* claude-fable-5 temperature -> 400
|
|
15
|
+
* claude-opus-4-8 temperature -> 400
|
|
16
|
+
* claude-opus-4-7 temperature -> 400
|
|
17
|
+
* claude-opus-4-6 temperature -> 200
|
|
18
|
+
* claude-sonnet-4-6 temperature -> 200
|
|
19
|
+
* claude-sonnet-4-5-* temperature -> 200
|
|
20
|
+
*/
|
|
21
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
22
|
+
|
|
23
|
+
import { AnthropicProvider } from '../providers/anthropic';
|
|
24
|
+
import { LLMProviderError } from '../errors';
|
|
25
|
+
|
|
26
|
+
interface Sent {
|
|
27
|
+
model: string;
|
|
28
|
+
temperature?: number;
|
|
29
|
+
top_p?: number;
|
|
30
|
+
max_tokens: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const OK_BODY = {
|
|
34
|
+
id: 'msg_1',
|
|
35
|
+
type: 'message',
|
|
36
|
+
role: 'assistant',
|
|
37
|
+
content: [{ type: 'text', text: '{"ok":true}' }],
|
|
38
|
+
model: 'server-reported-model',
|
|
39
|
+
stop_reason: 'end_turn',
|
|
40
|
+
usage: { input_tokens: 10, output_tokens: 4 },
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
function ok() {
|
|
44
|
+
return {
|
|
45
|
+
ok: true,
|
|
46
|
+
status: 200,
|
|
47
|
+
json: async () => OK_BODY,
|
|
48
|
+
} as unknown as Response;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function deprecated(param: string) {
|
|
52
|
+
return {
|
|
53
|
+
ok: false,
|
|
54
|
+
status: 400,
|
|
55
|
+
json: async () => ({
|
|
56
|
+
type: 'error',
|
|
57
|
+
error: {
|
|
58
|
+
type: 'invalid_request_error',
|
|
59
|
+
message: `\`${param}\` is deprecated for this model.`,
|
|
60
|
+
},
|
|
61
|
+
}),
|
|
62
|
+
} as unknown as Response;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
let sent: Sent[];
|
|
66
|
+
let fetchMock: ReturnType<typeof vi.fn>;
|
|
67
|
+
|
|
68
|
+
function provider(defaultModel: string) {
|
|
69
|
+
return new AnthropicProvider({ apiKey: 'sk-ant-test', defaultModel, defaultTemperature: 0.2 });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
beforeEach(() => {
|
|
73
|
+
sent = [];
|
|
74
|
+
fetchMock = vi.fn(async (_url: unknown, init: RequestInit) => {
|
|
75
|
+
sent.push(JSON.parse(String(init.body)) as Sent);
|
|
76
|
+
return ok();
|
|
77
|
+
});
|
|
78
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
afterEach(() => {
|
|
82
|
+
vi.unstubAllGlobals();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe('AnthropicProvider request body — sampling parameters', () => {
|
|
86
|
+
it('omits temperature for a Claude 5 model that rejects it', async () => {
|
|
87
|
+
await provider('claude-sonnet-5').generate('hi', 'sys', { temperature: 0.2 });
|
|
88
|
+
|
|
89
|
+
expect(sent).toHaveLength(1);
|
|
90
|
+
expect(sent[0].model).toBe('claude-sonnet-5');
|
|
91
|
+
expect(sent[0]).not.toHaveProperty('temperature');
|
|
92
|
+
// The parameters that ARE required still go, or the call is malformed.
|
|
93
|
+
expect(sent[0].max_tokens).toBeGreaterThan(0);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it.each(['claude-opus-5', 'claude-fable-5', 'claude-opus-4-8', 'claude-opus-4-7'])(
|
|
97
|
+
'omits temperature for %s',
|
|
98
|
+
async (model) => {
|
|
99
|
+
await provider(model).generate('hi', 'sys', { temperature: 0.2 });
|
|
100
|
+
expect(sent[0]).not.toHaveProperty('temperature');
|
|
101
|
+
},
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
it('still sends temperature for a 4.6-era model, which accepts it', async () => {
|
|
105
|
+
await provider('claude-sonnet-4-6').generate('hi', 'sys', { temperature: 0.2 });
|
|
106
|
+
|
|
107
|
+
expect(sent[0].model).toBe('claude-sonnet-4-6');
|
|
108
|
+
expect(sent[0].temperature).toBe(0.2);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it.each(['claude-opus-4-6', 'claude-sonnet-4-5-20250929', 'claude-haiku-4-5-20251001'])(
|
|
112
|
+
'still sends temperature for %s',
|
|
113
|
+
async (model) => {
|
|
114
|
+
await provider(model).generate('hi', 'sys', { temperature: 0.35 });
|
|
115
|
+
expect(sent[0].temperature).toBe(0.35);
|
|
116
|
+
},
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
it('omits temperature for a model id it has never heard of', async () => {
|
|
120
|
+
// The whole point of the allowlist shape: an id that does not exist yet gets
|
|
121
|
+
// the request that CANNOT 400, not the one that historically could.
|
|
122
|
+
await provider('claude-sonnet-7-hypothetical').generate('hi', 'sys', { temperature: 0.2 });
|
|
123
|
+
expect(sent[0]).not.toHaveProperty('temperature');
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('names a model that is actually callable as the package default', async () => {
|
|
127
|
+
// The old default, claude-sonnet-4-20250514, was retired and 404s. A default
|
|
128
|
+
// nobody can call is a broken default.
|
|
129
|
+
const p = new AnthropicProvider({ apiKey: 'sk-ant-test' });
|
|
130
|
+
await p.generate('hi', 'sys');
|
|
131
|
+
expect(sent[0].model).toBe(p.defaultModel);
|
|
132
|
+
expect(p.availableModels).toContain(p.defaultModel);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
describe('AnthropicProvider — recovering from a parameter the model rejects', () => {
|
|
137
|
+
it('drops the named parameter and retries once when the API calls it deprecated', async () => {
|
|
138
|
+
// A model on the allowlist that the API has since changed its mind about:
|
|
139
|
+
// the safety net must un-break it without waiting for a release.
|
|
140
|
+
fetchMock.mockImplementationOnce(async (_url: unknown, init: RequestInit) => {
|
|
141
|
+
sent.push(JSON.parse(String(init.body)) as Sent);
|
|
142
|
+
return deprecated('temperature');
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
const response = await provider('claude-opus-4-6').generate('hi', 'sys', { temperature: 0.2 });
|
|
146
|
+
|
|
147
|
+
expect(sent).toHaveLength(2);
|
|
148
|
+
expect(sent[0].temperature).toBe(0.2);
|
|
149
|
+
expect(sent[1]).not.toHaveProperty('temperature');
|
|
150
|
+
expect(response.content).toBe('{"ok":true}');
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('does not retry a 400 that is not about a deprecated parameter', async () => {
|
|
154
|
+
fetchMock.mockImplementation(async (_url: unknown, init: RequestInit) => {
|
|
155
|
+
sent.push(JSON.parse(String(init.body)) as Sent);
|
|
156
|
+
return {
|
|
157
|
+
ok: false,
|
|
158
|
+
status: 400,
|
|
159
|
+
json: async () => ({
|
|
160
|
+
type: 'error',
|
|
161
|
+
error: { type: 'invalid_request_error', message: 'messages: at least one message required' },
|
|
162
|
+
}),
|
|
163
|
+
} as unknown as Response;
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
await expect(provider('claude-sonnet-4-6').generate('hi', 'sys')).rejects.toBeInstanceOf(
|
|
167
|
+
LLMProviderError,
|
|
168
|
+
);
|
|
169
|
+
expect(sent).toHaveLength(1);
|
|
170
|
+
});
|
|
171
|
+
});
|
package/src/agent/agent.ts
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* changing the ToolProvider API.
|
|
11
11
|
*/
|
|
12
12
|
import { extractJson } from '../utils/json-extraction';
|
|
13
|
+
import type { NativeChatMessage, NativeToolSpec } from '../types';
|
|
13
14
|
import type {
|
|
14
15
|
AgentMessage,
|
|
15
16
|
AgentToolCall,
|
|
@@ -75,9 +76,76 @@ function parseToolCall(content: string): ParsedToolCall | null {
|
|
|
75
76
|
return { tool: obj.tool, input: 'input' in obj ? obj.input : {} };
|
|
76
77
|
}
|
|
77
78
|
|
|
79
|
+
/** Native (structured) tool loop for providers that implement generateWithTools.
|
|
80
|
+
* Tool calls come back structured (no JSON-in-text parsing) and finals are real
|
|
81
|
+
* text, so there's no ambiguity and the model doesn't re-emit a satisfied call. */
|
|
82
|
+
async function runNativeToolLoop(
|
|
83
|
+
opts: RunAgentOptions,
|
|
84
|
+
registry: Map<string, ToolDefinition>,
|
|
85
|
+
): Promise<AgentTurnResult> {
|
|
86
|
+
const { provider, context = {}, maxSteps = 6, generateOptions } = opts;
|
|
87
|
+
const system = opts.systemPrompt?.trim() ?? '';
|
|
88
|
+
const toolSpecs: NativeToolSpec[] = [...registry.values()].map((t) => ({
|
|
89
|
+
name: t.name,
|
|
90
|
+
description: t.description,
|
|
91
|
+
parameters: t.inputSchema ?? { type: 'object', properties: {} },
|
|
92
|
+
}));
|
|
93
|
+
|
|
94
|
+
const messages: NativeChatMessage[] = opts.messages.map((m) => ({
|
|
95
|
+
role: m.role === 'tool' ? 'tool' : m.role,
|
|
96
|
+
content: m.content,
|
|
97
|
+
}));
|
|
98
|
+
const toolCalls: AgentToolCall[] = [];
|
|
99
|
+
const toAgent = (): AgentMessage[] =>
|
|
100
|
+
messages.map((m) => ({ role: m.role, content: m.content ?? '' }));
|
|
101
|
+
|
|
102
|
+
for (let step = 1; step <= maxSteps; step++) {
|
|
103
|
+
const resp = await provider.generateWithTools!(messages, system, toolSpecs, generateOptions);
|
|
104
|
+
if (!resp.toolCalls.length) {
|
|
105
|
+
messages.push({ role: 'assistant', content: resp.content ?? '' });
|
|
106
|
+
return { finalText: resp.content ?? '', toolCalls, messages: toAgent(), steps: step, stoppedReason: 'final' };
|
|
107
|
+
}
|
|
108
|
+
messages.push({ role: 'assistant', content: resp.content, toolCalls: resp.toolCalls });
|
|
109
|
+
for (const call of resp.toolCalls) {
|
|
110
|
+
const record: AgentToolCall = { tool: call.name, input: call.arguments };
|
|
111
|
+
const tool = registry.get(call.name);
|
|
112
|
+
if (!tool) {
|
|
113
|
+
record.error = `unknown tool '${call.name}'`;
|
|
114
|
+
messages.push({ role: 'tool', toolCallId: call.id, content: `ERROR: ${record.error}` });
|
|
115
|
+
} else {
|
|
116
|
+
try {
|
|
117
|
+
const output = await tool.handler(call.arguments, context);
|
|
118
|
+
record.output = output;
|
|
119
|
+
messages.push({ role: 'tool', toolCallId: call.id, content: JSON.stringify(output ?? null) });
|
|
120
|
+
} catch (err) {
|
|
121
|
+
record.error = err instanceof Error ? err.message : String(err);
|
|
122
|
+
messages.push({ role: 'tool', toolCallId: call.id, content: `ERROR: ${record.error}` });
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
toolCalls.push(record);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Budget exhausted — ask for a plain-text wrap-up (tool_choice stays 'auto').
|
|
130
|
+
const resp = await provider.generateWithTools!(
|
|
131
|
+
messages,
|
|
132
|
+
`${system}\n\nYou have reached the tool-call limit. Reply to the user now in plain text.`,
|
|
133
|
+
toolSpecs,
|
|
134
|
+
generateOptions,
|
|
135
|
+
);
|
|
136
|
+
messages.push({ role: 'assistant', content: resp.content ?? '' });
|
|
137
|
+
return { finalText: resp.content ?? '', toolCalls, messages: toAgent(), steps: maxSteps, stoppedReason: 'max_steps' };
|
|
138
|
+
}
|
|
139
|
+
|
|
78
140
|
export async function runAgentTurn(opts: RunAgentOptions): Promise<AgentTurnResult> {
|
|
79
141
|
const { provider, toolProviders, context = {}, maxSteps = 6, generateOptions } = opts;
|
|
80
142
|
const registry = buildToolRegistry(toolProviders);
|
|
143
|
+
|
|
144
|
+
// Prefer native structured tool-calling when the provider supports it.
|
|
145
|
+
if (typeof provider.generateWithTools === 'function' && registry.size > 0) {
|
|
146
|
+
return runNativeToolLoop(opts, registry);
|
|
147
|
+
}
|
|
148
|
+
|
|
81
149
|
const protocol = toolProtocolPrompt(registry);
|
|
82
150
|
const baseSystem = [opts.systemPrompt?.trim(), protocol].filter(Boolean).join('\n\n');
|
|
83
151
|
|
package/src/index.ts
CHANGED
|
@@ -11,6 +11,10 @@ export type {
|
|
|
11
11
|
JsonExtractionResult,
|
|
12
12
|
ValidationResult,
|
|
13
13
|
LLMServiceConfig,
|
|
14
|
+
NativeToolSpec,
|
|
15
|
+
NativeToolCall,
|
|
16
|
+
NativeChatMessage,
|
|
17
|
+
NativeToolResponse,
|
|
14
18
|
} from './types';
|
|
15
19
|
export { LLM_DEFAULTS } from './types';
|
|
16
20
|
|
|
@@ -19,7 +23,11 @@ export type { ILLMProvider } from './providers/interface';
|
|
|
19
23
|
|
|
20
24
|
// Provider implementations
|
|
21
25
|
export { OpenAIProvider, createOpenAIProvider } from './providers/openai';
|
|
22
|
-
export {
|
|
26
|
+
export {
|
|
27
|
+
AnthropicProvider,
|
|
28
|
+
createAnthropicProvider,
|
|
29
|
+
supportsSamplingParams,
|
|
30
|
+
} from './providers/anthropic';
|
|
23
31
|
export { MockLLMProvider, setMockBehavior, resetMockBehavior } from './providers/mock';
|
|
24
32
|
export type { MockBehavior } from './providers/mock';
|
|
25
33
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Anthropic Provider Implementation
|
|
3
3
|
*
|
|
4
4
|
* Uses native fetch to minimize dependencies.
|
|
5
|
-
* Supports Claude
|
|
5
|
+
* Supports the Claude 5 family and the Claude 4.x models still served.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import type { ILLMProvider } from './interface';
|
|
@@ -21,6 +21,10 @@ interface AnthropicRequest {
|
|
|
21
21
|
max_tokens: number;
|
|
22
22
|
messages: AnthropicMessage[];
|
|
23
23
|
system?: string;
|
|
24
|
+
/**
|
|
25
|
+
* Sent ONLY for models known to accept it — see `supportsSamplingParams`.
|
|
26
|
+
* The Claude 5 family rejects it outright.
|
|
27
|
+
*/
|
|
24
28
|
temperature?: number;
|
|
25
29
|
}
|
|
26
30
|
|
|
@@ -45,30 +49,94 @@ interface AnthropicErrorResponse {
|
|
|
45
49
|
};
|
|
46
50
|
}
|
|
47
51
|
|
|
52
|
+
/**
|
|
53
|
+
* The models this provider advertises. Verified callable against
|
|
54
|
+
* api.anthropic.com on 2026-08-10 — every id in the previous version of this
|
|
55
|
+
* list except the two 4.6 entries had been retired and returned 404, which is
|
|
56
|
+
* the same staleness failure the temperature handling below is designed around.
|
|
57
|
+
*/
|
|
48
58
|
const ANTHROPIC_MODELS = [
|
|
59
|
+
'claude-opus-5',
|
|
60
|
+
'claude-sonnet-5',
|
|
61
|
+
'claude-fable-5',
|
|
62
|
+
'claude-opus-4-8',
|
|
63
|
+
'claude-opus-4-7',
|
|
49
64
|
'claude-opus-4-6',
|
|
50
|
-
'claude-opus-4-20250514',
|
|
51
65
|
'claude-sonnet-4-6',
|
|
52
|
-
'claude-
|
|
53
|
-
'claude-
|
|
54
|
-
'claude-
|
|
55
|
-
'claude-3-opus-20240229',
|
|
56
|
-
'claude-3-sonnet-20240229',
|
|
57
|
-
'claude-3-haiku-20240307',
|
|
66
|
+
'claude-opus-4-5-20251101',
|
|
67
|
+
'claude-sonnet-4-5-20250929',
|
|
68
|
+
'claude-haiku-4-5-20251001',
|
|
58
69
|
] as const;
|
|
59
70
|
|
|
71
|
+
/** USD per 1K tokens (list prices per million, divided by 1000). */
|
|
60
72
|
const MODEL_COSTS: Record<string, { input: number; output: number }> = {
|
|
61
|
-
'claude-
|
|
62
|
-
'claude-opus-
|
|
73
|
+
'claude-fable-5': { input: 0.01, output: 0.05 },
|
|
74
|
+
'claude-opus-5': { input: 0.005, output: 0.025 },
|
|
75
|
+
'claude-opus-4-8': { input: 0.005, output: 0.025 },
|
|
76
|
+
'claude-opus-4-7': { input: 0.005, output: 0.025 },
|
|
77
|
+
'claude-opus-4-6': { input: 0.005, output: 0.025 },
|
|
78
|
+
'claude-opus-4-5-20251101': { input: 0.005, output: 0.025 },
|
|
79
|
+
'claude-sonnet-5': { input: 0.003, output: 0.015 },
|
|
63
80
|
'claude-sonnet-4-6': { input: 0.003, output: 0.015 },
|
|
64
|
-
'claude-sonnet-4-
|
|
65
|
-
'claude-
|
|
66
|
-
'claude-3-5-haiku-20241022': { input: 0.0008, output: 0.004 },
|
|
67
|
-
'claude-3-opus-20240229': { input: 0.015, output: 0.075 },
|
|
68
|
-
'claude-3-sonnet-20240229': { input: 0.003, output: 0.015 },
|
|
69
|
-
'claude-3-haiku-20240307': { input: 0.00025, output: 0.00125 },
|
|
81
|
+
'claude-sonnet-4-5-20250929': { input: 0.003, output: 0.015 },
|
|
82
|
+
'claude-haiku-4-5-20251001': { input: 0.001, output: 0.005 },
|
|
70
83
|
};
|
|
71
84
|
|
|
85
|
+
/**
|
|
86
|
+
* Model-id prefixes KNOWN to accept the sampling parameters (`temperature`,
|
|
87
|
+
* `top_p`, `top_k`). Anything not on this list is sent WITHOUT them.
|
|
88
|
+
*
|
|
89
|
+
* WHY AN ALLOWLIST, NOT A LIST OF MODELS THAT REJECT IT. A deny-list of literal
|
|
90
|
+
* ids — the shape `OpenAIProvider` uses for its `gpt-5` temperature=1 special
|
|
91
|
+
* case — is correct exactly until the next model ships, and then it silently
|
|
92
|
+
* sends a parameter the API refuses. That is not hypothetical: it is the bug
|
|
93
|
+
* this list exists to fix, and the failure is total (every call 400s) rather
|
|
94
|
+
* than degraded. Inverting it makes an unknown id — i.e. every future model —
|
|
95
|
+
* get the request that CANNOT 400. The two error cases are not symmetric:
|
|
96
|
+
* omitting temperature from a model that would have accepted it costs some
|
|
97
|
+
* determinism; sending it to a model that rejects it costs the whole feature.
|
|
98
|
+
*
|
|
99
|
+
* WHY NOT A VERSION COMPARISON. Measured against api.anthropic.com 2026-08-10:
|
|
100
|
+
* 4.5 and 4.6 accept `temperature`; 4.7, 4.8 and the entire 5 family return
|
|
101
|
+
* `400 \`temperature\` is deprecated for this model.` The boundary sits between
|
|
102
|
+
* 4.6 and 4.7, which no plausible "removed in v5" or "removed in 4.8" rule
|
|
103
|
+
* predicts — so this is recorded from observation, not derived from a guess.
|
|
104
|
+
*
|
|
105
|
+
* The list is also closed in the direction that matters: it can only ever go
|
|
106
|
+
* stale by omitting a NEW model that would have accepted the parameter, never
|
|
107
|
+
* by naming one that rejects it. Should that happen, the retry in `generate()`
|
|
108
|
+
* covers it without a release.
|
|
109
|
+
*/
|
|
110
|
+
const SAMPLING_PARAM_MODEL_PREFIXES = [
|
|
111
|
+
'claude-3',
|
|
112
|
+
'claude-opus-4-5',
|
|
113
|
+
'claude-opus-4-6',
|
|
114
|
+
'claude-sonnet-4-5',
|
|
115
|
+
'claude-sonnet-4-6',
|
|
116
|
+
'claude-haiku-4-5',
|
|
117
|
+
'claude-haiku-4-6',
|
|
118
|
+
'claude-opus-4-2025',
|
|
119
|
+
'claude-sonnet-4-2025',
|
|
120
|
+
'claude-opus-4-1',
|
|
121
|
+
] as const;
|
|
122
|
+
|
|
123
|
+
/** True when `model` is known to accept `temperature` / `top_p` / `top_k`. */
|
|
124
|
+
export function supportsSamplingParams(model: string): boolean {
|
|
125
|
+
const id = model.trim().toLowerCase();
|
|
126
|
+
return SAMPLING_PARAM_MODEL_PREFIXES.some((prefix) => id.startsWith(prefix));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The parameter named by a "deprecated for this model" 400, if that is what the
|
|
131
|
+
* error is. Anthropic's message is `` `temperature` is deprecated for this model. ``
|
|
132
|
+
* and the same wording covers `top_p` and `top_k`, so one matcher handles every
|
|
133
|
+
* sampling parameter — including ones not yet deprecated when this was written.
|
|
134
|
+
*/
|
|
135
|
+
export function deprecatedParameter(message: string): string | null {
|
|
136
|
+
const match = /`([a-z_]+)`\s+is\s+deprecated\s+for\s+this\s+model/i.exec(message);
|
|
137
|
+
return match ? match[1] : null;
|
|
138
|
+
}
|
|
139
|
+
|
|
72
140
|
const DEFAULTS = {
|
|
73
141
|
baseUrl: 'https://api.anthropic.com/v1',
|
|
74
142
|
model: LLM_DEFAULTS.anthropicModel,
|
|
@@ -133,35 +201,32 @@ Please respond with a valid JSON object only. Do not include any text before or
|
|
|
133
201
|
max_tokens: maxTokens,
|
|
134
202
|
messages: [{ role: 'user', content: jsonWrappedPrompt }],
|
|
135
203
|
system: systemPrompt,
|
|
136
|
-
temperature,
|
|
137
204
|
};
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
205
|
+
// Only for models known to take it. The Claude 5 family answers a request
|
|
206
|
+
// carrying `temperature` with a 400 and nothing else.
|
|
207
|
+
if (supportsSamplingParams(model)) {
|
|
208
|
+
requestBody.temperature = temperature;
|
|
209
|
+
}
|
|
141
210
|
|
|
142
211
|
try {
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
}))) as AnthropicErrorResponse;
|
|
160
|
-
throw this.handleApiError(response.status, errorData);
|
|
212
|
+
let data: AnthropicResponse;
|
|
213
|
+
try {
|
|
214
|
+
data = await this.post(requestBody, timeout);
|
|
215
|
+
} catch (error) {
|
|
216
|
+
// SAFETY NET for a parameter the API has since stopped accepting on a
|
|
217
|
+
// model the allowlist still vouches for. It also covers parameters this
|
|
218
|
+
// provider does not send today (`top_p`, `top_k`), so adding one later
|
|
219
|
+
// cannot reintroduce this failure. One retry, only when the API itself
|
|
220
|
+
// named the offending key — never a blind resend.
|
|
221
|
+
const param =
|
|
222
|
+
error instanceof LLMProviderError && error.statusCode === 400
|
|
223
|
+
? deprecatedParameter(error.message)
|
|
224
|
+
: null;
|
|
225
|
+
if (!param || !(param in requestBody)) throw error;
|
|
226
|
+
delete (requestBody as unknown as Record<string, unknown>)[param];
|
|
227
|
+
data = await this.post(requestBody, timeout);
|
|
161
228
|
}
|
|
162
229
|
|
|
163
|
-
const data = (await response.json()) as AnthropicResponse;
|
|
164
|
-
|
|
165
230
|
const textContent = data.content.find((c) => c.type === 'text');
|
|
166
231
|
if (!textContent?.text) {
|
|
167
232
|
throw new LLMProviderError(
|
|
@@ -185,8 +250,6 @@ Please respond with a valid JSON object only. Do not include any text before or
|
|
|
185
250
|
finishReason: data.stop_reason ?? undefined,
|
|
186
251
|
};
|
|
187
252
|
} catch (error) {
|
|
188
|
-
clearTimeout(timeoutId);
|
|
189
|
-
|
|
190
253
|
if (error instanceof LLMProviderError) {
|
|
191
254
|
throw error;
|
|
192
255
|
}
|
|
@@ -211,6 +274,39 @@ Please respond with a valid JSON object only. Do not include any text before or
|
|
|
211
274
|
}
|
|
212
275
|
}
|
|
213
276
|
|
|
277
|
+
/**
|
|
278
|
+
* One POST to /messages. Each attempt gets its own timeout — a retry that
|
|
279
|
+
* strips a rejected parameter is a fresh request, and inheriting an
|
|
280
|
+
* already-consumed deadline would abort it before it could succeed.
|
|
281
|
+
*/
|
|
282
|
+
private async post(body: AnthropicRequest, timeout: number): Promise<AnthropicResponse> {
|
|
283
|
+
const controller = new AbortController();
|
|
284
|
+
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
|
285
|
+
try {
|
|
286
|
+
const response = await fetch(`${this.config.baseUrl}/messages`, {
|
|
287
|
+
method: 'POST',
|
|
288
|
+
headers: {
|
|
289
|
+
'Content-Type': 'application/json',
|
|
290
|
+
'x-api-key': this.config.apiKey,
|
|
291
|
+
'anthropic-version': this.apiVersion,
|
|
292
|
+
},
|
|
293
|
+
body: JSON.stringify(body),
|
|
294
|
+
signal: controller.signal,
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
if (!response.ok) {
|
|
298
|
+
const errorData = (await response.json().catch(() => ({
|
|
299
|
+
error: { type: 'unknown', message: 'Unknown error' },
|
|
300
|
+
}))) as AnthropicErrorResponse;
|
|
301
|
+
throw this.handleApiError(response.status, errorData);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
return (await response.json()) as AnthropicResponse;
|
|
305
|
+
} finally {
|
|
306
|
+
clearTimeout(timeoutId);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
214
310
|
estimateCost(
|
|
215
311
|
promptTokens: number,
|
|
216
312
|
completionTokens: number,
|
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
LLMResponse,
|
|
3
|
+
GenerateOptions,
|
|
4
|
+
NativeToolSpec,
|
|
5
|
+
NativeChatMessage,
|
|
6
|
+
NativeToolResponse,
|
|
7
|
+
} from '../types';
|
|
2
8
|
|
|
3
9
|
/**
|
|
4
10
|
* Contract that every LLM provider must implement.
|
|
@@ -29,6 +35,20 @@ export interface ILLMProvider {
|
|
|
29
35
|
options?: GenerateOptions
|
|
30
36
|
): Promise<LLMResponse>;
|
|
31
37
|
|
|
38
|
+
/**
|
|
39
|
+
* Optional NATIVE (structured) tool-calling. Providers backed by a real tools
|
|
40
|
+
* API (OpenAI functions, Anthropic tool blocks) implement this so the agent
|
|
41
|
+
* harness can use structured tool calls + plain-text finals instead of the
|
|
42
|
+
* fragile JSON-in-text protocol. If omitted, the harness falls back to the
|
|
43
|
+
* string protocol — so existing providers (Mock, etc.) keep working unchanged.
|
|
44
|
+
*/
|
|
45
|
+
generateWithTools?(
|
|
46
|
+
messages: NativeChatMessage[],
|
|
47
|
+
systemPrompt: string,
|
|
48
|
+
tools: NativeToolSpec[],
|
|
49
|
+
options?: GenerateOptions
|
|
50
|
+
): Promise<NativeToolResponse>;
|
|
51
|
+
|
|
32
52
|
/** Check if the provider is properly configured and available */
|
|
33
53
|
isAvailable(): boolean;
|
|
34
54
|
|
package/src/providers/openai.ts
CHANGED
|
@@ -10,7 +10,14 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import type { ILLMProvider } from './interface';
|
|
13
|
-
import type {
|
|
13
|
+
import type {
|
|
14
|
+
LLMResponse,
|
|
15
|
+
GenerateOptions,
|
|
16
|
+
ProviderConfig,
|
|
17
|
+
NativeToolSpec,
|
|
18
|
+
NativeChatMessage,
|
|
19
|
+
NativeToolResponse,
|
|
20
|
+
} from '../types';
|
|
14
21
|
import { LLM_DEFAULTS } from '../types';
|
|
15
22
|
import { LLMProviderError } from '../errors';
|
|
16
23
|
import type { LLMErrorCode } from '../errors';
|
|
@@ -54,6 +61,30 @@ interface OpenAIErrorResponse {
|
|
|
54
61
|
};
|
|
55
62
|
}
|
|
56
63
|
|
|
64
|
+
interface OpenAIToolCall {
|
|
65
|
+
id: string;
|
|
66
|
+
type: 'function';
|
|
67
|
+
function: { name: string; arguments: string };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
interface OpenAIToolMessage {
|
|
71
|
+
role: 'system' | 'user' | 'assistant' | 'tool';
|
|
72
|
+
content: string | null;
|
|
73
|
+
tool_calls?: OpenAIToolCall[];
|
|
74
|
+
tool_call_id?: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
interface OpenAIToolsResponse {
|
|
78
|
+
id: string;
|
|
79
|
+
model: string;
|
|
80
|
+
choices: Array<{
|
|
81
|
+
index: number;
|
|
82
|
+
message: { role: string; content: string | null; tool_calls?: OpenAIToolCall[] };
|
|
83
|
+
finish_reason: string;
|
|
84
|
+
}>;
|
|
85
|
+
usage: { prompt_tokens: number; completion_tokens: number; total_tokens: number };
|
|
86
|
+
}
|
|
87
|
+
|
|
57
88
|
const OPENAI_MODELS = [
|
|
58
89
|
'gpt-5.2',
|
|
59
90
|
'gpt-5.1',
|
|
@@ -210,6 +241,136 @@ export class OpenAIProvider implements ILLMProvider {
|
|
|
210
241
|
}
|
|
211
242
|
}
|
|
212
243
|
|
|
244
|
+
/**
|
|
245
|
+
* Native tool-calling via OpenAI's `tools` API. Unlike generate(), this does
|
|
246
|
+
* NOT force response_format=json_object — so the model returns structured
|
|
247
|
+
* tool_calls when calling a tool and plain text when it's done, which the agent
|
|
248
|
+
* harness can tell apart unambiguously (no JSON-in-text parsing).
|
|
249
|
+
*/
|
|
250
|
+
async generateWithTools(
|
|
251
|
+
messages: NativeChatMessage[],
|
|
252
|
+
systemPrompt: string,
|
|
253
|
+
tools: NativeToolSpec[],
|
|
254
|
+
options?: GenerateOptions
|
|
255
|
+
): Promise<NativeToolResponse> {
|
|
256
|
+
if (!this.isAvailable()) {
|
|
257
|
+
throw new LLMProviderError(
|
|
258
|
+
'OpenAI API key not configured',
|
|
259
|
+
'AUTHENTICATION_ERROR',
|
|
260
|
+
false,
|
|
261
|
+
undefined,
|
|
262
|
+
this.name
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const model = options?.model ?? this.config.defaultModel;
|
|
267
|
+
const isBaseGpt5 = model === 'gpt-5' || model === 'gpt-5.1' || model === 'gpt-5.2';
|
|
268
|
+
const isFineTuned = model.startsWith('ft:');
|
|
269
|
+
const temperature = isBaseGpt5 && !isFineTuned
|
|
270
|
+
? 1.0
|
|
271
|
+
: (options?.temperature ?? this.config.defaultTemperature);
|
|
272
|
+
const maxTokens = options?.maxTokens ?? this.config.defaultMaxTokens;
|
|
273
|
+
const timeout = options?.timeout ?? this.config.timeoutMs;
|
|
274
|
+
|
|
275
|
+
const oaiMessages: OpenAIToolMessage[] = [
|
|
276
|
+
{ role: 'system', content: systemPrompt },
|
|
277
|
+
...messages.map((m): OpenAIToolMessage => {
|
|
278
|
+
if (m.role === 'tool') {
|
|
279
|
+
return { role: 'tool', content: m.content ?? '', tool_call_id: m.toolCallId };
|
|
280
|
+
}
|
|
281
|
+
if (m.role === 'assistant' && m.toolCalls?.length) {
|
|
282
|
+
return {
|
|
283
|
+
role: 'assistant',
|
|
284
|
+
content: m.content,
|
|
285
|
+
tool_calls: m.toolCalls.map((tc) => ({
|
|
286
|
+
id: tc.id,
|
|
287
|
+
type: 'function',
|
|
288
|
+
function: { name: tc.name, arguments: JSON.stringify(tc.arguments ?? {}) },
|
|
289
|
+
})),
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
return { role: m.role, content: m.content ?? '' };
|
|
293
|
+
}),
|
|
294
|
+
];
|
|
295
|
+
|
|
296
|
+
const requestBody = {
|
|
297
|
+
model,
|
|
298
|
+
messages: oaiMessages,
|
|
299
|
+
temperature,
|
|
300
|
+
max_tokens: maxTokens,
|
|
301
|
+
tools: tools.map((t) => ({
|
|
302
|
+
type: 'function' as const,
|
|
303
|
+
function: { name: t.name, description: t.description, parameters: t.parameters },
|
|
304
|
+
})),
|
|
305
|
+
tool_choice: 'auto' as const,
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
const controller = new AbortController();
|
|
309
|
+
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
|
310
|
+
try {
|
|
311
|
+
const response = await fetch(`${this.config.baseUrl}/chat/completions`, {
|
|
312
|
+
method: 'POST',
|
|
313
|
+
headers: {
|
|
314
|
+
'Content-Type': 'application/json',
|
|
315
|
+
Authorization: `Bearer ${this.config.apiKey}`,
|
|
316
|
+
},
|
|
317
|
+
body: JSON.stringify(requestBody),
|
|
318
|
+
signal: controller.signal,
|
|
319
|
+
});
|
|
320
|
+
clearTimeout(timeoutId);
|
|
321
|
+
|
|
322
|
+
if (!response.ok) {
|
|
323
|
+
const errorData = (await response.json().catch(() => ({
|
|
324
|
+
error: { message: 'Unknown error' },
|
|
325
|
+
}))) as OpenAIErrorResponse;
|
|
326
|
+
throw this.handleApiError(response.status, errorData);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const data = (await response.json()) as OpenAIToolsResponse;
|
|
330
|
+
const choice = data.choices?.[0];
|
|
331
|
+
const msg = choice?.message;
|
|
332
|
+
const toolCalls = (msg?.tool_calls ?? []).map((tc) => {
|
|
333
|
+
let args: unknown = {};
|
|
334
|
+
try {
|
|
335
|
+
args = tc.function.arguments ? JSON.parse(tc.function.arguments) : {};
|
|
336
|
+
} catch {
|
|
337
|
+
args = { _raw: tc.function.arguments };
|
|
338
|
+
}
|
|
339
|
+
return { id: tc.id, name: tc.function.name, arguments: args };
|
|
340
|
+
});
|
|
341
|
+
return {
|
|
342
|
+
content: msg?.content ?? null,
|
|
343
|
+
toolCalls,
|
|
344
|
+
usage: {
|
|
345
|
+
promptTokens: data.usage?.prompt_tokens ?? 0,
|
|
346
|
+
completionTokens: data.usage?.completion_tokens ?? 0,
|
|
347
|
+
totalTokens: data.usage?.total_tokens ?? 0,
|
|
348
|
+
},
|
|
349
|
+
model: data.model,
|
|
350
|
+
finishReason: choice?.finish_reason,
|
|
351
|
+
};
|
|
352
|
+
} catch (error) {
|
|
353
|
+
clearTimeout(timeoutId);
|
|
354
|
+
if (error instanceof LLMProviderError) throw error;
|
|
355
|
+
if (error instanceof Error && error.name === 'AbortError') {
|
|
356
|
+
throw new LLMProviderError(
|
|
357
|
+
`Request timed out after ${timeout}ms`,
|
|
358
|
+
'TIMEOUT',
|
|
359
|
+
true,
|
|
360
|
+
undefined,
|
|
361
|
+
this.name
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
throw new LLMProviderError(
|
|
365
|
+
`OpenAI tools request failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
366
|
+
'UNKNOWN_ERROR',
|
|
367
|
+
true,
|
|
368
|
+
undefined,
|
|
369
|
+
this.name
|
|
370
|
+
);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
213
374
|
estimateCost(
|
|
214
375
|
promptTokens: number,
|
|
215
376
|
completionTokens: number,
|
package/src/types.ts
CHANGED
|
@@ -41,6 +41,44 @@ export interface GenerateOptions {
|
|
|
41
41
|
extra?: Record<string, unknown>;
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
/**
|
|
45
|
+
* Native (structured) tool-calling types. Providers that support a real tools API
|
|
46
|
+
* (OpenAI functions, Anthropic tool blocks) implement ILLMProvider.generateWithTools
|
|
47
|
+
* so the agent harness can use structured calls instead of the JSON-in-text protocol.
|
|
48
|
+
*/
|
|
49
|
+
export interface NativeToolSpec {
|
|
50
|
+
name: string;
|
|
51
|
+
description: string;
|
|
52
|
+
/** JSON Schema for the tool's arguments. */
|
|
53
|
+
parameters: Record<string, unknown>;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** A structured tool call the model emitted (arguments already parsed from JSON). */
|
|
57
|
+
export interface NativeToolCall {
|
|
58
|
+
id: string;
|
|
59
|
+
name: string;
|
|
60
|
+
arguments: unknown;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** One message in a native tool-calling conversation. */
|
|
64
|
+
export interface NativeChatMessage {
|
|
65
|
+
role: 'user' | 'assistant' | 'tool';
|
|
66
|
+
content: string | null;
|
|
67
|
+
/** Set on assistant turns that called tools. */
|
|
68
|
+
toolCalls?: NativeToolCall[];
|
|
69
|
+
/** Set on tool-result turns: which call this answers. */
|
|
70
|
+
toolCallId?: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Result of one native tool-calling round-trip. */
|
|
74
|
+
export interface NativeToolResponse {
|
|
75
|
+
content: string | null;
|
|
76
|
+
toolCalls: NativeToolCall[];
|
|
77
|
+
usage?: TokenUsage;
|
|
78
|
+
model?: string;
|
|
79
|
+
finishReason?: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
44
82
|
/**
|
|
45
83
|
* Provider configuration — everything needed to construct a provider instance.
|
|
46
84
|
*/
|
|
@@ -95,11 +133,17 @@ export interface LLMServiceConfig {
|
|
|
95
133
|
*/
|
|
96
134
|
export const LLM_DEFAULTS = {
|
|
97
135
|
openaiModel: 'gpt-4o-mini',
|
|
98
|
-
|
|
136
|
+
/**
|
|
137
|
+
* The previous default, `claude-sonnet-4-20250514`, was retired and returns
|
|
138
|
+
* 404 — a default nobody can call. Verified callable 2026-08-10.
|
|
139
|
+
*/
|
|
140
|
+
anthropicModel: 'claude-sonnet-5',
|
|
99
141
|
/**
|
|
100
142
|
* Default sampling temperature.
|
|
101
143
|
* Note: base gpt-5 models (gpt-5, gpt-5.1, gpt-5.2) require temperature=1.0
|
|
102
|
-
* and the OpenAI provider enforces this automatically.
|
|
144
|
+
* and the OpenAI provider enforces this automatically. On Anthropic the
|
|
145
|
+
* parameter is not sent at all to models that reject it (Claude 4.7+),
|
|
146
|
+
* so this is a request, not a guarantee — see providers/anthropic.ts.
|
|
103
147
|
*/
|
|
104
148
|
temperature: 0.7,
|
|
105
149
|
maxTokens: 8192,
|