saeeol 1.2.0 → 1.2.1

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.
Files changed (81) hide show
  1. package/package.json +14 -14
  2. package/src/session/compaction-helpers.ts +1 -169
  3. package/src/session/compaction.ts +1 -712
  4. package/src/session/core/compaction/compaction-helpers.ts +169 -0
  5. package/src/session/core/compaction/compaction.ts +712 -0
  6. package/src/session/core/compaction/overflow.ts +28 -0
  7. package/src/session/core/instruction.ts +234 -0
  8. package/src/session/core/llm.ts +504 -0
  9. package/src/session/core/network.ts +392 -0
  10. package/src/session/core/processor.ts +731 -0
  11. package/src/session/core/projectors.ts +139 -0
  12. package/src/session/core/resolve-tools.ts +241 -0
  13. package/src/session/core/retry.ts +149 -0
  14. package/src/session/core/revert.ts +173 -0
  15. package/src/session/core/run-state.ts +110 -0
  16. package/src/session/core/schema.ts +35 -0
  17. package/src/session/core/session-types.ts +160 -0
  18. package/src/session/core/session.sql.ts +124 -0
  19. package/src/session/core/session.ts +948 -0
  20. package/src/session/core/shell-exec.ts +205 -0
  21. package/src/session/core/status.ts +100 -0
  22. package/src/session/core/subtask.ts +268 -0
  23. package/src/session/core/summary.ts +173 -0
  24. package/src/session/core/system.ts +114 -0
  25. package/src/session/core/todo.ts +86 -0
  26. package/src/session/core/user-part.ts +293 -0
  27. package/src/session/instruction.ts +1 -234
  28. package/src/session/llm.ts +1 -504
  29. package/src/session/message/message-errors.ts +83 -0
  30. package/src/session/message/message-parts.ts +89 -0
  31. package/src/session/message/message-query.ts +107 -0
  32. package/src/session/message/message-transform.ts +156 -0
  33. package/src/session/message/message-types.ts +68 -0
  34. package/src/session/message/message-v2.ts +73 -0
  35. package/src/session/message/message.ts +192 -0
  36. package/src/session/message-errors.ts +1 -83
  37. package/src/session/message-parts.ts +1 -89
  38. package/src/session/message-query.ts +1 -107
  39. package/src/session/message-transform.ts +1 -156
  40. package/src/session/message-types.ts +1 -68
  41. package/src/session/message-v2.ts +1 -73
  42. package/src/session/message.ts +1 -192
  43. package/src/session/network.ts +1 -392
  44. package/src/session/overflow.ts +1 -28
  45. package/src/session/processor.ts +1 -731
  46. package/src/session/projectors.ts +2 -139
  47. package/src/session/prompt/prompt-command.ts +93 -0
  48. package/src/session/prompt/prompt-loop.ts +299 -0
  49. package/src/session/prompt/prompt-model.ts +44 -0
  50. package/src/session/prompt/prompt-reminders.ts +120 -0
  51. package/src/session/prompt/prompt-resolve.ts +42 -0
  52. package/src/session/prompt/prompt-schemas.ts +128 -0
  53. package/src/session/prompt/prompt-title.ts +55 -0
  54. package/src/session/prompt/prompt-types.ts +47 -0
  55. package/src/session/prompt/prompt-user-msg.ts +80 -0
  56. package/src/session/prompt/prompt.ts +211 -0
  57. package/src/session/prompt-command.ts +1 -93
  58. package/src/session/prompt-loop.ts +1 -299
  59. package/src/session/prompt-model.ts +1 -44
  60. package/src/session/prompt-reminders.ts +1 -120
  61. package/src/session/prompt-resolve.ts +1 -42
  62. package/src/session/prompt-schemas.ts +1 -128
  63. package/src/session/prompt-title.ts +1 -55
  64. package/src/session/prompt-types.ts +1 -47
  65. package/src/session/prompt-user-msg.ts +1 -80
  66. package/src/session/prompt.ts +1 -211
  67. package/src/session/resolve-tools.ts +1 -241
  68. package/src/session/retry.ts +1 -149
  69. package/src/session/revert.ts +1 -173
  70. package/src/session/run-state.ts +1 -110
  71. package/src/session/schema.ts +1 -35
  72. package/src/session/session-types.ts +1 -160
  73. package/src/session/session.sql.ts +1 -124
  74. package/src/session/session.ts +1 -948
  75. package/src/session/shell-exec.ts +1 -205
  76. package/src/session/status.ts +1 -100
  77. package/src/session/subtask.ts +1 -268
  78. package/src/session/summary.ts +1 -173
  79. package/src/session/system.ts +1 -114
  80. package/src/session/todo.ts +1 -86
  81. package/src/session/user-part.ts +1 -293
