diff --git a/pages/index.md b/pages/index.md
new file mode 100644
index 0000000..2cf8faf
--- /dev/null
+++ b/pages/index.md
@@ -0,0 +1,10 @@
+# OpenCode Go Proxy
+
+A small OpenAI-compatible and Anthropic-compatible proxy for OpenCode Go.
+
+## Endpoints
+
+- OpenAI-compatible API: `/oai/v1`
+- Anthropic-compatible API: `/ant/v1`
+
+Use the API with your preferred OpenAI or Anthropic-compatible client. See the [README](https://github.com/owenqwenstarsky/opencode-proxy) for setup and request examples.
diff --git a/src/markdown.js b/src/markdown.js
new file mode 100644
index 0000000..31cbb29
--- /dev/null
+++ b/src/markdown.js
@@ -0,0 +1,92 @@
+function escapeHtml(value) {
+ return value
+ .replaceAll('&', '&')
+ .replaceAll('<', '<')
+ .replaceAll('>', '>')
+ .replaceAll('"', '"')
+ .replaceAll("'", ''');
+}
+
+function inlineMarkdown(value) {
+ let html = escapeHtml(value);
+ html = html.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, '$1');
+ html = html.replace(/`([^`]+)`/g, '$1');
+ html = html.replace(/\*\*([^*]+)\*\*/g, '$1');
+ return html;
+}
+
+export function renderMarkdown(markdown) {
+ const lines = markdown.replaceAll('\r\n', '\n').split('\n');
+ const output = [];
+ let paragraph = [];
+ let list = [];
+ let code = null;
+
+ const flushParagraph = () => {
+ if (paragraph.length) {
+ output.push(`
${inlineMarkdown(paragraph.join(' '))}
`);
+ paragraph = [];
+ }
+ };
+ const flushList = () => {
+ if (list.length) {
+ output.push(`${list.map((item) => `- ${inlineMarkdown(item)}
`).join('')}
`);
+ list = [];
+ }
+ };
+
+ for (const line of lines) {
+ if (code) {
+ if (line.startsWith('```')) {
+ output.push(`${escapeHtml(code.join('\n'))}
`);
+ code = null;
+ } else {
+ code.push(line);
+ }
+ continue;
+ }
+
+ if (line.startsWith('```')) {
+ flushParagraph();
+ flushList();
+ code = [];
+ } else if (/^#{1,6} /.test(line)) {
+ flushParagraph();
+ flushList();
+ const [, hashes, heading] = line.match(/^(#{1,6}) (.+)$/);
+ output.push(`${inlineMarkdown(heading)}`);
+ } else if (/^- /.test(line)) {
+ flushParagraph();
+ list.push(line.slice(2));
+ } else if (line.trim() === '') {
+ flushParagraph();
+ flushList();
+ } else {
+ flushList();
+ paragraph.push(line.trim());
+ }
+ }
+
+ if (code) output.push(`${escapeHtml(code.join('\n'))}
`);
+ flushParagraph();
+ flushList();
+ return output.join('\n');
+}
+
+export function renderPage(markdown, { title = 'OpenCode Go Proxy' } = {}) {
+ return `
+
+
+
+
+ ${escapeHtml(title)}
+
+
+ ${renderMarkdown(markdown)}
+`;
+}
diff --git a/src/proxy.js b/src/proxy.js
index f75aa6d..a27f18d 100644
--- a/src/proxy.js
+++ b/src/proxy.js
@@ -1,5 +1,9 @@
import { Readable } from 'node:stream';
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
import express from 'express';
+import { renderPage } from './markdown.js';
import { createKeyRotator } from './key-store.js';
import { clientIp, createRateLimiter, numericCost, rateLimitError } from './rate-limit.js';
import {
@@ -26,6 +30,7 @@ async function forwardError(upstream, response) {
}
export const DEFAULT_MAX_CONTEXT_TOKENS = 262144;
+const defaultPagesDirectory = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'pages');
function maxContextFromEnv(value = process.env.MAX_CONTEXT_TOKENS) {
if (value === undefined) return DEFAULT_MAX_CONTEXT_TOKENS;
@@ -54,11 +59,20 @@ function withContextLimit(payload, maxContextTokens) {
};
}
-export function createProxyApp({ keys, fetchImpl = globalThis.fetch, upstreamBaseUrl = DEFAULT_UPSTREAM_BASE_URL, maxContextTokens = maxContextFromEnv(), rateLimiter = createRateLimiter() } = {}) {
+export function createProxyApp({ keys, fetchImpl = globalThis.fetch, upstreamBaseUrl = DEFAULT_UPSTREAM_BASE_URL, maxContextTokens = maxContextFromEnv(), rateLimiter = createRateLimiter(), pagesDirectory = defaultPagesDirectory } = {}) {
const rotator = createKeyRotator(keys);
const app = express();
app.use(express.json({ limit: '10mb' }));
+ app.get('/', (_request, response) => {
+ try {
+ const markdown = fs.readFileSync(path.join(pagesDirectory, 'index.md'), 'utf8');
+ response.type('html').send(renderPage(markdown));
+ } catch (error) {
+ response.status(404).send('Page not found');
+ }
+ });
+
async function fetchWithKeyRetries(url, options = {}) {
let lastError;
diff --git a/test/markdown.test.js b/test/markdown.test.js
new file mode 100644
index 0000000..c6b6671
--- /dev/null
+++ b/test/markdown.test.js
@@ -0,0 +1,13 @@
+import { describe, expect, it } from 'vitest';
+import request from 'supertest';
+import { createProxyApp } from '../src/proxy.js';
+
+describe('pages', () => {
+ it('renders the Markdown home page at /', async () => {
+ const result = await request(createProxyApp({ keys: ['key'] })).get('/').expect(200);
+
+ expect(result.headers['content-type']).toMatch(/text\/html/);
+ expect(result.text).toContain('OpenCode Go Proxy
');
+ expect(result.text).toContain('OpenAI-compatible API');
+ });
+});