ghc-tunnel 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 sxwxs
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,132 @@
1
+ # ghc-tunnel
2
+
3
+ GitHub Copilot API Proxy — exposes standard **OpenAI** and **Anthropic** compatible endpoints so any tool (including Claude Code) can use GitHub Copilot models.
4
+
5
+ ## Quick Start
6
+
7
+ ```bash
8
+ # Run directly (Node.js 18+ required)
9
+ npx ghc-tunnel
10
+
11
+ # Or install globally
12
+ npm install -g ghc-tunnel
13
+ ghc-tunnel
14
+
15
+ # Interactive setup (configures models + Claude Code settings)
16
+ ghc-tunnel --setup
17
+
18
+ # Update Claude Code settings only
19
+ ghc-tunnel --setup --claudecode
20
+ ```
21
+
22
+ On first run the proxy initiates GitHub Device Flow authentication if no `GITHUB_TOKEN` is set.
23
+
24
+ ## Features
25
+
26
+ - **OpenAI-compatible** `/v1/chat/completions` and `/v1/responses` endpoints
27
+ - **Anthropic-compatible** `/v1/messages` endpoint (direct or translated)
28
+ - Automatic **model name translation** via configurable mappings
29
+ - **Streaming** support (SSE) for all endpoints
30
+ - **Request cache** with analytics dashboard
31
+ - **Retry with backoff** for upstream connection errors
32
+ - **Content filtering** (system prompt manipulation, tool result cleaning)
33
+ - **Token management** with automatic refresh
34
+
35
+ ## CLI Options
36
+
37
+ ```
38
+ ghc-tunnel [options]
39
+
40
+ -s, --setup Interactive setup wizard (configure models + Claude Code)
41
+ --claudecode Update Claude Code settings only (use with --setup)
42
+ -p, --port <port> Port to listen on (default: 8314)
43
+ -a, --address <addr> Address to listen on (default: localhost)
44
+ -c, --config Generate default config file
45
+ -v, --version Show version
46
+ -h, --help Show help
47
+ ```
48
+
49
+ ## Claude Code Integration
50
+
51
+ Run `ghc-tunnel --setup --claudecode` or manually configure `~/.claude/settings.json`:
52
+
53
+ ```json
54
+ {
55
+ "env": {
56
+ "ANTHROPIC_BASE_URL": "http://localhost:8314/",
57
+ "ANTHROPIC_AUTH_TOKEN": "dummy",
58
+ "ANTHROPIC_MODEL": "claude-opus-4-6[1m]",
59
+ "ANTHROPIC_DEFAULT_HAIKU_MODEL": "claude-sonnet-4-6"
60
+ }
61
+ }
62
+ ```
63
+
64
+ ## Configuration
65
+
66
+ Config file: `~/.ghc-tunnel/config.yaml` (generated on first run or with `--config`).
67
+
68
+ See [docs/configuration.md](docs/configuration.md) for full reference.
69
+
70
+ ## API Endpoints
71
+
72
+ | Endpoint | Description |
73
+ |----------|-------------|
74
+ | `POST /v1/chat/completions` | OpenAI chat completions |
75
+ | `POST /v1/responses` | OpenAI responses API |
76
+ | `GET /v1/models` | List available models |
77
+ | `POST /v1/messages` | Anthropic messages API |
78
+ | `GET /` | Web dashboard |
79
+ | `GET /requests` | Request browser |
80
+
81
+ ## Example Usage
82
+
83
+ ### OpenAI SDK
84
+
85
+ ```python
86
+ from openai import OpenAI
87
+
88
+ client = OpenAI(
89
+ base_url="http://localhost:8314/v1",
90
+ api_key="not-needed"
91
+ )
92
+
93
+ response = client.chat.completions.create(
94
+ model="gpt-4o",
95
+ messages=[{"role": "user", "content": "Hello!"}]
96
+ )
97
+ ```
98
+
99
+ ### Anthropic SDK
100
+
101
+ ```python
102
+ import anthropic
103
+
104
+ client = anthropic.Anthropic(
105
+ base_url="http://localhost:8314",
106
+ api_key="not-needed"
107
+ )
108
+
109
+ message = client.messages.create(
110
+ model="claude-sonnet-4",
111
+ max_tokens=1024,
112
+ messages=[{"role": "user", "content": "Hello!"}]
113
+ )
114
+ ```
115
+
116
+ ### cURL
117
+
118
+ ```bash
119
+ curl http://localhost:8314/v1/chat/completions \
120
+ -H "Content-Type: application/json" \
121
+ -d '{"model": "gpt-4o", "messages": [{"role": "user", "content": "Hello!"}]}'
122
+ ```
123
+
124
+ ## Documentation
125
+
126
+ - [Architecture](docs/architecture.md) — system design and data flow
127
+ - [API Reference](docs/api.md) — all HTTP endpoints
128
+ - [Configuration](docs/configuration.md) — config file, env vars, CLI options
129
+
130
+ ## License
131
+
132
+ MIT
package/dist/index.js ADDED
@@ -0,0 +1,186 @@
1
+ #!/usr/bin/env node
2
+ "use strict";var dt=Object.create;var Ie=Object.defineProperty;var gt=Object.getOwnPropertyDescriptor;var mt=Object.getOwnPropertyNames;var ft=Object.getPrototypeOf,_t=Object.prototype.hasOwnProperty;var ht=(t,e,o,n)=>{if(e&&typeof e=="object"||typeof e=="function")for(let s of mt(e))!_t.call(t,s)&&s!==o&&Ie(t,s,{get:()=>e[s],enumerable:!(n=gt(e,s))||n.enumerable});return t};var A=(t,e,o)=>(o=t!=null?dt(ft(t)):{},ht(e||!t||!t.__esModule?Ie(o,"default",{value:t,enumerable:!0}):o,t));var lt=A(require("fs")),pt=A(require("path")),he=A(require("express"));var te=A(require("fs")),ce=A(require("path")),ye=A(require("os")),Ne=A(require("js-yaml")),je=parseInt(process.env.PORT||"8314",10),He=process.env.HOST||"localhost",Le="https://api.github.com",ne="0.26.7",oe="2025-04-01",se="1.93.0",Re={exact:{opus:"claude-opus-4.6-1m",sonnet:"claude-opus-4.6-1m",haiku:"claude-haiku-4.5"},prefix:{"claude-sonnet-4-":"claude-opus-4.6-1m","claude-opus-4.5-":"claude-opus-4.6-1m","claude-opus-4.6-":"claude-opus-4.6-1m","claude-opus-4-5-":"claude-opus-4.6-1m","claude-opus-4-6-":"claude-opus-4.6-1m","claude-opus-4.5":"claude-opus-4.6-1m","claude-opus-4.6":"claude-opus-4.6-1m","claude-opus-4-6":"claude-opus-4.6-1m","claude-opus-4-6[1m]":"claude-opus-4.6-1m","claude-sonnet-4-6":"claude-opus-4.6-1m","claude-sonnet-4-5":"claude-opus-4.6-1m","claude-haiku-4.5-":"claude-haiku-4.5","claude-haiku-4-5-":"claude-haiku-4.5"}},be="01ab8ac9400c4e429b23",ke=class{exactMappings={};prefixMappings={};translate(e){if(e in this.exactMappings)return this.exactMappings[e];for(let[o,n]of Object.entries(this.prefixMappings))if(e.startsWith(o))return n;return e}loadFromConfig(e){let o=e.model_mappings;this.exactMappings=o?.exact??{},this.prefixMappings=o?.prefix??{}}},M=new ke;function P(){return process.platform==="win32"?ce.default.join(process.env.APPDATA||ye.default.homedir(),"ghc-tunnel"):ce.default.join(ye.default.homedir(),".ghc-tunnel")}function Me(t){let e=te.default.readFileSync(t,"utf-8");return Ne.default.load(e)||{}}function we(){let t=P();te.default.mkdirSync(t,{recursive:!0});let e=ce.default.join(t,"config.yaml");if(te.default.existsSync(e)){console.log(`Configuration file already exists at: ${e}`);return}let o=`# GitHub Copilot API Proxy Configuration
3
+ # ========================================
4
+
5
+ # Server Settings
6
+ address: localhost
7
+ port: 8314
8
+ debug: false
9
+
10
+ # GitHub Copilot Account Type
11
+ # Options: "individual" | "business" | "enterprise"
12
+ account_type: individual
13
+
14
+ # Header version strings (only affect request headers to Copilot API)
15
+ vscode_version: "${se}"
16
+ api_version: "${oe}"
17
+ copilot_version: "${ne}"
18
+
19
+ # Model Name Mappings
20
+ # Two types: exact (full name match) and prefix (starts-with match)
21
+ model_mappings:
22
+ exact:
23
+ opus: claude-opus-4.6-1m
24
+ sonnet: claude-opus-4.6-1m
25
+ haiku: claude-haiku-4.5
26
+ prefix:
27
+ claude-sonnet-4-: claude-opus-4.6-1m
28
+ claude-opus-4.5-: claude-opus-4.6-1m
29
+ claude-opus-4.6-: claude-opus-4.6-1m
30
+ claude-opus-4-5-: claude-opus-4.6-1m
31
+ claude-opus-4-6-: claude-opus-4.6-1m
32
+ "claude-opus-4.5": claude-opus-4.6-1m
33
+ "claude-opus-4.6": claude-opus-4.6-1m
34
+ "claude-opus-4-6": claude-opus-4.6-1m
35
+ "claude-opus-4-6[1m]": claude-opus-4.6-1m
36
+ claude-sonnet-4-6: claude-opus-4.6-1m
37
+ claude-sonnet-4-5: claude-opus-4.6-1m
38
+ claude-haiku-4.5-: claude-haiku-4.5
39
+ claude-haiku-4-5-: claude-haiku-4.5
40
+
41
+ # Content Filtering
42
+ # system_prompt_remove: strings to strip from system prompts
43
+ # system_prompt_add: strings to append to system prompts
44
+ # tool_result_suffix_remove: trailing strings to strip from tool results
45
+ system_prompt_remove: []
46
+ system_prompt_add: []
47
+ tool_result_suffix_remove: []
48
+
49
+ # Retry Settings
50
+ # Max retries for upstream connection errors (0 = no retries)
51
+ max_connection_retries: 3
52
+ `;te.default.writeFileSync(e,o,"utf-8"),console.log(`Configuration file generated at: ${e}`)}var ve=class{githubToken="";copilotToken=null;models=null;accountType="individual";tokenExpiresAt=0;vscodeVersion=se;copilotVersion=ne;apiVersion=oe;systemPromptRemove=[];systemPromptAdd=[];toolResultSuffixRemove=[];redirectAnthropic=!1;maxConnectionRetries=3;get editorPluginVersion(){return`copilot-chat/${this.copilotVersion}`}get userAgent(){return`GitHubCopilotChat/${this.copilotVersion}`}},g=new ve;var re=A(require("fs")),Ue=require("child_process"),Fe=A(require("path"));function ze(){let t=P();return re.default.mkdirSync(t,{recursive:!0}),Fe.default.join(t,"github_token.txt")}function yt(){let t=ze();if(!re.default.existsSync(t))return null;try{let e=re.default.readFileSync(t,"utf-8").trim();if(e)return console.log(`Loaded GitHub token from ${t}`),e}catch(e){console.log(`Failed to read token file: ${e}`)}return null}function kt(t){try{let e=ze();re.default.writeFileSync(e,t,"utf-8"),console.log(`Saved GitHub token to ${e}`)}catch(e){console.log(`Failed to save token file: ${e}`)}}function Rt(t){let e=process.platform==="darwin"?"open":process.platform==="win32"?"start":"xdg-open";(0,Ue.exec)(`${e} ${t}`,()=>{})}function bt(t){return new Promise(e=>setTimeout(e,t))}async function wt(){console.log(`
53
+ `+"=".repeat(60)),console.log("GitHub Device Flow Authentication"),console.log("=".repeat(60));let t=await fetch("https://github.com/login/device/code",{method:"POST",headers:{Accept:"application/json","Content-Type":"application/x-www-form-urlencoded"},body:new URLSearchParams({client_id:be,scope:"read:user copilot"})});if(!t.ok)return console.log(`Failed to get device code: ${t.status} ${await t.text()}`),null;let e=await t.json(),{device_code:o,user_code:n,verification_uri:s,expires_in:r=900}=e,i=e.interval??5;console.log(`
54
+ Please visit: ${s}`),console.log(`And enter the code: ${n}`),console.log(`
55
+ Waiting for authorization (expires in ${r} seconds)...`),Rt(s),console.log("(Browser opened automatically)");let c=Date.now()+r*1e3;for(;Date.now()<c;){await bt(i*1e3);let a=await fetch("https://github.com/login/oauth/access_token",{method:"POST",headers:{Accept:"application/json","Content-Type":"application/x-www-form-urlencoded"},body:new URLSearchParams({client_id:be,device_code:o,grant_type:"urn:ietf:params:oauth:grant-type:device_code"})});if(!a.ok)continue;let u=await a.json();if(u.error==="authorization_pending"){process.stdout.write(".");continue}if(u.error==="slow_down"){i+=5;continue}if(u.error==="expired_token")return console.log(`
56
+ Authorization expired. Please try again.`),null;if(u.error==="access_denied")return console.log(`
57
+ Authorization denied by user.`),null;if(u.error)return console.log(`
58
+ Error: ${u.error_description||u.error}`),null;if(u.access_token)return console.log(`
59
+
60
+ Authorization successful!`),u.access_token}return console.log(`
61
+ Authorization timed out. Please try again.`),null}async function ae(){let t=(process.env.GITHUB_TOKEN||"").trim();if(t)return console.log("Using GitHub token from GITHUB_TOKEN environment variable"),t;let e=yt();if(e)return e;console.log(`
62
+ No GitHub token found. Starting GitHub Device Flow authentication...`);let o=await wt();return o?(kt(o),o):null}function D(){return g.accountType==="individual"?"https://api.githubcopilot.com":`https://api.${g.accountType}.githubcopilot.com`}function vt(){return{"Content-Type":"application/json",Accept:"application/json",Authorization:`token ${g.githubToken}`,"Editor-Version":`vscode/${g.vscodeVersion}`,"Editor-Plugin-Version":g.editorPluginVersion,"User-Agent":g.userAgent,"X-GitHub-Api-Version":g.apiVersion,"X-VSCode-User-Agent-Library-Version":"electron-fetch"}}function V(t=!1){let e={Authorization:`Bearer ${g.copilotToken}`,"Content-Type":"application/json","Copilot-Integration-Id":"vscode-chat","Editor-Version":`vscode/${g.vscodeVersion}`,"Editor-Plugin-Version":g.editorPluginVersion,"User-Agent":g.userAgent,"OpenAI-Intent":"conversation-panel","X-GitHub-Api-Version":g.apiVersion,"X-Request-Id":crypto.randomUUID(),"X-VSCode-User-Agent-Library-Version":"electron-fetch"};return t&&(e["Copilot-Vision-Request"]="true"),e}async function ie(){if(g.copilotToken&&Date.now()/1e3<g.tokenExpiresAt-60)return;console.log("Refreshing Copilot token...");let t=await fetch(`${Le}/copilot_internal/v2/token`,{headers:vt()});if(!t.ok)throw new Error(`Failed to get Copilot token: ${t.status} ${await t.text()}`);let e=await t.json();g.copilotToken=e.token,g.tokenExpiresAt=Date.now()/1e3+(e.refresh_in??1800),console.log("Copilot token refreshed successfully")}async function N(){(!g.copilotToken||Date.now()/1e3>=g.tokenExpiresAt-60)&&await ie()}async function W(){await N();let t=await fetch(`${D()}/models`,{headers:V()});t.ok?(g.models=await t.json(),console.log(`Loaded ${g.models.data?.length??0} models`)):console.log(`Failed to fetch models: ${t.status}`)}function Be(t){return g.redirectAnthropic?!1:g.models?.data?.find(o=>o.id===t)?.supported_endpoints?.includes("/v1/messages")??!1}function Ve(t){return g.models?.data?.find(o=>o.id===t)?.supported_endpoints?.includes("/responses")??!1}function j(t){return Math.ceil(t.length/4)}var J=A(require("fs")),ue=A(require("path"));function K(t,e,o,n){let s=P();J.default.mkdirSync(s,{recursive:!0});let r=ue.default.join(s,"error.log"),i={timestamp:new Date().toISOString(),endpoint:t,status_code:n,request:e,response:o};J.default.appendFileSync(r,JSON.stringify(i)+`
63
+ `,"utf-8")}function le(t,e,o,n,s){try{let r=P();J.default.mkdirSync(r,{recursive:!0});let i=ue.default.join(r,"connection_retry.jl"),c={timestamp:new Date().toISOString(),request_id:t,endpoint:e,attempt:o+1,max_attempts:n+1,error_type:s.constructor.name,error_message:s.message};J.default.appendFileSync(i,JSON.stringify(c)+`
64
+ `,"utf-8")}catch{}}function pe(t){try{let e=P();J.default.mkdirSync(e,{recursive:!0});let o=ue.default.join(e,"tool_result_cleanup.jl");t.timestamp=new Date().toISOString(),J.default.appendFileSync(o,JSON.stringify(t)+`
65
+ `,"utf-8")}catch{}}function xe(t){let e=[],o="unexpected `tool_use_id` found in `tool_result` blocks: ",n=t.indexOf(o);if(n!==-1){let s=n+o.length,r=s;for(;r<t.length&&!/[. "'\\\n]/.test(t[r]);)r++;let i=t.slice(s,r).trim();i&&e.push(i)}if(e.length===0){let s=t;for(;s.includes("toolu_");){let r=s.indexOf("toolu_"),i=r+6;for(;i<s.length&&/[\w-]/.test(s[i]);)i++;let c=s.slice(r,i);c&&!e.includes(c)&&e.push(c),s=s.slice(i)}}return e}function $e(t,e){if(!e.length)return t;let o=new Set(e),n=[];for(let s of t){if(s.role!=="user"){n.push(s);continue}let r=s.content;if(!Array.isArray(r)){n.push(s);continue}let i=r.filter(c=>c.type==="tool_result"&&o.has(c.tool_use_id)?(console.log(`[Tool Result Cleanup] Removing orphaned tool_result: ${c.tool_use_id}`),!1):!0);i.length>0&&n.push({...s,content:i})}return n}function Se(t,e){return t===400&&e.includes("tool_use_id")&&e.includes("tool_result")}function Ae(){console.log(`
66
+ `+"=".repeat(60)),console.log("Model Name Mappings"),console.log("=".repeat(60));let t=Object.entries(M.exactMappings);if(t.length){console.log(`
67
+ Exact Mappings:`);for(let[o,n]of t)console.log(` ${o} -> ${n}`)}else console.log(`
68
+ Exact Mappings: (none)`);let e=Object.entries(M.prefixMappings);if(e.length){console.log(`
69
+ Prefix Mappings:`);for(let[o,n]of e)console.log(` ${o}* -> ${n}`)}else console.log(`
70
+ Prefix Mappings: (none)`);console.log("=".repeat(60)+`
71
+ `)}function Ce(){if(!g.models?.data?.length){console.log("No models available yet.");return}console.log(`
72
+ `+"=".repeat(60)),console.log("Available Models"),console.log("=".repeat(60));for(let t of g.models.data){let e=t.capabilities,o=c=>c&&c>=1e3?`${Math.floor(c/1e3)}K`:String(c??"N/A"),n=o(e?.limits?.max_context_window_tokens),s=o(e?.limits?.max_prompt_tokens),r=o(e?.limits?.max_output_tokens),i=[];e?.supports?.vision&&i.push("Vision"),e?.supports?.tool_calls&&i.push("Tool"),t.supported_endpoints?.includes("/v1/messages")&&i.push("Anthropic"),t.preview&&i.push("Preview"),console.log(`${t.id.padEnd(30)} ctx: ${n} in: ${s} out: ${r} [${t.vendor??"unknown"}] (${i.join(",")})`)}console.log(`
73
+ `+"=".repeat(60)+`
74
+ `)}var We=require("express");var Oe=class{cache=new Map;maxEntries;requestCount=0;bytesSent=0;bytesReceived=0;modelStats={};endpointStats={};constructor(e=1e3){this.maxEntries=e}startRequest(e,o){this.evictIfFull(),this.cache.set(e,{id:e,timestamp:new Date().toISOString(),original_request_body:o.original_request_body??null,request_body:o.request_body??null,response_body:null,model:o.model??"unknown",translated_model:o.translated_model??null,endpoint:o.endpoint??"unknown",status_code:null,request_size:o.request_size??0,response_size:0,input_tokens:0,output_tokens:0,duration:0,state:"pending"})}updateRequestState(e,o,n){let s=this.cache.get(e);s&&(s.state=o,n&&Object.assign(s,n))}completeRequest(e,o){let n=this.cache.get(e);n?Object.assign(n,{response_body:o.response_body,status_code:o.status_code??200,response_size:o.response_size??0,input_tokens:o.input_tokens??0,output_tokens:o.output_tokens??0,duration:o.duration??0,state:(o.status_code??200)<400?"completed":"error"}):(this.evictIfFull(),n={id:e,timestamp:new Date().toISOString(),original_request_body:o.original_request_body??null,request_body:o.request_body??null,response_body:o.response_body??null,model:o.model??"unknown",translated_model:o.translated_model??null,endpoint:o.endpoint??"unknown",status_code:o.status_code??200,request_size:o.request_size??0,response_size:o.response_size??0,input_tokens:o.input_tokens??0,output_tokens:o.output_tokens??0,duration:o.duration??0,state:(o.status_code??200)<400?"completed":"error"},this.cache.set(e,n)),this.requestCount++,this.bytesSent+=o.request_size??0,this.bytesReceived+=o.response_size??0,this.updateModelStats(o),this.updateEndpointStats(o)}addRequest(e,o){this.cache.has(e)?this.completeRequest(e,o):(this.startRequest(e,o),this.completeRequest(e,o))}getRequest(e){return this.cache.get(e)}getRecentRequests(e=50,o=0){return[...this.cache.values()].reverse().slice(o,o+e)}getTotalCount(){return this.cache.size}getStats(){return{total_requests:this.requestCount,cached_requests:this.cache.size,bytes_sent:this.bytesSent,bytes_received:this.bytesReceived,model_stats:{...this.modelStats},endpoint_stats:{...this.endpointStats}}}searchRequests(e,o=50,n=0){let s=e.toLowerCase(),r=[];for(let i of[...this.cache.values()].reverse())(i.model.toLowerCase().includes(s)||i.endpoint.toLowerCase().includes(s)||JSON.stringify(i.request_body).toLowerCase().includes(s))&&r.push(i);return r.slice(n,n+o)}fulltextSearch(e,o=50,n=0){let s=e.toLowerCase(),r=[];for(let i of[...this.cache.values()].reverse()){let c=JSON.stringify(i.request_body).toLowerCase(),a=JSON.stringify(i.response_body).toLowerCase();(c.includes(s)||a.includes(s))&&r.push(i)}return[r.slice(n,n+o),r.length]}getAllRequests(){return[...this.cache.values()]}importRequest(e){let o=e.id||crypto.randomUUID();this.evictIfFull();let n={id:o,timestamp:e.timestamp||new Date().toISOString(),original_request_body:e.original_request_body??null,request_body:e.request_body??null,response_body:e.response_body??null,model:e.model||"unknown",translated_model:e.translated_model||null,endpoint:e.endpoint||"unknown",status_code:e.status_code??200,request_size:e.request_size??0,response_size:e.response_size??0,input_tokens:e.input_tokens??0,output_tokens:e.output_tokens??0,duration:e.duration??0,state:e.state||"completed"};this.cache.set(o,n),this.requestCount++,this.bytesSent+=n.request_size,this.bytesReceived+=n.response_size,this.updateModelStats(n),this.updateEndpointStats(n)}evictIfFull(){if(this.cache.size>=this.maxEntries){let e=this.cache.keys().next().value;this.cache.delete(e)}}updateModelStats(e){let o=e.model??"unknown";this.modelStats[o]||(this.modelStats[o]={request_count:0,input_tokens:0,output_tokens:0,bytes_sent:0,bytes_received:0});let n=this.modelStats[o];n.request_count++,n.input_tokens+=e.input_tokens??0,n.output_tokens+=e.output_tokens??0,n.bytes_sent+=e.request_size??0,n.bytes_received+=e.response_size??0}updateEndpointStats(e){let o=e.endpoint??"unknown";this.endpointStats[o]||(this.endpointStats[o]={request_count:0,bytes_sent:0,bytes_received:0});let n=this.endpointStats[o];n.request_count++,n.bytes_sent+=e.request_size??0,n.bytes_received+=e.response_size??0}},y=new Oe;function X(t){return M.translate(t)}function de(t){for(let e of g.systemPromptRemove)t.includes(e)&&(t=t.replaceAll(e,""),console.log(`[Content Filter] Removed from system prompt: ${e.slice(0,50)}${e.length>50?"...":""}`));return t}function Te(t){for(let e of g.toolResultSuffixRemove)t.endsWith(e)&&(t=t.slice(0,-e.length),console.log(`[Content Filter] Removed suffix from tool result: ${e.slice(0,50)}${e.length>50?"...":""}`));return t}function Je(t){let e=[],o=t.system;if(o){if(typeof o=="string")e.push({role:"system",content:de(o)});else if(Array.isArray(o)){let r=o.filter(i=>i.type==="text"&&!(i.text||"").includes("x-anthropic-billing-header")).map(i=>i.text);r.length&&e.push({role:"system",content:de(r.join(`
75
+
76
+ `))})}}for(let r of t.messages||[]){let i=r.role,c=r.content;if(i==="user")if(Array.isArray(c)){let a=c.filter(p=>p.type==="tool_result"),u=c.filter(p=>p.type!=="tool_result");for(let p of a){let l=p.content??"";typeof l=="string"&&(l=Te(l)),e.push({role:"tool",tool_call_id:p.tool_use_id,content:l})}if(u.length){let p=xt(u);p!=null&&e.push({role:"user",content:p})}}else e.push({role:"user",content:c});else if(i==="assistant")if(Array.isArray(c)){let a=c.filter(l=>l.type==="tool_use"),p=c.filter(l=>l.type==="text"||l.type==="thinking").map(l=>(l.type==="text"?l.text:l.thinking)??"").join(`
77
+
78
+ `);a.length?e.push({role:"assistant",content:p||null,tool_calls:a.map(l=>({id:l.id,type:"function",function:{name:l.name,arguments:JSON.stringify(l.input??{})}}))}):e.push({role:"assistant",content:p})}else e.push({role:"assistant",content:c})}let n={model:X(t.model??""),messages:e,max_tokens:t.max_tokens,stream:t.stream??!1};t.temperature!=null&&(n.temperature=t.temperature),t.top_p!=null&&(n.top_p=t.top_p),t.stop_sequences&&(n.stop=t.stop_sequences),Array.isArray(t.tools)&&(n.tools=t.tools.map(r=>({type:"function",function:{name:r.name,description:r.description,parameters:r.input_schema??{}}})));let s=t.tool_choice;if(s){let r=s.type;r==="auto"?n.tool_choice="auto":r==="any"?n.tool_choice="required":r==="none"?n.tool_choice="none":r==="tool"&&s.name&&(n.tool_choice={type:"function",function:{name:s.name}})}return n}function xt(t){if(!t.some(n=>n.type==="image")){let n=[];for(let s of t)s.type==="text"?n.push(s.text??""):s.type==="thinking"&&n.push(s.thinking??"");return n.length?n.join(`
79
+
80
+ `):null}let o=[];for(let n of t)if(n.type==="text")o.push({type:"text",text:n.text});else if(n.type==="thinking")o.push({type:"text",text:n.thinking});else if(n.type==="image"){let s=n.source;o.push({type:"image_url",image_url:{url:`data:${s?.media_type};base64,${s?.data}`}})}return o.length?o:null}var $t={stop:"end_turn",length:"max_tokens",tool_calls:"tool_use",content_filter:"refusal"};function Pe(t){return t?$t[t]??null:null}function Ee(t){let e=[],o=null;for(let i of t.choices||[]){let c=i.message;if(c){if(c.content&&e.push({type:"text",text:c.content}),Array.isArray(c.tool_calls))for(let a of c.tool_calls){let u=a.function,p;try{p=JSON.parse(u.arguments||"{}")}catch{p={_raw_arguments:u.arguments}}e.push({type:"tool_use",id:a.id,name:u.name,input:p})}i.finish_reason&&(o=i.finish_reason)}}let n=t.usage??{},s=n.prompt_tokens_details?.cached_tokens??0,r=(n.prompt_tokens??0)-s;return{id:t.id??crypto.randomUUID(),type:"message",role:"assistant",content:e,model:t.model??"",stop_reason:Pe(o),stop_sequence:null,usage:{input_tokens:r,output_tokens:n.completion_tokens??0,...s?{cache_read_input_tokens:s}:{}}}}var ge=class{messageStartSent=!1;contentBlockIndex=0;contentBlockOpen=!1;toolCalls={}};function Ge(t,e){let o=[];if(!t.choices?.length)return o;let n=t.choices[0],s=n.delta??{};if(!e.messageStartSent){let r=t.usage??{},i=r.prompt_tokens_details?.cached_tokens??0;o.push({type:"message_start",message:{id:t.id??crypto.randomUUID(),type:"message",role:"assistant",content:[],model:t.model??"",stop_reason:null,stop_sequence:null,usage:{input_tokens:(r.prompt_tokens??0)-i,output_tokens:0,...i?{cache_read_input_tokens:i}:{}}}}),e.messageStartSent=!0}if(s.content&&(e.contentBlockOpen&&Object.values(e.toolCalls).some(r=>r.anthropicBlockIndex===e.contentBlockIndex)&&(o.push({type:"content_block_stop",index:e.contentBlockIndex}),e.contentBlockIndex++,e.contentBlockOpen=!1),e.contentBlockOpen||(o.push({type:"content_block_start",index:e.contentBlockIndex,content_block:{type:"text",text:""}}),e.contentBlockOpen=!0),o.push({type:"content_block_delta",index:e.contentBlockIndex,delta:{type:"text_delta",text:s.content}})),s.tool_calls)for(let r of s.tool_calls){let i=r.index??0;if(r.id&&r.function?.name){e.contentBlockOpen&&(o.push({type:"content_block_stop",index:e.contentBlockIndex}),e.contentBlockIndex++,e.contentBlockOpen=!1);let c=e.contentBlockIndex;e.toolCalls[i]={id:r.id,name:r.function.name,anthropicBlockIndex:c},o.push({type:"content_block_start",index:c,content_block:{type:"tool_use",id:r.id,name:r.function.name,input:{}}}),e.contentBlockOpen=!0}if(r.function?.arguments){let c=e.toolCalls[i];c&&o.push({type:"content_block_delta",index:c.anthropicBlockIndex,delta:{type:"input_json_delta",partial_json:r.function.arguments}})}}if(n.finish_reason){e.contentBlockOpen&&(o.push({type:"content_block_stop",index:e.contentBlockIndex}),e.contentBlockOpen=!1);let r=t.usage??{},i=r.prompt_tokens_details?.cached_tokens??0;o.push({type:"message_delta",delta:{stop_reason:Pe(n.finish_reason),stop_sequence:null},usage:{input_tokens:(r.prompt_tokens??0)-i,output_tokens:r.completion_tokens??0,...i?{cache_read_input_tokens:i}:{}}}),o.push({type:"message_stop"})}return o}function me(t){if(!t.length)return{};let e=t[0],o=[],n={},s=null,r={};for(let a of t){a.usage&&(r=a.usage);for(let u of a.choices??[]){let p=u.delta;if(p?.content&&o.push(p.content),p?.tool_calls)for(let l of p.tool_calls){let f=l.index??0;n[f]||(n[f]={id:"",type:"function",function:{name:"",arguments:""}}),l.id&&(n[f].id=l.id),l.function?.name&&(n[f].function.name=l.function.name),l.function?.arguments&&(n[f].function.arguments+=l.function.arguments)}u.finish_reason&&(s=u.finish_reason)}}let i={role:"assistant",content:o.length?o.join(""):null},c=Object.keys(n).map(Number).sort((a,u)=>a-u);return c.length&&(i.tool_calls=c.map(a=>n[a])),{id:e.id??"",object:"chat.completion",created:e.created??0,model:e.model??"",choices:[{index:0,message:i,finish_reason:s}],usage:r}}var Y=(0,We.Router)();Y.get(["/v1/models","/models"],async(t,e)=>{try{await N(),g.models||await W();let o=(g.models?.data??[]).map(n=>({id:n.id,object:"model",type:"model",created:0,created_at:new Date(0).toISOString(),owned_by:n.vendor??"unknown",display_name:n.name??n.id}));e.json({object:"list",data:o,has_more:!1})}catch(o){e.status(500).json({error:String(o)})}});Y.get(["/v1/models/full/","/models/full/"],(t,e)=>{e.json(g.models)});Y.post(["/v1/chat/completions","/chat/completions"],async(t,e)=>{try{let o=Date.now();await N();let n=t.body,s=crypto.randomUUID(),r=n.model??"unknown",i=X(r);i!==r&&(n={...n,model:i});let c=(n.messages??[]).some(d=>Array.isArray(d.content)&&d.content.some(w=>w.type==="image_url")),a=(n.messages??[]).some(d=>d.role==="assistant"||d.role==="tool"),u=V(c);u["X-Initiator"]=a?"agent":"user";let p=JSON.stringify(n).length;if(n.stream)return St(e,n,u,s,p,o,r,i);let l=await Ke(`${D()}/chat/completions`,{method:"POST",headers:u,body:JSON.stringify(n)},s,"/v1/chat/completions"),f=Math.round((Date.now()-o)/1e3*100)/100;if(!l)return e.status(504).json({error:`Upstream connection error after ${g.maxConnectionRetries+1} attempts`});let _=await l.text(),k=_.length;if(l.ok){let d=JSON.parse(_),w=d.usage??{};y.addRequest(s,{request_body:n,response_body:d,model:r,translated_model:i!==r?i:null,endpoint:"/v1/chat/completions",status_code:l.status,request_size:p,response_size:k,input_tokens:w.prompt_tokens??0,output_tokens:w.completion_tokens??0,duration:f}),e.json(d)}else K("/v1/chat/completions",n,_,l.status),e.status(l.status).type("json").send(_)}catch(o){e.status(500).json({error:String(o)})}});async function St(t,e,o,n,s,r,i,c){y.startRequest(n,{request_body:e,model:i,translated_model:c!==i?c:null,endpoint:"/v1/chat/completions",request_size:s}),t.writeHead(200,{"Content-Type":"text/event-stream","Cache-Control":"no-cache",Connection:"keep-alive","X-Accel-Buffering":"no"});let a=[],u=0,p=0,l=200,f=!1;t.on("close",()=>{f=!0});try{y.updateRequestState(n,"sending");let d=await fetch(`${D()}/chat/completions`,{method:"POST",headers:o,body:JSON.stringify(e),signal:AbortSignal.timeout(12e5)});l=d.status;let w=d.body.getReader(),b=new TextDecoder,h="";for(y.updateRequestState(n,"receiving");;){let{done:R,value:v}=await w.read();if(R)break;if(f){w.cancel();break}h+=b.decode(v,{stream:!0});let $=h.split(`
81
+ `);h=$.pop();for(let S of $)if(S.trim()&&S.startsWith("data: ")){let C=S.slice(6);if(C==="[DONE]"){f||t.write(`data: [DONE]
82
+
83
+ `);continue}try{let O=JSON.parse(C);a.push(O),O.usage&&(u=O.usage.prompt_tokens??0,p=O.usage.completion_tokens??0),f||t.write(`data: ${C}
84
+
85
+ `)}catch{}}}}catch(d){l=504,console.log(`[Stream] Error for ${n}: ${d}`)}f?y.updateRequestState(n,"error",{status_code:499}):t.end();let _=Math.round((Date.now()-r)/1e3*100)/100,k=me(a);y.completeRequest(n,{request_body:e,response_body:k,model:i,translated_model:c!==i?c:null,endpoint:"/v1/chat/completions",status_code:f?499:l,request_size:s,response_size:a.reduce((d,w)=>d+JSON.stringify(w).length,0),input_tokens:u,output_tokens:p,duration:_})}Y.post(["/v1/responses","/responses"],async(t,e)=>{try{let o=Date.now();await N();let n=t.body,s=crypto.randomUUID(),r=n.model??"unknown",i=X(r);if(i!==r&&(n={...n,model:i}),!Ve(i))return e.status(400).json({error:{message:`Model '${r}' does not support the /v1/responses endpoint.`,type:"invalid_request_error",code:"unsupported_model"}});Array.isArray(n.tools)&&(n={...n,tools:n.tools.filter(_=>_.type==="web_search"?(console.log(`Removed unsupported tool 'web_search' from request ${s}`),!1):!0)});let c=Ct(n.input),a=V(c),u=JSON.stringify(n).length;if(n.stream)return At(e,n,a,s,u,o,r,i);let p=await Ke(`${D()}/v1/responses`,{method:"POST",headers:a,body:JSON.stringify(n)},s,"/v1/responses"),l=Math.round((Date.now()-o)/1e3*100)/100;if(!p)return e.status(504).json({error:`Upstream connection error after ${g.maxConnectionRetries+1} attempts`});let f=await p.text();if(p.ok){let _=JSON.parse(f),k=_.usage??{};y.addRequest(s,{request_body:n,response_body:_,model:r,translated_model:i!==r?i:null,endpoint:"/v1/responses",status_code:p.status,request_size:u,response_size:f.length,input_tokens:k.input_tokens??0,output_tokens:k.output_tokens??0,duration:l}),e.json(_)}else K("/v1/responses",n,f,p.status),e.status(p.status).type("json").send(f)}catch(o){e.status(500).json({error:String(o)})}});async function At(t,e,o,n,s,r,i,c){y.startRequest(n,{request_body:e,model:i,translated_model:c!==i?c:null,endpoint:"/v1/responses",request_size:s}),t.writeHead(200,{"Content-Type":"text/event-stream","Cache-Control":"no-cache",Connection:"keep-alive","X-Accel-Buffering":"no"});let a=0,u=0,p=200,l={},f=[],_=!1;t.on("close",()=>{_=!0});try{y.updateRequestState(n,"sending");let w=await fetch(`${D()}/v1/responses`,{method:"POST",headers:o,body:JSON.stringify(e),signal:AbortSignal.timeout(12e5)});p=w.status;let b=w.body.getReader(),h=new TextDecoder,R="";for(y.updateRequestState(n,"receiving");;){let{done:v,value:$}=await b.read();if(v)break;if(_){b.cancel();break}R+=h.decode($,{stream:!0});let S=R.split(`
86
+ `);R=S.pop();for(let C of S)if(C.trim()){if(C.startsWith("event: ")){_||t.write(`${C}
87
+ `);continue}if(C.startsWith("data: ")){let O=C.slice(6);if(O==="[DONE]"){_||t.write(`data: [DONE]
88
+
89
+ `);continue}try{let I=JSON.parse(O);if(I.type==="response.completed"){let T=I.response??{};l=T;let L=T.usage??{};a=L.input_tokens??0,u=L.output_tokens??0}else I.type==="response.output_text.delta"&&f.push(I.delta??"");_||t.write(`data: ${O}
90
+
91
+ `)}catch{_||t.write(`data: ${O}
92
+
93
+ `)}}}}}catch(w){p=504,console.log(`[Stream Responses] Error for ${n}: ${w}`)}_?y.updateRequestState(n,"error",{status_code:499}):t.end();let k=Math.round((Date.now()-r)/1e3*100)/100,d=Object.keys(l).length?l:null;!d&&f.length&&(d={output:[{type:"message",content:[{type:"output_text",text:f.join("")}]}]}),y.completeRequest(n,{request_body:e,response_body:d??{error:"Stream interrupted"},model:i,translated_model:c!==i?c:null,endpoint:"/v1/responses",status_code:_?499:p,request_size:s,response_size:JSON.stringify(d??{}).length,input_tokens:a,output_tokens:u,duration:k})}function Ct(t){if(!Array.isArray(t))return!1;for(let e of t){if(typeof e!="object"||!e)continue;let o=e.content;if(Array.isArray(o)&&o.some(n=>n.type==="input_image")||e.type==="input_image")return!0}return!1}async function Ke(t,e,o,n){let s=g.maxConnectionRetries;for(let r=0;r<=s;r++)try{return await fetch(t,{...e,signal:AbortSignal.timeout(12e5)})}catch(i){let c=i;if(le(o,n,r,s,c),await N(),r<s){let a=Math.min(2**r,8)*1e3;console.log(`[${n}] Connection error (attempt ${r+1}/${s+1}): ${c.message}`),await new Promise(u=>setTimeout(u,a))}else console.log(`[${n}] Connection error (final attempt): ${c.message}`)}return null}var Xe=require("express");var fe=(0,Xe.Router)(),Ot=new Set(["model","messages","max_tokens","system","metadata","stop_sequences","stream","temperature","top_p","top_k","tools","tool_choice","thinking","service_tier"]);function Tt(t){let e=o=>{let n=o?.cache_control;n?.type==="ephemeral"&&"scope"in n&&delete n.scope};for(let o of t.tools??[])e(o);if(Array.isArray(t.system))for(let o of t.system)e(o);for(let o of t.messages??[])if(Array.isArray(o.content))for(let n of o.content)e(n)}function Pt(t){let e={};for(let[o,n]of Object.entries(t))Ot.has(o)&&(e[o]=n);return Tt(e),e}function Et(t){let e=t.thinking;if(!e?.budget_tokens)return t;let o=e.budget_tokens,n=t.max_tokens??0;if(n<=o){let s=o+Math.min(16384,o);return console.log(`[DirectAnthropic] Adjusted max_tokens: ${n} \u2192 ${s} (thinking.budget_tokens=${o})`),{...t,max_tokens:s}}return t}function qt(t){let e=t.system;if(!e)return g.systemPromptAdd.length?{...t,system:g.systemPromptAdd.map(o=>({type:"text",text:o}))}:t;if(typeof e=="string"){let o=de(e);for(let n of g.systemPromptAdd)o.includes(n)||(o+=`
94
+
95
+ `+n);return{...t,system:o}}if(Array.isArray(e)){let o=e,n=o.filter(i=>i.type==="text").map(i=>i.text).join(`
96
+ `),s=!1,r=[];for(let i of o)if(i.type==="text"){let c=i.text;if(c.startsWith("x-anthropic-billing-header:")){s=!0;continue}let a=c;for(let u of g.systemPromptRemove)a.includes(u)&&(a=a.replaceAll(u,""),s=!0);r.push(a!==c?{...i,text:a}:i)}else r.push(i);for(let i of g.systemPromptAdd)n.includes(i)||(r.push({type:"text",text:i}),s=!0);return s?{...t,system:r}:t}return t}function Dt(t){let e=t.messages;if(!e?.length)return t;let o=!1,n=e.map(s=>{if(!Array.isArray(s.content))return s;let r=!1,i=s.content.map(c=>{if(c.type!=="tool_result"||typeof c.content!="string")return c;let a=Te(c.content);return a!==c.content?(r=!0,{...c,content:a}):c});return r?(o=!0,{...s,content:i}):s});return o?{...t,messages:n}:t}fe.post("/v1/messages/count_tokens",async(t,e)=>{try{await N();let o=t.body,n=o.model??"",s=g.models?.data?.find(c=>c.id===n);if(!s)return e.json({input_tokens:1});let r=0,i=o.system;if(typeof i=="string")r+=j(i);else if(Array.isArray(i))for(let c of i)c.type==="text"&&(r+=j(c.text??""));for(let c of o.messages??[]){let a=c.content;if(typeof a=="string")r+=j(a);else if(Array.isArray(a))for(let u of a)u.type==="text"?r+=j(u.text??""):u.type==="tool_result"&&typeof u.content=="string"?r+=j(u.content):u.type==="tool_use"&&(r+=j(JSON.stringify(u.input??{})))}if(o.tools?.length){r+=n.startsWith("claude")?346:n.startsWith("grok")?480:346;for(let c of o.tools)r+=j(c.name??""),r+=j(c.description??""),r+=j(JSON.stringify(c.input_schema??{}))}s.vendor!=="Anthropic"&&(r=Math.ceil(r*(n.startsWith("grok")?1.03:1.05))),e.json({input_tokens:r})}catch(o){console.log(`[count_tokens] Error: ${o}`),e.json({input_tokens:1})}});fe.post("/v1/messages",async(t,e)=>{let o=Date.now();await N();let n=t.body,s=crypto.randomUUID(),r=n,i=n.model??"unknown",c=X(i);return c!==i&&(console.log(`[Anthropic API] Model name translated: ${i} -> ${c}`),n={...n,model:c}),n=qt(n),n=Dt(n),Be(c)?(console.log(`[Anthropic API] Using direct Anthropic API path for: ${c}`),It(e,n,s,o,i,c,r)):(console.log(`[Anthropic API] Using OpenAI translation path for: ${c}`),jt(e,n,s,o,i,c,r))});async function It(t,e,o,n,s,r,i){let c=JSON.stringify(e).length,a=Ye(e),u=(e.messages??[]).some(_=>_.role==="assistant"),p=V(a);p["anthropic-version"]="2023-06-01",p["X-Initiator"]=u?"agent":"user";let l=e,f=null;for(let _=0;_<=3;_++){let k=Pt(l);if(k=Et(k),k.stream)return Nt(t,k,p,o,l,c,n,s,r,i);let d=await Qe(`${D()}/v1/messages`,{method:"POST",headers:p,body:JSON.stringify(k)},o,"/v1/messages");if(!d)return void t.status(504).json({type:"error",error:{type:"api_error",message:"Upstream connection error"}});let w=Math.round((Date.now()-n)/1e3*100)/100;if(d.ok){let h=await d.json(),R=h.usage;return y.addRequest(o,{original_request_body:i,request_body:l,response_body:h,model:s,translated_model:r!==s?r:null,endpoint:"/v1/messages",status_code:d.status,request_size:c,response_size:JSON.stringify(h).length,input_tokens:R?.input_tokens??0,output_tokens:R?.output_tokens??0,duration:w}),f&&pe({...f,modified_request:l,final_status_code:d.status,final_response:h}),void t.json(h)}let b=await d.text();if(K("/v1/messages",l,b,d.status),Se(d.status,b)){let h=xe(b);if(h.length){console.log(`[Direct Anthropic] Attempt ${_+1}: orphaned IDs: ${h}`),f?f.orphaned_ids.push(...h):f={request_id:o,original_request:e,error_response:b,error_status_code:d.status,orphaned_ids:h},l={...l,messages:$e(l.messages,h)};continue}}return f&&pe({...f,modified_request:l,final_status_code:d.status,final_response:b}),void t.status(d.status).type("json").send(b)}}async function Nt(t,e,o,n,s,r,i,c,a,u){y.startRequest(n,{original_request_body:u,request_body:s,model:c,translated_model:a!==c?a:null,endpoint:"/v1/messages",request_size:r}),t.writeHead(200,{"Content-Type":"text/event-stream","Cache-Control":"no-cache",Connection:"keep-alive","X-Accel-Buffering":"no"});let p=0,l=0,f=200,_=c,k=[],d=!1;t.on("close",()=>{d=!0});try{y.updateRequestState(n,"sending");let h=await fetch(`${D()}/v1/messages`,{method:"POST",headers:o,body:JSON.stringify(e),signal:AbortSignal.timeout(12e5)});f=h.status,y.updateRequestState(n,"receiving");let R=h.body.getReader(),v=new TextDecoder,$="",S="";for(;;){let{done:C,value:O}=await R.read();if(C)break;if(d){R.cancel();break}$+=v.decode(O,{stream:!0});let I=$.split(`
97
+ `);$=I.pop();for(let T of I){if(!T.trim())continue;if(T.startsWith("event: ")){S=T.slice(7);continue}if(!T.startsWith("data: "))continue;let L=T.slice(6);if(L==="[DONE]")break;try{let q=JSON.parse(L),G=S||q.type||"";if(S="",G==="message_start"){let ee=q.message??{};_=ee.model??c,p=ee.usage?.input_tokens??0}else G==="message_delta"?l=q.usage?.output_tokens??0:G==="content_block_delta"&&q.delta?.type==="text_delta"&&k.push(q.delta.text??"");d||t.write(`event: ${G}
98
+ data: ${L}
99
+
100
+ `)}catch{}}}}catch(h){f=504,console.log(`[Stream Direct Anthropic] Error for ${n}: ${h}`)}d?y.updateRequestState(n,"error",{status_code:499}):t.end();let w=Math.round((Date.now()-i)/1e3*100)/100,b=k.length?{id:n,type:"message",role:"assistant",content:[{type:"text",text:k.join("")}],model:_,usage:{input_tokens:p,output_tokens:l}}:{error:{type:"api_error",message:"Stream interrupted"}};y.completeRequest(n,{request_body:s,response_body:b,model:c,translated_model:a!==c?a:null,endpoint:"/v1/messages",status_code:d?499:f,request_size:r,response_size:JSON.stringify(b).length,input_tokens:p,output_tokens:l,duration:w})}async function jt(t,e,o,n,s,r,i){let c=Ye(e),a=JSON.stringify(e).length,u=e,p=null;for(let l=0;l<=3;l++){let f=Je(u),_=(f.messages??[]).some(h=>h.role==="assistant"||h.role==="tool"),k=V(c);if(k["X-Initiator"]=_?"agent":"user",e.stream)return Ht(t,f,k,o,u,a,n,s,r,i);let d=await Qe(`${D()}/chat/completions`,{method:"POST",headers:k,body:JSON.stringify(f)},o,"/v1/messages (translated)");if(!d)return void t.status(504).json({type:"error",error:{type:"api_error",message:"Upstream connection error"}});let w=Math.round((Date.now()-n)/1e3*100)/100;if(d.ok){let h=await d.json(),R=Ee(h),v=h.usage;return y.addRequest(o,{original_request_body:i,request_body:u,response_body:R,model:s,translated_model:r!==s?r:null,endpoint:"/v1/messages",status_code:d.status,request_size:a,response_size:JSON.stringify(R).length,input_tokens:v?.prompt_tokens??0,output_tokens:v?.completion_tokens??0,duration:w}),void t.json(R)}let b=await d.text();if(K("/v1/messages",u,b,d.status),Se(d.status,b)){let h=xe(b);if(h.length){p?p.orphaned_ids.push(...h):p={request_id:o,original_request:e,error_response:b,error_status_code:d.status,orphaned_ids:h},u={...u,messages:$e(u.messages,h)};continue}}return p&&pe({...p,modified_request:u,final_status_code:d.status,final_response:b}),void t.status(d.status).type("json").send(b)}}async function Ht(t,e,o,n,s,r,i,c,a,u){y.startRequest(n,{original_request_body:u,request_body:s,model:c,translated_model:a!==c?a:null,endpoint:"/v1/messages",request_size:r}),t.writeHead(200,{"Content-Type":"text/event-stream","Cache-Control":"no-cache",Connection:"keep-alive","X-Accel-Buffering":"no"});let p=new ge,l=[],f=0,_=0,k=200,d=!1;t.on("close",()=>{d=!0});try{y.updateRequestState(n,"sending");let R=await fetch(`${D()}/chat/completions`,{method:"POST",headers:o,body:JSON.stringify(e),signal:AbortSignal.timeout(12e5)});k=R.status,y.updateRequestState(n,"receiving");let v=R.body.getReader(),$=new TextDecoder,S="";for(;;){let{done:C,value:O}=await v.read();if(C)break;if(d){v.cancel();break}S+=$.decode(O,{stream:!0});let I=S.split(`
101
+ `);S=I.pop();for(let T of I){if(!T.trim()||!T.startsWith("data: "))continue;let L=T.slice(6);if(L==="[DONE]")break;try{let q=JSON.parse(L);l.push(q),q.usage&&(f=q.usage.prompt_tokens??0,_=q.usage.completion_tokens??0);let G=Ge(q,p);for(let ee of G)d||t.write(`event: ${ee.type}
102
+ data: ${JSON.stringify(ee)}
103
+
104
+ `)}catch{}}}}catch(R){k=504,console.log(`[Stream Anthropic] Error for ${n}: ${R}`)}d?y.updateRequestState(n,"error",{status_code:499}):t.end();let w=Math.round((Date.now()-i)/1e3*100)/100,b=me(l),h=Object.keys(b).length?Ee(b):{error:{type:"api_error",message:"Stream interrupted"}};y.completeRequest(n,{request_body:s,response_body:h,model:c,translated_model:a!==c?a:null,endpoint:"/v1/messages",status_code:d?499:k,request_size:r,response_size:l.reduce((R,v)=>R+JSON.stringify(v).length,0),input_tokens:f,output_tokens:_,duration:w})}function Ye(t){return(t.messages??[]).some(e=>Array.isArray(e.content)&&e.content.some(o=>o.type==="image"))}async function Qe(t,e,o,n){let s=g.maxConnectionRetries;for(let r=0;r<=s;r++)try{return await fetch(t,{...e,signal:AbortSignal.timeout(12e5)})}catch(i){let c=i;le(o,n,r,s,c),await N(),r<s&&(console.log(`[${n}] Connection error (attempt ${r+1}/${s+1}): ${c.message}`),await new Promise(a=>setTimeout(a,Math.min(2**r,8)*1e3)))}return null}var Ze=require("express"),_e=A(require("path"));var E=(0,Ze.Router)();function et(){return _e.default.join(__dirname,"..","..","public")}E.get("/",(t,e)=>{e.sendFile(_e.default.join(et(),"dashboard.html"))});E.get("/requests",(t,e)=>{e.sendFile(_e.default.join(et(),"requests.html"))});E.get("/api/stats",(t,e)=>{e.json(y.getStats())});E.get("/api/requests",(t,e)=>{let o=parseInt(t.query.page)||1,n=parseInt(t.query.per_page)||50,s=t.query.search||"",r=(o-1)*n,i,c;s?(i=y.searchRequests(s,n,r),c=y.searchRequests(s,1e4,0).length):(i=y.getRecentRequests(n,r),c=y.getTotalCount());let a=i.map(u=>({...u,request_body:null,response_body:null}));e.json({items:a,total:c,page:o,per_page:n,total_pages:Math.ceil(c/n)})});E.get("/api/request/:id",(t,e)=>{let o=y.getRequest(t.params.id);if(!o)return e.status(404).json({error:"Request not found"});e.json(o)});E.get("/api/request/:id/request-body",(t,e)=>{let o=y.getRequest(t.params.id);if(!o)return e.status(404).json({error:"Request not found"});e.json(o.request_body)});E.get("/api/request/:id/response-body",(t,e)=>{let o=y.getRequest(t.params.id);if(!o)return e.status(404).json({error:"Request not found"});e.json(o.response_body)});E.get("/api/requests/search",(t,e)=>{let o=parseInt(t.query.page)||1,n=parseInt(t.query.per_page)||50,s=t.query.q||"",r=(o-1)*n;if(!s)return e.json({items:[],total:0,page:o,per_page:n,total_pages:0});let[i,c]=y.fulltextSearch(s,n,r),a=i.map(u=>({...u,request_body:null,response_body:null}));e.json({items:a,total:c,page:o,per_page:n,total_pages:c>0?Math.ceil(c/n):0})});E.get("/api/requests/export",(t,e)=>{let o=new Date().toISOString().replace(/[:.]/g,"").slice(0,15);e.setHeader("Content-Type","application/x-jsonlines"),e.setHeader("Content-Disposition",`attachment; filename=requests_${o}.jl`);for(let n of y.getAllRequests())e.write(JSON.stringify(n)+`
105
+ `);e.end()});E.post("/api/requests/import",(t,e)=>{let n=(typeof t.body=="string"?t.body:JSON.stringify(t.body)).split(`
106
+ `).filter(i=>i.trim()),s=0,r=[];for(let i=0;i<n.length;i++)try{let c=JSON.parse(n[i]);y.importRequest(c),s++}catch(c){r.push(`Line ${i+1}: ${c}`)}e.json({imported:s,errors:r.slice(0,10),total_errors:r.length})});var U=A(require("fs")),Z=A(require("path")),nt=A(require("readline"));var H="\x1B[1m",x="\x1B[2m",m="\x1B[0m",Q="\x1B[36m",z="\x1B[32m",De="\x1B[33m",B="\x1B[35m";function Lt(){console.log(`
107
+ ${B}\u2554\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2557${m}
108
+ ${B}\u2551${m} ${H}ghc-tunnel Setup Wizard${m} ${B}\u2551${m}
109
+ ${B}\u255A\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u255D${m}
110
+ `)}function ot(){return nt.default.createInterface({input:process.stdin,output:process.stdout})}function F(t,e){return new Promise(o=>t.question(e,o))}async function st(){Lt(),console.log(`${H}Step 1: GitHub Authentication${m}`),console.log(`${x}Checking for GitHub token...${m}
111
+ `);let t=await ae();t||(console.error("Failed to get GitHub token. Cannot continue setup."),process.exit(1)),g.githubToken=t,await ie(),await W();let e=g.models?.data??[];e.length||(console.error("No models available. Check your Copilot subscription."),process.exit(1)),console.log(`
112
+ ${H}Step 2: Configure Models${m}
113
+ `),console.log(`${x}Available Copilot models:${m}
114
+ `);for(let R=0;R<e.length;R++){let v=e[R],$=[];v.capabilities?.supports?.vision&&$.push("Vision"),v.capabilities?.supports?.tool_calls&&$.push("Tools"),v.supported_endpoints?.includes("/v1/messages")&&$.push("Anthropic"),v.preview&&$.push("Preview");let S=$.length?` ${x}(${$.join(", ")})${m}`:"";console.log(` ${Q}${String(R+1).padStart(2)}.${m} ${v.id}${S}`)}let o=ot();console.log(`
115
+ ${De}Pick models by number. Press Enter to accept the default.${m}
116
+ `);let n=qe(e,"claude-opus-4.6-1m")??qe(e,"claude-opus-4.6")??0,s=await tt(o,e,'Primary model (mapped to "opus", "sonnet" aliases)',n),r=e[s],i=qe(e,"claude-haiku-4.5")??0,c=await tt(o,e,'Small/fast model (mapped to "haiku" alias)',i),a=e[c];console.log(`
117
+ ${z}Primary: ${r.id}${m}`),console.log(`${z}Small: ${a.id}${m}
118
+ `),console.log(`${H}Step 3: Server Settings${m}
119
+ `);let u=await F(o,` Port ${x}[8314]${m}: `),p=u.trim()?parseInt(u.trim(),10):8314,f=(await F(o,` Host ${x}[localhost]${m}: `)).trim()||"localhost";console.log(`
120
+ ${H}Step 4: Claude Code Model Settings${m}`),console.log(`${x}These are the model identifiers written to ~/.claude/settings.json.${m}`),console.log(`${x}The proxy's model_mappings translate them to actual Copilot models.${m}
121
+ `);let _="claude-opus-4-6[1m]",k="claude-sonnet-4-6",w=(await F(o,` ANTHROPIC_MODEL ${x}[${_}]${m}: `)).trim()||_,h=(await F(o,` ANTHROPIC_DEFAULT_HAIKU_MODEL ${x}[${k}]${m}: `)).trim()||k;return console.log(`
122
+ ${z}ANTHROPIC_MODEL: ${w}${m}`),console.log(`${z}ANTHROPIC_DEFAULT_HAIKU_MODEL: ${h}${m}
123
+ `),o.close(),console.log(`${H}Step 5: Saving Configuration${m}
124
+ `),Mt(r.id,a.id,p,f),rt(p,f,w,h),console.log(`
125
+ ${z}${H}Setup complete!${m}
126
+ `),{port:p,host:f}}async function tt(t,e,o,n){let s=e[n]?.id??"?";for(;;){let r=await F(t,` ${o} ${x}[${n+1}: ${s}]${m}: `);if(!r.trim())return n;let i=parseInt(r.trim(),10);if(i>=1&&i<=e.length)return i-1;let c=e.findIndex(a=>a.id===r.trim());if(c!==-1)return c;console.log(` ${De}Invalid choice. Enter a number 1-${e.length} or a model name.${m}`)}}function qe(t,e){let o=t.findIndex(n=>n.id===e);return o>=0?o:void 0}function Mt(t,e,o,n){let s=P();U.default.mkdirSync(s,{recursive:!0});let r=Z.default.join(s,"config.yaml"),i=`# ghc-tunnel Configuration (generated by --setup)
127
+ # ================================================
128
+
129
+ address: ${n}
130
+ port: ${o}
131
+ debug: false
132
+
133
+ account_type: individual
134
+
135
+ vscode_version: "${se}"
136
+ api_version: "${oe}"
137
+ copilot_version: "${ne}"
138
+
139
+ model_mappings:
140
+ exact:
141
+ opus: ${t}
142
+ sonnet: ${t}
143
+ haiku: ${e}
144
+ prefix:
145
+ claude-sonnet-4-: ${t}
146
+ claude-opus-4.5-: ${t}
147
+ claude-opus-4.6-: ${t}
148
+ claude-opus-4-5-: ${t}
149
+ claude-opus-4-6-: ${t}
150
+ "claude-opus-4.5": ${t}
151
+ "claude-opus-4.6": ${t}
152
+ "claude-opus-4-6": ${t}
153
+ "claude-opus-4-6[1m]": ${t}
154
+ claude-sonnet-4-6: ${t}
155
+ claude-sonnet-4-5: ${t}
156
+ claude-haiku-4.5-: ${e}
157
+ claude-haiku-4-5-: ${e}
158
+
159
+ system_prompt_remove: []
160
+ system_prompt_add: []
161
+ tool_result_suffix_remove: []
162
+
163
+ max_connection_retries: 3
164
+ `;U.default.writeFileSync(r,i,"utf-8"),console.log(` ${z}\u2713${m} Config saved to ${Q}${r}${m}`)}function rt(t,e,o,n){let s=Z.default.join(process.env.HOME||"~",".claude"),r=Z.default.join(s,"settings.json"),i={};if(U.default.existsSync(r))try{i=JSON.parse(U.default.readFileSync(r,"utf-8"))}catch{console.log(` ${De}\u26A0${m} Could not parse existing settings.json, creating new one`)}let c=`http://${e}:${t}/`,a=i.env??{};a.ANTHROPIC_BASE_URL=c,a.ANTHROPIC_AUTH_TOKEN="dummy",a.ANTHROPIC_MODEL=o,a.ANTHROPIC_DEFAULT_HAIKU_MODEL=n,delete a.ANTHROPIC_SMALL_FAST_MODEL,i.env=a,U.default.mkdirSync(s,{recursive:!0}),U.default.writeFileSync(r,JSON.stringify(i,null,2)+`
165
+ `,"utf-8"),console.log(` ${z}\u2713${m} Claude Code settings updated at ${Q}${r}${m}`),console.log(` ${x}ANTHROPIC_BASE_URL = ${c}${m}`),console.log(` ${x}ANTHROPIC_MODEL = ${o}${m}`),console.log(` ${x}ANTHROPIC_DEFAULT_HAIKU_MODEL = ${n}${m}`)}async function it(){console.log(`
166
+ ${B}\u2554\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2557${m}
167
+ ${B}\u2551${m} ${H}Claude Code Settings${m} ${B}\u2551${m}
168
+ ${B}\u255A\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u255D${m}
169
+ `);let t=Z.default.join(process.env.HOME||"~",".claude"),e=Z.default.join(t,"settings.json"),o={},n={};if(U.default.existsSync(e))try{o=JSON.parse(U.default.readFileSync(e,"utf-8")),n=o.env??{}}catch{}(n.ANTHROPIC_BASE_URL||n.ANTHROPIC_MODEL||n.ANTHROPIC_DEFAULT_HAIKU_MODEL)&&(console.log(`${H}Current configuration:${m}`),n.ANTHROPIC_BASE_URL&&console.log(` ANTHROPIC_BASE_URL: ${Q}${n.ANTHROPIC_BASE_URL}${m}`),n.ANTHROPIC_MODEL&&console.log(` ANTHROPIC_MODEL: ${Q}${n.ANTHROPIC_MODEL}${m}`),n.ANTHROPIC_DEFAULT_HAIKU_MODEL&&console.log(` ANTHROPIC_DEFAULT_HAIKU_MODEL: ${Q}${n.ANTHROPIC_DEFAULT_HAIKU_MODEL}${m}`),console.log(`
170
+ ${x}Press Enter to keep current values.${m}
171
+ `));let r=8314,i="localhost",c=n.ANTHROPIC_MODEL||"claude-opus-4-6[1m]",a=n.ANTHROPIC_DEFAULT_HAIKU_MODEL||"claude-sonnet-4-6",u=r,p=i;if(n.ANTHROPIC_BASE_URL)try{let v=new URL(n.ANTHROPIC_BASE_URL);p=v.hostname,u=parseInt(v.port,10)||r}catch{}let l=ot(),f=await F(l,` Port ${x}[${u}]${m}: `),_=f.trim()?parseInt(f.trim(),10):u,d=(await F(l,` Host ${x}[${p}]${m}: `)).trim()||p,b=(await F(l,` ANTHROPIC_MODEL ${x}[${c}]${m}: `)).trim()||c,R=(await F(l,` ANTHROPIC_DEFAULT_HAIKU_MODEL ${x}[${a}]${m}: `)).trim()||a;l.close(),rt(_,d,b,R),console.log(`
172
+ ${z}${H}Claude Code settings updated!${m}
173
+ `)}var ct="1.0.0";function Ut(){let t=process.argv.slice(2),e={};for(let o=0;o<t.length;o++){let n=t[o];n==="-p"||n==="--port"?e.port=parseInt(t[++o],10):n==="-a"||n==="--address"?e.address=t[++o]:n==="-c"||n==="--config"?e.config=!0:n==="-s"||n==="--setup"?e.setup=!0:n==="--claudecode"?e.claudecode=!0:n==="-v"||n==="--version"?e.version=!0:(n==="-h"||n==="--help")&&(e.help=!0)}return e}function at(t,e){let o=(0,he.default)();o.use(he.default.json({limit:"50mb"})),o.use(he.default.text({limit:"50mb"})),o.use(fe),o.use(Y),o.use(E),o.use((n,s)=>s.status(404).json({error:"Not found"})),o.listen(e,t,()=>{console.log(`
174
+ Starting GitHub Copilot API Proxy on ${t}:${e}`),console.log(`Dashboard: http://${t}:${e}/`),console.log(`OpenAI API: http://${t}:${e}/v1/chat/completions`),console.log(`Responses API: http://${t}:${e}/v1/responses`),console.log(`Anthropic API: http://${t}:${e}/v1/messages`)})}function ut(t,e){let o=P(),n=pt.default.join(o,"config.yaml");lt.default.existsSync(n)||(console.log(`No config file found at ${n}, generating one.`),we());try{let s=Me(n);t=s.address??t,e=s.port??e,s.account_type&&(g.accountType=s.account_type),s.vscode_version&&(g.vscodeVersion=s.vscode_version),s.api_version&&(g.apiVersion=s.api_version),s.copilot_version&&(g.copilotVersion=s.copilot_version),s.system_prompt_remove&&(g.systemPromptRemove=s.system_prompt_remove),s.system_prompt_add&&(g.systemPromptAdd=s.system_prompt_add),s.tool_result_suffix_remove&&(g.toolResultSuffixRemove=s.tool_result_suffix_remove),s.max_connection_retries!=null&&(g.maxConnectionRetries=s.max_connection_retries),s.model_mappings?M.loadFromConfig(s):M.loadFromConfig({model_mappings:Re}),console.log(`Loaded configuration from: ${n}`)}catch(s){console.log(`Error loading config: ${s}`),M.loadFromConfig({model_mappings:Re})}return{host:t,port:e}}async function Ft(){let t=Ut();if(t.help&&(console.log(`ghc-tunnel v${ct} \u2013 GitHub Copilot API Proxy
175
+
176
+ Usage: ghc-tunnel [options]
177
+
178
+ Options:
179
+ -s, --setup Interactive setup wizard (configure models + Claude Code)
180
+ --claudecode Update Claude Code settings only (skip model/config setup)
181
+ -p, --port <port> Port to listen on (default: 8314)
182
+ -a, --address <addr> Address to listen on (default: localhost)
183
+ -c, --config Generate default config file
184
+ -v, --version Show version
185
+ -h, --help Show this help`),process.exit(0)),t.version&&(console.log(`ghc-tunnel ${ct}`),process.exit(0)),t.config&&(we(),process.exit(0)),t.setup&&t.claudecode&&(await it(),process.exit(0)),t.setup){let{port:s,host:r}=await st(),{host:i,port:c}=ut(r,s);Ce(),Ae(),at(i,c);return}let{host:e,port:o}=ut(He,je);t.address!=null&&(e=t.address),t.port!=null&&(o=t.port);let n=await ae();n||(console.error(`
186
+ `+"=".repeat(60)),console.error("ERROR: No GitHub token available!"),console.error("Options:"),console.error(" 1. Set GITHUB_TOKEN environment variable"),console.error(" 2. Create github_token.txt in "+P()),console.error(" 3. Run again for interactive Device Flow auth"),console.error("=".repeat(60)),process.exit(1)),g.githubToken=n,await ie(),await W(),Ce(),Ae(),at(e,o)}Ft().catch(t=>{console.error("Fatal error:",t),process.exit(1)});
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "ghc-tunnel",
3
+ "version": "1.0.0",
4
+ "description": "GitHub Copilot API Proxy - Provides OpenAI and Anthropic compatible endpoints via GitHub Copilot",
5
+ "main": "dist/index.js",
6
+ "bin": {
7
+ "ghc-tunnel": "dist/index.js"
8
+ },
9
+ "files": [
10
+ "dist",
11
+ "public"
12
+ ],
13
+ "scripts": {
14
+ "typecheck": "tsc",
15
+ "build": "tsc && node esbuild.config.mjs",
16
+ "start": "node dist/index.js",
17
+ "dev": "tsx src/index.ts",
18
+ "prepublishOnly": "npm run build"
19
+ },
20
+ "engines": {
21
+ "node": ">=18.0.0"
22
+ },
23
+ "keywords": [
24
+ "github",
25
+ "copilot",
26
+ "proxy",
27
+ "openai",
28
+ "anthropic",
29
+ "api",
30
+ "claude"
31
+ ],
32
+ "license": "MIT",
33
+ "dependencies": {
34
+ "express": "^4.21.0",
35
+ "js-yaml": "^4.1.0"
36
+ },
37
+ "devDependencies": {
38
+ "@types/express": "^5.0.0",
39
+ "@types/js-yaml": "^4.0.9",
40
+ "@types/node": "^22.0.0",
41
+ "esbuild": "^0.27.3",
42
+ "tsx": "^4.0.0",
43
+ "typescript": "^5.6.0"
44
+ }
45
+ }
@@ -0,0 +1,175 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>GHC Proxy - Dashboard</title>
7
+ <style>
8
+ * { box-sizing: border-box; margin: 0; padding: 0; }
9
+ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; color: #333; line-height: 1.6; }
10
+ .navbar { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 1rem 2rem; display: flex; justify-content: space-between; align-items: center; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }
11
+ .navbar h1 { font-size: 1.5rem; font-weight: 600; }
12
+ .navbar-actions { display: flex; gap: 1rem; }
13
+ .navbar-actions a { color: white; text-decoration: none; padding: 0.5rem 1rem; border-radius: 4px; background: rgba(255,255,255,0.2); transition: background 0.2s; }
14
+ .navbar-actions a:hover { background: rgba(255,255,255,0.3); }
15
+ .container { max-width: 1400px; margin: 0 auto; padding: 2rem; }
16
+ .stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 1.5rem; margin-bottom: 2rem; }
17
+ .stat-card { background: white; border-radius: 12px; padding: 1.5rem; box-shadow: 0 2px 8px rgba(0,0,0,0.08); }
18
+ .stat-card h3 { color: #666; font-size: 0.875rem; font-weight: 500; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 0.5rem; }
19
+ .stat-card .value { font-size: 2rem; font-weight: 700; color: #333; }
20
+ .section { background: white; border-radius: 12px; padding: 1.5rem; box-shadow: 0 2px 8px rgba(0,0,0,0.08); margin-bottom: 2rem; }
21
+ .section h2 { font-size: 1.25rem; font-weight: 600; margin-bottom: 1rem; color: #333; border-bottom: 2px solid #667eea; padding-bottom: 0.5rem; }
22
+ table { width: 100%; border-collapse: collapse; }
23
+ th, td { padding: 0.75rem 1rem; text-align: left; border-bottom: 1px solid #eee; }
24
+ th { background: #f8f9fa; font-weight: 600; color: #555; font-size: 0.875rem; text-transform: uppercase; letter-spacing: 0.5px; }
25
+ tbody tr { cursor: pointer; transition: background 0.2s; }
26
+ tbody tr:hover { background: #f8f9fa; }
27
+ .model-badge { display: inline-block; padding: 0.25rem 0.75rem; background: #e3e8ff; color: #667eea; border-radius: 20px; font-size: 0.75rem; font-weight: 600; }
28
+ .endpoint-badge { display: inline-block; padding: 0.25rem 0.5rem; background: #e8f5e9; color: #2e7d32; border-radius: 4px; font-size: 0.75rem; font-family: monospace; }
29
+ .state-badge { display: inline-block; padding: 0.25rem 0.5rem; border-radius: 4px; font-size: 0.75rem; font-weight: 600; }
30
+ .state-badge.pending { background: #fff3e0; color: #e65100; }
31
+ .state-badge.sending { background: #e3f2fd; color: #1565c0; animation: pulse 1.5s infinite; }
32
+ .state-badge.receiving { background: #f3e5f5; color: #7b1fa2; animation: pulse 1.5s infinite; }
33
+ .state-badge.completed { background: #e8f5e9; color: #2e7d32; }
34
+ .state-badge.error { background: #ffebee; color: #c62828; }
35
+ @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.6; } }
36
+ .refresh-btn { background: #667eea; color: white; border: none; padding: 0.5rem 1rem; border-radius: 6px; cursor: pointer; font-size: 0.875rem; transition: background 0.2s; }
37
+ .refresh-btn:hover { background: #5a6fd6; }
38
+ .header-row { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; }
39
+ /* Modal */
40
+ .modal { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); z-index: 1000; justify-content: center; align-items: center; }
41
+ .modal.active { display: flex; }
42
+ .modal-content { background: white; border-radius: 12px; width: 90%; max-width: 900px; max-height: 85vh; overflow: hidden; display: flex; flex-direction: column; }
43
+ .modal-header { padding: 1rem 1.5rem; border-bottom: 1px solid #eee; display: flex; justify-content: space-between; align-items: center; }
44
+ .modal-header h3 { font-size: 1.125rem; font-weight: 600; }
45
+ .modal-close { background: none; border: none; font-size: 1.5rem; cursor: pointer; color: #666; padding: 0.25rem; }
46
+ .modal-close:hover { color: #333; }
47
+ .modal-tabs { display: flex; border-bottom: 1px solid #eee; padding: 0 1.5rem; }
48
+ .modal-tab { padding: 0.75rem 1.5rem; border: none; background: none; cursor: pointer; color: #666; font-size: 0.875rem; font-weight: 500; border-bottom: 2px solid transparent; margin-bottom: -1px; }
49
+ .modal-tab.active { color: #667eea; border-bottom-color: #667eea; }
50
+ .modal-body { padding: 1.5rem; overflow-y: auto; flex: 1; }
51
+ .tab-content { display: none; }
52
+ .tab-content.active { display: block; }
53
+ .overview-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 1rem; }
54
+ .overview-item { padding: 0.75rem; background: #f8f9fa; border-radius: 6px; }
55
+ .overview-item label { font-size: 0.75rem; color: #666; text-transform: uppercase; letter-spacing: 0.5px; }
56
+ .overview-item .value { font-size: 0.9375rem; font-weight: 500; margin-top: 0.25rem; word-break: break-all; }
57
+ pre.json-display { background: #1e1e1e; color: #d4d4d4; padding: 1rem; border-radius: 8px; overflow-x: auto; font-size: 0.8125rem; line-height: 1.5; max-height: 400px; white-space: pre-wrap; word-break: break-all; }
58
+ .json-key { color: #9cdcfe; } .json-string { color: #ce9178; } .json-number { color: #b5cea8; } .json-boolean { color: #569cd6; } .json-null { color: #569cd6; }
59
+ @media (max-width: 768px) { .container { padding: 1rem; } .stats-grid { grid-template-columns: 1fr; } .modal-content { width: 95%; } .overview-grid { grid-template-columns: 1fr; } }
60
+ </style>
61
+ </head>
62
+ <body>
63
+ <nav class="navbar">
64
+ <h1>GHC Proxy Dashboard</h1>
65
+ <div class="navbar-actions">
66
+ <a href="/requests">Request Browser</a>
67
+ <a href="/api/stats">API Stats</a>
68
+ </div>
69
+ </nav>
70
+ <div class="container">
71
+ <div class="stats-grid">
72
+ <div class="stat-card"><h3>Total Requests</h3><div class="value" id="total-requests">-</div></div>
73
+ <div class="stat-card"><h3>Data Sent</h3><div class="value" id="bytes-sent">-</div></div>
74
+ <div class="stat-card"><h3>Data Received</h3><div class="value" id="bytes-received">-</div></div>
75
+ <div class="stat-card"><h3>Cached Requests</h3><div class="value" id="cached-requests">-</div></div>
76
+ </div>
77
+ <div class="section">
78
+ <div class="header-row"><h2>Model Statistics</h2><button class="refresh-btn" onclick="loadStats()">Refresh</button></div>
79
+ <table><thead><tr><th>Model</th><th>Requests</th><th>Input Tokens</th><th>Output Tokens</th><th>Data Sent</th><th>Data Received</th></tr></thead><tbody id="model-stats-body"></tbody></table>
80
+ </div>
81
+ <div class="section">
82
+ <h2>Endpoint Statistics</h2>
83
+ <table><thead><tr><th>Endpoint</th><th>Requests</th><th>Data Sent</th><th>Data Received</th></tr></thead><tbody id="endpoint-stats-body"></tbody></table>
84
+ </div>
85
+ <div class="section">
86
+ <div class="header-row"><h2>Recent Requests</h2><a href="/requests" class="refresh-btn">View All</a></div>
87
+ <table><thead><tr><th>Timestamp</th><th>Model</th><th>Endpoint</th><th>State</th><th>Status</th><th>Duration</th><th>Request Size</th><th>Response Size</th></tr></thead><tbody id="recent-requests-body"></tbody></table>
88
+ </div>
89
+ </div>
90
+ <!-- Detail Modal -->
91
+ <div class="modal" id="request-modal">
92
+ <div class="modal-content">
93
+ <div class="modal-header"><h3>Request Details</h3><button class="modal-close" onclick="closeModal()">&times;</button></div>
94
+ <div class="modal-tabs">
95
+ <button class="modal-tab active" data-tab="overview">Overview</button>
96
+ <button class="modal-tab" data-tab="request">Request Body</button>
97
+ <button class="modal-tab" data-tab="response">Response Body</button>
98
+ </div>
99
+ <div class="modal-body">
100
+ <div class="tab-content active" id="tab-overview"><div class="overview-grid" id="overview-content"></div></div>
101
+ <div class="tab-content" id="tab-request"><pre class="json-display" id="request-json"></pre></div>
102
+ <div class="tab-content" id="tab-response"><pre class="json-display" id="response-json"></pre></div>
103
+ </div>
104
+ </div>
105
+ </div>
106
+ <script>
107
+ const fmt = (b) => { if (!b) return '0 B'; const k=1024, s=['B','KB','MB','GB']; const i=Math.floor(Math.log(b)/Math.log(k)); return (b/Math.pow(k,i)).toFixed(2)+' '+s[i]; };
108
+ const num = (n) => new Intl.NumberFormat().format(n);
109
+ const stateLabel = {pending:'Pending',sending:'Sending',receiving:'Receiving',completed:'Completed',error:'Error'};
110
+ function highlight(json) {
111
+ if (typeof json !== 'string') json = JSON.stringify(json, null, 2);
112
+ return json.replace(/("(\\u[\da-fA-F]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+-]?\d+)?)/g, m => {
113
+ let c = 'json-number'; if (/^"/.test(m)) c = /:$/.test(m) ? 'json-key' : 'json-string'; else if (/true|false/.test(m)) c = 'json-boolean'; else if (/null/.test(m)) c = 'json-null';
114
+ return `<span class="${c}">${m}</span>`;
115
+ });
116
+ }
117
+ async function loadStats() {
118
+ const s = await (await fetch('/api/stats')).json();
119
+ document.getElementById('total-requests').textContent = num(s.total_requests);
120
+ document.getElementById('bytes-sent').textContent = fmt(s.bytes_sent);
121
+ document.getElementById('bytes-received').textContent = fmt(s.bytes_received);
122
+ document.getElementById('cached-requests').textContent = num(s.cached_requests);
123
+ const mb = document.getElementById('model-stats-body'); mb.innerHTML = '';
124
+ for (const [m, d] of Object.entries(s.model_stats || {})) {
125
+ mb.innerHTML += `<tr><td><span class="model-badge">${m}</span></td><td>${num(d.request_count)}</td><td>${num(d.input_tokens)}</td><td>${num(d.output_tokens)}</td><td>${fmt(d.bytes_sent)}</td><td>${fmt(d.bytes_received)}</td></tr>`;
126
+ }
127
+ const eb = document.getElementById('endpoint-stats-body'); eb.innerHTML = '';
128
+ for (const [e, d] of Object.entries(s.endpoint_stats || {})) {
129
+ eb.innerHTML += `<tr><td><span class="endpoint-badge">${e}</span></td><td>${num(d.request_count)}</td><td>${fmt(d.bytes_sent)}</td><td>${fmt(d.bytes_received)}</td></tr>`;
130
+ }
131
+ }
132
+ async function loadRecent() {
133
+ const d = await (await fetch('/api/requests?per_page=50')).json();
134
+ const tb = document.getElementById('recent-requests-body'); tb.innerHTML = '';
135
+ for (const i of d.items || []) {
136
+ const st = i.state || 'completed';
137
+ let model = i.model;
138
+ if (i.translated_model && i.translated_model !== i.model) model += ` \u2192 ${i.translated_model}`;
139
+ tb.innerHTML += `<tr onclick="showDetail('${i.id}')"><td>${new Date(i.timestamp).toLocaleString()}</td><td><span class="model-badge">${model}</span></td><td><span class="endpoint-badge">${i.endpoint}</span></td><td><span class="state-badge ${st}">${stateLabel[st]||st}</span></td><td>${i.status_code??'-'}</td><td>${i.duration!=null?i.duration+'s':'-'}</td><td>${fmt(i.request_size)}</td><td>${fmt(i.response_size)}</td></tr>`;
140
+ }
141
+ }
142
+ async function showDetail(id) {
143
+ const d = await (await fetch(`/api/request/${id}`)).json();
144
+ const st = d.state||'completed';
145
+ let html = `<div class="overview-item"><label>Request ID</label><div class="value">${d.id}</div></div>
146
+ <div class="overview-item"><label>Timestamp</label><div class="value">${new Date(d.timestamp).toLocaleString()}</div></div>
147
+ <div class="overview-item"><label>Model</label><div class="value">${d.model}</div></div>`;
148
+ if (d.translated_model && d.translated_model !== d.model) html += `<div class="overview-item"><label>Translated Model</label><div class="value">${d.translated_model}</div></div>`;
149
+ html += `<div class="overview-item"><label>Endpoint</label><div class="value">${d.endpoint}</div></div>
150
+ <div class="overview-item"><label>State</label><div class="value"><span class="state-badge ${st}">${stateLabel[st]||st}</span></div></div>
151
+ <div class="overview-item"><label>Status Code</label><div class="value">${d.status_code??'-'}</div></div>
152
+ <div class="overview-item"><label>Duration</label><div class="value">${d.duration!=null?d.duration+'s':'-'}</div></div>
153
+ <div class="overview-item"><label>Request Size</label><div class="value">${fmt(d.request_size)}</div></div>
154
+ <div class="overview-item"><label>Response Size</label><div class="value">${fmt(d.response_size)}</div></div>
155
+ <div class="overview-item"><label>Input Tokens</label><div class="value">${num(d.input_tokens)}</div></div>
156
+ <div class="overview-item"><label>Output Tokens</label><div class="value">${num(d.output_tokens)}</div></div>`;
157
+ document.getElementById('overview-content').innerHTML = html;
158
+ document.getElementById('request-json').innerHTML = highlight(d.request_body || {});
159
+ document.getElementById('response-json').innerHTML = highlight(d.response_body || {});
160
+ document.getElementById('request-modal').classList.add('active');
161
+ }
162
+ function closeModal() { document.getElementById('request-modal').classList.remove('active'); }
163
+ document.querySelectorAll('.modal-tab').forEach(t => t.addEventListener('click', () => {
164
+ document.querySelectorAll('.modal-tab').forEach(x => x.classList.remove('active'));
165
+ document.querySelectorAll('.tab-content').forEach(x => x.classList.remove('active'));
166
+ t.classList.add('active');
167
+ document.getElementById('tab-' + t.dataset.tab).classList.add('active');
168
+ }));
169
+ document.getElementById('request-modal').addEventListener('click', e => { if (e.target === e.currentTarget) closeModal(); });
170
+ document.addEventListener('keydown', e => { if (e.key === 'Escape') closeModal(); });
171
+ loadStats(); loadRecent();
172
+ setInterval(() => { loadStats(); loadRecent(); }, 30000);
173
+ </script>
174
+ </body>
175
+ </html>
@@ -0,0 +1,193 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>GHC Proxy - Request Browser</title>
7
+ <style>
8
+ * { box-sizing: border-box; margin: 0; padding: 0; }
9
+ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; color: #333; line-height: 1.6; height: 100vh; overflow: hidden; }
10
+ .navbar { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 0.75rem 1.5rem; display: flex; justify-content: space-between; align-items: center; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }
11
+ .navbar h1 { font-size: 1.25rem; font-weight: 600; }
12
+ .navbar-actions { display: flex; gap: 0.75rem; align-items: center; }
13
+ .navbar-actions a, .navbar-actions button { color: white; text-decoration: none; padding: 0.4rem 0.75rem; border-radius: 4px; background: rgba(255,255,255,0.2); border: none; font-size: 0.875rem; cursor: pointer; }
14
+ .navbar-actions a:hover, .navbar-actions button:hover { background: rgba(255,255,255,0.3); }
15
+ .main-container { display: flex; height: calc(100vh - 52px); }
16
+ .detail-panel { flex: 1; min-width: 0; display: flex; flex-direction: column; background: white; border-right: 1px solid #e0e0e0; }
17
+ .detail-header { padding: 1rem; border-bottom: 1px solid #eee; background: #fafafa; }
18
+ .detail-header h2 { font-size: 1rem; font-weight: 600; }
19
+ .detail-controls { display: flex; gap: 1rem; padding: 0.75rem 1rem; border-bottom: 1px solid #eee; background: #fafafa; flex-wrap: wrap; align-items: center; }
20
+ .detail-controls label { display: flex; align-items: center; gap: 0.35rem; cursor: pointer; font-size: 0.8125rem; color: #555; }
21
+ .detail-controls input[type="checkbox"] { cursor: pointer; }
22
+ .detail-content { flex: 1; overflow-y: auto; padding: 1rem; }
23
+ .detail-empty { display: flex; align-items: center; justify-content: center; height: 100%; color: #999; }
24
+ .section-title { font-size: 0.875rem; font-weight: 600; color: #667eea; margin-bottom: 0.75rem; padding-bottom: 0.5rem; border-bottom: 2px solid #667eea; }
25
+ .detail-section { margin-bottom: 1.5rem; }
26
+ .detail-section.hidden { display: none; }
27
+ .overview-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 0.75rem; }
28
+ .overview-item { padding: 0.75rem; background: #f8f9fa; border-radius: 6px; }
29
+ .overview-item label { font-size: 0.6875rem; color: #666; text-transform: uppercase; letter-spacing: 0.5px; }
30
+ .overview-item .value { font-size: 0.875rem; font-weight: 500; margin-top: 0.25rem; word-break: break-all; }
31
+ pre.json-display { background: #1e1e1e; color: #d4d4d4; padding: 1rem; border-radius: 8px; overflow-x: auto; font-size: 0.75rem; line-height: 1.5; white-space: pre-wrap; word-break: break-all; margin: 0; }
32
+ .json-key { color: #9cdcfe; } .json-string { color: #ce9178; } .json-number { color: #b5cea8; } .json-boolean, .json-null { color: #569cd6; }
33
+ /* Right panel */
34
+ .list-panel { width: 420px; min-width: 320px; display: flex; flex-direction: column; background: #fafafa; }
35
+ .list-header { padding: 0.75rem 1rem; border-bottom: 1px solid #eee; }
36
+ .search-box { width: 100%; padding: 0.5rem 0.75rem; border: 1px solid #ddd; border-radius: 6px; font-size: 0.875rem; }
37
+ .list-content { flex: 1; overflow-y: auto; }
38
+ .request-item { padding: 0.75rem 1rem; border-bottom: 1px solid #eee; cursor: pointer; transition: background 0.15s; }
39
+ .request-item:hover { background: #eef; }
40
+ .request-item.active { background: #e3e8ff; border-left: 3px solid #667eea; }
41
+ .request-item .meta { display: flex; justify-content: space-between; align-items: center; font-size: 0.75rem; color: #888; }
42
+ .request-item .model { font-weight: 600; font-size: 0.8125rem; color: #333; margin-top: 0.25rem; }
43
+ .request-item .info { font-size: 0.75rem; color: #666; margin-top: 0.2rem; }
44
+ .model-badge { display: inline-block; padding: 0.15rem 0.5rem; background: #e3e8ff; color: #667eea; border-radius: 12px; font-size: 0.6875rem; font-weight: 600; }
45
+ .endpoint-badge { display: inline-block; padding: 0.15rem 0.4rem; background: #e8f5e9; color: #2e7d32; border-radius: 4px; font-size: 0.6875rem; font-family: monospace; }
46
+ .state-badge { display: inline-block; padding: 0.15rem 0.4rem; border-radius: 4px; font-size: 0.6875rem; font-weight: 600; }
47
+ .state-badge.completed { background: #e8f5e9; color: #2e7d32; }
48
+ .state-badge.error { background: #ffebee; color: #c62828; }
49
+ .state-badge.sending, .state-badge.receiving { background: #e3f2fd; color: #1565c0; animation: pulse 1.5s infinite; }
50
+ .state-badge.pending { background: #fff3e0; color: #e65100; }
51
+ @keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.6} }
52
+ .pagination { display: flex; justify-content: center; gap: 0.5rem; padding: 0.75rem; border-top: 1px solid #eee; }
53
+ .pagination button { padding: 0.35rem 0.75rem; border: 1px solid #ddd; border-radius: 4px; background: white; cursor: pointer; font-size: 0.8125rem; }
54
+ .pagination button:hover { background: #f0f0f0; }
55
+ .pagination button:disabled { opacity: 0.5; cursor: default; }
56
+ .pagination span { padding: 0.35rem 0.5rem; font-size: 0.8125rem; color: #666; }
57
+ .resize-handle { width: 4px; cursor: col-resize; background: #e0e0e0; transition: background 0.2s; }
58
+ .resize-handle:hover { background: #667eea; }
59
+ @media (max-width: 768px) { .main-container { flex-direction: column-reverse; } .list-panel { width: 100%; height: 40vh; } .resize-handle { display: none; } }
60
+ </style>
61
+ </head>
62
+ <body>
63
+ <nav class="navbar">
64
+ <h1>Request Browser</h1>
65
+ <div class="navbar-actions">
66
+ <a href="/">Dashboard</a>
67
+ <button onclick="exportRequests()">Export</button>
68
+ <label style="color:white;cursor:pointer;padding:0.4rem 0.75rem;border-radius:4px;background:rgba(255,255,255,0.2);font-size:0.875rem">Import<input type="file" accept=".jl,.jsonl,.json" style="display:none" onchange="importRequests(this)"></label>
69
+ </div>
70
+ </nav>
71
+ <div class="main-container">
72
+ <div class="detail-panel">
73
+ <div class="detail-header"><h2 id="detail-title">Select a request</h2></div>
74
+ <div class="detail-controls">
75
+ <label><input type="checkbox" id="show-overview" checked> Overview</label>
76
+ <label><input type="checkbox" id="show-request" checked> Request</label>
77
+ <label><input type="checkbox" id="show-response" checked> Response</label>
78
+ </div>
79
+ <div class="detail-content" id="detail-content">
80
+ <div class="detail-empty">Select a request from the list to view details</div>
81
+ </div>
82
+ </div>
83
+ <div class="resize-handle" id="resize-handle"></div>
84
+ <div class="list-panel">
85
+ <div class="list-header"><input class="search-box" id="search-input" placeholder="Search by model, endpoint, or content..." oninput="debounceSearch()"></div>
86
+ <div class="list-content" id="request-list"></div>
87
+ <div class="pagination">
88
+ <button onclick="changePage(-1)" id="prev-btn" disabled>Prev</button>
89
+ <span id="page-info">Page 1</span>
90
+ <button onclick="changePage(1)" id="next-btn">Next</button>
91
+ </div>
92
+ </div>
93
+ </div>
94
+ <script>
95
+ const fmt = (b) => { if (!b) return '0 B'; const k=1024, s=['B','KB','MB','GB']; const i=Math.floor(Math.log(b)/Math.log(k)); return (b/Math.pow(k,i)).toFixed(2)+' '+s[i]; };
96
+ const num = (n) => new Intl.NumberFormat().format(n);
97
+ const stateLabel = {pending:'Pending',sending:'Sending',receiving:'Receiving',completed:'Completed',error:'Error'};
98
+ function highlight(json) {
99
+ if (typeof json !== 'string') json = JSON.stringify(json, null, 2);
100
+ if (!json) return '';
101
+ return json.replace(/("(\\u[\da-fA-F]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+-]?\d+)?)/g, m => {
102
+ let c = 'json-number'; if (/^"/.test(m)) c = /:$/.test(m)?'json-key':'json-string'; else if (/true|false/.test(m)) c = 'json-boolean'; else if (/null/.test(m)) c = 'json-null';
103
+ return `<span class="${c}">${m}</span>`;
104
+ });
105
+ }
106
+ let currentPage = 1, totalPages = 1, selectedId = null, searchTimer = null;
107
+ function debounceSearch() { clearTimeout(searchTimer); searchTimer = setTimeout(() => { currentPage = 1; loadRequests(); }, 300); }
108
+ async function loadRequests() {
109
+ const search = document.getElementById('search-input').value;
110
+ const url = search ? `/api/requests?page=${currentPage}&per_page=50&search=${encodeURIComponent(search)}` : `/api/requests?page=${currentPage}&per_page=50`;
111
+ const d = await (await fetch(url)).json();
112
+ totalPages = d.total_pages || 1;
113
+ document.getElementById('page-info').textContent = `Page ${currentPage} of ${totalPages}`;
114
+ document.getElementById('prev-btn').disabled = currentPage <= 1;
115
+ document.getElementById('next-btn').disabled = currentPage >= totalPages;
116
+ const list = document.getElementById('request-list'); list.innerHTML = '';
117
+ for (const i of d.items || []) {
118
+ const st = i.state || 'completed';
119
+ let model = i.model;
120
+ if (i.translated_model && i.translated_model !== i.model) model += ` \u2192 ${i.translated_model}`;
121
+ const el = document.createElement('div');
122
+ el.className = 'request-item' + (i.id === selectedId ? ' active' : '');
123
+ el.onclick = () => showDetail(i.id);
124
+ el.innerHTML = `<div class="meta"><span>${new Date(i.timestamp).toLocaleString()}</span><span class="state-badge ${st}">${stateLabel[st]||st}</span></div>
125
+ <div class="model"><span class="model-badge">${model}</span> <span class="endpoint-badge">${i.endpoint}</span></div>
126
+ <div class="info">${i.status_code??'-'} | ${i.duration!=null?i.duration+'s':'-'} | ${fmt(i.request_size)} / ${fmt(i.response_size)}</div>`;
127
+ list.appendChild(el);
128
+ }
129
+ }
130
+ function changePage(delta) { currentPage = Math.max(1, Math.min(totalPages, currentPage + delta)); loadRequests(); }
131
+ async function showDetail(id) {
132
+ selectedId = id;
133
+ document.querySelectorAll('.request-item').forEach(el => el.classList.remove('active'));
134
+ const items = document.querySelectorAll('.request-item');
135
+ items.forEach(el => { if (el.querySelector('.model-badge') && el.onclick) el.classList.toggle('active', false); });
136
+ loadRequests(); // refresh to update active class
137
+ const d = await (await fetch(`/api/request/${id}`)).json();
138
+ const st = d.state||'completed';
139
+ document.getElementById('detail-title').textContent = `${d.model} - ${d.endpoint}`;
140
+ let html = `<div class="detail-section" id="sec-overview"><div class="section-title">Overview</div><div class="overview-grid">
141
+ <div class="overview-item"><label>Request ID</label><div class="value">${d.id}</div></div>
142
+ <div class="overview-item"><label>Timestamp</label><div class="value">${new Date(d.timestamp).toLocaleString()}</div></div>
143
+ <div class="overview-item"><label>Model</label><div class="value">${d.model}</div></div>`;
144
+ if (d.translated_model && d.translated_model !== d.model) html += `<div class="overview-item"><label>Translated</label><div class="value">${d.translated_model}</div></div>`;
145
+ html += `<div class="overview-item"><label>Endpoint</label><div class="value">${d.endpoint}</div></div>
146
+ <div class="overview-item"><label>State</label><div class="value"><span class="state-badge ${st}">${stateLabel[st]||st}</span></div></div>
147
+ <div class="overview-item"><label>Status</label><div class="value">${d.status_code??'-'}</div></div>
148
+ <div class="overview-item"><label>Duration</label><div class="value">${d.duration!=null?d.duration+'s':'-'}</div></div>
149
+ <div class="overview-item"><label>Req Size</label><div class="value">${fmt(d.request_size)}</div></div>
150
+ <div class="overview-item"><label>Res Size</label><div class="value">${fmt(d.response_size)}</div></div>
151
+ <div class="overview-item"><label>Input Tokens</label><div class="value">${num(d.input_tokens)}</div></div>
152
+ <div class="overview-item"><label>Output Tokens</label><div class="value">${num(d.output_tokens)}</div></div>
153
+ </div></div>`;
154
+ html += `<div class="detail-section" id="sec-request"><div class="section-title">Request Body</div><pre class="json-display">${highlight(d.request_body||{})}</pre></div>`;
155
+ html += `<div class="detail-section" id="sec-response"><div class="section-title">Response Body</div><pre class="json-display">${highlight(d.response_body||{})}</pre></div>`;
156
+ document.getElementById('detail-content').innerHTML = html;
157
+ updateVisibility();
158
+ }
159
+ function updateVisibility() {
160
+ const show = (id, vis) => { const el = document.getElementById(id); if (el) el.classList.toggle('hidden', !vis); };
161
+ show('sec-overview', document.getElementById('show-overview').checked);
162
+ show('sec-request', document.getElementById('show-request').checked);
163
+ show('sec-response', document.getElementById('show-response').checked);
164
+ }
165
+ document.getElementById('show-overview').onchange = updateVisibility;
166
+ document.getElementById('show-request').onchange = updateVisibility;
167
+ document.getElementById('show-response').onchange = updateVisibility;
168
+ async function exportRequests() { window.location.href = '/api/requests/export'; }
169
+ async function importRequests(input) {
170
+ if (!input.files.length) return;
171
+ const text = await input.files[0].text();
172
+ const res = await fetch('/api/requests/import', { method: 'POST', headers: {'Content-Type':'text/plain'}, body: text });
173
+ const d = await res.json();
174
+ alert(`Imported ${d.imported} requests. Errors: ${d.total_errors}`);
175
+ loadRequests();
176
+ input.value = '';
177
+ }
178
+ // Resize handle
179
+ const handle = document.getElementById('resize-handle');
180
+ let resizing = false;
181
+ handle.addEventListener('mousedown', () => { resizing = true; document.body.style.cursor = 'col-resize'; });
182
+ document.addEventListener('mousemove', e => {
183
+ if (!resizing) return;
184
+ const listPanel = document.querySelector('.list-panel');
185
+ const w = window.innerWidth - e.clientX;
186
+ if (w > 200 && w < window.innerWidth * 0.7) listPanel.style.width = w + 'px';
187
+ });
188
+ document.addEventListener('mouseup', () => { resizing = false; document.body.style.cursor = ''; });
189
+ loadRequests();
190
+ setInterval(loadRequests, 30000);
191
+ </script>
192
+ </body>
193
+ </html>