@@ -1,504 +1 @@
1
- import { Provider } from "@/provider/provider"
2
- import * as Log from "@saeeol/core/util/log"
3
- import { Context, Effect, Layer, Record } from "effect"
4
- import * as Stream from "effect/Stream"
5
- import { streamText, wrapLanguageModel, type ModelMessage, type Tool, tool, jsonSchema } from "ai"
6
- import { mergeDeep } from "remeda"
7
- import { GitLabWorkflowLanguageModel } from "gitlab-ai-provider"
8
- import { ProviderTransform } from "@/provider/transform"
9
- import { Config } from "@/config/config"
10
- import { InstanceState } from "@/effect/instance-state"
11
- import type { Agent } from "@/agent/agent"
12
- import type { MessageV2 } from "./message-v2"
13
- import { Plugin } from "@/plugin"
14
- import { SystemPrompt } from "./system"
15
- import { Flag } from "@saeeol/core/flag/flag"
16
- import { Permission } from "@/permission"
17
- import { PermissionID } from "@/permission/schema"
18
- import { Bus } from "@/bus"
19
- import { Wildcard } from "@/util/wildcard"
20
- import { SessionID } from "@/session/schema"
21
- import { Auth } from "@/auth"
22
- import { DEFAULT_HEADERS } from "@/saeeol/const"
23
- import { getSaeeolProjectId } from "@/saeeol/project-id"
24
- import {
25
- HEADER_FEATURE,
26
- HEADER_PARENT_TASKID,
27
- HEADER_PROJECTID,
28
- HEADER_MACHINEID,
29
- HEADER_TASKID,
30
- } from "@saeeol/gateway"
31
- import { Identity } from "@saeeol/telemetry"
32
- import { makeRuntime } from "@/effect/run-service"
33
- import { SaeeolSession } from "@/saeeol/session"
34
- import { SaeeolLLM } from "@/saeeol/session/llm"
35
- import { Installation } from "@/installation"
36
- import { InstallationVersion } from "@saeeol/core/installation/version"
37
- import { EffectBridge } from "@/effect/bridge"
38
- import * as Option from "effect/Option"
39
- import * as OtelTracer from "@effect/opentelemetry/Tracer"
40
-
41
- const log = Log.create({ service: "llm" })
42
- export const OUTPUT_TOKEN_MAX = ProviderTransform.OUTPUT_TOKEN_MAX
43
- type Result = Awaited<ReturnType<typeof streamText>>
44
-
45
- // Avoid re-instantiating remeda's deep merge types in this hot LLM path; the runtime behavior is still mergeDeep.
46
- const mergeOptions = (target: Record<string, any>, source: Record<string, any> | undefined): Record<string, any> =>
47
- mergeDeep(target, source ?? {}) as Record<string, any>
48
-
49
- export type StreamInput = {
50
- user: MessageV2.User
51
- sessionID: string
52
- parentSessionID?: string
53
- model: Provider.Model
54
- agent: Agent.Info
55
- permission?: Permission.Ruleset
56
- system: string[]
57
- messages: ModelMessage[]
58
- small?: boolean
59
- tools: Record<string, Tool>
60
- retries?: number
61
- toolChoice?: "auto" | "required" | "none"
62
- }
63
-
64
- export type StreamRequest = StreamInput & {
65
- abort: AbortSignal
66
- }
67
-
68
- export type Event = Result["fullStream"] extends AsyncIterable<infer T> ? T : never
69
-
70
- export interface Interface {
71
- readonly stream: (input: StreamInput) => Stream.Stream<Event, unknown>
72
- readonly raw: (input: StreamRequest) => Effect.Effect<Result>
73
- }
74
-
75
- export class Service extends Context.Service<Service, Interface>()("@saeeol/LLM") {}
76
-
77
- const live: Layer.Layer<
78
- Service,
79
- never,
80
- Auth.Service | Config.Service | Provider.Service | Plugin.Service | Permission.Service
81
- > = Layer.effect(
82
- Service,
83
- Effect.gen(function* () {
84
- const auth = yield* Auth.Service
85
- const config = yield* Config.Service
86
- const provider = yield* Provider.Service
87
- const plugin = yield* Plugin.Service
88
- const perm = yield* Permission.Service
89
-
90
- const run = Effect.fn("LLM.run")(function* (input: StreamRequest) {
91
- const l = log
92
- .clone()
93
- .tag("providerID", input.model.providerID)
94
- .tag("modelID", input.model.id)
95
- .tag("session.id", input.sessionID)
96
- .tag("small", (input.small ?? false).toString())
97
- .tag("agent", input.agent.name)
98
- .tag("mode", input.agent.mode)
99
- l.info("stream", {
100
- modelID: input.model.id,
101
- providerID: input.model.providerID,
102
- })
103
-
104
- const [language, cfg, item, info] = yield* Effect.all(
105
- [
106
- provider.getLanguage(input.model),
107
- config.get(),
108
- provider.getProvider(input.model.providerID),
109
- auth.get(input.model.providerID),
110
- ],
111
- { concurrency: "unbounded" },
112
- )
113
- const attr = SaeeolSession.attribution(input.sessionID)
114
-
115
- // TODO: move this to a proper hook
116
- const isOpenaiOauth = item.id === "openai" && info?.type === "oauth"
117
-
118
- const system: string[] = []
119
- system.push(
120
- [
121
- ...(isOpenaiOauth ? [] : [SystemPrompt.soul()]),
122
- // use agent prompt otherwise provider prompt
123
- ...(input.agent.prompt ? [input.agent.prompt] : SystemPrompt.provider(input.model)),
124
- // any custom prompt passed into this call
125
- ...input.system,
126
- // any custom prompt from last user message
127
- ...(input.user.system ? [input.user.system] : []),
128
- ]
129
- .filter((x) => x)
130
- .join("\n"),
131
- )
132
-
133
- const header = system[0]
134
- yield* plugin.trigger(
135
- "experimental.chat.system.transform",
136
- { sessionID: input.sessionID, model: input.model },
137
- { system },
138
- )
139
- // rejoin to maintain 2-part structure for caching if header unchanged
140
- if (system.length > 2 && system[0] === header) {
141
- const rest = system.slice(1)
142
- system.length = 0
143
- system.push(header, rest.join("\n"))
144
- }
145
-
146
- const variant =
147
- !input.small && input.model.variants && input.user.model.variant
148
- ? input.model.variants[input.user.model.variant]
149
- : {}
150
- const base = input.small
151
- ? ProviderTransform.smallOptions(input.model)
152
- : ProviderTransform.options({
153
- model: input.model,
154
- sessionID: input.sessionID,
155
- providerOptions: item.options,
156
- })
157
- const options = mergeOptions(mergeOptions(mergeOptions(base, input.model.options), input.agent.options), variant)
158
- if (isOpenaiOauth) {
159
- options.instructions = SystemPrompt.soul() + "\n" + system.join("\n")
160
- }
161
-
162
- const isWorkflow = language instanceof GitLabWorkflowLanguageModel
163
- const messages = isOpenaiOauth
164
- ? input.messages
165
- : isWorkflow
166
- ? input.messages
167
- : [
168
- ...system.map(
169
- (x): ModelMessage => ({
170
- role: "system",
171
- content: x,
172
- }),
173
- ),
174
- ...input.messages,
175
- ]
176
-
177
- const params = yield* plugin.trigger(
178
- "chat.params",
179
- {
180
- sessionID: input.sessionID,
181
- agent: input.agent.name,
182
- model: input.model,
183
- provider: item,
184
- message: input.user,
185
- },
186
- {
187
- temperature: input.model.capabilities.temperature
188
- ? (input.agent.temperature ?? ProviderTransform.temperature(input.model))
189
- : undefined,
190
- topP: input.agent.topP ?? ProviderTransform.topP(input.model),
191
- topK: ProviderTransform.topK(input.model),
192
- // rejects `max_tokens`; OpenAI requires `max_completion_tokens` and the compatible
193
- // SDK cannot rename the field, so drop the cap and let the upstream default apply.
194
- maxOutputTokens:
195
- input.model.api.npm === "@ai-sdk/openai-compatible" && input.model.api.id.toLowerCase().includes("gpt-5")
196
- ? undefined
197
- : ProviderTransform.maxOutputTokens(input.model),
198
- options,
199
- },
200
- )
201
-
202
- const { headers } = yield* plugin.trigger(
203
- "chat.headers",
204
- {
205
- sessionID: input.sessionID,
206
- agent: input.agent.name,
207
- model: input.model,
208
- provider: item,
209
- message: input.user,
210
- },
211
- {
212
- headers: {},
213
- },
214
- )
215
- const isSaeeol = input.model.api.npm === "@saeeol/gateway"
216
- const saeeolProjectId = yield* isSaeeol
217
- ? Effect.promise(() => getSaeeolProjectId().catch(() => undefined))
218
- : Effect.succeed(undefined)
219
- const machineId = yield* isSaeeol
220
- ? Effect.promise(() => Identity.getMachineId().catch(() => undefined))
221
- : Effect.succeed(undefined)
222
-
223
- const tools = resolveTools(input)
224
- params.maxOutputTokens = SaeeolLLM.capOutputTokens({
225
- model: input.model,
226
- messages,
227
- tools,
228
- configured: params.maxOutputTokens,
229
- })
230
-
231
- // LiteLLM and some Anthropic proxies require the tools parameter to be present
232
- // when message history contains tool calls, even if no tools are being used.
233
- // Add a dummy tool that is never called to satisfy this validation.
234
- // This is enabled for:
235
- // 1. Providers with "litellm" in their ID or API ID (auto-detected)
236
- // 2. Providers with explicit "litellmProxy: true" option (opt-in for custom gateways)
237
- const isLiteLLMProxy =
238
- item.options?.["litellmProxy"] === true ||
239
- input.model.providerID.toLowerCase().includes("litellm") ||
240
- input.model.api.id.toLowerCase().includes("litellm")
241
-
242
- // LiteLLM/Bedrock rejects requests where the message history contains tool
243
- // calls but no tools param is present. When there are no active tools (e.g.
244
- // during compaction), inject a stub tool to satisfy the validation requirement.
245
- // The stub description explicitly tells the model not to call it.
246
- if (
247
- (isLiteLLMProxy || input.model.providerID.includes("github-copilot")) &&
248
- Object.keys(tools).length === 0 &&
249
- hasToolCalls(input.messages)
250
- ) {
251
- tools["_noop"] = tool({
252
- description: "Do not call this tool. It exists only for API compatibility and must never be invoked.",
253
- inputSchema: jsonSchema({
254
- type: "object",
255
- properties: {
256
- reason: { type: "string", description: "Unused" },
257
- },
258
- }),
259
- execute: async () => ({ output: "", title: "", metadata: {} }),
260
- })
261
- }
262
-
263
- // Wire up toolExecutor for DWS workflow models so that tool calls
264
- // from the workflow service are executed via saeeol's tool system
265
- // and results sent back over the WebSocket.
266
- if (language instanceof GitLabWorkflowLanguageModel) {
267
- const workflowModel = language as GitLabWorkflowLanguageModel & {
268
- sessionID?: string
269
- sessionPreapprovedTools?: string[]
270
- approvalHandler?: (approvalTools: { name: string; args: string }[]) => Promise<{ approved: boolean }>
271
- }
272
- workflowModel.sessionID = input.sessionID
273
- workflowModel.systemPrompt = system.join("\n")
274
- workflowModel.toolExecutor = async (toolName, argsJson, _requestID) => {
275
- const t = tools[toolName]
276
- if (!t || !t.execute) {
277
- return { result: "", error: `Unknown tool: ${toolName}` }
278
- }
279
- try {
280
- const result = await t.execute!(JSON.parse(argsJson), {
281
- toolCallId: _requestID,
282
- messages: input.messages,
283
- abortSignal: input.abort,
284
- })
285
- const output = typeof result === "string" ? result : (result?.output ?? JSON.stringify(result))
286
- return {
287
- result: output,
288
- metadata: typeof result === "object" ? result?.metadata : undefined,
289
- title: typeof result === "object" ? result?.title : undefined,
290
- }
291
- } catch (e: any) {
292
- return { result: "", error: e.message ?? String(e) }
293
- }
294
- }
295
-
296
- const ruleset = Permission.merge(input.agent.permission ?? [], input.permission ?? [])
297
- workflowModel.sessionPreapprovedTools = Object.keys(tools).filter((name) => {
298
- const match = ruleset.findLast((rule) => Wildcard.match(name, rule.permission))
299
- return !match || match.action !== "ask"
300
- })
301
-
302
- const bridge = yield* EffectBridge.make()
303
- const approvedToolsForSession = new Set<string>()
304
- workflowModel.approvalHandler = InstanceState.bind(async (approvalTools) => {
305
- const uniqueNames = [...new Set(approvalTools.map((t: { name: string }) => t.name))] as string[]
306
- // Auto-approve tools that were already approved in this session
307
- // (prevents infinite approval loops for server-side MCP tools)
308
- if (uniqueNames.every((name) => approvedToolsForSession.has(name))) {
309
- return { approved: true }
310
- }
311
-
312
- const id = PermissionID.ascending()
313
- let unsub: (() => void) | undefined
314
- try {
315
- unsub = Bus.subscribe(Permission.Event.Replied, (evt) => {
316
- if (evt.properties.requestID === id) void evt.properties.reply
317
- })
318
- const toolPatterns = approvalTools.map((t: { name: string; args: string }) => {
319
- try {
320
- const parsed = JSON.parse(t.args) as Record<string, unknown>
321
- const title = (parsed?.title ?? parsed?.name ?? "") as string
322
- return title ? `${t.name}: ${title}` : t.name
323
- } catch {
324
- return t.name
325
- }
326
- })
327
- const uniquePatterns = [...new Set(toolPatterns)] as string[]
328
- await bridge.promise(
329
- perm.ask({
330
- id,
331
- sessionID: SessionID.make(input.sessionID),
332
- permission: "workflow_tool_approval",
333
- patterns: uniquePatterns,
334
- metadata: { tools: approvalTools },
335
- always: uniquePatterns,
336
- ruleset: [],
337
- }),
338
- )
339
- for (const name of uniqueNames) approvedToolsForSession.add(name)
340
- workflowModel.sessionPreapprovedTools = [...(workflowModel.sessionPreapprovedTools ?? []), ...uniqueNames]
341
- return { approved: true }
342
- } catch {
343
- return { approved: false }
344
- } finally {
345
- unsub?.()
346
- }
347
- })
348
- }
349
-
350
- const tracer = cfg.experimental?.openTelemetry
351
- ? Option.getOrUndefined(yield* Effect.serviceOption(OtelTracer.OtelTracer))
352
- : undefined
353
- const telemetryTracer = tracer
354
- ? new Proxy(tracer, {
355
- get(target, prop, receiver) {
356
- if (prop !== "startSpan") return Reflect.get(target, prop, receiver)
357
- return (...args: Parameters<typeof target.startSpan>) => {
358
- const span = target.startSpan(...args)
359
- span.setAttribute("session.id", input.sessionID)
360
- return span
361
- }
362
- },
363
- })
364
- : undefined
365
-
366
- const saeeolProjectID = input.model.providerID.startsWith("saeeol")
367
- ? (yield* InstanceState.context).project.id
368
- : undefined
369
-
370
- return streamText({
371
- onError(error) {
372
- l.error("stream error", {
373
- error,
374
- })
375
- },
376
- async experimental_repairToolCall(failed) {
377
- const lower = failed.toolCall.toolName.toLowerCase()
378
- if (lower !== failed.toolCall.toolName && tools[lower]) {
379
- l.info("repairing tool call", {
380
- tool: failed.toolCall.toolName,
381
- repaired: lower,
382
- })
383
- return {
384
- ...failed.toolCall,
385
- toolName: lower,
386
- }
387
- }
388
- return {
389
- ...failed.toolCall,
390
- input: JSON.stringify({
391
- tool: failed.toolCall.toolName,
392
- error: failed.error.message,
393
- }),
394
- toolName: "invalid",
395
- }
396
- },
397
- temperature: params.temperature,
398
- topP: params.topP,
399
- topK: params.topK,
400
- providerOptions: ProviderTransform.providerOptions(input.model, params.options),
401
- activeTools: Object.keys(tools).filter((x) => x !== "invalid"),
402
- tools,
403
- toolChoice: input.toolChoice,
404
- maxOutputTokens: params.maxOutputTokens,
405
- abortSignal: input.abort,
406
- headers: {
407
- ...(input.model.providerID.startsWith("saeeol")
408
- ? {
409
- "x-saeeol-project": saeeolProjectID,
410
- "x-saeeol-session": input.sessionID,
411
- "x-saeeol-request": input.user.id,
412
- "x-saeeol-client": Flag.SAEEOL_CLIENT,
413
- }
414
- : {
415
- "x-session-affinity": input.sessionID,
416
- ...(input.parentSessionID ? { "x-parent-session-id": input.parentSessionID } : {}),
417
- "User-Agent": `saeeol/${InstallationVersion}`,
418
- ...(input.model.providerID !== "anthropic" ? DEFAULT_HEADERS : undefined),
419
- }),
420
- ...(isSaeeol && input.agent.name ? { "x-saeeol-mode": input.agent.name.toLowerCase() } : {}),
421
- ...(isSaeeol && saeeolProjectId ? { [HEADER_PROJECTID]: saeeolProjectId } : {}),
422
- ...(isSaeeol && machineId ? { [HEADER_MACHINEID]: machineId } : {}),
423
- ...(isSaeeol ? { [HEADER_TASKID]: input.sessionID } : {}),
424
- ...(isSaeeol && input.parentSessionID ? { [HEADER_PARENT_TASKID]: input.parentSessionID } : {}),
425
- ...(isSaeeol && attr.feature ? { [HEADER_FEATURE]: attr.feature } : {}),
426
- ...input.model.headers,
427
- ...headers,
428
- },
429
- maxRetries: input.retries ?? 0,
430
- messages,
431
- model: wrapLanguageModel({
432
- model: language,
433
- middleware: [
434
- {
435
- specificationVersion: "v3" as const,
436
- async transformParams(args) {
437
- if (args.type === "stream") {
438
- // @ts-expect-error
439
- args.params.prompt = ProviderTransform.message(args.params.prompt, input.model, options)
440
- }
441
- return args.params
442
- },
443
- },
444
- ],
445
- }),
446
- experimental_telemetry: { isEnabled: false },
447
- })
448
- })
449
-
450
- const stream: Interface["stream"] = (input) =>
451
- Stream.scoped(
452
- Stream.unwrap(
453
- Effect.gen(function* () {
454
- const ctrl = yield* Effect.acquireRelease(
455
- Effect.sync(() => new AbortController()),
456
- (ctrl) => Effect.sync(() => ctrl.abort()),
457
- )
458
-
459
- const result = yield* run({ ...input, abort: ctrl.signal })
460
-
461
- return Stream.fromAsyncIterable(result.fullStream, (e) => (e instanceof Error ? e : new Error(String(e))))
462
- }),
463
- ),
464
- )
465
- return Service.of({ stream, raw: (input) => run(input).pipe(Effect.orDie) })
466
- }),
467
- )
468
-
469
- export const layer = live.pipe(Layer.provide(Permission.defaultLayer))
470
-
471
- export const defaultLayer = Layer.suspend(() =>
472
- layer.pipe(
473
- Layer.provide(Auth.defaultLayer),
474
- Layer.provide(Config.defaultLayer),
475
- Layer.provide(Provider.defaultLayer),
476
- Layer.provide(Plugin.defaultLayer),
477
- ),
478
- )
479
- const runtime = makeRuntime(Service, defaultLayer)
480
- export async function stream(input: StreamRequest) {
481
- return runtime.runPromise((svc) => svc.raw(input), { signal: input.abort })
482
- }
483
-
484
- function resolveTools(input: Pick<StreamInput, "tools" | "agent" | "permission" | "user">) {
485
- const disabled = Permission.disabled(
486
- Object.keys(input.tools),
487
- Permission.merge(input.agent.permission, input.permission ?? []),
488
- )
489
- return Record.filter(input.tools, (_, k) => input.user.tools?.[k] !== false && !disabled.has(k))
490
- }
491
-
492
- // Check if messages contain any tool-call content
493
- // Used to determine if a dummy tool should be added for LiteLLM proxy compatibility
494
- export function hasToolCalls(messages: ModelMessage[]): boolean {
495
- for (const msg of messages) {
496
- if (!Array.isArray(msg.content)) continue
497
- for (const part of msg.content) {
498
- if (part.type === "tool-call" || part.type === "tool-result") return true
499
- }
500
- }
501
- return false
502
- }
503
-
504
- export * as LLM from "./llm"
1
+ export * from "./core/llm"
@@ -0,0 +1,83 @@
1
+ /** 에러 타입 + fromError + OutputFormat — message-v2.ts에서 분리 */
2
+
3
+ import { APICallError, LoadAPIKeyError } from "ai"
4
+ import { NamedError } from "@saeeol/core/util/error"
5
+ import * as ProviderError from "@/provider/error"
6
+ import { SessionNetwork } from "../core/network"
7
+ import { CodexAuthExpiredError } from "@/saeeol/provider/codex-refresh"
8
+ import { Effect, Schema } from "effect"
9
+ import { zod } from "@/util/effect-zod"
10
+ import { NonNegativeInt } from "@/util/schema"
11
+ import { namedSchemaError } from "@/util/named-schema-error"
12
+ import { errorMessage } from "@/util/error"
13
+ import { ProviderID } from "@/provider/schema"
14
+ import type { Assistant } from "./message-types"
15
+
16
+ interface FetchDecompressionError extends Error { code: "ZlibError"; errno: number; path: string }
17
+
18
+ export const OutputLengthError = namedSchemaError("MessageOutputLengthError", {})
19
+ export const AbortedError = namedSchemaError("MessageAbortedError", { message: Schema.String })
20
+ export const StructuredOutputError = namedSchemaError("StructuredOutputError", { message: Schema.String, retries: NonNegativeInt })
21
+ export const AuthError = namedSchemaError("ProviderAuthError", { providerID: Schema.String, message: Schema.String })
22
+ export const APIError = namedSchemaError("APIError", {
23
+ message: Schema.String, statusCode: Schema.optional(NonNegativeInt), isRetryable: Schema.Boolean,
24
+ responseHeaders: Schema.optional(Schema.Record(Schema.String, Schema.String)), responseBody: Schema.optional(Schema.String),
25
+ metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)),
26
+ })
27
+ export type APIError = import("zod").infer<typeof APIError.Schema>
28
+ export const ContextOverflowError = namedSchemaError("ContextOverflowError", { message: Schema.String, responseBody: Schema.optional(Schema.String) })
29
+
30
+ export class OutputFormatText extends Schema.Class<OutputFormatText>("OutputFormatText")({ type: Schema.Literal("text") }) { static readonly zod = zod(this) }
31
+ export class OutputFormatJsonSchema extends Schema.Class<OutputFormatJsonSchema>("OutputFormatJsonSchema")({
32
+ type: Schema.Literal("json_schema"), schema: Schema.Record(Schema.String, Schema.Any).annotate({ identifier: "JSONSchema" }),
33
+ retryCount: NonNegativeInt.pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed(2))),
34
+ }) { static readonly zod = zod(this) }
35
+
36
+ const _Format = Schema.Union([OutputFormatText, OutputFormatJsonSchema]).annotate({ discriminator: "type", identifier: "OutputFormat" })
37
+ export { _Format }
38
+ export const Format = Object.assign(_Format, { zod: zod(_Format) })
39
+ export type OutputFormat = Schema.Schema.Type<typeof _Format>
40
+
41
+ // Assistant error union (Zod)
42
+ import z from "zod"
43
+ const AssistantErrorZod = z.discriminatedUnion("name", [
44
+ AuthError.Schema, NamedError.Unknown.Schema, OutputLengthError.Schema, AbortedError.Schema,
45
+ StructuredOutputError.Schema, ContextOverflowError.Schema, APIError.Schema,
46
+ ])
47
+ export type AssistantError = z.infer<typeof AssistantErrorZod>
48
+ export { AssistantErrorZod }
49
+
50
+ // Assistant error union (Effect Schema)
51
+ export const AssistantErrorSchema = Schema.Union([
52
+ AuthError.EffectSchema,
53
+ Schema.Struct({ name: Schema.Literal("UnknownError"), data: Schema.Struct({ message: Schema.String }) }).annotate({ identifier: "UnknownError" }),
54
+ OutputLengthError.EffectSchema, AbortedError.EffectSchema, StructuredOutputError.EffectSchema, ContextOverflowError.EffectSchema, APIError.EffectSchema,
55
+ ]).annotate({ discriminator: "name" })
56
+
57
+ export function fromError(e: unknown, ctx: { providerID: ProviderID; aborted?: boolean }): NonNullable<Assistant["error"]> {
58
+ switch (true) {
59
+ case e instanceof DOMException && e.name === "AbortError": return new AbortedError({ message: e.message }, { cause: e }).toObject()
60
+ case OutputLengthError.isInstance(e): return e
61
+ case LoadAPIKeyError.isInstance(e): return new AuthError({ providerID: ctx.providerID, message: e.message }, { cause: e }).toObject()
62
+ case e instanceof CodexAuthExpiredError: return new AuthError({ providerID: "openai", message: e.message }, { cause: e }).toObject()
63
+ case SessionNetwork.disconnected(e):
64
+ return new APIError({ message: SessionNetwork.message(e), isRetryable: true, metadata: { code: (e as import("bun").SystemError).code ?? "", syscall: (e as import("bun").SystemError).syscall ?? "", message: (e as import("bun").SystemError).message ?? "" } }, { cause: e }).toObject()
65
+ case e instanceof Error && (e as FetchDecompressionError).code === "ZlibError":
66
+ if (ctx.aborted) return new AbortedError({ message: e.message }, { cause: e }).toObject()
67
+ return new APIError({ message: "Response decompression failed", isRetryable: true, metadata: { code: (e as FetchDecompressionError).code, message: e.message } }, { cause: e }).toObject()
68
+ case APICallError.isInstance(e):
69
+ const parsed = ProviderError.parseAPICallError({ providerID: ctx.providerID, error: e })
70
+ if (parsed.type === "context_overflow") return new ContextOverflowError({ message: parsed.message, responseBody: parsed.responseBody }, { cause: e }).toObject()
71
+ return new APIError({ message: parsed.message, statusCode: parsed.statusCode, isRetryable: parsed.isRetryable, responseHeaders: parsed.responseHeaders, responseBody: parsed.responseBody, metadata: parsed.metadata }, { cause: e }).toObject()
72
+ case e instanceof Error: return new NamedError.Unknown({ message: errorMessage(e) }, { cause: e }).toObject()
73
+ default:
74
+ try {
75
+ const parsed = ProviderError.parseStreamError(e)
76
+ if (parsed) {
77
+ if (parsed.type === "context_overflow") return new ContextOverflowError({ message: parsed.message, responseBody: parsed.responseBody }, { cause: e }).toObject()
78
+ return new APIError({ message: parsed.message, isRetryable: parsed.isRetryable, responseBody: parsed.responseBody }, { cause: e }).toObject()
79
+ }
80
+ } catch {}
81
+ return new NamedError.Unknown({ message: JSON.stringify(e) }, { cause: e }).toObject()
82
+ }
83
+ }