---
openapi: 3.0.3
info:
  title: Vibe Earning API
  description: |
    Distributed LLM grid computing platform. Clients submit AI inference jobs via this API;
    volunteer workers running the `grid-worker` agent long-poll for jobs, execute them against
    their local Ollama instance, and submit results back.

    ## Authentication
    All client API requests must include your API key in the `Authorization` header:
    ```
    Authorization: Bearer vk_live_your_api_key_here
    ```

    Worker endpoints (`/worker/*`) use the same scheme with a worker-bound API key. Create a
    key for a worker in the dashboard (or let `POST /worker/register` bind an existing key to a
    new worker) and use it directly — there is no separate token to refresh.

    ## Quota
    Free tier: 100 requests/day, 50,000 tokens/day. Jobs are always accepted (`201`) as long as
    your API key is active — quota only pauses processing, not submission. A job submitted while
    over quota stays `pending` and is picked up by a worker automatically once the quota resets.

    ## Webhooks
    When a job includes a `webhook_url`, the platform POSTs the result payload to that URL
    with an `X-Vibe-Signature: sha256=<hex>` header. The signature is HMAC-SHA256 of the
    JSON body using your API key as the secret.
  version: 1.0.0
  contact:
    email: support@codehospital.com
  license:
    name: Proprietary
servers:
- url: https://llmarkt.codehospital.com/api/v1
  description: Production
- url: http://localhost:3000/api/v1
  description: Local development
tags:
- name: Jobs
  description: Submit and manage inference jobs
- name: Models
  description: Browse available grid models
- name: Usage
  description: Query token and request usage
- name: Blobs
  description: Upload binary attachments (images, documents) for jobs
- name: Worker — Auth
  description: Worker agent registration (uses an API key; binds it to the worker)
- name: Worker — Jobs
  description: Job polling and result submission (worker-bound API key required)
- name: Worker — Credits
  description: Credit balance and payout requests (worker-bound API key required)
