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
16 changes: 13 additions & 3 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,8 @@ jobs:
tags: |
ghcr.io/${{ github.repository }}/api-proxy:${{ needs.bump-version.outputs.version_number }}
ghcr.io/${{ github.repository }}/api-proxy:latest
build-args: |
AWF_VERSION=${{ needs.bump-version.outputs.version_number }}
cache-from: type=gha,scope=api-proxy
cache-to: type=gha,mode=max,scope=api-proxy

Expand Down Expand Up @@ -523,10 +525,16 @@ jobs:
- name: Generate versioned JSON Schema
run: |
mkdir -p release
node scripts/generate-schema.mjs --version ${{ needs.bump-version.outputs.version }} --print > release/awf-config.v1.schema.json
cp release/awf-config.v1.schema.json release/awf-config.schema.json
# Generate all schemas with versioned $id URLs
node scripts/generate-schema.mjs --version ${{ needs.bump-version.outputs.version }}
# Copy generated files to release/ for upload
cp docs/awf-config.schema.json release/awf-config.schema.json
# Compatibility alias for consumers that previously pinned to awf-config.v1.schema.json
cp docs/awf-config.schema.json release/awf-config.v1.schema.json
cp schemas/audit.schema.json release/audit.schema.json
cp schemas/token-usage.schema.json release/token-usage.schema.json
echo "=== Schema preview (first 10 lines) ==="
head -10 release/awf-config.v1.schema.json
head -10 release/awf-config.schema.json

- name: Generate checksums
run: |
Expand Down Expand Up @@ -655,6 +663,8 @@ jobs:
release/containers.txt
release/awf-config.schema.json
release/awf-config.v1.schema.json
release/audit.schema.json
release/token-usage.schema.json
Comment on lines 664 to +667
release/checksums.txt
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
Expand Down
7 changes: 7 additions & 0 deletions containers/api-proxy/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,13 @@ RUN addgroup -S apiproxy && adduser -S apiproxy -G apiproxy
# Switch to non-root user
USER apiproxy

# AWF version baked into the image at release build time.
# The release workflow passes --build-arg AWF_VERSION=<tag> so that
# token-usage.jsonl records carry the exact api-proxy image version.
# Falls back to "0.0.0-dev" for local/un-versioned builds.
ARG AWF_VERSION=0.0.0-dev
ENV AWF_VERSION=${AWF_VERSION}

# Expose ports
# 10000 - OpenAI API proxy (also serves as health check endpoint)
# 10001 - Anthropic API proxy
Expand Down
24 changes: 18 additions & 6 deletions containers/api-proxy/token-tracker.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,18 @@ const TOKEN_LOG_FILE = path.join(TOKEN_LOG_DIR, 'token-usage.jsonl');
const DIAG_LOG_FILE = path.join(TOKEN_LOG_DIR, 'token-diag.log');
const DIAG_ENABLED = process.env.AWF_DEBUG_TOKENS === '1';

// AWF version used to identify schema version in JSONL records.
// Set to the container image version at build time via ARG AWF_VERSION in the Dockerfile
// (baked in by the release workflow with --build-arg AWF_VERSION=<version>).
// Falls back to "0.0.0-dev" for local/un-versioned builds.
const AWF_VERSION = process.env.AWF_VERSION;
if (!AWF_VERSION) {
// Log a warning (to stderr to avoid polluting stdout) when running without the env var.
// This can happen during local development or tests outside the container.
process.stderr.write('{"level":"warn","event":"awf_version_missing","message":"AWF_VERSION env var not set; _schema will use 0.0.0-dev"}\n');
}
const TOKEN_USAGE_SCHEMA = `token-usage/v${AWF_VERSION || '0.0.0-dev'}`;

let logStream = null;
let diagStream = null;

Expand Down Expand Up @@ -85,7 +97,7 @@ function getLogStream() {
}

