diff --git a/.env (Copy) b/.env (Copy) new file mode 100755 index 0000000..7e1db38 --- /dev/null +++ b/.env (Copy) @@ -0,0 +1,79 @@ +APP_NAME=Laravel +APP_ENV=production +APP_KEY=base64:D71urbf6EWko9MribnCex3FRzFgKIyW/KIbA5z5Bnjg= +APP_DEBUG=false +APP_URL=https://ai.hellyer.test + +APP_LOCALE=en +APP_FALLBACK_LOCALE=en +APP_FAKER_LOCALE=en_US + +APP_MAINTENANCE_DRIVER=file +# APP_MAINTENANCE_STORE=database + +# PHP_CLI_SERVER_WORKERS=4 + +BCRYPT_ROUNDS=12 + +LOG_CHANNEL=stack +LOG_STACK=single +LOG_DEPRECATIONS_CHANNEL=null +LOG_LEVEL=error + +DB_CONNECTION=sqlite +# DB_DATABASE defaults to database/database.sqlite when unset +# DB_HOST=127.0.0.1 +# DB_PORT=3306 +# DB_DATABASE=laravel +# DB_USERNAME=root +# DB_PASSWORD= + +SESSION_DRIVER=database +SESSION_LIFETIME=120 +SESSION_ENCRYPT=false +SESSION_PATH=/ +SESSION_DOMAIN=null + +BROADCAST_CONNECTION=log +FILESYSTEM_DISK=local +QUEUE_CONNECTION=database + +CACHE_STORE=redis +# CACHE_PREFIX= + +MEMCACHED_HOST=127.0.0.1 + +REDIS_CLIENT=phpredis +REDIS_HOST=127.0.0.1 +REDIS_PASSWORD=null +REDIS_PORT=6379 + +MAIL_MAILER=log +MAIL_SCHEME=null +MAIL_HOST=127.0.0.1 +MAIL_PORT=2525 +MAIL_USERNAME=null +MAIL_PASSWORD=null +MAIL_FROM_ADDRESS="hello@example.com" +MAIL_FROM_NAME="${APP_NAME}" + +AWS_ACCESS_KEY_ID= +AWS_SECRET_ACCESS_KEY= +AWS_DEFAULT_REGION=us-east-1 +AWS_BUCKET= +AWS_USE_PATH_STYLE_ENDPOINT=false + +VITE_APP_NAME="${APP_NAME}" + +# AI provider +GITMEH_PROVIDER=opencode +OPENCODE_API_KEY=sk-LPYs1WVmEeSpMKXGwk2q2uAek9ikBIvg6hVAXxhEp6AL2UXNbAtMAUjLaMbvYt5o +# OPENCODE_MODEL=big-pickle + +GITMEH_CHAT_INFERENCE_TIMEOUT=120 +GITMEH_CHAT_TIMEOUT=120 + +API_DAILY_LIMIT=1000 +SESSION_SECURE_COOKIE=true + +TRUSTED_PROXIES=* \ No newline at end of file diff --git a/.env.example b/.env.example index 8d861c4..38a2790 100755 --- a/.env.example +++ b/.env.example @@ -60,22 +60,30 @@ AWS_ACCESS_KEY_ID= AWS_SECRET_ACCESS_KEY= AWS_DEFAULT_REGION=us-east-1 AWS_BUCKET= -AWS_USE_PATH_STYLE_ENDPOINT=false +AWS_USE_FILE_STYLE_ENDPOINT=false VITE_APP_NAME="${APP_NAME}" -OPENROUTER_API_KEY= -OPENROUTER_MODEL=google/gemma-3-4b-it -# GITMEH_PROMPT= -# OPENROUTER_HTTP_REFERER="${APP_URL}" -# OPENROUTER_TITLE="${APP_NAME}" -# OPENROUTER_TIMEOUT=120 +# AI provider (opencode, openrouter, openai, anthropic, groq, mistral, xai, deepseek, gemini, ollama) +GITMEH_PROVIDER=opencode +OPENCODE_API_KEY= +# OPENCODE_MODEL=big-pickle + +# Other providers (uncomment and fill as needed) +# OPENAI_API_KEY= +# ANTHROPIC_API_KEY= +# OPENROUTER_API_KEY= API_DAILY_LIMIT=1000 # API_RATE_LIMIT_TIMEZONE= +# Hosted API overrides (optional) +# GITMEH_HOSTED_TOKEN=gitmeh-public-client +# GITMEH_MAX_JSON_BYTES=2097152 +# GITMEH_CHAT_INFERENCE_TIMEOUT=120 +# GITMEH_PROMPT= +# GITMEH_CHAT_TIMEOUT=120 # Production (set APP_ENV=production, APP_DEBUG=false; use https APP_URL; then php artisan optimize) # SESSION_SECURE_COOKIE=true -# TRUSTED_PROXIES=* - +# TRUSTED_PROXIES=* \ No newline at end of file diff --git a/README.md b/README.md index a6d4a0b..6131a4a 100755 --- a/README.md +++ b/README.md @@ -4,6 +4,57 @@ This application provides a free API for [gitmeh](http://github.com/ryanhellyer/ This API uses the Gemma 3-4b model, which is extremely cost-effective, allowing me to provide this service for free to users of `gitmeh`. The application acts as a proxy, routing requests through its own API to protect the underlying AI provider's API key from being exposed or abused by end-users. +The JSON endpoint is **`POST /v1/chat/completions`**, intentionally the same path pattern as [OpenAI’s Chat Completions API](https://platform.openai.com/docs/api-reference/chat/create) (`/v1/` API version + `chat/completions` resource). That lets the gitmeh client (and anything else expecting an OpenAI-compatible base URL) call `https:///v1` and append `/chat/completions` without a special case for this server. + +## Authentication + +Bearer auth is optional. Three modes: + +| Mode | Behavior | API key forwarded downstream | +|---|---|---| +| No `Authorization` header | Uses the server's configured `OPENAI_API_KEY` | No | +| `Authorization: Bearer gitmeh-public-client` (or `GITMEH_HOSTED_TOKEN`) | Uses the server's configured `OPENAI_API_KEY` | No | +| `Authorization: Bearer ` (any non-hosted token) | Uses **your key** as the downstream API key | Yes | + +## Model selection + +The `model` field in the JSON body sets the primary model. When omitted, the provider's default model is used. + +```json +"model": "gpt-4o-mini" +``` + +## Fallback models + +The optional `fallback_models` field provides models to try if the primary fails (context-length exceeded, transient errors, rate limits). Each model is retried up to 3 times with exponential backoff before moving to the next fallback. + +```json +"fallback_models": ["gpt-4o", "gpt-3.5-turbo"] +``` + +Full example with custom key and fallbacks: + +```bash +curl -k -sS -X POST 'https://ai.hellyer.test/v1/chat/completions' \ + -H 'Content-Type: application/json' \ + -H 'Authorization: Bearer sk-your-openai-key' \ + -d '{ + "model": "gpt-4o-mini", + "fallback_models": ["gpt-4o", "gpt-3.5-turbo"], + "messages": [ + {"role": "user", "content": "Unified diff:\n+a\n"} + ] + }' | jq . +``` + +## Provider selection + +The optional `provider` field selects an upstream provider from `config/ai.php` (default from `GITMEH_PROVIDER`, falls back to `openrouter`). Available providers include `openai`, `anthropic`, `groq`, `mistral`, `deepseek`, `gemini`, `xai`, `ollama`, and others defined in that config file. Each provider uses its own configured API key and default model unless overridden via the `Authorization` header or `model` field. + +## Legacy endpoint + +The plain-text `POST /gitmeh` endpoint still works and always routes through the server's default provider configuration. + Uses PHP 8.5 and Laravel 13. ## Tests @@ -22,6 +73,26 @@ Uses PHP 8.5 and Laravel 13. Use this command to test the API. +```bash +curl -k -sS -X POST 'https://ai.hellyer.test/v1/chat/completions' \ + -H 'Content-Type: application/json' \ + -H 'Authorization: Bearer gitmeh-public-client' \ + -d '{ + "model": "gitmeh-hosted", + "fallback_models": ["google/gemma-3-4b-it"], + "messages": [ + {"role": "user", "content": "Unified diff:\n+a\n"} + ] + }' | jq . +``` + +Smoke-test against a live host (defaults to production URL; override with `GITMEH_VERIFY_BASE` and `GITMEH_VERIFY_TOKEN` for staging): + +```bash +./scripts/verify-hosted-api.sh +``` + +Legacy API request: ```bash curl -skS --request POST \ --header 'Content-Type: text/plain; charset=UTF-8' \ diff --git a/TEST.sh b/TEST.sh new file mode 100755 index 0000000..0ccc661 --- /dev/null +++ b/TEST.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +set -euo pipefail + +BASE="${GITMEH_VERIFY_BASE:-https://ai.hellyer.test/v1}" +TOKEN="${GITMEH_VERIFY_TOKEN:-gitmeh-public-client}" + +curl -k -X POST "${BASE}/chat/completions" \ + -H "Authorization: Bearer ${TOKEN}" \ + -H "Content-Type: application/json" \ + -H "Accept: application/json" \ + -d '{"model":"gitmeh-hosted","messages":[{"role":"system","content":"Write a short git commit message. Imperative mood. Message only."},{"role":"user","content":"Unified diff:\n--- a/foo\n+++ b/foo\n@@ -0,0 +1 @@\n+bar\n"}],"temperature":0.3,"max_tokens":512}' diff --git a/app/Http/Controllers/GitmehChatCompletionsController.php b/app/Http/Controllers/GitmehChatCompletionsController.php new file mode 100755 index 0000000..44c5e19 --- /dev/null +++ b/app/Http/Controllers/GitmehChatCompletionsController.php @@ -0,0 +1,187 @@ +getContent(); + if (strlen($raw) > $maxBytes) { + return $this->errorResponse( + 'Request body exceeds maximum allowed size.', + 'invalid_request_error', + 'request_too_large', + 413 + ); + } + + if ($raw === '') { + return $this->errorResponse('Malformed JSON body.', 'invalid_request_error', 'invalid_json', 400); + } + + try { + /** @var mixed $data */ + $data = json_decode($raw, true, 512, JSON_THROW_ON_ERROR); + } catch (\JsonException) { + return $this->errorResponse('Malformed JSON body.', 'invalid_request_error', 'invalid_json', 400); + } + + if (! is_array($data)) { + return $this->errorResponse('Malformed JSON body.', 'invalid_request_error', 'invalid_json', 400); + } + + if (array_key_exists('model', $data) && ! is_string($data['model'])) { + return $this->errorResponse('Field "model" must be a string.', 'invalid_request_error', 'invalid_model', 400); + } + + if (array_key_exists('provider', $data) && ! is_string($data['provider'])) { + return $this->errorResponse('Field "provider" must be a string.', 'invalid_request_error', 'invalid_provider', 400); + } + + if (array_key_exists('fallback_models', $data) && (! is_array($data['fallback_models']) || ! array_all($data['fallback_models'], fn ($v) => is_string($v)))) { + return $this->errorResponse('Field "fallback_models" must be an array of strings.', 'invalid_request_error', 'invalid_fallback_models', 400); + } + + if (! isset($data['messages']) || ! is_array($data['messages'])) { + return $this->errorResponse('Field "messages" is required and must be an array.', 'invalid_request_error', 'missing_messages', 400); + } + + $systemParts = []; + $lastUserContent = null; + + foreach ($data['messages'] as $index => $message) { + if (! is_array($message)) { + return $this->errorResponse("messages[{$index}] must be an object.", 'invalid_request_error', 'invalid_messages', 400); + } + $role = $message['role'] ?? null; + if (! is_string($role)) { + return $this->errorResponse("messages[{$index}].role is required.", 'invalid_request_error', 'invalid_messages', 400); + } + $content = $message['content'] ?? null; + if ($role === 'system') { + if (! is_string($content)) { + return $this->errorResponse("messages[{$index}].content must be a string.", 'invalid_request_error', 'invalid_messages', 400); + } + if ($content !== '') { + $systemParts[] = $content; + } + } + if ($role === 'user') { + if (! is_string($content)) { + return $this->errorResponse("messages[{$index}].content must be a string.", 'invalid_request_error', 'invalid_messages', 400); + } + $lastUserContent = $content; + } + } + + if ($lastUserContent === null) { + return $this->errorResponse('At least one user message is required.', 'invalid_request_error', 'missing_user_message', 400); + } + + $diff = $this->stripUnifiedDiffPrefix($lastUserContent); + if (trim($diff) === '') { + return $this->errorResponse('User message content is empty after extracting the diff.', 'invalid_request_error', 'empty_diff', 400); + } + + $instruction = $systemParts !== [] + ? implode("\n\n", $systemParts) + : $this->generator->defaultInstruction(); + + $provider = is_string($data['provider'] ?? null) ? $data['provider'] : null; + $clientApiKey = $request->attributes->get('gitmeh_client_api_key'); + $clientApiKey = is_string($clientApiKey) ? $clientApiKey : null; + $model = is_string($data['model'] ?? null) ? $data['model'] : null; + $fallbackModels = array_filter( + (array) ($data['fallback_models'] ?? []), + 'is_string' + ); + $timeout = (int) config('gitmeh.chat_inference_timeout_seconds', 20); + $result = $this->generator->generate( + instruction: $instruction, + unifiedDiff: $diff, + inferenceTimeoutSeconds: $timeout, + provider: $provider, + apiKey: $clientApiKey, + model: $model, + fallbackModels: $fallbackModels, + ); + + $latencyMs = (int) round((microtime(true) - $started) * 1000); + Log::info('gitmeh.chat_completions', [ + 'status' => $result['ok'] ? 200 : $result['status'], + 'latency_ms' => $latencyMs, + ]); + + if (! $result['ok']) { + return $this->errorResponse($result['error'], 'api_error', 'inference_error', $result['status']); + } + + $message = $result['message']; + if ($message === '') { + return $this->errorResponse('Model returned an empty commit message.', 'api_error', 'empty_content', 502); + } + + return response()->json([ + 'id' => 'chatcmpl-gitmeh-'.bin2hex(random_bytes(8)), + 'object' => 'chat.completion', + 'created' => time(), + 'model' => $result['model'] ?? (is_string($data['model'] ?? null) ? $data['model'] : 'gitmeh-hosted'), + 'choices' => [ + [ + 'index' => 0, + 'message' => [ + 'role' => 'assistant', + 'content' => $message, + ], + 'finish_reason' => 'stop', + ], + ], + ], 200, ['Content-Type' => 'application/json']); + } + + private function stripUnifiedDiffPrefix(string $content): string + { + if (str_starts_with($content, self::UNIFIED_DIFF_PREFIX)) { + return substr($content, strlen(self::UNIFIED_DIFF_PREFIX)); + } + if (str_starts_with($content, self::UNIFIED_DIFF_PREFIX_CR)) { + return substr($content, strlen(self::UNIFIED_DIFF_PREFIX_CR)); + } + + return $content; + } + + private function errorResponse(string $message, string $type, string $code, int $status): JsonResponse + { + return response()->json([ + 'error' => [ + 'message' => $message, + 'type' => $type, + 'code' => $code, + ], + ], $status, ['Content-Type' => 'application/json']); + } +} diff --git a/app/Http/Controllers/GitmehController.php b/app/Http/Controllers/GitmehController.php index 618d5a7..b6c52a0 100755 --- a/app/Http/Controllers/GitmehController.php +++ b/app/Http/Controllers/GitmehController.php @@ -4,51 +4,27 @@ namespace App\Http\Controllers; +use App\Services\GitmehCommitMessageGenerator; use Illuminate\Http\Request; -use Throwable; - -use function Laravel\Ai\agent; class GitmehController extends Controller { - private const DEFAULT_PROMPT = 'Write a short, professional git commit message for these changes. Use imperative mood. Only return the message text:'; + public function __construct( + private GitmehCommitMessageGenerator $generator + ) {} public function __invoke(Request $request) { - $key = config('ai.providers.openrouter.key'); - if ($key === null || $key === '') { - return $this->plain('Error: OPENROUTER_API_KEY is missing.', 500); - } - $smartDiff = $request->getContent(); - $instruction = config('services.openrouter.prompt'); - if (! is_string($instruction) || trim($instruction) === '') { - $instruction = self::DEFAULT_PROMPT; - } + $instruction = $this->generator->defaultInstruction(); - $model = config('ai.providers.openrouter.models.text.default', 'google/gemma-3-4b-it'); - $timeout = (int) config('services.openrouter.timeout', 120); - - try { - $agentResponse = agent($instruction)->prompt( - $smartDiff, - [], - 'openrouter', - $model, - $timeout, - ); - } catch (Throwable $e) { - return $this->plain('OpenRouter error: '.$e->getMessage(), 502); - } + $result = $this->generator->generate($instruction, $smartDiff); - $content = trim($agentResponse->text); - if ($content === '' || $content === 'null') { - return $this->plain('The AI failed. Probably went on a coffee break.', 502); + if (! $result['ok']) { + return $this->plain($result['error'], $result['status']); } - $firstLine = explode("\n", $content, 2)[0]; - - return $this->plain(trim($firstLine), 200); + return $this->plain($result['message'], 200); } private function plain(string $body, int $status) diff --git a/app/Http/Middleware/EnforceGitmehDailyLimit.php b/app/Http/Middleware/EnforceGitmehDailyLimit.php index cf77ec0..f1d9d8c 100755 --- a/app/Http/Middleware/EnforceGitmehDailyLimit.php +++ b/app/Http/Middleware/EnforceGitmehDailyLimit.php @@ -19,12 +19,27 @@ public function handle(Request $request, Closure $next): Response { $ip = $request->ip(); if (! $this->limiter->attempt($ip)) { + $retryAfter = (string) $this->limiter->retryAfterSeconds(); + + if ($request->path() === 'v1/chat/completions') { + return response()->json([ + 'error' => [ + 'message' => 'Daily API limit reached for your IP.', + 'type' => 'rate_limit_error', + 'code' => 'rate_limit_exceeded', + ], + ], 429, [ + 'Content-Type' => 'application/json', + 'Retry-After' => $retryAfter, + ]); + } + return response( 'Too Many Requests: daily API limit reached for your IP.', 429, [ 'Content-Type' => 'text/plain; charset=UTF-8', - 'Retry-After' => (string) $this->limiter->retryAfterSeconds(), + 'Retry-After' => $retryAfter, ] ); } diff --git a/app/Http/Middleware/EnsureGitmehChatCompletionsPostOnly.php b/app/Http/Middleware/EnsureGitmehChatCompletionsPostOnly.php new file mode 100755 index 0000000..a694b59 --- /dev/null +++ b/app/Http/Middleware/EnsureGitmehChatCompletionsPostOnly.php @@ -0,0 +1,30 @@ +path() === 'v1/chat/completions' && ! $request->isMethod('POST')) { + return response()->json([ + 'error' => [ + 'message' => 'Method not allowed.', + 'type' => 'invalid_request_error', + 'code' => 'method_not_allowed', + ], + ], 405, ['Allow' => 'POST']); + } + + return $next($request); + } +} diff --git a/app/Http/Middleware/GitmehJsonRequestBodySizeLimit.php b/app/Http/Middleware/GitmehJsonRequestBodySizeLimit.php new file mode 100755 index 0000000..344cfcd --- /dev/null +++ b/app/Http/Middleware/GitmehJsonRequestBodySizeLimit.php @@ -0,0 +1,41 @@ +path() !== 'v1/chat/completions') { + return $next($request); + } + + $max = (int) config('gitmeh.max_json_request_bytes', 2_097_152); + $contentLength = $request->header('Content-Length'); + if ($contentLength !== null && $contentLength !== '' && (int) $contentLength > $max) { + return $this->tooLargeResponse(); + } + + return $next($request); + } + + private function tooLargeResponse(): Response + { + return response()->json([ + 'error' => [ + 'message' => 'Request body exceeds maximum allowed size.', + 'type' => 'invalid_request_error', + 'code' => 'request_too_large', + ], + ], 413); + } +} diff --git a/app/Http/Middleware/ValidateOptionalGitmehHostedBearer.php b/app/Http/Middleware/ValidateOptionalGitmehHostedBearer.php new file mode 100755 index 0000000..6e676b1 --- /dev/null +++ b/app/Http/Middleware/ValidateOptionalGitmehHostedBearer.php @@ -0,0 +1,57 @@ +path() !== 'v1/chat/completions') { + return $next($request); + } + + $header = $request->header('Authorization'); + if ($header === null || $header === '') { + return $next($request); + } + + $expected = (string) config('gitmeh.hosted_bearer_token', 'gitmeh-public-client'); + $prefix = 'Bearer '; + if (! str_starts_with($header, $prefix)) { + return $this->unauthorized('Invalid Authorization scheme.'); + } + + $token = trim(substr($header, strlen($prefix))); + if ($token === '') { + return $this->unauthorized('Invalid bearer token.'); + } + + if (! hash_equals($expected, $token)) { + // Non-hosted token: store it for the controller to forward downstream. + $request->attributes->set('gitmeh_client_api_key', $token); + } + + return $next($request); + } + + private function unauthorized(string $message): Response + { + return response()->json([ + 'error' => [ + 'message' => $message, + 'type' => 'invalid_request_error', + 'code' => 'invalid_api_key', + ], + ], 401); + } +} diff --git a/app/Services/GitmehCommitMessageGenerator.php b/app/Services/GitmehCommitMessageGenerator.php new file mode 100755 index 0000000..844762b --- /dev/null +++ b/app/Services/GitmehCommitMessageGenerator.php @@ -0,0 +1,366 @@ +|null $providerConfig */ + $providerConfig = config("ai.providers.{$provider}"); + + if (! is_array($providerConfig)) { + return ['ok' => false, 'error' => "Unknown provider '{$provider}'.", 'status' => 400]; + } + + $baseUrl = $this->resolveBaseUrl($provider, $providerConfig); + $apiKey ??= $this->resolveApiKey($providerConfig); + if ($model === 'gitmeh-hosted') { + $model = null; + } + $model ??= $this->resolveModel($providerConfig, $provider); + $timeout = $inferenceTimeoutSeconds ?? (int) config('gitmeh.timeout', 120); + + if ($apiKey === null || $apiKey === '') { + return ['ok' => false, 'error' => "Error: API key is missing for provider '{$provider}'.", 'status' => 500]; + } + if ($model === '') { + return ['ok' => false, 'error' => "Error: model is empty for provider '{$provider}'.", 'status' => 500]; + } + + $models = $this->buildModelList($model, $fallbackModels); + $lastError = null; + + foreach ($models as $i => $m) { + /** @var array{ok: true, message: string}|array{ok: false, error: string} $result */ + $result = $this->tryModelWithRetry($baseUrl, $apiKey, $m, $instruction, $unifiedDiff, $timeout); + + if ($result['ok']) { + return $result; + } + + $lastError = $result; + + if ($i < count($models) - 1) { + error_log( "\n → trying fallback model \"{$models[$i + 1]}\" ...\n"); + } + } + + $modelsList = implode(', ', $models); + $errorMsg = $lastError !== null ? $lastError['error'] : 'unknown error'; + + return ['ok' => false, 'error' => "all " . count($models) . " models failed: {$errorMsg} (models: {$modelsList})", 'status' => 502]; + } + + /** + * @return array{ok: true, message: string}|array{ok: false, error: string} + */ + private function tryModelWithRetry(string $baseUrl, string $apiKey, string $model, string $instruction, string $diff, int $timeout): array + { + $lastErr = ''; + + for ($attempt = 0; $attempt < self::MAX_RETRIES_PER_MODEL; $attempt++) { + if ($attempt > 0) { + sleep(1 << ($attempt - 1)); + } + + /** @var array{ok: true, message: string}|array{ok: false, error: string} $result */ + $result = $this->doChatRequest($baseUrl, $apiKey, $model, $instruction, $diff, $timeout); + + if ($result['ok']) { + return $result; + } + + $lastErr = $result['error']; + + if ($this->isContextLengthError($lastErr)) { + error_log( "\n {$model}: context length exceeded\n"); + + return ['ok' => false, 'error' => $lastErr]; + } + + if (! $this->isRetryable($lastErr)) { + error_log( "\n {$model}: {$lastErr}\n"); + + return ['ok' => false, 'error' => $lastErr]; + } + + error_log( "\n {$model} attempt " . ($attempt + 1) . '/' . self::MAX_RETRIES_PER_MODEL . ": {$lastErr}\n"); + } + + error_log( "\n {$model} failed after " . self::MAX_RETRIES_PER_MODEL . " attempts\n"); + + return ['ok' => false, 'error' => $lastErr !== '' ? $lastErr : 'unknown error']; + } + + /** + * @return array{ok: true, message: string}|array{ok: false, error: string} + */ + private function doChatRequest(string $baseUrl, string $apiKey, string $model, string $instruction, string $diff, int $timeout): array + { + $baseUrl = rtrim($baseUrl, '/'); + + $payload = [ + 'model' => $model, + 'messages' => [ + ['role' => 'system', 'content' => $instruction], + ['role' => 'user', 'content' => "Unified diff:\n" . $diff], + ], + 'temperature' => self::TEMPERATURE, + 'max_tokens' => self::MAX_TOKENS, + ]; + + try { + $http = Http::timeout($timeout) + ->withHeaders([ + 'Content-Type' => 'application/json', + 'Accept' => 'application/json', + 'Authorization' => 'Bearer ' . $apiKey, + ]); + + if (str_contains(strtolower($baseUrl), 'openrouter.ai')) { + $referer = config('app.url'); + $title = config('app.name', 'gitmeh'); + $http = $http->withHeaders([ + 'HTTP-Referer' => is_string($referer) ? $referer : '', + 'X-Title' => is_string($title) ? $title : 'gitmeh', + ]); + } + + $response = $http->post("{$baseUrl}/chat/completions", $payload); + + $status = $response->status(); + $body = $response->body(); + + if ($status < 200 || $status >= 300) { + $message = $this->summarizeApiError($body); + + return ['ok' => false, 'error' => "{$status} | {$message}"]; + } + + /** @var mixed $parsed */ + $parsed = $response->json(); + + if (! is_array($parsed)) { + return ['ok' => false, 'error' => 'decode response: invalid JSON (body: ' . $this->truncate($body, 800) . ')']; + } + + $choices = $parsed['choices'] ?? []; + + if (! is_array($choices) || $choices === []) { + return ['ok' => false, 'error' => 'no choices in response: ' . $this->truncate($body, 800)]; + } + + /** @var mixed $firstChoice */ + $firstChoice = $choices[0] ?? null; + + if (! is_array($firstChoice)) { + return ['ok' => false, 'error' => 'no choices in response: ' . $this->truncate($body, 800)]; + } + + /** @var mixed $messageData */ + $messageData = $firstChoice['message'] ?? null; + + if (! is_array($messageData)) { + return ['ok' => false, 'error' => 'empty assistant content: ' . $this->truncate($body, 800)]; + } + + $content = $messageData['content'] ?? null; + + if (! is_string($content) || trim($content) === '') { + return ['ok' => false, 'error' => 'empty assistant content: ' . $this->truncate($body, 800)]; + } + + $firstLine = explode("\n", trim($content), 2)[0]; + + return ['ok' => true, 'message' => trim($firstLine), 'model' => $model]; + } catch (ConnectionException $e) { + return ['ok' => false, 'error' => 'connection error: ' . $e->getMessage()]; + } catch (Throwable $e) { + return ['ok' => false, 'error' => $e->getMessage()]; + } + } + + public function defaultInstruction(): string + { + $instruction = config('gitmeh.prompt'); + if (is_string($instruction) && trim($instruction) !== '') { + return $instruction; + } + + return 'Write a Git commit message (Conventional Commits format) for this diff. Reply with ONLY the commit message. No analysis, no explanation, no preamble. Start with a verb. No numbering. No bullet points.'; + } + + /** + * @param array $providerConfig + */ + private function resolveBaseUrl(string $provider, array $providerConfig): string + { + $defaults = [ + 'opencode' => 'https://opencode.ai/zen/v1', + 'openrouter' => 'https://openrouter.ai/api/v1', + 'openai' => 'https://api.openai.com/v1', + 'anthropic' => 'https://api.anthropic.com/v1', + 'groq' => 'https://api.groq.com/openai/v1', + 'mistral' => 'https://api.mistral.ai/v1', + 'ollama' => 'http://localhost:11434', + 'xai' => 'https://api.x.ai/v1', + ]; + + $url = $providerConfig['url'] ?? null; + + return is_string($url) && $url !== '' ? $url : ($defaults[$provider] ?? 'https://api.openai.com/v1'); + } + + /** + * @param array $providerConfig + */ + private function resolveApiKey(array $providerConfig): ?string + { + $key = $providerConfig['key'] ?? null; + + return is_string($key) && $key !== '' ? $key : null; + } + + /** + * @param array $providerConfig + */ + private function resolveModel(array $providerConfig, string $provider): string + { + /** @var mixed $models */ + $models = $providerConfig['models'] ?? []; + + if (is_array($models)) { + /** @var mixed $text */ + $text = $models['text'] ?? null; + + if (is_array($text)) { + $default = $text['default'] ?? null; + + if (is_string($default) && $default !== '') { + return $default; + } + } + } + + $defaults = [ + 'opencode' => 'big-pickle', + 'openrouter' => 'google/gemma-3-4b-it', + 'openai' => 'gpt-4o-mini', + 'groq' => 'llama-3.1-8b-instant', + 'mistral' => 'mistral-small-latest', + 'ollama' => 'llama3.2', + 'xai' => 'grok-2-latest', + ]; + + return $defaults[$provider] ?? 'gpt-4o-mini'; + } + + /** + * @param string[] $fallbacks + * @return string[] + */ + private function buildModelList(string $primary, array $fallbacks): array + { + $models = [$primary]; + + foreach ($fallbacks as $m) { + $m = trim($m); + if ($m !== '' && $m !== $primary && ! in_array($m, $models, true)) { + $models[] = $m; + } + } + + return $models; + } + + private function isRetryable(string $error): bool + { + $patterns = [ + 'timeout', + 'connection refused', + 'no such host', + 'connection reset', + 'TLS handshake', + '429', + '500', + '502', + '503', + '504', + 'Provider returned error', + ]; + + foreach ($patterns as $pattern) { + if (str_contains($error, $pattern)) { + return true; + } + } + + return false; + } + + private function isContextLengthError(string $error): bool + { + return str_contains($error, 'maximum context length') + || str_contains($error, 'context length') + || str_contains($error, 'too many tokens'); + } + + private function summarizeApiError(string $body): string + { + /** @var mixed $parsed */ + $parsed = json_decode($body, true); + + if (is_array($parsed)) { + /** @var mixed $error */ + $error = $parsed['error'] ?? null; + + if (is_array($error)) { + $message = $error['message'] ?? null; + + if (is_string($message) && trim($message) !== '') { + return $message; + } + } + } + + return 'raw body: ' . $this->truncate($body, 800); + } + + private function truncate(string $s, int $max): string + { + if (mb_strlen($s) <= $max) { + return $s; + } + + return mb_substr($s, 0, $max) . '...'; + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index 809bfe2..a09cbf4 100755 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -3,6 +3,9 @@ declare(strict_types=1); use App\Http\Middleware\EnforceGitmehDailyLimit; +use App\Http\Middleware\EnsureGitmehChatCompletionsPostOnly; +use App\Http\Middleware\GitmehJsonRequestBodySizeLimit; +use App\Http\Middleware\ValidateOptionalGitmehHostedBearer; use Illuminate\Console\Scheduling\Schedule; use Illuminate\Foundation\Application; use Illuminate\Foundation\Configuration\Exceptions; @@ -20,10 +23,14 @@ $middleware->validateCsrfTokens(except: [ 'gitmeh', 'gitmeh/*', + 'v1/chat/completions', ]); $middleware->alias([ 'gitmeh.daily' => EnforceGitmehDailyLimit::class, + 'gitmeh.chat_post_only' => EnsureGitmehChatCompletionsPostOnly::class, + 'gitmeh.json_body' => GitmehJsonRequestBodySizeLimit::class, + 'gitmeh.optional_bearer' => ValidateOptionalGitmehHostedBearer::class, ]); }) ->withSchedule(function (Schedule $schedule): void { diff --git a/config/ai.php b/config/ai.php index d55d0e7..8b3e29f 100755 --- a/config/ai.php +++ b/config/ai.php @@ -116,6 +116,23 @@ 'url' => env('OPENAI_URL', 'https://api.openai.com/v1'), ], + 'opencode' => [ + 'driver' => 'openai', + 'key' => env('OPENCODE_API_KEY'), + 'url' => 'https://opencode.ai/zen/v1', + 'models' => [ + 'text' => [ + // Not working + // 'default' => env('OPENCODE_MODEL', 'kimi-k2.5'), + // 'default' => env('OPENCODE_MODEL', 'ling-2.6-flash'), + + // Works + //'default' => env('OPENCODE_MODEL', 'big-pickle'), + 'default' => env('OPENCODE_MODEL', 'gpt-5-nano'), + ], + ], + ], + 'openrouter' => [ 'driver' => 'openrouter', 'key' => env('OPENROUTER_API_KEY'), diff --git a/config/gitmeh.php b/config/gitmeh.php index f82d063..3a711d3 100755 --- a/config/gitmeh.php +++ b/config/gitmeh.php @@ -8,4 +8,61 @@ 'timezone' => env('API_RATE_LIMIT_TIMEZONE'), -]; + /* + |-------------------------------------------------------------------------- + | Default AI Provider + |-------------------------------------------------------------------------- + | + | The provider used when no "provider" field is sent in the request. + | Must match a key under config('ai.providers'), e.g. "openrouter", + | "openai", "anthropic", "groq", "mistral", etc. + | + */ + + 'default_provider' => env('GITMEH_PROVIDER', 'openrouter'), + + /* + |-------------------------------------------------------------------------- + | Default System Prompt + |-------------------------------------------------------------------------- + | + | Sent as the system message when the request does not include one. + | Override with GITMEH_PROMPT. + | + */ + + 'prompt' => env('GITMEH_PROMPT', 'Write a Git commit message (Conventional Commits format) for this diff. Reply with ONLY the commit message. No analysis, no explanation, no preamble. Start with a verb. No numbering. No bullet points.'), + + /* + |-------------------------------------------------------------------------- + | Inference Timeout (seconds) + |-------------------------------------------------------------------------- + | + | Caps the upstream AI call so responses usually finish within the + | gitmeh client's HTTP timeout. + | + */ + + 'timeout' => (int) env('GITMEH_CHAT_TIMEOUT', 120), + + /* + |-------------------------------------------------------------------------- + | Hosted OpenAI-compatible API (/v1/chat/completions) + |-------------------------------------------------------------------------- + | + | Route path follows OpenAI's REST layout (POST /v1/chat/completions) so the + | gitmeh client can use the same base URL as for other OpenAI-compatible hosts. + | + | Auth: Bearer is optional. Requests without Authorization are allowed (same + | daily rate limit). If Authorization is present, the Bearer token must match + | hosted_bearer_token or the server returns 401 JSON (GITMEH_HOSTED_TOKEN). + | + */ + + 'hosted_bearer_token' => env('GITMEH_HOSTED_TOKEN', 'gitmeh-public-client'), + + 'max_json_request_bytes' => (int) env('GITMEH_MAX_JSON_BYTES', 2_097_152), + + 'chat_inference_timeout_seconds' => (int) env('GITMEH_CHAT_INFERENCE_TIMEOUT', 120), + +]; \ No newline at end of file diff --git a/config/services.php b/config/services.php index 428a382..11886ee 100755 --- a/config/services.php +++ b/config/services.php @@ -37,9 +37,4 @@ ], ], - 'openrouter' => [ - 'prompt' => env('GITMEH_PROMPT'), - 'timeout' => env('OPENROUTER_TIMEOUT', 120), - ], - -]; + ]; diff --git a/docs/hosted-api-migration-instructions.md b/docs/hosted-api-migration-instructions.md new file mode 100755 index 0000000..8300b41 --- /dev/null +++ b/docs/hosted-api-migration-instructions.md @@ -0,0 +1,86 @@ +# Hosted gitmeh API: OpenAI-compatible chat (server-side work) + +Use this document when modifying **your server** (the stack behind `https://ai.hellyer.kiwi/`). In Cursor or another AI tool, open your **server or infra repository** and attach this file (or paste its body) so the model can implement the contract. The **gitmeh** Go client in this repo already defaults to the request shape below. + +## Filled parameters (sync with gitmeh client) + +| Item | Value | +|------|--------| +| Public bearer token (weak client id, not a billing secret) | `gitmeh-public-client` | +| Default chat API base URL (no trailing slash) | `https://ai.hellyer.kiwi/v1` | +| Full completions URL | `https://ai.hellyer.kiwi/v1/chat/completions` | +| Legacy plain endpoint (keep during transition) | `POST https://ai.hellyer.kiwi/gitmeh` with `Content-Type: text/plain; charset=UTF-8`, body = raw unified diff, response = plain text commit message | +| Keep legacy plain path | **Yes** until old binaries are gone; same per-IP limits as today | +| Max JSON request body | **2097152** bytes (2 MiB) before parsing; reject larger with `413` or `400` and a short JSON error | + +Optional: allow overriding the public token on the server via env; the client can override the bearer string with `GITMEH_HOSTED_TOKEN` for staging. + +--- + +## Goal + +Align the **default / built-in** gitmeh hosted service with the **same HTTP contract** the gitmeh Go client uses for external OpenAI-compatible providers: **`POST {baseURL}/chat/completions`** with JSON request/response. + +Legacy behavior remains on `POST https://ai.hellyer.kiwi/gitmeh` as `text/plain` for users who set `GITMEH_LEGACY_PLAIN=true`. + +## Reference client behavior (must match) + +- **Method/path:** `POST {baseURL}/chat/completions` where `baseURL` has **no trailing slash** (client uses `base + "/chat/completions"`). +- **Headers (request):** + - `Content-Type: application/json` + - `Accept: application/json` + - `Authorization: Bearer gitmeh-public-client` for official builds (or value from `GITMEH_HOSTED_TOKEN` when set). Treat the token as **optional** or **required** on the server, but document which; mismatches should return **401** with JSON error if you require it. +- **JSON request body (minimum fields to support):** + - `model` (string) — client sends `gitmeh-hosted` by default for the hosted path; you may **ignore** and always run your local model, or **map** ids; return **400** if you require a specific model and it is missing. + - `messages` (array) — client sends **two** messages: `role: "system"` (instructions) and `role: "user"` with content `Unified diff:\n` + unified diff text. + - `temperature` (number, e.g. 0.3) — optional to honor; safe to clamp. + - `max_tokens` (number, e.g. 512) — optional to honor; cap server-side for cost control. +- **JSON response body (success):** OpenAI shape, at minimum: + - `choices` non-empty array + - `choices[0].message.content` string = assistant commit message (plain text; client trims whitespace). +- **Errors (non-2xx):** Prefer `{"error":{"message":"...","type":"...","code":"..."}}` so clients can surface `error.message`. + +## Auth policy (implemented on server) + +Use **optional Bearer** for `https://ai.hellyer.kiwi/v1/chat/completions`: + +- If `Authorization: Bearer gitmeh-public-client` matches, treat as official gitmeh client (same rate limits as legacy). +- If header missing or wrong: either same limits (public) or **401** — pick one and document; client always sends the bearer for the hosted default. + +## Server implementation checklist + +1. **Routing:** `POST` on `/v1/chat/completions` (under your TLS host); **405** for wrong methods. +2. **Parse JSON** with **2 MiB** max body; reject oversized bodies before model call. +3. **Extract diff** from `messages`: prefer the last `user` message; strip an optional `Unified diff:\n` prefix for robustness. +4. **Build prompt** for your local model: system text from `role == "system"` messages; user content = diff. +5. **Reuse** the same inference path as the legacy `text/plain` endpoint so behavior stays consistent. +6. **Response:** `Content-Type: application/json`; **200** with `choices[0].message.content` set to the commit message only (no markdown fences). +7. **Rate limiting:** Same per-IP limits as legacy `/gitmeh`. +8. **Timeouts:** Compatible with ~20s client HTTP timeout. +9. **Logging:** Status, latency; avoid logging full diffs if privacy matters. +10. **Tests:** Happy path JSON; missing `messages`; empty `choices`; malformed JSON; oversize body; rate limit if testable. + +## Deployment notes + +- Reverse proxy: allow at least **2 MiB** upload for this route. +- Preserve real client IP for rate limiting (`X-Forwarded-For` trust). + +## Flow (optional) + +```mermaid +flowchart LR + subgraph client [gitmeh_client] + ChatPOST[POST_chat_completions_JSON] + end + subgraph server [hellyer_hosted_API] + Legacy[legacy_text_plain_gitmeh] + NewChat[v1_chat_completions] + Model[shared_model_inference] + end + ChatPOST --> NewChat --> Model + Legacy --> Model +``` + +## After the API is live + +Run `./scripts/verify-hosted-api.sh` from this repo (or set `GITMEH_VERIFY_BASE` / `GITMEH_VERIFY_TOKEN` for staging). Expect HTTP **200** and non-empty `choices[0].message.content`. diff --git a/gitmeh.sh b/gitmeh.sh index 04ad34b..93facc4 100755 --- a/gitmeh.sh +++ b/gitmeh.sh @@ -6,8 +6,8 @@ # GitHub: https://github.com/ryanhellyer/gitmeh # Configuration -API_KEY="${OPENROUTER_API_KEY:-$GEMINI_API_KEY}" -MODEL="${OPENROUTER_MODEL:-google/gemma-3-4b-it}" +API_KEY="${OPENAI_API_KEY:-$GEMINI_API_KEY}" +MODEL="${OPENAI_MODEL:-google/gemma-3-4b-it}" BRANCH=$(git rev-parse --abbrev-ref HEAD) MAX_TOTAL_CHARS=10000 CHARS_PER_FILE=800 @@ -85,10 +85,10 @@ if [[ "$1" == "--help" ]] || [[ "$1" == "-h" ]]; then echo -e "${CYAN}$(get_random "${INTRO_PHRASES[@]}")${NC}" echo "Usage: gitmeh" echo "" - echo "Setup: Store your OpenRouter API key in your shell config (~/.bashrc, ~/.zshrc, or ~/.profile):" - echo "export OPENROUTER_API_KEY='your_key_here'" + echo "Setup: Store your API key in your shell config (~/.bashrc, ~/.zshrc, or ~/.profile):" + echo "export OPENAI_API_KEY='your_key_here'" echo "" - echo "Optional: Set OPENROUTER_MODEL (default: google/gemma-3-4b-it). See https://openrouter.ai/models" + echo "Optional: Set OPENAI_MODEL (default: google/gemma-3-4b-it). See https://openrouter.ai/models" echo "Optional: Set GITMEH_PROMPT to customize the instruction sent to the AI (the diff is always appended)." echo "" echo "Author: Ryan Hellyer (https://ryan.hellyer.kiwi)" @@ -97,7 +97,7 @@ fi # Check API Key if [ -z "$API_KEY" ]; then - echo -e "${YELLOW}Error: OPENROUTER_API_KEY is missing.${NC}" + echo -e "${YELLOW}Error: OPENAI_API_KEY is missing.${NC}" echo "Get a key at https://openrouter.ai/keys and put it in ~/.bashrc or ~/.zshrc." exit 1 fi diff --git a/routes/web.php b/routes/web.php index f4c986d..561c94f 100755 --- a/routes/web.php +++ b/routes/web.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use App\Http\Controllers\GitmehChatCompletionsController; use App\Http\Controllers\GitmehController; use App\Http\Controllers\GitmehStatusController; use Illuminate\Support\Facades\Route; @@ -14,6 +15,15 @@ Route::post('/gitmeh', GitmehController::class)->middleware('gitmeh.daily'); Route::post('/gitmeh/', GitmehController::class)->middleware('gitmeh.daily'); +// Path matches OpenAI's REST API (POST /v1/chat/completions) so gitmeh and other clients can use +// the same base URL + /chat/completions pattern as for OpenAI-compatible providers. +Route::any('/v1/chat/completions', GitmehChatCompletionsController::class)->middleware([ + 'gitmeh.chat_post_only', + 'gitmeh.json_body', + 'gitmeh.optional_bearer', + 'gitmeh.daily', +]); + Route::get('/', $apiOnly); Route::fallback($apiOnly); diff --git a/scripts/verify-hosted-api.sh b/scripts/verify-hosted-api.sh new file mode 100755 index 0000000..9e9526e --- /dev/null +++ b/scripts/verify-hosted-api.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Verify the hosted OpenAI-compatible chat completions endpoint. +# Override for staging: GITMEH_VERIFY_BASE, GITMEH_VERIFY_TOKEN + +BASE="${GITMEH_VERIFY_BASE:-https://ai.hellyer.kiwi/v1}" +TOKEN="${GITMEH_VERIFY_TOKEN:-gitmeh-public-client}" + +payload='{"model":"gitmeh-hosted","messages":[{"role":"system","content":"Write a short git commit message. Imperative mood. Message only."},{"role":"user","content":"Unified diff:\n--- a/foo\n+++ b/foo\n@@ -0,0 +1 @@\n+bar\n"}],"temperature":0.3,"max_tokens":512}' + +resp="$(curl -sS -w "\n%{http_code}" -X POST "${BASE}/chat/completions" \ + -H "Authorization: Bearer ${TOKEN}" \ + -H "Content-Type: application/json" \ + -H "Accept: application/json" \ + -d "${payload}")" + +code="$(echo "${resp}" | tail -n1)" +body="$(echo "${resp}" | sed '$d')" + +if [[ "${code}" != "200" ]]; then + echo "Expected HTTP 200, got ${code}" >&2 + echo "${body}" >&2 + exit 1 +fi + +if command -v jq >/dev/null 2>&1; then + content="$(echo "${body}" | jq -r '.choices[0].message.content // empty')" +else + content="$(python3 -c "import json,sys; d=json.loads(sys.stdin.read()); print((d.get('choices') or [{}])[0].get('message',{}).get('content') or '')" <<< "${body}")" +fi + +if [[ -z "${content// /}" ]]; then + echo "Empty choices[0].message.content" >&2 + echo "${body}" >&2 + exit 1 +fi + +echo "${content}" diff --git a/tests/Feature/GitmehChatCompletionsTest.php b/tests/Feature/GitmehChatCompletionsTest.php new file mode 100755 index 0000000..44e6b0e --- /dev/null +++ b/tests/Feature/GitmehChatCompletionsTest.php @@ -0,0 +1,321 @@ + 100, + 'gitmeh.max_json_request_bytes' => 4096, + 'gitmeh.hosted_bearer_token' => 'gitmeh-public-client', + 'gitmeh.chat_inference_timeout_seconds' => 20, + 'gitmeh.default_provider' => 'openrouter', + 'ai.providers.openrouter.key' => 'sk-test-key', + 'ai.providers.openrouter.url' => 'https://openrouter.ai/api/v1', + ]); + + Http::fake([ + 'openrouter.ai/*' => Http::response([ + 'choices' => [ + [ + 'message' => [ + 'role' => 'assistant', + 'content' => 'Add bar to foo', + ], + ], + ], + ], 200), + ]); + + try { + Redis::connection()->flushdb(); + } catch (\Throwable) { + $this->markTestSkipped('Redis is not available.'); + } + } + + private function validPayload(): string + { + return json_encode([ + 'model' => 'gitmeh-hosted', + 'messages' => [ + ['role' => 'system', 'content' => 'Write a commit message.'], + ['role' => 'user', 'content' => "Unified diff:\n--- a/foo\n+++ b/foo\n@@ -0,0 +1 @@\n+bar\n"], + ], + 'temperature' => 0.3, + 'max_tokens' => 512, + ], JSON_THROW_ON_ERROR); + } + + public function test_post_chat_completions_returns_json_choice(): void + { + $this->call('POST', '/v1/chat/completions', [], [], [], [ + 'CONTENT_TYPE' => 'application/json', + 'HTTP_AUTHORIZATION' => 'Bearer gitmeh-public-client', + 'HTTP_ACCEPT' => 'application/json', + ], $this->validPayload()) + ->assertOk() + ->assertJsonPath('choices.0.message.role', 'assistant') + ->assertJsonPath('choices.0.message.content', 'Add bar to foo') + ->assertJsonPath('model', 'google/gemma-3-4b-it'); + } + + public function test_gitmeh_hosted_model_resolved_to_provider_default(): void + { + $this->call('POST', '/v1/chat/completions', [], [], [], [ + 'CONTENT_TYPE' => 'application/json', + ], json_encode([ + 'model' => 'gitmeh-hosted', + 'messages' => [ + ['role' => 'user', 'content' => "Unified diff:\n--- a/foo\n+++ b/foo\n@@ -0,0 +1 @@\n+baz\n"], + ], + ], JSON_THROW_ON_ERROR)) + ->assertOk() + ->assertJsonPath('model', 'google/gemma-3-4b-it'); + } + + public function test_missing_messages_returns_400_json(): void + { + $payload = json_encode(['model' => 'gitmeh-hosted'], JSON_THROW_ON_ERROR); + + $this->call('POST', '/v1/chat/completions', [], [], [], [ + 'CONTENT_TYPE' => 'application/json', + 'HTTP_ACCEPT' => 'application/json', + ], $payload) + ->assertStatus(400) + ->assertJsonPath('error.code', 'missing_messages'); + } + + public function test_malformed_json_returns_400(): void + { + $this->call('POST', '/v1/chat/completions', [], [], [], [ + 'CONTENT_TYPE' => 'application/json', + ], '{not json') + ->assertStatus(400) + ->assertJsonPath('error.code', 'invalid_json'); + } + + public function test_oversized_body_returns_413(): void + { + config(['gitmeh.max_json_request_bytes' => 10]); + + $big = str_repeat('a', 50); + $this->call('POST', '/v1/chat/completions', [], [], [], [ + 'CONTENT_TYPE' => 'application/json', + ], $big) + ->assertStatus(413) + ->assertJsonPath('error.code', 'request_too_large'); + } + + public function test_get_returns_405_without_json_body_middleware_side_effects(): void + { + $limiter = app(GitmehDailyApiLimiter::class); + $before = $limiter->currentUsage('127.0.0.1'); + + $this->get('/v1/chat/completions') + ->assertStatus(405) + ->assertHeader('Allow', 'POST') + ->assertJsonPath('error.code', 'method_not_allowed'); + + $this->assertSame($before, $limiter->currentUsage('127.0.0.1')); + } + + public function test_invalid_auth_scheme_returns_401(): void + { + $this->call('POST', '/v1/chat/completions', [], [], [], [ + 'CONTENT_TYPE' => 'application/json', + 'HTTP_AUTHORIZATION' => 'Basic dXNlcjpwYXNz', + ], $this->validPayload()) + ->assertStatus(401) + ->assertJsonPath('error.code', 'invalid_api_key'); + } + + public function test_empty_bearer_returns_401(): void + { + $this->call('POST', '/v1/chat/completions', [], [], [], [ + 'CONTENT_TYPE' => 'application/json', + 'HTTP_AUTHORIZATION' => 'Bearer ', + ], $this->validPayload()) + ->assertStatus(401) + ->assertJsonPath('error.code', 'invalid_api_key'); + } + + public function test_custom_bearer_is_forwarded_to_provider(): void + { + Http::fake([ + 'openrouter.ai/*' => function (\Illuminate\Http\Client\Request $request) { + $this->assertSame('Bearer my-custom-key', $request->header('Authorization')[0]); + + return Http::response([ + 'choices' => [ + ['message' => ['role' => 'assistant', 'content' => 'custom key msg']], + ], + ], 200); + }, + ]); + + config(['gitmeh.hosted_bearer_token' => 'gitmeh-public-client']); + + $this->call('POST', '/v1/chat/completions', [], [], [], [ + 'CONTENT_TYPE' => 'application/json', + 'HTTP_AUTHORIZATION' => 'Bearer my-custom-key', + ], json_encode([ + 'model' => 'custom-model', + 'messages' => [ + ['role' => 'user', 'content' => 'diff content'], + ], + ], JSON_THROW_ON_ERROR)) + ->assertOk() + ->assertJsonPath('choices.0.message.content', 'custom key msg'); + } + + public function test_provider_field_selects_upstream_provider(): void + { + Http::fake([ + 'api.openai.com/*' => Http::response([ + 'choices' => [ + ['message' => ['role' => 'assistant', 'content' => 'openai response']], + ], + ], 200), + ]); + + config([ + 'ai.providers.openai.key' => 'sk-openai-test', + 'ai.providers.openai.url' => 'https://api.openai.com/v1', + ]); + + $this->call('POST', '/v1/chat/completions', [], [], [], [ + 'CONTENT_TYPE' => 'application/json', + ], json_encode([ + 'provider' => 'openai', + 'model' => 'gpt-4', + 'messages' => [ + ['role' => 'user', 'content' => 'diff content'], + ], + ], JSON_THROW_ON_ERROR)) + ->assertOk() + ->assertJsonPath('choices.0.message.content', 'openai response'); + } + + public function test_fallback_models_are_tried_on_context_length_error(): void + { + Http::fake([ + 'openrouter.ai/*' => Http::sequence() + ->push(['error' => ['message' => 'context length exceeded']], 400) + ->push(['choices' => [['message' => ['role' => 'assistant', 'content' => 'fallback worked']]]], 200), + ]); + + $this->call('POST', '/v1/chat/completions', [], [], [], [ + 'CONTENT_TYPE' => 'application/json', + ], json_encode([ + 'model' => 'primary-model', + 'fallback_models' => ['fallback-model'], + 'messages' => [ + ['role' => 'user', 'content' => 'diff content'], + ], + ], JSON_THROW_ON_ERROR)) + ->assertOk() + ->assertJsonPath('choices.0.message.content', 'fallback worked'); + } + + public function test_empty_model_text_returns_502_json(): void + { + Http::fake([ + 'openrouter.ai/*' => Http::response([ + 'choices' => [ + ['message' => ['role' => 'assistant', 'content' => '']], + ], + ], 200), + ]); + + $this->call('POST', '/v1/chat/completions', [], [], [], [ + 'CONTENT_TYPE' => 'application/json', + ], $this->validPayload()) + ->assertStatus(502) + ->assertJsonPath('error.code', 'inference_error'); + } + + public function test_v1_shares_daily_limit_with_legacy_gitmeh(): void + { + config(['gitmeh.daily_limit' => 2]); + + for ($i = 0; $i < 2; $i++) { + $this->call('POST', '/v1/chat/completions', [], [], [], [ + 'CONTENT_TYPE' => 'application/json', + ], $this->validPayload())->assertOk(); + } + + $this->call('POST', '/gitmeh', [], [], [], ['CONTENT_TYPE' => 'text/plain'], 'diff') + ->assertStatus(429); + } + + public function test_unknown_provider_returns_400(): void + { + $this->call('POST', '/v1/chat/completions', [], [], [], [ + 'CONTENT_TYPE' => 'application/json', + ], json_encode([ + 'provider' => 'nonexistent', + 'messages' => [ + ['role' => 'user', 'content' => 'diff content'], + ], + ], JSON_THROW_ON_ERROR)) + ->assertStatus(400) + ->assertJsonPath('error.code', 'inference_error'); + } + + public function test_invalid_provider_type_returns_400(): void + { + $this->call('POST', '/v1/chat/completions', [], [], [], [ + 'CONTENT_TYPE' => 'application/json', + ], json_encode([ + 'provider' => 123, + 'messages' => [ + ['role' => 'user', 'content' => 'diff'], + ], + ], JSON_THROW_ON_ERROR)) + ->assertStatus(400) + ->assertJsonPath('error.code', 'invalid_provider'); + } + + public function test_invalid_fallback_models_type_returns_400(): void + { + $this->call('POST', '/v1/chat/completions', [], [], [], [ + 'CONTENT_TYPE' => 'application/json', + ], json_encode([ + 'fallback_models' => 'not-an-array', + 'messages' => [ + ['role' => 'user', 'content' => 'diff'], + ], + ], JSON_THROW_ON_ERROR)) + ->assertStatus(400) + ->assertJsonPath('error.code', 'invalid_fallback_models'); + } + + public function test_invalid_fallback_models_element_type_returns_400(): void + { + $this->call('POST', '/v1/chat/completions', [], [], [], [ + 'CONTENT_TYPE' => 'application/json', + ], json_encode([ + 'fallback_models' => [123], + 'messages' => [ + ['role' => 'user', 'content' => 'diff'], + ], + ], JSON_THROW_ON_ERROR)) + ->assertStatus(400) + ->assertJsonPath('error.code', 'invalid_fallback_models'); + } +} diff --git a/tests/Feature/GitmehRateLimitTest.php b/tests/Feature/GitmehRateLimitTest.php index af94407..98d0888 100755 --- a/tests/Feature/GitmehRateLimitTest.php +++ b/tests/Feature/GitmehRateLimitTest.php @@ -23,6 +23,7 @@ protected function setUp(): void config([ 'gitmeh.daily_limit' => 3, + 'gitmeh.default_provider' => 'openrouter', 'ai.providers.openrouter.key' => 'sk-test-key', ]);