components:
  securitySchemes:
    ApiKeyAuth:
      type: http
      scheme: bearer
      description: 'API key (Authorization: Bearer <token>). A plain key authenticates
        client `/jobs`, `/models`, `/usage`, `/blobs`; a worker-bound key additionally
        authenticates that worker''s `/worker/*` calls.'
  parameters:
    JobId:
      name: id
      in: path
      required: true
      schema:
        type: integer
      description: Job ID
  responses:
    Unauthorized:
      description: Missing or invalid API key
      content:
        application/json:
          schema:
            "$ref": "#/components/schemas/Error"
          example:
            error: unauthorized
    NotFound:
      description: Resource not found
      content:
        application/json:
          schema:
            "$ref": "#/components/schemas/Error"
          example:
            error: not_found
  schemas:
    Job:
      type: object
      properties:
        job_id:
          type: integer
          example: 42
        status:
          type: string
          enum:
          - pending
          - claimed
          - running
          - completed
          - failed
          - cancelled
        model:
          type: string
          nullable: true
          example: llama3:8b
        model_match:
          type: string
          enum:
          - exact
          - family
          - any
          example: family
        output:
          type: string
          nullable: true
          description: Generated text (null until completed)
        input_tokens:
          type: integer
          nullable: true
        output_tokens:
          type: integer
          nullable: true
        duration_ms:
          type: integer
          nullable: true
        priority:
          type: integer
          example: 0
        timeout_seconds:
          type: integer
          example: 600
          description: Auto-cancelled if still claimed/running this long after being
            claimed.
        tag:
          type: string
          nullable: true
          example: my-project
        created_at:
          type: string
          format: date-time
        completed_at:
          type: string
          format: date-time
          nullable: true
    JobCreateRequest:
      type: object
      required:
      - prompt
      properties:
        model:
          type: string
          example: llama3:8b
          description: Target model name. Leave blank to use `any` match.
        model_match:
          type: string
          enum:
          - exact
          - family
          - any
          default: family
          description: |
            - `exact`: only workers with this exact model tag
            - `family`: workers with the same base model (llama3, mistral, etc.)
            - `any`: any available worker regardless of model
        prompt:
          type: string
          example: Summarize the following document in three bullet points.
        priority:
          type: integer
          example: 0
          description: Higher = processed sooner. Defaults to your subscription's
            priority boost when omitted.
        timeout_seconds:
          type: integer
          example: 600
          default: 600
          description: Auto-cancelled if still claimed/running this long after being
            claimed. Defaults to 600 (10 minutes).
        tag:
          type: string
          example: my-project
          description: Arbitrary string for usage grouping and filtering.
        webhook_url:
          type: string
          format: uri
          example: https://yourapp.com/hooks/llm
        priority_boost:
          type: boolean
          default: false
          description: Request a Stripe one-time priority boost (+50 to job priority).
        blob_ids:
          type: array
          items:
            type: integer
          description: IDs of previously confirmed blobs to attach to this job.
    Error:
      type: object
      properties:
        error:
          type: string
          example: unauthorized
    GridModel:
      type: object
      properties:
        name:
          type: string
          example: llama3:8b
        family:
          type: string
          example: llama3
        display_name:
          type: string
          example: Llama 3 8B
        description:
          type: string
          nullable: true
        worker_count:
          type: integer
          description: Workers serving this exact model tag
        family_worker_count:
          type: integer
          description: Workers that serve any model in this family
    UsageResponse:
      type: object
      properties:
        period:
          type: string
          enum:
          - daily
          - weekly
          - monthly
        tag:
          type: string
          nullable: true
        tokens_used:
          type: integer
        requests_used:
          type: integer
        by_tag:
          type: object
          additionalProperties:
            type: integer
    BlobCreateResponse:
      type: object
      properties:
        blob_id:
          type: integer
        upload_url:
          type: string
          format: uri
          description: Presigned S3 URL — PUT your file directly to this URL.
        expires_in:
          type: integer
          description: Seconds until the presigned URL expires (900 = 15 minutes).
    WorkerRegisterRequest:
      type: object
      required:
      - models
      properties:
        name:
          type: string
          example: my-home-server
        agent_version:
          type: string
          example: 1.0.0
        platform:
          type: string
          example: darwin/arm64
        models:
          type: array
          items:
            "$ref": "#/components/schemas/WorkerModelEntry"
        catch_all:
          type: boolean
          default: false
          description: Accept any job regardless of model, even if the worker doesn't
            have it locally.
    WorkerModelEntry:
      type: object
      required:
      - name
      properties:
        name:
          type: string
          example: llama3:8b
        serves_family:
          type: boolean
          default: false
          description: If true, this worker will also accept jobs requesting any model
            in the same family.
    WorkerRegisterResponse:
      type: object
      properties:
        worker_token:
          type: string
          description: The worker-bound API key to use as the Bearer token for all
            /worker/* calls (echoes the key you registered with).
        worker_id:
          type: integer
    JobAssignment:
      type: object
      properties:
        job_id:
          type: integer
        model:
          type: string
          example: llama3:8b
        model_family:
          type: string
          example: llama3
        model_match:
          type: string
          enum:
          - exact
          - family
          - any
        prompt:
          type: string
        blobs:
          type: array
          items:
            type: object
            properties:
              id:
                type: integer
              type:
                type: string
                enum:
                - image
                - document
              url:
                type: string
                nullable: true
    JobResultRequest:
      type: object
      required:
      - output
      - output_tokens
      - duration_ms
      properties:
        output:
          type: string
          description: Full text generated by the model.
        input_tokens:
          type: integer
        output_tokens:
          type: integer
        duration_ms:
          type: integer
        model_used:
          type: string
          example: llama3:8b
    CreditBalance:
      type: object
      properties:
        balance:
          type: number
          format: double
          description: Current unpaid credit balance.
        total_earned:
          type: number
          format: double
        credit_value_usd:
          type: number
          example: 0.01
          description: USD value of one credit.
        history:
          type: array
          items:
            type: object
            properties:
              id:
                type: integer
              credits_earned:
                type: number
                format: double
              output_tokens:
                type: integer
                nullable: true
              duration_ms:
                type: integer
                nullable: true
              paid_out:
                type: boolean
              created_at:
                type: string
                format: date-time
paths:
  "/jobs":
    get:
      tags:
      - Jobs
      summary: List jobs
      description: Returns your jobs, newest first. Supports filtering by status,
        model, tag, and date range.
      security:
      - ApiKeyAuth: []
      parameters:
      - name: status
        in: query
        schema:
          type: string
          enum:
          - pending
          - claimed
          - running
          - completed
          - failed
          - cancelled
      - name: model
        in: query
        schema:
          type: string
        example: llama3:8b
      - name: tag
        in: query
        schema:
          type: string
      - name: from
        in: query
        schema:
          type: string
          format: date-time
      - name: to
        in: query
        schema:
          type: string
          format: date-time
      - name: offset
        in: query
        schema:
          type: integer
          default: 0
      responses:
        '200':
          description: Array of jobs
          content:
            application/json:
              schema:
                type: array
                items:
                  "$ref": "#/components/schemas/Job"
        '401':
          "$ref": "#/components/responses/Unauthorized"
    post:
      tags:
      - Jobs
      summary: Submit a job
      description: |
        Enqueues a new inference job. The job enters `pending` state and will be
        dispatched to the first eligible worker. Use `GET /jobs/{id}` to poll for
        completion, or provide a `webhook_url` to receive the result via HTTP POST.
      security:
      - ApiKeyAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              "$ref": "#/components/schemas/JobCreateRequest"
            examples:
              basic:
                summary: Simple prompt
                value:
                  model: llama3:8b
                  model_match: family
                  prompt: Explain quantum entanglement in one paragraph.
              with_webhook:
                summary: With webhook and tag
                value:
                  model: mistral:7b
                  model_match: exact
                  prompt: Summarize the attached document.
                  tag: doc-processor
                  webhook_url: https://yourapp.com/hooks/llm
                  blob_ids:
                  - 17
              any_model:
                summary: Any available model
                value:
                  model_match: any
                  prompt: Write a haiku about distributed computing.
              with_priority:
                summary: Explicit priority
                value:
                  model_match: any
                  prompt: Handle this ahead of my other jobs.
                  priority: 25
      responses:
        '201':
          description: Job created
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Job"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '422':
          description: Validation error
          content:
            application/json:
              schema:
                type: object
                properties:
                  errors:
                    type: array
                    items:
                      type: string
  "/jobs/cancel_all":
    delete:
      tags:
      - Jobs
      summary: Cancel all pending jobs
      security:
      - ApiKeyAuth: []
      responses:
        '200':
          description: Number of cancelled jobs
          content:
            application/json:
              schema:
                type: object
                properties:
                  cancelled:
                    type: integer
        '401':
          "$ref": "#/components/responses/Unauthorized"
  "/jobs/{id}":
    get:
      tags:
      - Jobs
      summary: Get job (poll for result)
      security:
      - ApiKeyAuth: []
      parameters:
      - "$ref": "#/components/parameters/JobId"
      responses:
        '200':
          description: Job detail
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Job"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '404':
          "$ref": "#/components/responses/NotFound"
    delete:
      tags:
      - Jobs
      summary: Cancel a pending job
      description: Only jobs in `pending` state can be cancelled.
      security:
      - ApiKeyAuth: []
      parameters:
      - "$ref": "#/components/parameters/JobId"
      responses:
        '200':
          description: Cancelled
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    example: cancelled
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '404':
          "$ref": "#/components/responses/NotFound"
        '422':
          description: Job cannot be cancelled (not in pending state)
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/jobs/{id}/priority":
    patch:
      tags:
      - Jobs
      summary: Increase or decrease job priority
      description: |
        Adjusts the job's priority score by a signed delta — send a positive `priority`
        to bump the job ahead of others, or a negative `priority` to deprioritize it.
        Only jobs in `pending` state can have their priority changed. Higher priority
        jobs are claimed first by workers.
      security:
      - ApiKeyAuth: []
      parameters:
      - "$ref": "#/components/parameters/JobId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - priority
              properties:
                priority:
                  type: integer
                  description: Signed delta to apply; negative values decrease priority.
                  example: 10
      responses:
        '200':
          description: New priority value
          content:
            application/json:
              schema:
                type: object
                properties:
                  priority:
                    type: integer
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '404':
          "$ref": "#/components/responses/NotFound"
        '422':
          description: Job not in pending state, or the priority delta is zero
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/jobs/{id}/retry":
    post:
      tags:
      - Jobs
      summary: Retry a failed job
      description: Requeues the same job in place — clears the error and worker assignment
        and transitions it back to `pending`. The `job_id` is unchanged.
      security:
      - ApiKeyAuth: []
      parameters:
      - "$ref": "#/components/parameters/JobId"
      responses:
        '200':
          description: Job requeued
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Job"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '404':
          "$ref": "#/components/responses/NotFound"
        '422':
          description: Job is not in failed state
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/models":
    get:
      tags:
      - Models
      summary: List available grid models
      description: Returns all active models with at least one online worker. Sorted
        by worker count descending.
      security:
      - ApiKeyAuth: []
      responses:
        '200':
          description: Active models
          content:
            application/json:
              schema:
                type: array
                items:
                  "$ref": "#/components/schemas/GridModel"
        '401':
          "$ref": "#/components/responses/Unauthorized"
  "/usage":
    get:
      tags:
      - Usage
      summary: Get usage statistics
      security:
      - ApiKeyAuth: []
      parameters:
      - name: period
        in: query
        schema:
          type: string
          enum:
          - daily
          - weekly
          - monthly
          default: daily
      - name: tag
        in: query
        schema:
          type: string
      responses:
        '200':
          description: Usage breakdown
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/UsageResponse"
        '401':
          "$ref": "#/components/responses/Unauthorized"
  "/blobs":
    post:
      tags:
      - Blobs
      summary: Get presigned upload URL
      description: |
        Returns a presigned S3 URL. Upload your file directly to that URL with a `PUT` request,
        then call `POST /blobs/{id}/confirm` to mark it ready. Pass the blob ID in `blob_ids`
        when submitting a job.
      security:
      - ApiKeyAuth: []
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                blob_type:
                  type: string
                  enum:
                  - image
                  - document
                  default: image
      responses:
        '201':
          description: Presigned URL created
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/BlobCreateResponse"
        '401':
          "$ref": "#/components/responses/Unauthorized"
  "/blobs/{id}/confirm":
    post:
      tags:
      - Blobs
      summary: Confirm blob upload complete
      security:
      - ApiKeyAuth: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: integer
      responses:
        '200':
          description: Confirmed
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    example: confirmed
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '404':
          "$ref": "#/components/responses/NotFound"
  "/worker/register":
    post:
      tags:
      - Worker — Auth
      summary: Register worker (binds the API key to the worker)
      description: |
        Authenticate with an **API key** to register this worker instance. If the key is not yet
        bound to a worker, a worker is created and the key is bound to it; a worker-bound key updates
        that worker. The same key is then used as the Bearer token for all `/worker/*` calls
        (echoed back as `worker_token` for convenience) — there is no separate token to refresh.

        Re-register any time to update your model list. If a worker with the same `name` already
        exists under your account it is updated in place.
      security:
      - ApiKeyAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              "$ref": "#/components/schemas/WorkerRegisterRequest"
            example:
              name: my-home-server
              agent_version: 1.0.0
              platform: darwin/arm64
              catch_all: false
              models:
              - name: llama3:8b
                serves_family: true
              - name: mistral:7b
                serves_family: false
      responses:
        '201':
          description: Worker registered
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/WorkerRegisterResponse"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          description: Account disabled
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/worker/heartbeat":
    patch:
      tags:
      - Worker — Auth
      summary: Update last-seen timestamp
      description: |
        Workers that haven't sent a heartbeat in **90 seconds** are automatically marked offline
        and removed from the job routing pool. The polling loop in `grid-worker` sends this
        implicitly with every `GET /worker/jobs/next` call, so explicit heartbeats are only
        needed if the worker is idle but wants to stay online.
      security:
      - ApiKeyAuth: []
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    example: ok
        '401':
          "$ref": "#/components/responses/Unauthorized"
  "/worker/models":
    patch:
      tags:
      - Worker — Auth
      summary: Update advertised model list
      description: Replaces the worker's current model list with the supplied array
        and syncs the grid model registry.
      security:
      - ApiKeyAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                models:
                  type: array
                  items:
                    "$ref": "#/components/schemas/WorkerModelEntry"
      responses:
        '200':
          description: Updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    example: updated
                  model_count:
                    type: integer
        '401':
          "$ref": "#/components/responses/Unauthorized"
  "/worker/jobs/next":
    get:
      tags:
      - Worker — Jobs
      summary: Long-poll for next job
      description: |
        Blocks for up to **30 seconds** waiting for a matching job. Returns the job immediately
        if one is available, or `204 No Content` on timeout. Retry immediately on 204.

        The query parameters declare the worker's current capabilities. A job matches if:
        - `model_match = exact` and the job's model is in `models[]`
        - `model_match = family` and the job's model family is in `families[]` (or exact match)
        - `model_match = any` and `catch_all = true`

        Jobs are claimed atomically using `SELECT ... FOR UPDATE SKIP LOCKED`, ordered by
        priority (highest first) then creation time — a job can never be dispatched to two
        workers simultaneously.
      security:
      - ApiKeyAuth: []
      parameters:
      - name: models[]
        in: query
        required: false
        style: form
        explode: true
        schema:
          type: array
          items:
            type: string
        example:
        - llama3:8b
        - mistral:7b
      - name: families[]
        in: query
        required: false
        style: form
        explode: true
        schema:
          type: array
          items:
            type: string
        example:
        - llama3
      - name: catch_all
        in: query
        required: false
        schema:
          type: boolean
          default: false
      responses:
        '200':
          description: Job assigned
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/JobAssignment"
        '204':
          description: No job available within the poll window — retry immediately
        '401':
          "$ref": "#/components/responses/Unauthorized"
  "/worker/jobs/{id}/start":
    patch:
      tags:
      - Worker — Jobs
      summary: Mark job as running
      description: Transitions job from `claimed` → `running`. Call this just before
        invoking Ollama.
      security:
      - ApiKeyAuth: []
      parameters:
      - "$ref": "#/components/parameters/JobId"
      responses:
        '200':
          description: Status updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    example: running
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '404':
          "$ref": "#/components/responses/NotFound"
        '422':
          description: Job not in claimed state
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/worker/jobs/{id}/result":
    post:
      tags:
      - Worker — Jobs
      summary: Submit job result
      description: |
        Transitions job to `completed`, creates a `WorkerCreditEntry`, records usage in the
        `UsageLedger`, and enqueues webhook delivery if the job has a `webhook_url`.

        Credits earned = `output_tokens / 1000 × 1.0` (rounded to 4 decimal places).
      security:
      - ApiKeyAuth: []
      parameters:
      - "$ref": "#/components/parameters/JobId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              "$ref": "#/components/schemas/JobResultRequest"
      responses:
        '200':
          description: Result accepted
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    example: accepted
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '404':
          "$ref": "#/components/responses/NotFound"
        '422':
          description: Job not in running state
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
  "/worker/jobs/{id}/fail":
    post:
      tags:
      - Worker — Jobs
      summary: Report job failure
      description: Transitions job to `failed` and stores the error message for the
        client.
      security:
      - ApiKeyAuth: []
      parameters:
      - "$ref": "#/components/parameters/JobId"
      requestBody:
        content:
          application/json:
            schema:
              type: object
              required:
              - error
              properties:
                error:
                  type: string
                  example: Ollama returned empty response after 300s
      responses:
        '200':
          description: Failure recorded
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    example: failed
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '404':
          "$ref": "#/components/responses/NotFound"
  "/worker/credits":
    get:
      tags:
      - Worker — Credits
      summary: Credit balance and history
      security:
      - ApiKeyAuth: []
      responses:
        '200':
          description: Credit summary
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/CreditBalance"
        '401':
          "$ref": "#/components/responses/Unauthorized"
  "/worker/payout_request":
    post:
      tags:
      - Worker — Credits
      summary: Request a payout
      description: |
        Creates a `PayoutBatch` for the worker's current `payout_balance`, marks all unpaid
        `WorkerCreditEntries` as paid, and resets `payout_balance` to zero. The platform
        processes payouts on a weekly/monthly cycle.
      security:
      - ApiKeyAuth: []
      responses:
        '201':
          description: Payout batch created
          content:
            application/json:
              schema:
                type: object
                properties:
                  batch_id:
                    type: integer
                  amount_credits:
                    type: number
                    format: double
                  amount_usd:
                    type: number
                    format: double
                  status:
                    type: string
                    example: pending
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '422':
          description: Insufficient balance
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
