oca-proxy 1.0.1

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.
Files changed (3) hide show
  1. package/README.md +258 -0
  2. package/bin/oca-proxy.js +644 -0
  3. package/package.json +48 -0
package/README.md ADDED
@@ -0,0 +1,258 @@
1
+ # OCA Proxy (TypeScript)
2
+
3
+ OpenAI-compatible proxy server for Oracle Code Assist (OCA).
4
+
5
+ This proxy handles OCI authentication via web-based OAuth flow and exposes standard OpenAI API endpoints, allowing any OpenAI-compatible tool to use OCA backend models.
6
+
7
+ ## Quick Start
8
+
9
+ ```bash
10
+ cd oca-proxy
11
+ npm install
12
+ npm run build
13
+ npx ./bin/oca-proxy.js
14
+ ```
15
+
16
+ Or install globally and run from anywhere:
17
+
18
+ ```bash
19
+ npm install -g .
20
+ oca-proxy
21
+ ```
22
+
23
+ You can also use npx after local or global install:
24
+
25
+ ```bash
26
+ npx oca-proxy
27
+ ```
28
+
29
+ On first run, the browser will automatically open for OAuth login. After authentication, the proxy is ready to use.
30
+
31
+ ## Authentication
32
+
33
+ The proxy uses web-based OAuth with PKCE on whitelisted ports (8669, 8668, 8667).
34
+
35
+ - **Login:** Visit `http://localhost:8669/login` or it opens automatically on first run
36
+ - **Logout:** Visit `http://localhost:8669/logout`
37
+ - **Status:** Visit `http://localhost:8669/health`
38
+
39
+ Tokens are stored in `~/.oca/refresh_token.json` (same location as Python proxy).
40
+
41
+ ## Usage with OpenAI SDK
42
+
43
+ ```python
44
+ from openai import OpenAI
45
+
46
+ client = OpenAI(
47
+ api_key="dummy", # Not used, but required by SDK
48
+ base_url="http://localhost:8669/v1"
49
+ )
50
+
51
+ response = client.chat.completions.create(
52
+ model="oca/gpt-4.1",
53
+ messages=[{"role": "user", "content": "Hello!"}],
54
+ stream=True
55
+ )
56
+
57
+ for chunk in response:
58
+ print(chunk.choices[0].delta.content, end="")
59
+ ```
60
+
61
+ ## Usage with curl
62
+
63
+ ```bash
64
+ curl http://localhost:8669/v1/chat/completions \
65
+ -H "Content-Type: application/json" \
66
+ -d '{
67
+ "model": "oca/gpt-4.1",
68
+ "messages": [{"role": "user", "content": "Hello!"}],
69
+ "stream": true
70
+ }'
71
+ ```
72
+
73
+ ## Environment Variables
74
+
75
+ | Variable | Default | Description |
76
+ | -------- | ------- | --------------------------------------------------------- |
77
+ | `PORT` | `8669` | Proxy server port (must be 8669, 8668, or 8667 for OAuth) |
78
+
79
+ ## Supported Endpoints
80
+
81
+ ### OpenAI Format (`/v1/...`)
82
+
83
+ | Endpoint | Method | Description |
84
+ | ---------------------- | ------ | -------------------------------------- |
85
+ | `/v1/models` | GET | List available models |
86
+ | `/v1/chat/completions` | POST | Chat completions (streaming supported) |
87
+ | `/v1/responses` | POST | Responses API (streaming supported) |
88
+ | `/v1/completions` | POST | Legacy completions |
89
+ | `/v1/embeddings` | POST | Text embeddings |
90
+
91
+ ### Anthropic Format (`/v1/messages`)
92
+
93
+ | Endpoint | Method | Description |
94
+ | -------------- | ------ | -------------------------------------------- |
95
+ | `/v1/messages` | POST | Anthropic Messages API (streaming supported) |
96
+
97
+ ### Other
98
+
99
+ | Endpoint | Method | Description |
100
+ | --------- | ------ | ------------------------------- |
101
+ | `/` | GET | Dashboard with status and links |
102
+ | `/login` | GET | Start OAuth login flow |
103
+ | `/logout` | GET | Clear authentication |
104
+ | `/health` | GET | Health check |
105
+
106
+ ## Model Mapping
107
+
108
+ Models not starting with `oca/` are automatically mapped to `oca/gpt-4.1` by default.
109
+
110
+ Custom mappings can be configured in `~/.config/oca/oca-proxy.config.json` (old path `~/.oca/oca-proxy-config.json` still read):
111
+
112
+ ```json
113
+ {
114
+ "model_mapping": {
115
+ "gpt-4": "oca/gpt-4.1",
116
+ "claude-3-opus": "oca/openai-o3"
117
+ }
118
+ }
119
+ ```
120
+
121
+ ## Integration Examples
122
+
123
+ ### Claude Code
124
+
125
+ Use the Anthropic endpoint with Claude Code:
126
+
127
+ ```bash
128
+ export ANTHROPIC_API_KEY=dummy
129
+ export ANTHROPIC_BASE_URL=http://localhost:8669
130
+ claude
131
+ ```
132
+
133
+ Or use environment variables in one line:
134
+
135
+ ```bash
136
+ ANTHROPIC_API_KEY=dummy ANTHROPIC_BASE_URL=http://localhost:8669 claude
137
+ ```
138
+
139
+ ### OpenCode
140
+
141
+ Create `opencode.json` in your project root:
142
+
143
+ ```json
144
+ {
145
+ "$schema": "https://opencode.ai/config.json",
146
+ "provider": {
147
+ "oca": {
148
+ "api": "openai",
149
+ "name": "Oracle Code Assist",
150
+ "options": {
151
+ "baseURL": "http://localhost:8669/v1",
152
+ "apiKey": "dummy"
153
+ },
154
+ "models": {
155
+ "gpt-4.1": {
156
+ "id": "oca/gpt-4.1",
157
+ "name": "OCA GPT 4.1"
158
+ }
159
+ }
160
+ }
161
+ },
162
+ "model": "oca/gpt-4.1"
163
+ }
164
+ ```
165
+
166
+ ### Aider
167
+
168
+ ```bash
169
+ aider --openai-api-key dummy --openai-api-base http://localhost:8669/v1
170
+ ```
171
+
172
+ ### Continue (VS Code)
173
+
174
+ ```json
175
+ {
176
+ "models": [
177
+ {
178
+ "provider": "openai",
179
+ "model": "oca/gpt-4.1",
180
+ "apiKey": "dummy",
181
+ "apiBase": "http://localhost:8669/v1"
182
+ }
183
+ ]
184
+ }
185
+ ```
186
+
187
+ ## Files
188
+
189
+ ```
190
+ oca-proxy/
191
+ ├── bin/
192
+ │ └── oca-proxy.js # Standalone CLI - single build output
193
+ ├── src/
194
+ │ ├── index.ts # Main proxy server with OAuth endpoints
195
+ │ ├── auth.ts # PKCE auth, token manager, OCA headers
196
+ │ ├── config.ts # Configuration and token storage
197
+ │ └── logger.ts # Logging utility
198
+ ├── package.json
199
+ ├── tsconfig.json
200
+ └── README.md
201
+ ```
202
+
203
+ ## Running with PM2
204
+
205
+ PM2 is a production process manager for Node.js applications. To run the OCA Proxy with PM2:
206
+
207
+ 1. Install PM2 globally:
208
+
209
+ ```bash
210
+ npm install -g pm2
211
+ ```
212
+
213
+ 2. Build the project:
214
+
215
+ ```bash
216
+ npm run build
217
+ ```
218
+
219
+ 3. Start the proxy:
220
+
221
+ ```bash
222
+ pm2 start dist/oca-proxy.js --name oca-proxy
223
+ ```
224
+
225
+ 4. Monitor and manage:
226
+ - View status: `pm2 status`
227
+ - View logs: `pm2 logs oca-proxy`
228
+ - Restart: `pm2 restart oca-proxy`
229
+ - Stop: `pm2 stop oca-proxy`
230
+ - Delete: `pm2 delete oca-proxy`
231
+
232
+ For advanced configuration, create `ecosystem.config.js`:
233
+
234
+ ```js
235
+ module.exports = {
236
+ apps: [
237
+ {
238
+ name: 'oca-proxy',
239
+ script: 'bin/oca-proxy.js',
240
+ env: {
241
+ NODE_ENV: 'production',
242
+ PORT: 8669,
243
+ },
244
+ },
245
+ ],
246
+ };
247
+ ```
248
+
249
+ Then start with `pm2 start ecosystem.config.js`.
250
+
251
+ ## Comparison with Python Proxy
252
+
253
+ This TypeScript proxy is functionally equivalent to the Python proxy at `~/project/ccr-oca/oca-proxy/`. Both:
254
+
255
+ - Use the same OAuth client (internal mode)
256
+ - Store tokens in the same location (`~/.oca/refresh_token.json`)
257
+ - Support the same whitelisted ports (8669, 8668, 8667)
258
+ - Provide OpenAI-compatible endpoints
@@ -0,0 +1,644 @@
1
+ #!/usr/bin/env node
2
+ "use strict";var me=Object.create;var X=Object.defineProperty;var he=Object.getOwnPropertyDescriptor;var ye=Object.getOwnPropertyNames;var be=Object.getPrototypeOf,xe=Object.prototype.hasOwnProperty;var _e=(e,t,o,n)=>{if(t&&typeof t=="object"||typeof t=="function")for(let s of ye(t))!xe.call(e,s)&&s!==o&&X(e,s,{get:()=>t[s],enumerable:!(n=he(t,s))||n.enumerable});return e};var x=(e,t,o)=>(o=e!=null?me(be(e)):{},_e(t||!e||!e.__esModule?X(o,"default",{value:e,enumerable:!0}):o,e));var w=x(require("axios")),z=x(require("express")),ue=require("uuid");var j=x(require("axios")),O=x(require("node:crypto"));var m=x(require("node:fs")),F=x(require("node:os")),E=x(require("node:path"));var k=x(require("node:fs")),Q=x(require("node:os")),D=x(require("node:path")),Z=require("node:events"),l={reset:"\x1B[0m",bright:"\x1B[1m",dim:"\x1B[2m",underscore:"\x1B[4m",black:"\x1B[30m",red:"\x1B[31m",green:"\x1B[32m",yellow:"\x1B[33m",blue:"\x1B[34m",magenta:"\x1B[35m",cyan:"\x1B[36m",white:"\x1B[37m",bgBlack:"\x1B[40m",bgRed:"\x1B[41m",bgGreen:"\x1B[42m",bgYellow:"\x1B[43m",bgBlue:"\x1B[44m",bgMagenta:"\x1B[45m",bgCyan:"\x1B[46m",bgWhite:"\x1B[47m"};var N=D.join(Q.homedir(),".config","oca","logs"),G=new Date().toISOString().slice(0,10),I=null;function ve(){k.existsSync(N)||k.mkdirSync(N,{recursive:!0})}function ee(){ve();let e=D.join(N,`${G}.log`);if(I)try{I.end()}catch{}I=k.createWriteStream(e,{flags:"a"}),we()}function ke(){let e=new Date().toISOString().slice(0,10);(e!==G||!I)&&(G=e,ee())}function v(e){try{ke();let t=JSON.stringify(e);I?.write(`${t}
3
+ `),H.emit("log",e)}catch{}}function we(){try{let e=k.readdirSync(N).filter(o=>/\d{4}-\d{2}-\d{2}\.log$/.test(o)),t=Date.now();for(let o of e){let n=o.slice(0,10),s=Date.parse(n);if(!Number.isNaN(s)&&t-s>720*60*60*1e3)try{k.unlinkSync(D.join(N,o))}catch{}}}catch{}}ee();var H=new Z.EventEmitter,a={info:e=>{console.log(`${l.cyan}\u2139${l.reset} ${e}`),v({ts:new Date().toISOString(),level:"info",message:e,type:"log"})},success:e=>{console.log(`${l.green}\u2713${l.reset} ${e}`),v({ts:new Date().toISOString(),level:"success",message:e,type:"log"})},warn:e=>{console.log(`${l.yellow}\u26A0${l.reset} ${l.yellow}${e}${l.reset}`),v({ts:new Date().toISOString(),level:"warn",message:e,type:"log"})},error:e=>{console.log(`${l.red}\u2717${l.reset} ${l.red}${e}${l.reset}`),v({ts:new Date().toISOString(),level:"error",message:e,type:"log"})},debug:e=>{console.log(`${l.dim}${e}${l.reset}`),v({ts:new Date().toISOString(),level:"debug",message:e,type:"log"})},auth:e=>{console.log(`${l.yellow}[Auth]${l.reset} ${e}`),v({ts:new Date().toISOString(),level:"auth",message:e,type:"log"})},anthropic:e=>{console.log(`${l.magenta}[Anthropic]${l.reset} ${e}`),v({ts:new Date().toISOString(),level:"anthropic",message:e,type:"log"})},openai:e=>{console.log(`${l.blue}[OpenAI]${l.reset} ${e}`),v({ts:new Date().toISOString(),level:"openai",message:e,type:"log"})},request:(e,t,o)=>{let s={GET:l.green,POST:l.blue,PUT:l.yellow,DELETE:l.red,PATCH:l.cyan}[e]||l.white,r=o?` ${l.dim}${o}${l.reset}`:"";console.log(`${s}${l.bright}${e}${l.reset} ${t}${r}`),v({ts:new Date().toISOString(),level:"request",type:"request",method:e,path:t,extra:o})},response:(e,t,o)=>{let n=l.green;e>=400?n=l.red:e>=300&&(n=l.yellow);let s=o?` ${l.dim}${o}ms${l.reset}`:"";console.log(`${n}${e}${l.reset} ${t}${s}`),v({ts:new Date().toISOString(),level:"response",type:"response",status:e,path:t,duration:o})},blank:()=>console.log(""),raw:(...e)=>console.log(...e)};function te(e,t=60){let o=l.cyan+l.bright,n=l.reset,s=[];s.push(`${o}\u2554${"\u2550".repeat(t)}\u2557${n}`);for(let r of e){let i="\\x1B\\[[0-9;]*m",d=r.replace(new RegExp(i,"g"),""),p=t-d.length;p>0?s.push(`${o}\u2551${n}${r}${" ".repeat(p)}${o}\u2551${n}`):s.push(`${o}\u2551${n}${r.substring(0,t)}${o}\u2551${n}`)}return s.push(`${o}\u255A${"\u2550".repeat(t)}\u255D${n}`),s}function P(e,t,o=14){let n=e.padEnd(o);return` ${l.dim}${n}${l.reset} ${t}`}var ne=E.join(F.homedir(),".oca","oca-proxy-config.json"),B=E.join(F.homedir(),".config","oca","oca-proxy.config.json");function oe(){try{if(m.existsSync(B))return JSON.parse(m.readFileSync(B,"utf-8"));if(m.existsSync(ne))return JSON.parse(m.readFileSync(ne,"utf-8"))}catch{}return{}}var W=oe(),c={client_id:"a8331954c0cf48ba99b5dd223a14c6ea",idcs_url:W.idcs_url||"https://idcs-9dc693e80d9b469480d7afe00e743931.identity.oraclecloud.com",scopes:"openid offline_access",base_url:W.base_url||"https://code-internal.aiservice.us-chicago-1.oci.oraclecloud.com/20250206/app/litellm"};var _=E.join(F.homedir(),".oca","refresh_token.json"),h=parseInt(process.env.PORT||String(W.port||8669),10);function q(){try{let e=oe();return{log_level:e.log_level||"INFO",model_mapping:e.model_mapping||{},default_model:e.default_model,base_url:e.base_url,idcs_url:e.idcs_url,port:e.port}}catch{return{log_level:"INFO",model_mapping:{}}}}function se(e){try{let t=E.dirname(B);return m.existsSync(t)||m.mkdirSync(t,{recursive:!0}),m.writeFileSync(B,JSON.stringify(e,null,2)),!0}catch(t){return a.error(`Failed to save config: ${String(t)}`),!1}}function re(){try{if(m.existsSync(_))return JSON.parse(m.readFileSync(_,"utf-8")).refresh_token}catch{}return null}function K(e){let t=E.dirname(_);m.existsSync(t)||m.mkdirSync(t,{recursive:!0});let o={refresh_token:e,created_at:new Date().toISOString()};m.writeFileSync(_,JSON.stringify(o,null,2),{mode:384}),a.info(`Refresh token saved to ${_}`)}function ae(){try{m.existsSync(_)&&m.unlinkSync(_)}catch{}}var L=new Map;function Se(){return O.randomBytes(96).toString("base64url")}function $e(e){return O.createHash("sha256").update(e).digest("base64url")}function ie(e=32){return O.randomBytes(e).toString("base64url").slice(0,e)}function Ce(){let e=Date.now()-6e5;for(let[t,o]of L.entries())o.created_at<e&&L.delete(t)}function le(e){Ce();let t=Se(),o=$e(t),n=ie(32),s=ie(32);L.set(n,{code_verifier:t,nonce:s,redirect_uri:e,created_at:Date.now()});let r=new URLSearchParams({client_id:c.client_id,response_type:"code",scope:c.scopes,redirect_uri:e,state:n,nonce:s,code_challenge:o,code_challenge_method:"S256"});return`${c.idcs_url}/oauth2/v1/authorize?${r.toString()}`}function de(e){return L.get(e)}function ce(e){L.delete(e)}async function pe(e,t,o){let s=(await j.default.get(`${c.idcs_url}/.well-known/openid-configuration`)).data.token_endpoint,r=new URLSearchParams({grant_type:"authorization_code",code:e,redirect_uri:o,client_id:c.client_id,code_verifier:t}),i=await j.default.post(s,r,{headers:{"Content-Type":"application/x-www-form-urlencoded"}});return{access_token:i.data.access_token,refresh_token:i.data.refresh_token,expires_in:i.data.expires_in||3600}}var U=class{refreshToken=null;accessToken=null;tokenExpiry=null;refreshPromise=null;constructor(){this.refreshToken=re(),this.refreshToken?a.info("Refresh token loaded successfully"):a.warn("No refresh token found. Visit /login to authenticate.")}isAuthenticated(){return this.refreshToken!==null}setRefreshToken(t){this.refreshToken=t,this.accessToken=null,this.tokenExpiry=null,K(t)}clearAuth(){this.refreshToken=null,this.accessToken=null,this.tokenExpiry=null,ae()}async getToken(){if(!this.refreshToken)throw new Error("Not authenticated. Please visit /login to authenticate with OCA.");if(this.accessToken&&this.tokenExpiry){let t=(this.tokenExpiry.getTime()-Date.now())/1e3;if(t>300)return a.debug(`Using cached access token (expires in ${Math.floor(t)}s)`),this.accessToken||""}if(this.refreshPromise)return a.debug("Waiting for in-flight token refresh..."),this.refreshPromise;this.refreshPromise=this.doRefreshToken();try{return await this.refreshPromise}finally{this.refreshPromise=null}}async doRefreshToken(){a.info("Refreshing access token using refresh token");try{let o=(await j.default.get(`${c.idcs_url}/.well-known/openid-configuration`)).data.token_endpoint,n=this.refreshToken;if(!n)throw new Error("Not authenticated. Missing refresh token.");let s=new URLSearchParams({grant_type:"refresh_token",refresh_token:n,client_id:c.client_id}),r=await j.default.post(o,s,{headers:{"Content-Type":"application/x-www-form-urlencoded"}});this.accessToken=r.data.access_token;let i=r.data.expires_in||3600;this.tokenExpiry=new Date(Date.now()+i*1e3);let d=r.data.refresh_token;return d&&d!==this.refreshToken&&(a.info("Received new refresh token, updating saved token"),this.refreshToken=d,K(this.refreshToken||"")),a.success(`Successfully refreshed access token (expires in ${i}s)`),this.accessToken||""}catch(t){let o=t,n=o.response?.status,s=o.response?.data;a.error(`Failed to refresh access token: ${n} ${JSON.stringify(s)}`);let r=s??{};throw n===400&&(r.error==="invalid_grant"||r.error==="invalid_token")?(a.error("Refresh token is invalid/expired. Clearing authentication."),this.clearAuth(),new Error("Refresh token expired. Please visit /login to re-authenticate.")):new Error(`Failed to refresh OCA access token: ${o.message||"unknown error"}`)}}getTokenExpiry(){return this.tokenExpiry}};function C(e){return{Authorization:`Bearer ${e}`,"opc-request-id":O.randomUUID(),"Content-Type":"application/json"}}function ge(e,t){e.get("/",async(o,n)=>{let s=t.isAuthenticated(),r=s?"green":"orange",i=s?"Authenticated":"Not Authenticated",d=s?'<a href="/logout" style="color: red;">Logout</a>':'<a href="/login">Login with Oracle Code Assist</a>';n.send(`
4
+ <html>
5
+ <head>
6
+ <title>OCA Proxy</title>
7
+ <style>
8
+ body { font-family: Arial, sans-serif; max-width: 1200px; margin: 50px auto; padding: 20px; }
9
+ .status { padding: 10px; border-radius: 5px; margin: 20px 0; }
10
+ .authenticated { background-color: #d4edda; border: 1px solid #c3e6cb; }
11
+ .not-authenticated { background-color: #fff3cd; border: 1px solid #ffeaa7; }
12
+ h1 { color: #333; }
13
+ h2 { color: #555; margin-top: 30px; }
14
+ a, button { color: #007bff; text-decoration: none; padding: 10px 20px; background: #f8f9fa; border: 1px solid #ddd; border-radius: 5px; display: inline-block; margin: 10px 5px; cursor: pointer; font-size: 14px; }
15
+ a:hover, button:hover { background: #e9ecef; }
16
+ button:disabled { opacity: 0.6; cursor: not-allowed; }
17
+ .endpoint { background: #f8f9fa; padding: 10px; margin: 10px 0; border-left: 3px solid #007bff; }
18
+ code { background: #f1f1f1; padding: 2px 6px; border-radius: 3px; font-size: 12px; }
19
+ .models-container { margin-top: 20px; }
20
+ .model-card { background: #fff; border: 1px solid #e0e0e0; border-radius: 8px; padding: 15px; margin: 10px 0; box-shadow: 0 2px 4px rgba(0,0,0,0.05); }
21
+ .model-card h4 { margin: 0 0 10px 0; color: #333; display: flex; align-items: center; gap: 10px; }
22
+ .model-card .model-id { font-family: monospace; background: #e7f3ff; padding: 3px 8px; border-radius: 4px; font-size: 13px; color: #0066cc; }
23
+ .model-card .badges { display: flex; gap: 5px; flex-wrap: wrap; margin: 10px 0; }
24
+ .model-card .badge { padding: 3px 8px; border-radius: 12px; font-size: 11px; font-weight: 500; }
25
+ .badge-vision { background: #d4edda; color: #155724; }
26
+ .badge-reasoning { background: #fff3cd; color: #856404; }
27
+ .badge-api { background: #e7f3ff; color: #004085; }
28
+ .model-details { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 10px; margin-top: 10px; font-size: 13px; }
29
+ .model-details .detail { background: #f8f9fa; padding: 8px; border-radius: 4px; }
30
+ .model-details .detail-label { color: #666; font-size: 11px; text-transform: uppercase; }
31
+ .model-details .detail-value { color: #333; font-weight: 500; }
32
+ .loading { text-align: center; padding: 40px; color: #666; }
33
+ .error { background: #f8d7da; border: 1px solid #f5c6cb; color: #721c24; padding: 15px; border-radius: 5px; margin: 10px 0; }
34
+ .model-count { background: #e9ecef; padding: 5px 15px; border-radius: 20px; font-size: 14px; margin-left: 10px; }
35
+ </style>
36
+ </head>
37
+ <body>
38
+ <h1>OCA Proxy Server</h1>
39
+ <div class="tabs">
40
+ <button class="tab-btn" onclick="showTab('status')">Status</button>
41
+ <button class="tab-btn" onclick="showTab('endpoints')">Endpoints</button>
42
+ <button class="tab-btn" onclick="showTab('models')">Models</button>
43
+ <button class="tab-btn" onclick="showTab('mapping')">Mapping</button>
44
+ <button class="tab-btn" onclick="showTab('config')">Config</button>
45
+ <button class="tab-btn" onclick="showTab('usage')">Usage</button>
46
+ <button class="tab-btn" onclick="showTab('logs')">Logs</button>
47
+ </div>
48
+
49
+
50
+ <div id="tab-status" class="tab-panel">
51
+ <div class="status ${s?"authenticated":"not-authenticated"}">
52
+ <strong>Status:</strong> <span style="color: ${r};">\u25CF</span> ${i}
53
+ </div>
54
+ <h2>Actions</h2>
55
+ <div>
56
+ ${d}
57
+ <a href="/health">Health Check</a>
58
+ </div>
59
+ </div>
60
+
61
+ <div id="tab-endpoints" class="tab-panel" style="display:none">
62
+ <h2>API Endpoints</h2>
63
+ <h3>OpenAI Format</h3>
64
+ <div class="endpoint">
65
+ <strong>POST /v1/chat/completions</strong><br>
66
+ OpenAI Chat Completions API
67
+ </div>
68
+ <div class="endpoint">
69
+ <strong>POST /v1/responses</strong><br>
70
+ OpenAI Responses API
71
+ </div>
72
+ <div class="endpoint">
73
+ <strong>GET /v1/models</strong><br>
74
+ List available models
75
+ </div>
76
+ <h3>Anthropic Format</h3>
77
+ <div class="endpoint" style="border-left-color: #d97706;">
78
+ <strong>POST /v1/messages</strong><br>
79
+ Anthropic Messages API (for Claude Code)
80
+ </div>
81
+ <h3>Other</h3>
82
+ <div class="endpoint">
83
+ <strong>GET /health</strong><br>
84
+ Health check and status
85
+ </div>
86
+ </div>
87
+
88
+ <div id="tab-models" class="tab-panel" style="display:none">
89
+ <h2>Available Models <span id="modelCount" class="model-count">-</span></h2>
90
+ <div><button id="refreshModels" onclick="loadModels()">\u{1F504} Refresh Models</button></div>
91
+ <div id="modelsContainer" class="models-container"><div class="loading">Click "Refresh Models" to load available models...</div></div>
92
+ </div>
93
+
94
+ <div id="tab-mapping" class="tab-panel" style="display:none">
95
+ <h2>Model Mappings</h2>
96
+ <div class="config-section" style="background: #fff; border: 1px solid #e0e0e0; border-radius: 8px; padding: 20px; margin: 15px 0;">
97
+ <h3 style="margin-top: 0;">Default Model</h3>
98
+ <p style="color: #666; font-size: 13px;">When a request uses a non-OCA model name, it will be mapped to this model.</p>
99
+ <div style="display: flex; gap: 10px; align-items: center; flex-wrap: wrap;">
100
+ <select id="defaultModel" style="padding: 8px 12px; border: 1px solid #ddd; border-radius: 4px; font-size: 14px; min-width: 250px;">
101
+ <option value="">Loading models...</option>
102
+ </select>
103
+ <select id="defaultReasoningEffort" style="padding: 8px 12px; border: 1px solid #ddd; border-radius: 4px; font-size: 14px;">
104
+ <option value="">No reasoning effort</option>
105
+ </select>
106
+ <button onclick="saveDefaultModel()" style="background: #28a745; color: white; border: none;">Save Default</button>
107
+ </div>
108
+
109
+ <h3 style="margin-top: 25px;">Custom Mappings</h3>
110
+ <p style="color: #666; font-size: 13px;">Map specific model names to OCA models. Example: <code>claude-sonnet-4-5</code> \u2192 <code>oca/gpt4</code></p>
111
+
112
+ <div id="mappingsContainer" style="margin: 15px 0;">
113
+ <div class="loading">Loading mappings...</div>
114
+ </div>
115
+
116
+ <div style="border: 1px dashed #ccc; border-radius: 8px; padding: 15px; margin-top: 15px; background: #fafafa;">
117
+ <h4 style="margin: 0 0 15px 0; color: #555;">Add New Mapping</h4>
118
+ <div style="display: flex; gap: 10px; align-items: center; flex-wrap: wrap;">
119
+ <input id="newSourceModel" type="text" placeholder="Source model (e.g., claude-sonnet-4-5)"
120
+ style="padding: 8px 12px; border: 1px solid #ddd; border-radius: 4px; font-size: 14px; width: 220px;" />
121
+ <span style="color: #666;">\u2192</span>
122
+ <select id="newTargetModel" style="padding: 8px 12px; border: 1px solid #ddd; border-radius: 4px; font-size: 14px; min-width: 200px;">
123
+ <option value="">Select target model...</option>
124
+ </select>
125
+ <select id="newReasoningEffort" style="padding: 8px 12px; border: 1px solid #ddd; border-radius: 4px; font-size: 14px;">
126
+ <option value="">No reasoning</option>
127
+ </select>
128
+ <button onclick="addMapping()" style="background: #007bff; color: white; border: none;">Add Mapping</button>
129
+ </div>
130
+ </div>
131
+ </div>
132
+ </div>
133
+
134
+ <div id="tab-config" class="tab-panel" style="display:none">
135
+ <h2>Server Configuration</h2>
136
+ <ul>
137
+ <li>IDCS URL: <code>${c.idcs_url}</code></li>
138
+ <li>Client ID: <code>${c.client_id}</code></li>
139
+ <li>OCA Base URL: <code>${c.base_url}</code></li>
140
+ <li>Token Storage: <code>${_}</code></li>
141
+ </ul>
142
+ </div>
143
+
144
+ <div id="tab-usage" class="tab-panel" style="display:none">
145
+ <h2>Usage</h2>
146
+ <div class="endpoint">
147
+ <strong>With Claude Code (Anthropic format):</strong><br>
148
+ <code>ANTHROPIC_API_KEY=dummy ANTHROPIC_BASE_URL=http://localhost:${h} claude</code>
149
+ </div>
150
+ <div class="endpoint">
151
+ <strong>With OpenAI SDK:</strong><br>
152
+ <code>OPENAI_API_KEY=dummy OPENAI_BASE_URL=http://localhost:${h}/v1</code>
153
+ </div>
154
+ </div>
155
+
156
+ <div id="tab-logs" class="tab-panel" style="display:none">
157
+ <h2>Logs</h2>
158
+ <div style="margin-bottom:10px; color:#666;">Realtime (SSE)</div>
159
+ <div id="logsContainer" style="background:#0b0b0b; color:#e5e5e5; padding:10px; border-radius:6px; height:400px; overflow:auto; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size:12px;">
160
+ <div class="loading">Connecting...</div>
161
+ </div>
162
+ </div>
163
+
164
+ <script>
165
+
166
+ function showTab(tab) {
167
+ document.querySelectorAll('.tab-panel').forEach(function(panel) {
168
+ panel.style.display = 'none';
169
+ });
170
+ document.querySelectorAll('.tab-btn').forEach(function(btn) {
171
+ btn.classList.remove('active');
172
+ });
173
+ document.getElementById('tab-' + tab).style.display = '';
174
+ document.querySelector('.tab-btn[onclick*="' + tab + '"]').classList.add('active');
175
+ if (tab === 'logs') startSSE();
176
+ else stopLogs();
177
+ }
178
+ window.onload = function() {
179
+ showTab('status');
180
+ };
181
+
182
+ let availableModels = [];
183
+ let currentConfig = { default_model: '', model_mapping: {} };
184
+ let modelReasoningOptions = {};
185
+
186
+ let logEventSource = null;
187
+ let lastRenderedCount = 0;
188
+
189
+ function logsUrl(base) {
190
+ return base;
191
+ }
192
+
193
+ function renderLogs(lines) {
194
+ const el = document.getElementById('logsContainer');
195
+ if (!Array.isArray(lines)) lines = [];
196
+ const html = lines.map(ev => {
197
+ const ts = ev.ts || '';
198
+ const lvl = (ev.level || '').padEnd(8);
199
+ let msg = ev.message || '';
200
+ if (ev.type === 'request') msg = (ev.method || '') + ' ' + (ev.path || '') + (ev.extra ? ' ' + ev.extra : '');
201
+ if (ev.type === 'response') msg = (ev.status || '') + ' ' + (ev.path || '') + (ev.duration ? ' ' + ev.duration + 'ms' : '');
202
+ return '<div>[' + ts + '] ' + lvl + ' ' + msg + '</div>';
203
+ }).join('');
204
+ el.innerHTML = html || '<div class="loading">No logs</div>';
205
+ el.scrollTop = el.scrollHeight;
206
+ lastRenderedCount = lines.length;
207
+ }
208
+
209
+ function startSSE() {
210
+ stopLogs();
211
+ const container = document.getElementById('logsContainer');
212
+ container.innerHTML = '';
213
+ const es = new EventSource(logsUrl('/api/logs/stream'));
214
+ logEventSource = es;
215
+ const buffer = [];
216
+ es.onmessage = (e) => {
217
+ try {
218
+ const ev = JSON.parse(e.data);
219
+ buffer.push(ev);
220
+ if (buffer.length > 5000) buffer.shift();
221
+ renderLogs(buffer);
222
+ } catch {}
223
+ };
224
+ es.onerror = () => {
225
+ es.close();
226
+ logEventSource = null;
227
+ container.innerHTML = '<div class="loading">SSE disconnected. Will retry...</div>';
228
+ setTimeout(() => {
229
+ if (document.getElementById('tab-logs').style.display !== 'none') startSSE();
230
+ }, 2000);
231
+ };
232
+ }
233
+
234
+ function stopLogs() {
235
+ if (logEventSource) { try { logEventSource.close(); } catch {} logEventSource = null; }
236
+ }
237
+
238
+ async function loadModels() {
239
+ const container = document.getElementById('modelsContainer');
240
+ const countSpan = document.getElementById('modelCount');
241
+ const btn = document.getElementById('refreshModels');
242
+
243
+ btn.disabled = true;
244
+ btn.textContent = '\u23F3 Loading...';
245
+ container.innerHTML = '<div class="loading">Loading models...</div>';
246
+
247
+ try {
248
+ const response = await fetch('/api/models/full');
249
+ const data = await response.json();
250
+
251
+ if (data.error) {
252
+ container.innerHTML = '<div class="error">' + data.error.message + '</div>';
253
+ countSpan.textContent = '0';
254
+ return;
255
+ }
256
+
257
+ const models = data.data || [];
258
+ availableModels = models;
259
+ countSpan.textContent = models.length;
260
+
261
+ // Build reasoning options map
262
+ modelReasoningOptions = {};
263
+ models.forEach(model => {
264
+ const modelId = model.litellm_params?.model || model.model_name;
265
+ const info = model.model_info || {};
266
+ if (info.reasoning_effort_options && info.reasoning_effort_options.length > 0) {
267
+ modelReasoningOptions[modelId] = info.reasoning_effort_options;
268
+ }
269
+ });
270
+
271
+ // Update model selectors
272
+ updateModelSelectors();
273
+
274
+ if (models.length === 0) {
275
+ container.innerHTML = '<div class="loading">No models available</div>';
276
+ return;
277
+ }
278
+
279
+ container.innerHTML = models.map(model => {
280
+ const info = model.model_info || {};
281
+ const params = model.litellm_params || {};
282
+
283
+ const badges = [];
284
+ if (info.supports_vision) badges.push('<span class="badge badge-vision">\u{1F441} Vision</span>');
285
+ if (info.is_reasoning_model) badges.push('<span class="badge badge-reasoning">\u{1F9E0} Reasoning</span>');
286
+ if (info.supported_api_list) {
287
+ info.supported_api_list.forEach(api => {
288
+ badges.push('<span class="badge badge-api">' + api + '</span>');
289
+ });
290
+ }
291
+
292
+ const contextWindow = info.context_window ? (info.context_window / 1000).toFixed(0) + 'K' : 'N/A';
293
+ const maxOutput = info.max_output_tokens ? (info.max_output_tokens / 1000).toFixed(0) + 'K' : 'N/A';
294
+
295
+ return \`
296
+ <div class="model-card">
297
+ <h4>
298
+ \${model.model_name || 'Unknown'}
299
+ <span class="model-id">\${params.model || 'N/A'}</span>
300
+ </h4>
301
+ <div class="badges">\${badges.join('')}</div>
302
+ \${info.description ? '<p style="color: #666; margin: 10px 0; font-size: 13px;">' + info.description + '</p>' : ''}
303
+ <div class="model-details">
304
+ <div class="detail">
305
+ <div class="detail-label">Context Window</div>
306
+ <div class="detail-value">\${contextWindow}</div>
307
+ </div>
308
+ <div class="detail">
309
+ <div class="detail-label">Max Output</div>
310
+ <div class="detail-value">\${maxOutput}</div>
311
+ </div>
312
+ <div class="detail">
313
+ <div class="detail-label">Version</div>
314
+ <div class="detail-value">\${info.version || 'N/A'}</div>
315
+ </div>
316
+ \${info.reasoning_effort_options && info.reasoning_effort_options.length > 0 ? \`
317
+ <div class="detail">
318
+ <div class="detail-label">Reasoning Efforts</div>
319
+ <div class="detail-value">\${info.reasoning_effort_options.join(', ')}</div>
320
+ </div>\` : ''}
321
+ </div>
322
+ </div>
323
+ \`;
324
+ }).join('');
325
+
326
+ } catch (err) {
327
+ container.innerHTML = '<div class="error">Failed to load models: ' + err.message + '</div>';
328
+ countSpan.textContent = '!';
329
+ } finally {
330
+ btn.disabled = false;
331
+ btn.textContent = '\u{1F504} Refresh Models';
332
+ }
333
+ }
334
+
335
+ function updateModelSelectors() {
336
+ const defaultSelect = document.getElementById('defaultModel');
337
+ const newTargetSelect = document.getElementById('newTargetModel');
338
+
339
+ const optionsHtml = availableModels.map(model => {
340
+ const modelId = model.litellm_params?.model || model.model_name;
341
+ const hasReasoning = modelReasoningOptions[modelId] ? ' \u{1F9E0}' : '';
342
+ return \`<option value="\${modelId}">\${model.model_name || modelId}\${hasReasoning}</option>\`;
343
+ }).join('');
344
+
345
+ defaultSelect.innerHTML = '<option value="">Select default model...</option>' + optionsHtml;
346
+ newTargetSelect.innerHTML = '<option value="">Select target model...</option>' + optionsHtml;
347
+
348
+ // Set current default if loaded
349
+ if (currentConfig.default_model) {
350
+ defaultSelect.value = currentConfig.default_model;
351
+ updateReasoningSelector('defaultReasoningEffort', currentConfig.default_model);
352
+ }
353
+ }
354
+
355
+ function updateReasoningSelector(selectorId, modelId) {
356
+ const select = document.getElementById(selectorId);
357
+ const options = modelReasoningOptions[modelId] || [];
358
+
359
+ if (options.length === 0) {
360
+ select.innerHTML = '<option value="">No reasoning effort</option>';
361
+ select.disabled = true;
362
+ } else {
363
+ select.innerHTML = '<option value="">No reasoning effort</option>' +
364
+ options.map(opt => \`<option value="\${opt}">\${opt}</option>\`).join('');
365
+ select.disabled = false;
366
+ }
367
+ }
368
+
369
+ async function loadConfig() {
370
+ try {
371
+ const response = await fetch('/api/config');
372
+ const data = await response.json();
373
+ currentConfig = data;
374
+ renderMappings();
375
+ updateModelSelectors();
376
+ } catch (err) {
377
+ console.error('Failed to load config:', err);
378
+ }
379
+ }
380
+
381
+ function renderMappings() {
382
+ const container = document.getElementById('mappingsContainer');
383
+ const mappings = currentConfig.model_mapping || {};
384
+ const entries = Object.entries(mappings);
385
+
386
+ if (entries.length === 0) {
387
+ container.innerHTML = '<div style="color: #666; padding: 10px; text-align: center;">No custom mappings configured</div>';
388
+ return;
389
+ }
390
+
391
+ container.innerHTML = entries.map(([source, target]) => {
392
+ let targetModel, reasoningEffort;
393
+ if (typeof target === 'string') {
394
+ targetModel = target;
395
+ reasoningEffort = '';
396
+ } else {
397
+ targetModel = target.target;
398
+ reasoningEffort = target.reasoning_effort || '';
399
+ }
400
+ const reasoningBadge = reasoningEffort ? \`<span class="badge badge-reasoning" style="margin-left: 5px;">\${reasoningEffort}</span>\` : '';
401
+
402
+ return \`
403
+ <div style="display: flex; align-items: center; gap: 10px; padding: 10px; background: #f8f9fa; border-radius: 4px; margin: 5px 0;">
404
+ <code style="flex: 1;">\${source}</code>
405
+ <span style="color: #666;">\u2192</span>
406
+ <code style="flex: 1;">\${targetModel}</code>
407
+ \${reasoningBadge}
408
+ <button onclick="deleteMapping('\${source}')" style="background: #dc3545; color: white; border: none; padding: 5px 10px; font-size: 12px;">Delete</button>
409
+ </div>
410
+ \`;
411
+ }).join('');
412
+ }
413
+
414
+ async function saveDefaultModel() {
415
+ const model = document.getElementById('defaultModel').value;
416
+ const reasoning = document.getElementById('defaultReasoningEffort').value;
417
+
418
+ if (!model) {
419
+ alert('Please select a model');
420
+ return;
421
+ }
422
+
423
+ try {
424
+ const response = await fetch('/api/config', {
425
+ method: 'POST',
426
+ headers: { 'Content-Type': 'application/json' },
427
+ body: JSON.stringify({
428
+ default_model: model,
429
+ default_reasoning_effort: reasoning || undefined,
430
+ model_mapping: currentConfig.model_mapping
431
+ })
432
+ });
433
+
434
+ if (response.ok) {
435
+ const data = await response.json();
436
+ currentConfig = data.config || currentConfig;
437
+ alert('Default model saved!');
438
+ } else {
439
+ alert('Failed to save default model');
440
+ }
441
+ } catch (err) {
442
+ alert('Error: ' + err.message);
443
+ }
444
+ }
445
+
446
+ async function addMapping() {
447
+ const source = document.getElementById('newSourceModel').value.trim();
448
+ const target = document.getElementById('newTargetModel').value;
449
+ const reasoning = document.getElementById('newReasoningEffort').value;
450
+
451
+ if (!source || !target) {
452
+ alert('Please enter both source and target models');
453
+ return;
454
+ }
455
+
456
+ const newMapping = reasoning ? { target, reasoning_effort: reasoning } : target;
457
+ const updatedMappings = { ...currentConfig.model_mapping, [source]: newMapping };
458
+
459
+ try {
460
+ const response = await fetch('/api/config', {
461
+ method: 'POST',
462
+ headers: { 'Content-Type': 'application/json' },
463
+ body: JSON.stringify({
464
+ default_model: currentConfig.default_model,
465
+ model_mapping: updatedMappings
466
+ })
467
+ });
468
+
469
+ if (response.ok) {
470
+ const data = await response.json();
471
+ currentConfig = data.config || currentConfig;
472
+ currentConfig.model_mapping = updatedMappings;
473
+ renderMappings();
474
+ document.getElementById('newSourceModel').value = '';
475
+ document.getElementById('newTargetModel').value = '';
476
+ document.getElementById('newReasoningEffort').value = '';
477
+ } else {
478
+ alert('Failed to add mapping');
479
+ }
480
+ } catch (err) {
481
+ alert('Error: ' + err.message);
482
+ }
483
+ }
484
+
485
+ async function deleteMapping(source) {
486
+ if (!confirm('Delete mapping for "' + source + '"?')) return;
487
+
488
+ const updatedMappings = { ...currentConfig.model_mapping };
489
+ delete updatedMappings[source];
490
+
491
+ try {
492
+ const response = await fetch('/api/config', {
493
+ method: 'POST',
494
+ headers: { 'Content-Type': 'application/json' },
495
+ body: JSON.stringify({
496
+ default_model: currentConfig.default_model,
497
+ model_mapping: updatedMappings
498
+ })
499
+ });
500
+
501
+ if (response.ok) {
502
+ const data = await response.json();
503
+ currentConfig = data.config || currentConfig;
504
+ currentConfig.model_mapping = updatedMappings;
505
+ renderMappings();
506
+ } else {
507
+ alert('Failed to delete mapping');
508
+ }
509
+ } catch (err) {
510
+ alert('Error: ' + err.message);
511
+ }
512
+ }
513
+
514
+ // Event listeners for reasoning effort updates
515
+ document.getElementById('defaultModel').addEventListener('change', function() {
516
+ updateReasoningSelector('defaultReasoningEffort', this.value);
517
+ });
518
+
519
+ document.getElementById('newTargetModel').addEventListener('change', function() {
520
+ updateReasoningSelector('newReasoningEffort', this.value);
521
+ });
522
+
523
+ // Initialize
524
+ ${s?"loadModels(); loadConfig();":`document.getElementById('mappingsContainer').innerHTML = '<div class=\\"error\\">Please login first to configure mappings</div>';`}
525
+ </script>
526
+ </body>
527
+ </html>
528
+ `)})}var u=(0,z.default)();u.use(z.default.json({limit:"50mb"}));u.use(z.default.text({type:"text/plain",limit:"50mb"}));u.use((e,t,o)=>{let n=["::1","127.0.0.1","::ffff:127.0.0.1"],s=e.ip||"";if(n.includes(s))return o();let r=e.headers["x-forwarded-for"],i=Array.isArray(r)?r[0]||"":r||"";if(i==="127.0.0.1"||i==="::1")return o();a.warn(`Rejected non-localhost access from: ${e.ip}`),t.status(403).send("Forbidden: Only localhost requests are allowed")});u.use((e,t,o)=>{let n=Date.now();a.request(e.method,e.originalUrl),t.on("finish",()=>{let s=Date.now()-n;a.response(t.statusCode,e.originalUrl,s)}),o()});var y=new U,J=q();ge(u,y);u.get("/api/logs/stream",(e,t)=>{t.setHeader("Content-Type","text/event-stream"),t.setHeader("Cache-Control","no-cache"),t.setHeader("Connection","keep-alive"),t.flushHeaders?.();let o=n=>{t.write(`data: ${JSON.stringify(n)}
529
+
530
+ `)};H.on("log",o),t.on("close",()=>{H.off("log",o),t.end()})});var fe="oca/gpt-4.1";function Y(e){if(e?.startsWith("oca/"))return{model:e};let t=J.model_mapping?.[e];if(t)return typeof t=="string"?{model:t}:{model:t.target,reasoning_effort:t.reasoning_effort};let o=J.default_model||fe,n=J.default_reasoning_effort;return{model:o,reasoning_effort:n}}u.get("/login",(e,t)=>{let n=`${`${e.protocol}://${e.get("host")}`}/callback`,s=le(n);a.auth("Redirecting to OAuth login..."),t.redirect(s)});u.get("/callback",async(e,t)=>{let{code:o,state:n,error:s}=e.query;if(s){a.error(`OAuth error: ${s}`),t.status(400).send(`
531
+ <html><body>
532
+ <h1>Authentication Failed</h1>
533
+ <p>Error: ${s}</p>
534
+ <a href="/">Go Home</a>
535
+ </body></html>
536
+ `);return}if(!o||!n){t.status(400).send(`
537
+ <html><body>
538
+ <h1>Invalid Callback</h1>
539
+ <p>Missing code or state parameter</p>
540
+ <a href="/">Go Home</a>
541
+ </body></html>
542
+ `);return}let r=de(n);if(!r){a.error(`Invalid or expired state: ${n.slice(0,8)}...`),t.status(400).send(`
543
+ <html><body>
544
+ <h1>Authentication Failed</h1>
545
+ <p>Invalid or expired state (session may have timed out)</p>
546
+ <a href="/login">Try Again</a>
547
+ </body></html>
548
+ `);return}ce(n);try{let i=await pe(o,r.code_verifier,r.redirect_uri);if(!i.refresh_token){t.status(500).send(`
549
+ <html><body>
550
+ <h1>Authentication Failed</h1>
551
+ <p>No refresh token received</p>
552
+ <a href="/login">Try Again</a>
553
+ </body></html>
554
+ `);return}y.setRefreshToken(i.refresh_token),a.success("Successfully authenticated with OCA!"),t.send(`
555
+ <html>
556
+ <head>
557
+ <title>Login Successful</title>
558
+ <style>
559
+ body { font-family: Arial, sans-serif; max-width: 600px; margin: 100px auto; text-align: center; }
560
+ h1 { color: green; }
561
+ a { color: #007bff; text-decoration: none; padding: 10px 20px; background: #f8f9fa; border: 1px solid #ddd; border-radius: 5px; display: inline-block; margin: 10px; }
562
+ a:hover { background: #e9ecef; }
563
+ </style>
564
+ </head>
565
+ <body>
566
+ <h1>Login Successful!</h1>
567
+ <p>You are now authenticated with Oracle Code Assist.</p>
568
+ <p>Your refresh token has been saved and the proxy is ready to use.</p>
569
+ <a href="/">Go Home</a>
570
+ <a href="/health">Check Status</a>
571
+ </body>
572
+ </html>
573
+ `)}catch(i){let d=i;a.error(`Token exchange failed: ${JSON.stringify(d.response?.data)||d.message}`),t.status(500).send(`
574
+ <html><body>
575
+ <h1>Authentication Failed</h1>
576
+ <p>Token exchange failed: ${d.message}</p>
577
+ <pre>${JSON.stringify(d.response?.data,null,2)}</pre>
578
+ <a href="/login">Try Again</a>
579
+ </body></html>
580
+ `)}});u.get("/logout",(e,t)=>{y.clearAuth(),a.auth("Logged out"),t.send(`
581
+ <html>
582
+ <head>
583
+ <title>Logged Out</title>
584
+ <style>
585
+ body { font-family: Arial, sans-serif; max-width: 600px; margin: 100px auto; text-align: center; }
586
+ a { color: #007bff; text-decoration: none; padding: 10px 20px; background: #f8f9fa; border: 1px solid #ddd; border-radius: 5px; display: inline-block; margin: 10px; }
587
+ a:hover { background: #e9ecef; }
588
+ </style>
589
+ </head>
590
+ <body>
591
+ <h1>Logged Out</h1>
592
+ <p>Your authentication has been cleared.</p>
593
+ <a href="/">Go Home</a>
594
+ <a href="/login">Login Again</a>
595
+ </body>
596
+ </html>
597
+ `)});u.get("/health",(e,t)=>{let o=y.isAuthenticated(),n=y.getTokenExpiry();t.json({status:o?"healthy":"unauthenticated",service:"oca-proxy",authenticated:o,token_expiry:n?.toISOString(),token_storage:_})});u.get("/api/models/full",async(e,t)=>{try{let o=await y.getToken(),n=C(o),s=await w.default.get(`${c.base_url}/v1/model/info`,{headers:n});t.json({data:s.data.data||s.data||[]})}catch(o){let n=o;a.error(`Error listing full models: ${n.message}`),n.message&&(n.message.includes("Not authenticated")||n.message.includes("Refresh token expired"))?t.status(401).json({error:{message:`\u{1F510} Authentication Required
598
+
599
+ Please visit http://localhost:${h}/login to authenticate.`}}):t.status(n.response?.status||500).json({error:{message:n.message||"Unknown error"}})}});u.get("/api/config",(e,t)=>{let o=q();t.json({default_model:o.default_model||fe,model_mapping:o.model_mapping||{}})});u.post("/api/config",(e,t)=>{try{let o=q(),{default_model:n,model_mapping:s}=e.body,r={...o,default_model:n||o.default_model,model_mapping:s||o.model_mapping};se(r)?(Object.assign(J,r),a.info("Config saved successfully"),t.json({success:!0,config:r})):t.status(500).json({error:"Failed to save config"})}catch(o){let n=o;a.error(`Error saving config: ${n.message}`),t.status(500).json({error:n.message||"Unknown error"})}});u.get("/v1/models",async(e,t)=>{try{let o=await y.getToken(),n=C(o),r=((await w.default.get(`${c.base_url}/v1/model/info`,{headers:n})).data.data||[]).map(i=>({id:i.litellm_params?.model||i.model_name||"",object:"model",created:Math.floor(Date.now()/1e3),owned_by:"oca"}));t.json({object:"list",data:r})}catch(o){let n=o;a.error(`Error listing models: ${n.message}`),n.message?.includes("Not authenticated")?t.status(401).json({error:{message:n.message}}):t.status(n.response?.status||500).json({error:{message:n.message||"Unknown error"}})}});u.post("/v1/chat/completions",async(e,t)=>{try{let o=await y.getToken(),n=C(o),s=e.body.stream===!0,r={...e.body},i=Y(r.model),d=r.model;r.model=i.model,i.reasoning_effort&&!r.reasoning_effort&&(r.reasoning_effort=i.reasoning_effort),d!==i.model&&a.openai(`Model mapped: ${d} -> ${i.model}${i.reasoning_effort?` (reasoning: ${i.reasoning_effort})`:""}`),a.openai(`Chat completion request: model=${r.model}, stream=${s}${r.reasoning_effort?`, reasoning=${r.reasoning_effort}`:""}`);let p=await w.default.post(`${c.base_url}/chat/completions`,r,{headers:n,responseType:s?"stream":"json"});s?(t.setHeader("Content-Type","text/event-stream"),t.setHeader("Cache-Control","no-cache"),t.setHeader("Connection","keep-alive"),p.data.pipe(t),p.data.on("error",g=>{a.error(`Stream error: ${g.message}`),t.end()})):t.json(p.data)}catch(o){let n=o;a.error(`Error in chat completions: ${n.message}`),n.message&&(n.message.includes("Not authenticated")||n.message.includes("Refresh token expired"))?t.status(401).json({error:{message:`\u{1F510} OCA Proxy Authentication Required
600
+
601
+ Please visit http://localhost:${h}/login in your browser to authenticate with Oracle Code Assist.
602
+
603
+ After logging in, retry your request.`}}):t.status(n.response?.status||500).json({error:{message:n.message||"Unknown error",code:n.code,status:n.response?.status,data:n.response?.data&&typeof n.response.data!="object"?n.response.data:void 0}})}});u.post("/v1/responses",async(e,t)=>{try{let o=await y.getToken(),n=C(o),s=e.body.stream!==!1,r={...e.body},i=Y(r.model),d=r.model;if(r.model=i.model,i.reasoning_effort&&!r.reasoning_effort&&(r.reasoning_effort=i.reasoning_effort),d!==i.model&&a.openai(`Model mapped: ${d} -> ${i.model}${i.reasoning_effort?` (reasoning: ${i.reasoning_effort})`:""}`),!r.messages&&r.input){let g;typeof r.input=="string"?g=r.input:Array.isArray(r.input)?g=r.input.filter(f=>f.type==="text").map(f=>f.text||"").join(`
604
+ `):g=String(r.input),r.messages=[{role:"user",content:g}],delete r.input}r.max_output_tokens&&(r.max_tokens=r.max_output_tokens,delete r.max_output_tokens),r.stream=s,a.openai(`Responses API request: model=${r.model}, stream=${s}`);let p=await w.default.post(`${c.base_url}/chat/completions`,r,{headers:n,responseType:s?"stream":"json"});s?(t.setHeader("Content-Type","text/event-stream"),t.setHeader("Cache-Control","no-cache"),t.setHeader("Connection","keep-alive"),p.data.pipe(t),p.data.on("error",g=>{a.error(`Stream error: ${g.message}`),t.end()})):t.json(p.data)}catch(o){let n=o;a.error(`Error in responses: ${n.message}`),n.message&&(n.message.includes("Not authenticated")||n.message.includes("Refresh token expired"))?t.status(401).json({error:{message:`\u{1F510} OCA Proxy Authentication Required
605
+
606
+ Please visit http://localhost:${h}/login in your browser to authenticate with Oracle Code Assist.
607
+
608
+ After logging in, retry your request.`}}):t.status(n.response?.status||500).json({error:{message:n.message||"Unknown error",code:n.code,status:n.response?.status,data:n.response?.data&&typeof n.response.data!="object"?n.response.data:void 0}})}});u.post("/v1/completions",async(e,t)=>{try{let o=await y.getToken(),n=C(o),s=e.body.stream===!0,r=await w.default.post(`${c.base_url}/completions`,e.body,{headers:n,responseType:s?"stream":"json"});s?(t.setHeader("Content-Type","text/event-stream"),t.setHeader("Cache-Control","no-cache"),t.setHeader("Connection","keep-alive"),r.data.pipe(t)):t.json(r.data)}catch(o){let n=o;a.error(`Error in completions: ${n.message}`),t.status(n.response?.status||500).json({error:{message:n.message||"Unknown error"}})}});u.post("/v1/embeddings",async(e,t)=>{try{let o=await y.getToken(),n=C(o),s=await w.default.post(`${c.base_url}/embeddings`,e.body,{headers:n});t.json(s.data)}catch(o){let n=o;a.error(`Error in embeddings: ${n.message}`),t.status(n.response?.status||500).json({error:{message:n.message||"Unknown error"}})}});function Re(e){let t=[];if(e.system){let s=e.system;Array.isArray(s)&&(s=s.filter(r=>r.type==="text").map(r=>r.text||"").join(" ")),t.push({role:"system",content:s})}for(let s of e.messages||[]){let r=s.content,i=s.role;if(Array.isArray(r)){let d=[],p=[];for(let f of r)f.type==="text"?d.push(f.text||""):f.type==="tool_use"?p.push({id:f.id,type:"function",function:{name:f.name,arguments:JSON.stringify(f.input||{})}}):f.type==="tool_result"&&t.push({role:"tool",tool_call_id:f.tool_use_id,content:String(f.content||"")});let g={role:i};d.length>0&&(g.content=d.join(" ")),p.length>0&&(g.tool_calls=p),(g.content||g.tool_calls)&&t.push(g)}else t.push({role:i,content:r})}let o=Y(e.model||"claude-3-5-sonnet-20241022"),n={model:o.model,messages:t,stream:!0};if(o.reasoning_effort&&(n.reasoning_effort=o.reasoning_effort),e.tools&&(n.tools=e.tools.map(s=>({type:"function",function:{name:s.name,description:s.description||"",parameters:s.input_schema||{}}})),e.tool_choice)){let s=e.tool_choice;typeof s=="object"&&s.type==="tool"?n.tool_choice={type:"function",function:{name:s.name}}:s==="auto"?n.tool_choice="auto":s==="any"&&(n.tool_choice="required")}return e.max_tokens&&(n.max_tokens=Math.min(e.max_tokens,16384)),e.temperature!==void 0&&(n.temperature=e.temperature),e.top_p!==void 0&&(n.top_p=e.top_p),n}async function*Te(e,t,o){yield`event: message_start
609
+ data: ${JSON.stringify({type:"message_start",message:{id:t,type:"message",role:"assistant",content:[],model:o,stop_reason:null,usage:{input_tokens:0,output_tokens:0}}})}
610
+
611
+ `;let n=0,s=!1,r=null,i="";for await(let d of e){i+=d.toString();let p=i.split(`
612
+ `);i=p.pop()||"";for(let g of p){if(!g||g.startsWith(":")||!g.startsWith("data: "))continue;let f=g.slice(6);if(f==="[DONE]"){yield`event: message_stop
613
+ data: ${JSON.stringify({type:"message_stop"})}
614
+
615
+ `;return}try{let A=JSON.parse(f),R=A.choices?.[0],M=R?.delta;if(M?.tool_calls)for(let b of M.tool_calls){let S=b.id,$=b.function||{};S&&(s&&(yield`event: content_block_stop
616
+ data: ${JSON.stringify({type:"content_block_stop",index:n})}
617
+
618
+ `,n++,s=!1),r={id:S,name:$.name||"",input:""},yield`event: content_block_start
619
+ data: ${JSON.stringify({type:"content_block_start",index:n,content_block:{type:"tool_use",id:S,name:$.name||""}})}
620
+
621
+ `),$.arguments&&r&&(r.input+=$.arguments,yield`event: content_block_delta
622
+ data: ${JSON.stringify({type:"content_block_delta",index:n,delta:{type:"input_json_delta",partial_json:$.arguments}})}
623
+
624
+ `)}else M?.content&&(s||(yield`event: content_block_start
625
+ data: ${JSON.stringify({type:"content_block_start",index:n,content_block:{type:"text",text:""}})}
626
+
627
+ `,s=!0),yield`event: content_block_delta
628
+ data: ${JSON.stringify({type:"content_block_delta",index:n,delta:{type:"text_delta",text:M.content}})}
629
+
630
+ `);if(R?.finish_reason){yield`event: content_block_stop
631
+ data: ${JSON.stringify({type:"content_block_stop",index:n})}
632
+
633
+ `;let b=R.finish_reason;b==="tool_calls"?b="tool_use":b==="stop"&&(b="end_turn");let S=A.usage||{};yield`event: message_delta
634
+ data: ${JSON.stringify({type:"message_delta",delta:{stop_reason:b,stop_sequence:null},usage:{output_tokens:S.completion_tokens||0}})}
635
+
636
+ `,yield`event: message_stop
637
+ data: ${JSON.stringify({type:"message_stop"})}
638
+
639
+ `}}catch{}}}}u.post("/v1/messages",async(e,t)=>{try{let o=await y.getToken(),n=C(o),s=e.body.stream===!0;a.anthropic(`Message request: model=${e.body.model}, stream=${s}`);let r=Re(e.body);a.anthropic(`Mapped model: ${e.body.model} -> ${r.model}`);let i=`msg_${(0,ue.v4)()}`;if(s){t.setHeader("Content-Type","text/event-stream"),t.setHeader("Cache-Control","no-cache"),t.setHeader("Connection","keep-alive");let d=await w.default.post(`${c.base_url}/chat/completions`,r,{headers:n,responseType:"stream"});for await(let p of Te(d.data,i,String(r.model)))t.write(p);t.end()}else{r.stream=!0;let d=await w.default.post(`${c.base_url}/chat/completions`,r,{headers:n,responseType:"stream"}),p="",g="end_turn",f=0,A=0,R="";for await(let M of d.data){R+=M.toString();let b=R.split(`
640
+ `);R=b.pop()||"";for(let S of b){if(!S.startsWith("data: "))continue;let $=S.slice(6);if($==="[DONE]")break;try{let T=JSON.parse($),V=T.choices?.[0]?.delta;V?.content&&(p+=V.content),T.choices?.[0]?.finish_reason&&(g=T.choices[0].finish_reason==="stop"?"end_turn":T.choices[0].finish_reason),T.usage&&(f=T.usage.prompt_tokens||0,A=T.usage.completion_tokens||0)}catch{}}}t.json({id:i,type:"message",role:"assistant",content:[{type:"text",text:p}],model:r.model,stop_reason:g,usage:{input_tokens:f,output_tokens:A}})}}catch(o){let n=o;a.anthropic(`Error: ${n.message}`),n.message&&(n.message.includes("Not authenticated")||n.message.includes("Refresh token expired"))?t.status(401).json({error:{type:"authentication_error",message:`\u{1F510} OCA Proxy Authentication Required
641
+
642
+ Please visit http://localhost:${h}/login in your browser to authenticate with Oracle Code Assist.
643
+
644
+ After logging in, retry your request.`}}):t.status(n.response?.status||500).json({error:{type:"api_error",message:n.message||"Unknown error"}})}});u.use((e,t)=>{a.warn(`404 Not Found: ${e.method} ${e.originalUrl}`),t.status(404).json({error:"Not Found"})});var Ee=u.listen(h,async()=>{let e=y.isAuthenticated()?"\u2713 Authenticated":"\u2717 Not authenticated",t=[" OCA Proxy Server"," ",P("OCA Base URL:",c.base_url),P("IDCS URL:",c.idcs_url),P("Client ID:",c.client_id),P("Auth Status:",e)," ",` \u25B6 Server listening on http://localhost:${h}`," "," Endpoints:",` OpenAI: http://localhost:${h}/v1`,` Messages: http://localhost:${h}/v1/messages`],o=te(t,58);for(let n of o)a.raw(n);if(y.isAuthenticated())a.success("Authenticated - Server ready to use!"),a.info(`Dashboard: http://localhost:${h}`);else{a.info(`Login: http://localhost:${h}/login`);try{let n=(await import("open")).default;setTimeout(()=>{n(`http://localhost:${h}/login`),a.info("Browser opened for authentication")},1500)}catch{}}});Ee.on("error",e=>{e.code==="EADDRINUSE"&&(a.error(`Port ${h} already in use; exiting`),process.exit(1)),a.error(`Server error: ${String(e)}`),process.exit(1)});
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "oca-proxy",
3
+ "version": "1.0.1",
4
+ "description": "OpenAI-compatible proxy for Oracle Code Assist (OCA)",
5
+ "main": "dist/index.js",
6
+ "bin": {
7
+ "oca-proxy": "bin/oca-proxy.js"
8
+ },
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/banzalik/oca-proxy.git"
12
+ },
13
+ "scripts": {
14
+ "build": "esbuild src/index.ts --bundle --platform=node --target=es2022 --outfile=bin/oca-proxy.js --format=cjs --banner:js='#!/usr/bin/env node' --packages=external --minify --legal-comments=none && chmod +x bin/oca-proxy.js",
15
+ "start": "bin/oca-proxy.js",
16
+ "dev": "tsx src/index.ts",
17
+ "format": "biome format --write .",
18
+ "lint": "biome lint .",
19
+ "check": "biome check --write .",
20
+ "typecheck": "tsc -p tsconfig.json --noEmit",
21
+ "prepublishOnly": "npm run build"
22
+ },
23
+ "dependencies": {
24
+ "axios": "1.13.2",
25
+ "express": "5.2.1",
26
+ "jwt-decode": "4.0.0",
27
+ "open": "11.0.0",
28
+ "uuid": "13.0.0"
29
+ },
30
+ "devDependencies": {
31
+ "@biomejs/biome": "2.3.11",
32
+ "@types/express": "5.0.6",
33
+ "@types/node": "25.0.9",
34
+ "@types/uuid": "11.0.0",
35
+ "esbuild": "0.27.2",
36
+ "tsx": "4.21.0",
37
+ "typescript": "5.9.3"
38
+ },
39
+ "publishConfig": {
40
+ "access": "public"
41
+ },
42
+ "files": [
43
+ "bin/**",
44
+ "dist/**",
45
+ "README.md",
46
+ "LICENSE"
47
+ ]
48
+ }