-
Notifications
You must be signed in to change notification settings - Fork 4
feat(ai): validate token permissions before running evaluations #244
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
base: main
Are you sure you want to change the base?
Changes from all commits
f6485c6
436dc69
67e5d01
e126238
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,144 @@ | ||
| import type { ResolvedAxiomConfig } from './index'; | ||
| import { resolveAxiomConnection } from './resolver'; | ||
| import { AxiomCLIError } from '../util/errors'; | ||
|
|
||
| /** | ||
| * Result of token permission validation | ||
| */ | ||
| export interface TokenPermissionValidationResult { | ||
| valid: boolean; | ||
| dataset: string; | ||
| permissions: { | ||
| canRead: boolean; | ||
| canWrite: boolean; | ||
| }; | ||
| errors: string[]; | ||
| } | ||
|
|
||
| /** | ||
| * Validates that the configured token has the required permissions to run evaluations. | ||
| * | ||
| * This function: | ||
| * 1. Attempts to send a test trace to verify ingestion permissions | ||
| * 2. Attempts to query the dataset to verify read permissions | ||
| * | ||
| * @param config - Resolved Axiom configuration | ||
| * @returns Validation result with permission details | ||
| * @throws {AxiomCLIError} If validation fails with detailed error messages | ||
| */ | ||
| const buildPermissionHelp = (consoleEndpointUrl: string) => [ | ||
| 'To run evaluations, your token needs:', | ||
| ' - Write permission to ingest traces', | ||
| ' - Read permission to query results', | ||
| `Manage tokens at: ${consoleEndpointUrl}/settings/api-tokens`, | ||
| ]; | ||
|
|
||
| const indentErrorDetails = (lines: string[]) => | ||
| lines.map((line, index) => (index === 0 ? line : ` ${line}`)); | ||
|
|
||
| const formatPermissionErrors = ( | ||
| errors: string[] | undefined, | ||
| dataset: string, | ||
| consoleEndpointUrl: string, | ||
| ) => { | ||
| const details: string[] = []; | ||
| const normalized = (errors || []).map((e) => e.toLowerCase()); | ||
|
|
||
| if (normalized.some((e) => e.includes('ingest') || e.includes('write'))) { | ||
| details.push('Missing write permission to ingest traces.'); | ||
| } | ||
| if (normalized.some((e) => e.includes('read') || e.includes('query'))) { | ||
| details.push('Missing read permission to query results.'); | ||
| } | ||
|
|
||
| if (details.length === 0 && errors && errors.length > 0) { | ||
| details.push(...errors); | ||
| } | ||
|
|
||
| return indentErrorDetails([ | ||
| `Token does not have required permissions for dataset "${dataset}".`, | ||
| ...details, | ||
| ...buildPermissionHelp(consoleEndpointUrl), | ||
| ]); | ||
| }; | ||
|
|
||
| export async function validateTokenPermissions(config: ResolvedAxiomConfig) { | ||
| const connection = resolveAxiomConnection(config); | ||
|
|
||
| try { | ||
| const headers: Record<string, string> = { | ||
| 'X-Axiom-Org-Id': connection.orgId ?? '', | ||
| 'X-Axiom-Dataset': connection.dataset, | ||
| }; | ||
|
|
||
| if (connection.token) { | ||
| headers.Authorization = `Bearer ${connection.token}`; | ||
| } | ||
|
|
||
| console.log({ region: connection.edgeRegion }); | ||
|
|
||
| const response = await fetch( | ||
| `${connection.url}/api/v3/evaluations/validate?dataset=${connection.dataset}®ion=${connection.edgeRegion}`, | ||
| { | ||
| headers, | ||
| }, | ||
| ); | ||
|
|
||
| if (!response.ok) { | ||
| let serverMessage: string | undefined; | ||
| try { | ||
| const data = await response.json(); | ||
| console.debug('validation response', { data }) | ||
| serverMessage = data?.error || data?.message; | ||
| } catch { | ||
| serverMessage = undefined; | ||
| } | ||
|
|
||
| if (response.status === 404) { | ||
| throw new AxiomCLIError( | ||
| indentErrorDetails([ | ||
| `Dataset not found: "${connection.dataset}".`, | ||
| 'Check eval.dataset in axiom.config.ts or AXIOM_DATASET in your environment.', | ||
| `Manage datasets at: ${connection.consoleEndpointUrl}/datasets`, | ||
| ]).join('\n'), | ||
| ); | ||
| } | ||
|
|
||
| const statusLabel = serverMessage || response.statusText || 'Unknown error'; | ||
| const baseMessage = `Failed to validate token: ${statusLabel}`; | ||
|
|
||
| if (response.status === 401 || response.status === 403) { | ||
| throw new AxiomCLIError( | ||
| indentErrorDetails([ | ||
| baseMessage, | ||
| response.status === 401 | ||
| ? 'The token is missing or invalid.' | ||
| : `The token does not have access to dataset "${connection.dataset}".`, | ||
| `Check AXIOM_TOKEN or eval.token in axiom.config.ts.`, | ||
| ...buildPermissionHelp(connection.consoleEndpointUrl), | ||
| ]).join('\n'), | ||
| ); | ||
| } | ||
|
|
||
| throw new AxiomCLIError( | ||
| indentErrorDetails([ | ||
| baseMessage, | ||
| ...buildPermissionHelp(connection.consoleEndpointUrl), | ||
| ]).join('\n'), | ||
| ); | ||
| } | ||
|
|
||
| const result = (await response.json()) as TokenPermissionValidationResult; | ||
| if (!result.valid) { | ||
| result.errors = formatPermissionErrors( | ||
| result.errors, | ||
| connection.dataset, | ||
| connection.consoleEndpointUrl, | ||
| ); | ||
| } | ||
| return result; | ||
| } catch (error) { | ||
| const errorMessage = error instanceof Error ? error.message : 'Unknown error'; | ||
| throw new AxiomCLIError(errorMessage); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -258,6 +258,7 @@ async function registerEval< | |
| const [, instrumentationInitError] = await tryCatchAsync(instrumentationReady); | ||
| if (instrumentationInitError) { | ||
| instrumentationError = instrumentationInitError; | ||
| suite.meta.evaluation.instrumentationError = errorToString(instrumentationInitError); | ||
| } | ||
|
|
||
| suiteSpan = startSpan(`eval ${evalName}-${evalVersion}`, { | ||
|
|
@@ -293,22 +294,28 @@ async function registerEval< | |
| suiteSpan.setAttribute(Attr.Eval.Config.Flags, flagConfigJson); | ||
|
|
||
| let createEvalResponse; | ||
| let registrationError: Error | undefined = undefined; | ||
| if (!isDebug && !isList) { | ||
| createEvalResponse = await evaluationApiClient.createEvaluation({ | ||
| id: evalId, | ||
| name: evalName, | ||
| capability: opts.capability, | ||
| step: opts.step, | ||
| dataset: axiomConfig.eval.dataset, | ||
| version: evalVersion, | ||
| baselineId: baselineId ?? undefined, | ||
| runId: runId, | ||
| totalCases: collection.length, | ||
| config: { overrides: injectedOverrides }, | ||
| configTimeoutMs: timeoutMs, | ||
| metadata: opts.metadata, | ||
| status: 'running', | ||
| }); | ||
| try { | ||
| createEvalResponse = await evaluationApiClient.createEvaluation({ | ||
| id: evalId, | ||
| name: evalName, | ||
| capability: opts.capability, | ||
| step: opts.step, | ||
| dataset: axiomConfig.eval.dataset, | ||
| version: evalVersion, | ||
| baselineId: baselineId ?? undefined, | ||
| runId: runId, | ||
| totalCases: collection.length, | ||
| config: { overrides: injectedOverrides }, | ||
| configTimeoutMs: timeoutMs, | ||
| metadata: opts.metadata, | ||
| status: 'running', | ||
| }); | ||
| registrationError = createEvalResponse?.error; | ||
| } catch (error) { | ||
| registrationError = error as Error; | ||
| } | ||
| } | ||
|
|
||
| const orgId = createEvalResponse?.data?.orgId; | ||
|
|
@@ -342,10 +349,10 @@ async function registerEval< | |
| orgId: orgId ?? undefined, | ||
| baseline: baseline ?? undefined, | ||
| configFlags: opts.configFlags, | ||
| registrationStatus: instrumentationError | ||
| registrationStatus: registrationError | ||
| ? { | ||
| status: 'failed', | ||
| error: errorToString(instrumentationError), | ||
| error: errorToString(registrationError), | ||
|
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.
|
||
| } | ||
| : { status: 'success' }, | ||
| trials: opts.trials, | ||
|
|
@@ -424,14 +431,23 @@ async function registerEval< | |
|
|
||
| // signal Axiom that evaluation finished to kick of summary calculations | ||
| if (!isDebug && !isList) { | ||
| await evaluationApiClient.updateEvaluation({ | ||
| id: evalId, | ||
| status: 'completed', | ||
| totalCases: collection.length, | ||
| successCases, | ||
| erroredCases, | ||
| durationMs, | ||
| }); | ||
| try { | ||
| await evaluationApiClient.updateEvaluation({ | ||
| id: evalId, | ||
| status: 'completed', | ||
| totalCases: collection.length, | ||
| successCases, | ||
| erroredCases, | ||
| durationMs, | ||
| }); | ||
| } catch (error) { | ||
| if (suite.meta.evaluation?.registrationStatus?.status !== 'failed') { | ||
| suite.meta.evaluation.registrationStatus = { | ||
| status: 'failed', | ||
| error: errorToString(error), | ||
| }; | ||
| } | ||
| } | ||
| } | ||
| }); | ||
|
|
||
|
|
||


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.
Debug
console.logstatements left in production codeMedium Severity
console.log({ region: connection.edgeRegion })on line 78 prints a debug object to stdout on every eval run.console.debug('validation response', { data })on line 91 logs error response payloads. Both appear to be leftover debugging statements that will produce unexpected noisy output for users running evaluations.Additional Locations (1)
packages/ai/src/config/validate-eval-token-permissions.ts#L90-L91