/**
* Validate a token usage record against the token-usage/v1 schema contract.
* Validate a token usage record against the token-usage schema contract.
*
* Checks that all required fields are present and have the expected types.
* Logs a warning and returns false if the record is non-conformant; does
Expand Down Expand Up @@ -133,11 +145,11 @@ function validateTokenUsageRecord(record) {
}
}

if (record._schema !== 'token-usage/v1') {
if (!/^token-usage\/v\d+\.\d+\.\d+(-\w+)?$/.test(record._schema)) {
logRequest('warn', 'token_record_schema_violation', {
request_id: record.request_id,
field: '_schema',
expected: 'token-usage/v1',
expected: 'token-usage/v<semver>',
actual: record._schema,
});
return false;
Expand All @@ -148,7 +160,7 @@ function validateTokenUsageRecord(record) {

/**
* Write a token usage record to the JSONL log file.
* Validates the record against the token-usage/v1 schema before writing.
* Validates the record against the token-usage schema before writing.
* Handles backpressure by dropping writes when the stream buffer is full.
*/
function writeTokenUsage(record) {
Expand Down Expand Up @@ -543,7 +555,7 @@ function trackTokenUsage(proxyRes, opts) {

// Build log record
const record = {
_schema: 'token-usage/v1',
_schema: TOKEN_USAGE_SCHEMA,
timestamp: new Date().toISOString(),
request_id: requestId,
provider,
Expand Down Expand Up @@ -759,7 +771,7 @@ function trackWebSocketTokenUsage(upstreamSocket, opts) {
}

const record = {
_schema: 'token-usage/v1',
_schema: TOKEN_USAGE_SCHEMA,
timestamp: new Date().toISOString(),
request_id: requestId,
provider,
Expand Down
82 changes: 74 additions & 8 deletions containers/api-proxy/token-tracker.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1052,7 +1052,7 @@ describe('trackWebSocketTokenUsage', () => {

describe('validateTokenUsageRecord', () => {
const validRecord = {
_schema: 'token-usage/v1',
_schema: 'token-usage/v0.0.0-dev',
timestamp: '2025-01-01T00:00:00.000Z',
request_id: 'req-123',
provider: 'anthropic',
Expand All @@ -1075,10 +1075,20 @@ describe('validateTokenUsageRecord', () => {
expect(validateTokenUsageRecord({ ...validRecord, response_bytes: 512 })).toBe(true);
});

test('accepts any semver version in _schema', () => {
expect(validateTokenUsageRecord({ ...validRecord, _schema: 'token-usage/v1.2.3' })).toBe(true);
expect(validateTokenUsageRecord({ ...validRecord, _schema: 'token-usage/v0.26.0' })).toBe(true);
expect(validateTokenUsageRecord({ ...validRecord, _schema: 'token-usage/v0.0.0-dev' })).toBe(true);
});

test('rejects a record with wrong _schema', () => {
expect(validateTokenUsageRecord({ ...validRecord, _schema: 'wrong/v99' })).toBe(false);
});

test('rejects a record with non-semver _schema', () => {
expect(validateTokenUsageRecord({ ...validRecord, _schema: 'token-usage/v1' })).toBe(false);
});

test('rejects a record missing _schema', () => {
const { _schema, ...noSchema } = validRecord;
expect(validateTokenUsageRecord(noSchema)).toBe(false);
Expand Down Expand Up @@ -1158,9 +1168,9 @@ describe('token-usage JSONL record schema field', () => {
await closeLogStream();
});

test('writeTokenUsage serializes _schema:"token-usage/v1" into the JSONL stream', () => {
test('writeTokenUsage serializes _schema with semver version into the JSONL stream', () => {
const record = {
_schema: 'token-usage/v1',
_schema: 'token-usage/v0.0.0',
timestamp: new Date().toISOString(),
request_id: 'direct-write-test',
provider: 'openai',
Expand All @@ -1179,11 +1189,11 @@ describe('token-usage JSONL record schema field', () => {

expect(mockStream.write).toHaveBeenCalledTimes(1);
const parsed = mockStream.writtenRecords[0];
expect(parsed._schema).toBe('token-usage/v1');
expect(parsed._schema).toMatch(/^token-usage\/v\d+\.\d+\.\d+(-\w+)?$/);
expect(parsed.request_id).toBe('direct-write-test');
});

test('trackTokenUsage HTTP path writes _schema:"token-usage/v1" to the stream', (done) => {
test('trackTokenUsage HTTP path writes versioned _schema to the stream', (done) => {
const proxyRes = new EventEmitter();
proxyRes.headers = { 'content-type': 'application/json' };
proxyRes.statusCode = 200;
Expand All @@ -1205,13 +1215,13 @@ describe('token-usage JSONL record schema field', () => {
setTimeout(() => {
expect(mockStream.write).toHaveBeenCalledTimes(1);
const parsed = mockStream.writtenRecords[0];
expect(parsed._schema).toBe('token-usage/v1');
expect(parsed._schema).toMatch(/^token-usage\/v\d+\.\d+\.\d+(-\w+)?$/);
expect(parsed.request_id).toBe('schema-field-http');
done();
}, 20);
});

test('trackWebSocketTokenUsage path writes _schema:"token-usage/v1" to the stream', (done) => {
test('trackWebSocketTokenUsage path writes versioned _schema to the stream', (done) => {
const socket = new EventEmitter();

function buildFrame(text) {
Expand Down Expand Up @@ -1246,9 +1256,65 @@ describe('token-usage JSONL record schema field', () => {
setTimeout(() => {
expect(mockStream.write).toHaveBeenCalledTimes(1);
const parsed = mockStream.writtenRecords[0];
expect(parsed._schema).toBe('token-usage/v1');
expect(parsed._schema).toMatch(/^token-usage\/v\d+\.\d+\.\d+(-\w+)?$/);
expect(parsed.request_id).toBe('schema-field-ws');
done();
}, 20);
});
});

// ── AWF_VERSION env var propagated as exact _schema value ─────────────
//
// Uses jest.isolateModules() to load a fresh token-tracker instance with a
// controlled AWF_VERSION env var so the test verifies the exact _schema value
// emitted, not just the semver pattern.

describe('token-usage _schema exact version from AWF_VERSION', () => {
test('emits exact AWF_VERSION in _schema field', (done) => {
const origVersion = process.env.AWF_VERSION;
process.env.AWF_VERSION = '9.8.7';

// Load an isolated copy of token-tracker with AWF_VERSION=9.8.7 already set
let isolated;
jest.isolateModules(() => {
isolated = require('./token-tracker');
});

// Restore env var right away — the isolated module already captured it
process.env.AWF_VERSION = origVersion;

const mockStream = makeMockStream();
const mkdirSpy = jest.spyOn(fs, 'mkdirSync').mockReturnValue(undefined);
const writeStreamSpy = jest.spyOn(fs, 'createWriteStream').mockReturnValue(mockStream);

const proxyRes = new EventEmitter();
proxyRes.headers = { 'content-type': 'application/json' };
proxyRes.statusCode = 200;

isolated.trackTokenUsage(proxyRes, {
requestId: 'exact-version-test',
provider: 'openai',
path: '/v1/chat/completions',
startTime: Date.now(),
metrics: null,
});

proxyRes.emit('data', Buffer.from(JSON.stringify({
model: 'gpt-4o',
usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 },
})));
proxyRes.emit('end');

setTimeout(async () => {
mkdirSpy.mockRestore();
writeStreamSpy.mockRestore();
await isolated.closeLogStream();

expect(mockStream.write).toHaveBeenCalledTimes(1);
const parsed = mockStream.writtenRecords[0];
expect(parsed._schema).toBe('token-usage/v9.8.7');
expect(parsed.request_id).toBe('exact-version-test');
done();
}, 20);
});
});
1 change: 0 additions & 1 deletion docs/awf-config.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://raw.githubusercontent.com/github/gh-aw-firewall/main/docs/awf-config.schema.json",
"title": "AWF Configuration",
"version": "1",
"description": "JSON/YAML configuration for awf CLI. CLI flags override config file values. See https://github.com/github/gh-aw-firewall for documentation.",
"type": "object",
"additionalProperties": false,
Expand Down
Loading
Loading