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
99 changes: 99 additions & 0 deletions src/lib/db.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { Database } from "bun:sqlite"
import fs from "node:fs"
import path from "node:path"

import migration001 from "./migrations/001_initial.sql" with { type: "text" }

export const CURRENT_SCHEMA_VERSION = 1

const MIGRATIONS: Array<{ version: number; sql: string }> = [
{ version: 1, sql: migration001 },
]

let dbInstance: Database | undefined

export function initDb(dbPath: string): Database {
if (dbInstance) return dbInstance

if (dbPath !== ":memory:") {
fs.mkdirSync(path.dirname(dbPath), { recursive: true })
}

const db = new Database(dbPath, { create: true })

// Pragmas — set before any schema work.
db.run("PRAGMA journal_mode = WAL")
db.run("PRAGMA synchronous = NORMAL")
db.run("PRAGMA foreign_keys = ON")

runMigrations(db)

dbInstance = db
return db
}

export function getDb(): Database {
if (!dbInstance) {
throw new Error(
"Database not initialized. Call initDb(path) before getDb().",
)
}
return dbInstance
}

export function withTransaction<T>(fn: (db: Database) => T): T {
const db = getDb()
const tx = db.transaction((arg: () => T) => arg())
return tx(() => fn(db))
}

/**
* Test-only helper. Closes any current instance and clears the singleton so
* the next initDb call starts from scratch. Production code must never call
* this — it exists to keep tests isolated.
*/
export function __resetDbForTests(): void {
if (dbInstance) {
try {
dbInstance.close()
} catch {
// ignore
}
dbInstance = undefined
}
}

function runMigrations(db: Database): void {
// Bootstrap meta table so we can read schema_version.
db.run(
"CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)",
)

const row = db
.query<
{ value: string },
[]
>("SELECT value FROM meta WHERE key='schema_version'")
.get()
const currentVersion = row ? Number.parseInt(row.value, 10) : 0

const pending = MIGRATIONS.filter((m) => m.version > currentVersion).sort(
(a, b) => a.version - b.version,
)

if (pending.length === 0) return

const apply = db.transaction(() => {
for (const m of pending) {
// Migration SQL contains multiple statements; only exec() handles that.
// eslint-disable-next-line @typescript-eslint/no-deprecated
db.exec(m.sql)
}
db.run(
"INSERT INTO meta (key, value) VALUES ('schema_version', ?) "
+ "ON CONFLICT(key) DO UPDATE SET value=excluded.value",
[String(CURRENT_SCHEMA_VERSION)],
)
})
apply()
}
113 changes: 113 additions & 0 deletions src/lib/migrations/001_initial.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
-- Initial schema for copilot-api multi-account & usage billing.
-- Owned by tasks #1 (this migration), #2 (accounts loader), #6 (usage recorder),
-- #13 (pricing version writer). See docs/design/.

CREATE TABLE IF NOT EXISTS accounts (
name TEXT PRIMARY KEY,
account_type TEXT NOT NULL,
created_at INTEGER NOT NULL
);

CREATE TABLE IF NOT EXISTS model_pricing (
model_id TEXT PRIMARY KEY,
input_per_mtok REAL,
cached_input_per_mtok REAL,
output_per_mtok REAL,
reasoning_per_mtok REAL,
premium_multiplier REAL,
premium_unit_price REAL,
currency TEXT NOT NULL DEFAULT 'USD',
source TEXT,
source_skus TEXT,
updated_at INTEGER NOT NULL
);

CREATE TABLE IF NOT EXISTS pricing_sync_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts INTEGER NOT NULL,
status TEXT NOT NULL,
source_count INTEGER,
llm_model TEXT,
models_updated INTEGER,
models_rejected INTEGER,
error TEXT,
raw_request_json TEXT,
raw_response_json TEXT,
diff_json TEXT
);

CREATE TABLE IF NOT EXISTS model_pricing_versions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
model_id TEXT NOT NULL,
effective_from INTEGER NOT NULL,
effective_to INTEGER,
input_per_mtok REAL,
cached_input_per_mtok REAL,
output_per_mtok REAL,
reasoning_per_mtok REAL,
premium_multiplier REAL,
premium_unit_price REAL,
currency TEXT NOT NULL DEFAULT 'USD',
source TEXT,
source_skus TEXT,
sync_log_id INTEGER,
created_at INTEGER NOT NULL,
FOREIGN KEY (sync_log_id) REFERENCES pricing_sync_log(id)
);

