Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 0 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,6 @@
"prepare": "npm run build"
},
"dependencies": {
"@smithy/eventstream-codec": "4.2.14",
"@smithy/util-utf8": "4.2.2",
"aws4fetch": "1.0.20",
"effect": "4.0.0-beta.91"
},
"devDependencies": {
Expand Down
4 changes: 3 additions & 1 deletion src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ type Usage = Schema.Schema.Type<typeof _Usage>
import { buildOpenAIChatBody, buildOpenAIChatURL, buildOpenAIChatHeaders, buildOpenAIChatStreamBody } from "./protocols/openai-chat.js"
import { streamFromBody, mapFinishReason } from "./protocols/sse-parser.js"
import { isRecord } from "./utils/record.js"
import { retryWithBackoff } from "./retry.js"

// --- Configuration ---

Expand Down Expand Up @@ -197,7 +198,7 @@ export const makeLLMClient = Layer.effect(
const url = buildOpenAIChatURL(baseUrl)
const headers = buildOpenAIChatHeaders(apiKey)

return Effect.tryPromise({
return retryWithBackoff(Effect.tryPromise({
try: () => fetch(url, { method: "POST", headers, body: JSON.stringify(finalBody) }),
catch: (error) => ({
_tag: "APIError" as const,
Expand Down Expand Up @@ -283,6 +284,7 @@ export const makeLLMClient = Layer.effect(
}))
}),
)
)
}

const generateObject = <T>(request: LLMRequest): Effect.Effect<T, LLMError> => {
Expand Down
4 changes: 4 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,10 @@ export {
export { LLMClient, LLMClientLayer, LLMConfig, simpleRequest, simpleStream, makeLLMClient } from "./client.js"
export type { LLMClientShape } from "./client.js"

// Retry
export { retryWithBackoff } from "./retry.js"
export type { RetryConfig } from "./retry.js"

// Cache
export { Cache, CacheLayer, makeCache } from "./cache.js"
export type { CacheShape } from "./cache.js"
Expand Down
62 changes: 42 additions & 20 deletions src/protocols/sse-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,32 +120,43 @@ function parseChunk(chunk: OpenAISSEChunk, state: ParseState): LLMEvent[] {
const key = `${tc.index}`
let toolCall = state.toolCalls.get(key)

// New tool call starting
if (tc.id && !toolCall) {
toolCall = {
id: tc.id,
name: tc.function?.name ?? "",
args: "",
}
state.toolCalls.set(key, toolCall)

events.push({
type: "tool-input-start",
id: ToolCallID.make(tc.id),
name: tc.function?.name ?? "unknown",
})
// New tool call starting — accept either explicit id or name+arguments as signal
if (!toolCall) {
const hasName = !!tc.function?.name
const hasArgs = !!tc.function?.arguments
const hasId = !!tc.id

// If we have a tool call with name but no registered state, create it
if (hasName || hasArgs) {
const generatedId = tc.id ?? `tool-${state.stepIndex}-${key}`
toolCall = {
id: generatedId,
name: tc.function?.name ?? "unknown",
args: "",
}
state.toolCalls.set(key, toolCall)

if (tc.function?.name) {
events.push({
type: "tool-call",
id: ToolCallID.make(tc.id),
name: tc.function.name,
input: {},
type: "tool-input-start",
id: ToolCallID.make(generatedId),
name: toolCall.name,
})

if (hasName) {
events.push({
type: "tool-call",
id: ToolCallID.make(generatedId),
name: toolCall.name,
input: {},
})
}
} else if (!hasId && !hasName && !hasArgs) {
// No useful data — skip this delta
continue
}
}

// Tool call continuing
// Tool call continuing — accumulate arguments and fill in missing name
if (toolCall && tc.function?.arguments) {
toolCall.args += tc.function.arguments
events.push({
Expand All @@ -155,6 +166,17 @@ function parseChunk(chunk: OpenAISSEChunk, state: ParseState): LLMEvent[] {
text: tc.function.arguments,
})
}

// Update name if it wasn't known at creation time (model sends args before name)
if (toolCall && tc.function?.name && toolCall.name === "unknown") {
toolCall.name = tc.function.name
events.push({
type: "tool-call",
id: ToolCallID.make(toolCall.id),
name: toolCall.name,
input: {},
})
}
}
}

Expand Down
35 changes: 35 additions & 0 deletions src/retry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/**
* HTTP retry with exponential backoff.
* Wraps an Effect and retries transient failures (isRetryable=true).
*/

import * as Effect from "effect/Effect"
import type { LLMError } from "./schema/index.js"

export interface RetryConfig {
/** Maximum retries before giving up (default: 3) */
readonly maxRetries?: number
/** Base delay in ms — doubles each attempt (default: 1000) */
readonly baseDelayMs?: number
}

export function retryWithBackoff<T>(
effect: Effect.Effect<T, LLMError>,
config?: RetryConfig,
): Effect.Effect<T, LLMError> {
const maxRetries = config?.maxRetries ?? 3
const baseDelay = config?.baseDelayMs ?? 1000

const attempt = (n: number): Effect.Effect<T, LLMError> =>
effect.pipe(
Effect.catch((error: LLMError) => {
if (typeof (error as any).isRetryable !== "undefined" && (error as any).isRetryable === true && n < maxRetries) {
const delay = Math.pow(2, n - 1) * baseDelay
console.log(`[harness:retry] ${error.message} — retrying in ${delay}ms (attempt ${n + 1}/${maxRetries})`)
return Effect.sleep(delay as number).pipe(Effect.flatMap(() => attempt(n + 1)))
}
return Effect.fail(error)
}),
)
return attempt(1)
}
Loading