Skip to content
Open
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
18 changes: 8 additions & 10 deletions src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,15 +100,6 @@ export interface ApiHandlerCreateMessageMetadata {
* When false, only one tool call is returned per response.
*/
parallelToolCalls?: boolean
/**
* Optional array of tool names that the model is allowed to call.
* When provided, all tool definitions are passed to the model (so it can reference
* historical tool calls), but only the specified tools can actually be invoked.
* This is used when switching modes to prevent model errors from missing tool
* definitions while still restricting callable tools to the current mode's permissions.
* Only applies to providers that support function calling restrictions (e.g., Gemini).
*/
allowedFunctionNames?: string[]
/**
* Abort signal for cancelling the HTTP request mid-stream.
* Passed through to AI SDK's streamText() so the underlying HTTP request is aborted
Expand All @@ -130,8 +121,15 @@ export interface ApiHandler {
* Ensures model metadata has been fetched from the remote API so that getModel()
* returns accurate info (context window, pricing, etc.) instead of hardcoded defaults.
* Only router providers that discover models over the network implement this.
*
* `signal` bounds the caller's wait: when it aborts (e.g. the caller's bounded
* metadata wait expired or the owning task was cancelled), the returned promise
* settles with a rejection so no handler-side waiter outlives its caller.
* Fetchers that observe the signal may also stop their network request; the
* shared, de-duplicated catalog fetch may still complete and populate the model
* cache, which is by design for concurrent waiters.
*/
ensureModelFetched?(): Promise<void>
ensureModelFetched?(signal?: AbortSignal): Promise<void>

/**
* Optional context window for context-management / auto-condense when it must differ from
Expand Down
144 changes: 3 additions & 141 deletions src/api/providers/__tests__/gemini-handler.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@ describe("GeminiHandler backend support", () => {
})
})

describe("allowedFunctionNames support", () => {
describe("tool_choice support", () => {
const testTools = [
{
type: "function" as const,
Expand Down Expand Up @@ -191,142 +191,7 @@ describe("GeminiHandler backend support", () => {
},
]

it("should ignore allowedFunctionNames because Gemini rejects larger restriction lists", async () => {
const options = {
apiProvider: providerIdentifiers.gemini,
} as ApiHandlerOptions
const handler = new GeminiHandler(options)
const stub = vi.fn().mockReturnValue((async function* () {})())
// @ts-ignore access private client
handler["client"].models.generateContentStream = stub

await handler
.createMessage("test", [] as any, {
taskId: "test-task",
tools: testTools,
allowedFunctionNames: ["read_file", "write_to_file"],
})
.next()

const config = stub.mock.calls[0][0].config
expect(config.toolConfig).toBeUndefined()
})

it("should include all tools when allowedFunctionNames is provided", async () => {
const options = {
apiProvider: providerIdentifiers.gemini,
} as ApiHandlerOptions
const handler = new GeminiHandler(options)
const stub = vi.fn().mockReturnValue((async function* () {})())
// @ts-ignore access private client
handler["client"].models.generateContentStream = stub

await handler
.createMessage("test", [] as any, {
taskId: "test-task",
tools: testTools,
allowedFunctionNames: ["read_file"],
})
.next()

const config = stub.mock.calls[0][0].config
// All tools should be passed to the model
expect(config.tools[0].functionDeclarations).toHaveLength(3)
expect(config.toolConfig).toBeUndefined()
})

it("should not pass large allowedFunctionNames lists to Gemini", async () => {
const options = {
apiProvider: providerIdentifiers.gemini,
} as ApiHandlerOptions
const handler = new GeminiHandler(options)
const stub = vi.fn().mockReturnValue((async function* () {})())
// @ts-ignore access private client
handler["client"].models.generateContentStream = stub

const manyTools = Array.from({ length: 30 }, (_, index) => ({
type: "function" as const,
function: {
name: `tool_${index}`,
description: `Tool ${index}`,
parameters: { type: "object", properties: {} },
},
}))

await handler
.createMessage("test", [] as any, {
taskId: "test-task",
tools: manyTools,
allowedFunctionNames: manyTools.map((tool) => tool.function.name),
})
.next()

const config = stub.mock.calls[0][0].config
expect(config.tools[0].functionDeclarations).toHaveLength(30)
expect(config.toolConfig).toBeUndefined()
})

it("should not pass allowedFunctionNames even when history includes tool calls", async () => {
const options = {
apiProvider: providerIdentifiers.gemini,
} as ApiHandlerOptions
const handler = new GeminiHandler(options)
const stub = vi.fn().mockReturnValue((async function* () {})())
// @ts-ignore access private client
handler["client"].models.generateContentStream = stub

const manyTools = Array.from({ length: 30 }, (_, index) => ({
type: "function" as const,
function: {
name: `tool_${index}`,
description: `Tool ${index}`,
parameters: { type: "object", properties: {} },
},
}))
const messages = [
{
role: "assistant",
content: [{ type: "tool_use", id: "tool-call-29", name: "tool_29", input: {} }],
},
]

await handler
.createMessage("test", messages as any, {
taskId: "test-task",
tools: manyTools,
allowedFunctionNames: manyTools.slice(0, 29).map((tool) => tool.function.name),
})
.next()

const config = stub.mock.calls[0][0].config
expect(config.tools[0].functionDeclarations).toHaveLength(30)
expect(config.toolConfig).toBeUndefined()
})

it("should fall back to tool_choice when allowedFunctionNames is provided", async () => {
const options = {
apiProvider: providerIdentifiers.gemini,
} as ApiHandlerOptions
const handler = new GeminiHandler(options)
const stub = vi.fn().mockReturnValue((async function* () {})())
// @ts-ignore access private client
handler["client"].models.generateContentStream = stub

await handler
.createMessage("test", [] as any, {
taskId: "test-task",
tools: testTools,
tool_choice: "auto",
allowedFunctionNames: ["read_file"],
})
.next()

const config = stub.mock.calls[0][0].config
expect(config.toolConfig.functionCallingConfig.mode).toBe(FunctionCallingConfigMode.AUTO)
expect(config.toolConfig.functionCallingConfig.allowedFunctionNames).toBeUndefined()
})

it("should fall back to tool_choice when allowedFunctionNames is empty", async () => {
it("maps tool_choice auto to AUTO without allowedFunctionNames", async () => {
const options = {
apiProvider: providerIdentifiers.gemini,
} as ApiHandlerOptions
Expand All @@ -340,17 +205,15 @@ describe("GeminiHandler backend support", () => {
taskId: "test-task",
tools: testTools,
tool_choice: "auto",
allowedFunctionNames: [],
})
.next()

const config = stub.mock.calls[0][0].config
// Empty allowedFunctionNames should fall back to tool_choice behavior
expect(config.toolConfig.functionCallingConfig.mode).toBe(FunctionCallingConfigMode.AUTO)
expect(config.toolConfig.functionCallingConfig.allowedFunctionNames).toBeUndefined()
})

it("should not set toolConfig when allowedFunctionNames is undefined and no tool_choice", async () => {
it("should not set toolConfig when no tool_choice", async () => {
const options = {
apiProvider: providerIdentifiers.gemini,
} as ApiHandlerOptions
Expand All @@ -367,7 +230,6 @@ describe("GeminiHandler backend support", () => {
.next()

const config = stub.mock.calls[0][0].config
// No toolConfig should be set when neither allowedFunctionNames nor tool_choice is provided
expect(config.toolConfig).toBeUndefined()
})
})
Expand Down
71 changes: 71 additions & 0 deletions src/api/providers/__tests__/zoo-gateway.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -724,6 +724,77 @@ describe("ZooGatewayHandler", () => {
expect(refreshModels).not.toHaveBeenCalled()
})

it("settles the waiter with a rejection when the signal aborts mid-fetch", async () => {
// A caller that gives up must not leave a handler-side waiter pending
// on the (shared) catalog fetch: with an observing signal, the
// ensureModelFetched promise rejects at abort time, while the
// underlying fetch continues untouched for any other waiter.
const { getModels } = await import("../fetchers/modelCache")
vitest.mocked(getModels).mockImplementationOnce(() => new Promise(() => {}))

const handler = new ZooGatewayHandler(mockOptions)
const controller = new AbortController()

const wait = handler.ensureModelFetched(controller.signal)
// Let the waiter attach its abort listener before cancelling.
await Promise.resolve()
controller.abort()

await expect(wait).rejects.toThrow()
})

it("settles the waiter when the fetch wins against a live signal and detaches the listener", async () => {
// Fetch-wins branch: resolve() must settle the await (a dropped
// resolve or a detached .then handler hangs this test), the abort
// listener must be registered with the real { once: true } options
// object, and the detach must target the *same* event name/handler
// pair that was registered — a mutated event name detaches nothing.
const handler = new ZooGatewayHandler(mockOptions)
const controller = new AbortController()
const addEventListenerSpy = vitest.spyOn(controller.signal, "addEventListener")
const removeEventListenerSpy = vitest.spyOn(controller.signal, "removeEventListener")

await handler.ensureModelFetched(controller.signal)

expect(addEventListenerSpy).toHaveBeenCalledWith("abort", expect.any(Function), { once: true })
const registered = addEventListenerSpy.mock.calls.find(([event]) => event === "abort")
expect(registered).toBeDefined()
expect(removeEventListenerSpy).toHaveBeenCalledWith("abort", registered?.[1])
})

it("rejects a signal-observing waiter with the fetch error and detaches the listener", async () => {
// Rejection-branch twin of the fetch-wins test: reject(error) must
// propagate the catalog failure to the waiter (a dropped reject hangs
// this test) and the listener must be detached under the right event
// name. The no-signal reject path cannot attach a listener, so this is the only
// coverage of the reject-side detach.
const { getModels } = await import("../fetchers/modelCache")
vitest.mocked(getModels).mockRejectedValueOnce(new Error("network down"))

const handler = new ZooGatewayHandler(mockOptions)
const controller = new AbortController()
const addEventListenerSpy = vitest.spyOn(controller.signal, "addEventListener")
const removeEventListenerSpy = vitest.spyOn(controller.signal, "removeEventListener")

await expect(handler.ensureModelFetched(controller.signal)).rejects.toThrow("network down")
const registered = addEventListenerSpy.mock.calls.find(([event]) => event === "abort")
expect(registered).toBeDefined()
expect(removeEventListenerSpy).toHaveBeenCalledWith("abort", registered?.[1])
})

it("never starts a wait when the signal is already aborted", async () => {
const { getModels } = await import("../fetchers/modelCache")
const handler = new ZooGatewayHandler(mockOptions)
const controller = new AbortController()
controller.abort()

await expect(handler.ensureModelFetched(controller.signal)).rejects.toThrow()
Comment thread
coderabbitai[bot] marked this conversation as resolved.
// Without the spy, a guard relocated after fetchModel() starts would
// still reject here and settle identically; zero getModels calls pins
// that the check runs before the fetch starts.
expect(vitest.mocked(getModels)).not.toHaveBeenCalled()
})

it("skips the fetch when models are already populated", async () => {
const handler = new ZooGatewayHandler(mockOptions)
const { getModels, refreshModels } = await import("../fetchers/modelCache")
Expand Down
11 changes: 5 additions & 6 deletions src/api/providers/gemini.ts
Original file line number Diff line number Diff line change
Expand Up @@ -305,12 +305,11 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl
...(tools.length > 0 ? { tools } : {}),
}

// Do not pass metadata.allowedFunctionNames to Gemini. Live API testing showed
// that allowedFunctionNames triggers a generic 400 INVALID_ARGUMENT at 26 or more
// names. It can also
// reject prior function calls if their names are absent from the current
// allowed list. We still pass all declarations for history compatibility;
// mode/tool restrictions are enforced by the tool execution layer.
// Tool policy is enforced upstream: metadata.tools already contains only the
// declarations allowed by the effective mode/tool policy. Do not add
// allowedFunctionNames to toolConfig; live API testing showed it triggers a
// generic 400 INVALID_ARGUMENT at 26 or more names and can reject prior
// function calls absent from the current list. toolConfig maps tool_choice only.
if (metadata?.tool_choice) {
const choice = metadata.tool_choice
let mode: FunctionCallingConfigMode
Expand Down
31 changes: 29 additions & 2 deletions src/api/providers/router-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,8 +108,35 @@ export abstract class RouterProvider extends BaseProvider {
return this.modelFetchPromise
}

async ensureModelFetched(): Promise<void> {
await this.fetchModel()
async ensureModelFetched(signal?: AbortSignal): Promise<void> {
// A caller that already gave up must not start (or keep) a wait on the
// shared catalog fetch.
if (signal?.aborted) {
throw signal.reason
}

const fetch = this.fetchModel()
if (!signal) {
await fetch
return
}

// Detach this waiter as soon as the signal aborts; the shared in-flight
// fetch continues for any other waiter and still populates the cache.
await new Promise<void>((resolve, reject) => {
const onAbort = () => reject(signal.reason)
signal.addEventListener("abort", onAbort, { once: true })
fetch.then(
() => {
signal.removeEventListener("abort", onAbort)
resolve()
},
(error: unknown) => {
signal.removeEventListener("abort", onAbort)
reject(error)
},
)
})
}

override getModel(): { id: string; info: ModelInfo } {
Expand Down
Loading
Loading