CREATE INDEX IF NOT EXISTS idx_pricing_versions_model_time
ON model_pricing_versions(model_id, effective_from);

CREATE INDEX IF NOT EXISTS idx_pricing_versions_current
ON model_pricing_versions(model_id) WHERE effective_to IS NULL;

CREATE TABLE IF NOT EXISTS usage_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts INTEGER NOT NULL,
account_name TEXT NOT NULL,
model_id TEXT NOT NULL,
endpoint TEXT NOT NULL,
upstream_format TEXT NOT NULL,
is_streaming INTEGER NOT NULL,
input_tokens INTEGER DEFAULT 0,
cached_input_tokens INTEGER DEFAULT 0,
output_tokens INTEGER DEFAULT 0,
reasoning_tokens INTEGER DEFAULT 0,
total_tokens INTEGER DEFAULT 0,
premium_request_count REAL DEFAULT 0,
input_price_snapshot REAL,
cached_input_price_snapshot REAL,
output_price_snapshot REAL,
reasoning_price_snapshot REAL,
premium_unit_price_snapshot REAL,
premium_multiplier_snapshot REAL,
request_id TEXT,
status TEXT NOT NULL,
duration_ms INTEGER,
FOREIGN KEY (account_name) REFERENCES accounts(name)
);

CREATE INDEX IF NOT EXISTS idx_usage_account_model_ts
ON usage_events(account_name, model_id, ts);

CREATE INDEX IF NOT EXISTS idx_usage_ts ON usage_events(ts);

CREATE TABLE IF NOT EXISTS usage_daily (
day TEXT NOT NULL,
account_name TEXT NOT NULL,
model_id TEXT NOT NULL,
endpoint TEXT NOT NULL,
req_count INTEGER NOT NULL DEFAULT 0,
input_tokens INTEGER NOT NULL DEFAULT 0,
cached_input_tokens INTEGER NOT NULL DEFAULT 0,
output_tokens INTEGER NOT NULL DEFAULT 0,
reasoning_tokens INTEGER NOT NULL DEFAULT 0,
total_tokens INTEGER NOT NULL DEFAULT 0,
premium_requests REAL NOT NULL DEFAULT 0,
PRIMARY KEY (day, account_name, model_id, endpoint)
);

CREATE TABLE IF NOT EXISTS meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
2 changes: 2 additions & 0 deletions src/lib/paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@ import path from "node:path"
const APP_DIR = path.join(os.homedir(), ".local", "share", "copilot-api")

const GITHUB_TOKEN_PATH = path.join(APP_DIR, "github_token")
const USAGE_DB_PATH = path.join(APP_DIR, "usage.sqlite")

export const PATHS = {
APP_DIR,
GITHUB_TOKEN_PATH,
USAGE_DB_PATH,
}

export async function ensurePaths(): Promise<void> {
Expand Down
12 changes: 11 additions & 1 deletion src/start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ import consola from "consola"
import { serve, type ServerHandler } from "srvx"
import invariant from "tiny-invariant"

import { ensurePaths } from "./lib/paths"
import { initDb } from "./lib/db"
import { ensurePaths, PATHS } from "./lib/paths"
import { initProxyFromEnv } from "./lib/proxy"
import { generateEnvScript } from "./lib/shell"
import { state } from "./lib/state"
Expand All @@ -25,6 +26,7 @@ interface RunServerOptions {
claudeCode: boolean
showToken: boolean
proxyEnv: boolean
dbPath: string
}

export async function runServer(options: RunServerOptions): Promise<void> {
Expand All @@ -48,6 +50,7 @@ export async function runServer(options: RunServerOptions): Promise<void> {
state.showToken = options.showToken

await ensurePaths()
initDb(options.dbPath)
await cacheVSCodeVersion()

if (options.githubToken) {
Expand Down Expand Up @@ -184,6 +187,12 @@ export const start = defineCommand({
default: false,
description: "Initialize proxy from environment variables",
},
"db-path": {
type: "string",
default: PATHS.USAGE_DB_PATH,
description:
"Path to the usage SQLite database (defaults to ~/.local/share/copilot-api/usage.sqlite)",
},
},
run({ args }) {
const rateLimitRaw = args["rate-limit"]
Expand All @@ -202,6 +211,7 @@ export const start = defineCommand({
claudeCode: args["claude-code"],
showToken: args["show-token"],
proxyEnv: args["proxy-env"],
dbPath: args["db-path"],
})
},
})
4 changes: 4 additions & 0 deletions src/types.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
declare module "*.sql" {
const content: string
export default content
}
Loading