Repository navigation
feat(ai, ai-openai): EvaluateError keeps usage and answers when OpenAI refuses a question #1743
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
fa90c7b
44d8a1d
70dab01
01a344d
cbf9e8d
3fc058d
ed05172
0bcebe3
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,30 @@ | ||
| --- | ||
| '@tanstack/ai': minor | ||
| '@tanstack/ai-openai': minor | ||
| --- | ||
|
|
||
| Add `EvaluateError`, a typed error an evaluate adapter throws when the provider answered but the call cannot become a result. `@tanstack/ai` and `@tanstack/ai/adapters` export the class, the `isEvaluateError()` type guard, the `EvaluateErrorInit` type and `EVALUATE_REFUSAL_CODE`. The error keeps what the provider sent back: | ||
|
|
||
| - `code`: a stable code to branch on. A refused question uses `EVALUATE_REFUSAL_CODE` (`'refusal'`). | ||
| - `usage`: the tokens the call used. | ||
| - `model`: the model that answered. | ||
| - `answers`: the raw wire answers that came back, before `decide()` maps them. A `choice()` answer has `choice` (for example `{ type: 'choice', choice: 'billing', ... }`), and a `boolean()` answer is `{ type: 'noul', noul: 0.9 }`, not `{ value }`. The refused question has no key. | ||
|
|
||
| When OpenAI refuses to answer a question, `decide()` now throws an `EvaluateError` with `code: 'refusal'` instead of a plain `Error`. If more than one question is refused, the message names the first one. | ||
|
|
||
| Use `isEvaluateError(error)` to narrow a caught error. It checks `name` and `code`, not `instanceof`, so it still works when the app installs more than one copy of `@tanstack/ai`. | ||
|
|
||
| When an adapter throws an `EvaluateError` that carries `usage`, `decide()` reports that usage through the `evaluate:usage` event and the middleware `onUsage` hook, then runs `onError` and rethrows the same error. `onFinish` does not run. | ||
|
|
||
| ```ts | ||
| try { | ||
| await decide({ adapter, state, questions }) | ||
| } catch (error) { | ||
| if (isEvaluateError(error) && error.code === EVALUATE_REFUSAL_CODE) { | ||
| console.log(error.usage, error.answers) | ||
| } | ||
| throw error | ||
| } | ||
| ``` | ||
|
|
||
| The message text does not change. The error is still an `Error`, but its `name` is now `'EvaluateError'`, so logs and the OpenTelemetry `error.type` show that name instead of `'Error'`. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -411,3 +411,68 @@ try { | |
| } | ||
| } | ||
| ``` | ||
|
|
||
| ### Refused questions | ||
|
|
||
| OpenAI can refuse to answer a question. | ||
| Then `decide()` throws an `EvaluateError` with `code: EVALUATE_REFUSAL_CODE`. | ||
| The error keeps what the provider sent back: | ||
|
|
||
| - `usage`: the tokens the call used. | ||
| - `model`: the model that answered. | ||
| - `answers`: the raw answers that came back, before `decide()` maps them. The refused question has no key. | ||
|
|
||
| The raw answers do not have `value`. | ||
| A `choice()` answer has `choice`, for example `{ type: 'choice', choice: 'billing', ... }`. | ||
| A `boolean()` answer is `{ type: 'noul', noul: 0.9 }`, where `noul` is the chance of true. | ||
|
|
||
| Use `isEvaluateError(error)` to narrow a caught error. | ||
| It checks `name` and `code`, so it works even when the app has two copies of `@tanstack/ai`. | ||
| `instanceof EvaluateError` does not. | ||
|
|
||
| ```typescript | ||
| import { | ||
| decide, | ||
| boolean, | ||
| choice, | ||
| isEvaluateError, | ||
| EVALUATE_REFUSAL_CODE, | ||
| } from '@tanstack/ai' | ||
| import { openaiDecider } from '@tanstack/ai-openai' | ||
|
|
||
| try { | ||
| const result = await decide({ | ||
| adapter: openaiDecider('gpt-6-luna'), | ||
| state: 'Please refund the extra payment.', | ||
| questions: { | ||
| queue: choice({ | ||
| instructions: 'Which team should handle this ticket?', | ||
| options: { | ||
| billing: 'Payments, invoices, refunds', | ||
| tech: 'Bugs, outages, integrations', | ||
| }, | ||
| }), | ||
| refund: boolean({ | ||
| instructions: 'Is the customer asking for a refund?', | ||
| }), | ||
| }, | ||
| }) | ||
| console.log(result.refund.value) | ||
| } catch (error) { | ||
| if (isEvaluateError(error) && error.code === EVALUATE_REFUSAL_CODE) { | ||
| console.error(error.message) // OpenAI declined to answer question "refund" | ||
| console.log(error.usage) | ||
| console.log(error.answers?.queue) // { type: 'choice', choice: 'billing', ... } | ||
| } | ||
| throw error | ||
| } | ||
| ``` | ||
|
|
||
| The provider still bills a refused call. | ||
| So before `decide()` rethrows an `EvaluateError` that has `usage`, it reports that usage: | ||
|
|
||
| 1. Middleware `onUsage` runs with the usage. | ||
| 2. Middleware `onError` runs with the error. | ||
| 3. `decide()` rethrows the same error. | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Qualify the rethrow behavior when If 🤖 Prompt for AI Agents |
||
|
|
||
| `onFinish` does not run. See [Track usage](#track-usage) for middleware. | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win
🔎 Supported by static analysis
🏁 Script executed:
Repository: TanStack/ai
Length of output: 20409
🏁 Script executed:
Repository: TanStack/ai
Length of output: 16854
Describe
answersas adapter-normalized wire answers.OpenAIEvaluateAdapterconverts provider answers before constructingEvaluateError. For example, it converts OpenAIpredicateanswers withprobabilityinto{ type: 'noul', noul }. These values are not yet mapped bydecide(), but they are not raw provider answers. Update the wording to identify the adapter normalization.Suggested documentation fix
📝 Committable suggestion
🤖 Prompt for AI Agents