From 26471cc3777c97991134126a237d4625168a62e2 Mon Sep 17 00:00:00 2001 From: ChrisJBurns <29541485+ChrisJBurns@users.noreply.github.com> Date: Wed, 19 Feb 2025 11:06:23 +0000 Subject: [PATCH 1/4] feat: adds Helm chart for deploying codegate Signed-off-by: ChrisJBurns <29541485+ChrisJBurns@users.noreply.github.com> --- .github/workflows/feature-launcher.yml | 50 ++++++- .github/workflows/helm-chart-publish.yaml | 58 ++++++++ .github/workflows/helm-chart-test.yaml | 50 +++++++ api/openapi.json | 116 +++++++++++++++ cr.yaml | 1 + ct.yaml | 5 + deploy/charts/codegate/.helmignore | 23 +++ deploy/charts/codegate/Chart.yaml | 6 + deploy/charts/codegate/README.md | 50 +++++++ deploy/charts/codegate/ci/default-values.yaml | 0 deploy/charts/codegate/templates/_helpers.tpl | 62 ++++++++ .../charts/codegate/templates/deployment.yaml | 69 +++++++++ deploy/charts/codegate/templates/hpa.yaml | 33 +++++ deploy/charts/codegate/templates/ingress.yaml | 44 ++++++ deploy/charts/codegate/templates/pvc.yaml | 15 ++ deploy/charts/codegate/templates/service.yaml | 19 +++ .../codegate/templates/serviceaccount.yaml | 14 ++ deploy/charts/codegate/values.yaml | 140 ++++++++++++++++++ docs/development.md | 25 ++-- poetry.lock | 8 +- pyproject.toml | 2 +- src/codegate/api/v1.py | 18 ++- src/codegate/api/v1_models.py | 13 +- src/codegate/db/connection.py | 21 ++- src/codegate/db/models.py | 8 + src/codegate/muxing/adapter.py | 2 +- src/codegate/muxing/models.py | 2 + src/codegate/pipeline/output.py | 10 +- src/codegate/providers/copilot/provider.py | 10 +- .../providers/litellmshim/generators.py | 4 +- src/codegate/providers/openai/provider.py | 3 +- src/codegate/providers/openrouter/provider.py | 42 +++++- src/codegate/workspaces/crud.py | 7 + 33 files changed, 890 insertions(+), 40 deletions(-) create mode 100644 .github/workflows/helm-chart-publish.yaml create mode 100644 .github/workflows/helm-chart-test.yaml create mode 100644 cr.yaml create mode 100644 ct.yaml create mode 100644 deploy/charts/codegate/.helmignore create mode 100644 deploy/charts/codegate/Chart.yaml create mode 100644 deploy/charts/codegate/README.md create mode 100644 deploy/charts/codegate/ci/default-values.yaml create mode 100644 deploy/charts/codegate/templates/_helpers.tpl create mode 100644 deploy/charts/codegate/templates/deployment.yaml create mode 100644 deploy/charts/codegate/templates/hpa.yaml create mode 100644 deploy/charts/codegate/templates/ingress.yaml create mode 100644 deploy/charts/codegate/templates/pvc.yaml create mode 100644 deploy/charts/codegate/templates/service.yaml create mode 100644 deploy/charts/codegate/templates/serviceaccount.yaml create mode 100644 deploy/charts/codegate/values.yaml diff --git a/.github/workflows/feature-launcher.yml b/.github/workflows/feature-launcher.yml index f21d1b98..e0707cd1 100644 --- a/.github/workflows/feature-launcher.yml +++ b/.github/workflows/feature-launcher.yml @@ -12,13 +12,51 @@ jobs: - name: Send Feature Release Notification to Discord env: DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }} + SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }} ISSUE_TITLE: ${{ github.event.issue.title }} ISSUE_BODY: ${{ github.event.issue.body }} ISSUE_URL: ${{ github.event.issue.html_url }} run: | - curl -H "Content-Type: application/json" \ - -X POST \ - -d '{ - "content": "**πŸš€ New Feature Launched!**\n\nπŸŽ‰ *${{ env.ISSUE_TITLE }}* is now available to try!\nπŸ“– Description: ${{ env.ISSUE_BODY }}\nπŸ”— [Check it out here](${{ env.ISSUE_URL }})" - }' \ - $DISCORD_WEBHOOK + node -e ' + const https = require("https"); + const discordWebhook = new URL(process.env.DISCORD_WEBHOOK); + const slackWebhook = new URL(process.env.SLACK_WEBHOOK); + + const issueTitle = process.env.ISSUE_TITLE; + const issueBody = process.env.ISSUE_BODY; + const issueUrl = process.env.ISSUE_URL; + + // Discord Payload + const discordPayload = { + content: [ + "**πŸš€ " +issueTitle + " has been released!**", + "", + "**🌟 Whats new in CodeGate:**", + issueBody, + "", + "We would 🀍 your feedback! πŸ”— [Here’s the GitHub issue](" + issueUrl + ")" + ].join("\n") + }; + + // Slack Payload + const slackPayload = { + text: `πŸš€ *${issueTitle}* has been released!\n\n πŸ”— <${issueUrl}|Here’s the GitHub issue>`, + }; + + function sendNotification(webhookUrl, payload) { + const url = new URL(webhookUrl); + const req = https.request(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + } + }); + + req.on("error", (error) => { + console.error("Error:", error); + process.exit(1); + }); + + req.write(JSON.stringify(payload)); + req.end(); + } diff --git a/.github/workflows/helm-chart-publish.yaml b/.github/workflows/helm-chart-publish.yaml new file mode 100644 index 00000000..fbc838d3 --- /dev/null +++ b/.github/workflows/helm-chart-publish.yaml @@ -0,0 +1,58 @@ +name: Release Charts + +on: + push: + branches: + - main + paths: + - "deploy/charts/**" + + +jobs: + release: + runs-on: ubuntu-latest + + permissions: + contents: write + packages: write + id-token: write + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Configure Git + run: | + git config user.name "$GITHUB_ACTOR" + git config user.email "$GITHUB_ACTOR@users.noreply.github.com" + + - name: Run chart-releaser + uses: helm/chart-releaser-action@3e001cb8c68933439c7e721650f20a07a1a5c61e # pin@v1.6.0 + with: + config: cr.yaml + env: + CR_TOKEN: "${{ secrets.GITHUB_TOKEN }}" + + - name: Login to GitHub Container Registry + uses: docker/login-action@327cd5a69de6c009b9ce71bce8395f28e651bf99 #pin@v3.3.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Install Cosign + uses: sigstore/cosign-installer@c56c2d3e59e4281cc41dea2217323ba5694b171e #pin@v3.7.0 + + - name: Publish and Sign OCI Charts + run: | + for chart in `find .cr-release-packages -name '*.tgz' -print`; do + helm push ${chart} oci://ghcr.io/${GITHUB_REPOSITORY} |& tee helm-push-output.log + file_name=${chart##*/} + chart_name=${file_name%-*} + digest=$(awk -F "[, ]+" '/Digest/{print $NF}' < helm-push-output.log) + cosign sign -y "ghcr.io/${GITHUB_REPOSITORY}/${chart_name}@${digest}" + done + env: + COSIGN_EXPERIMENTAL: 1 \ No newline at end of file diff --git a/.github/workflows/helm-chart-test.yaml b/.github/workflows/helm-chart-test.yaml new file mode 100644 index 00000000..f5dab01b --- /dev/null +++ b/.github/workflows/helm-chart-test.yaml @@ -0,0 +1,50 @@ +name: Test Charts + +on: + pull_request: + paths: + - deploy/charts/** + +jobs: + check-readme: + runs-on: ubuntu-latest + env: + GO111MODULE: on + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + + - uses: actions/setup-python@42375524e23c412d93fb67b49958b491fce71c38 # v5 + with: + python-version: '3.x' + + - uses: actions/setup-go@5a083d0e9a84784eb32078397cf5459adecb4c40 # pin@v3 + with: + go-version: ^1 + + test: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + with: + fetch-depth: 0 + + - name: Set up Helm + uses: azure/setup-helm@fe7b79cd5ee1e45176fcad797de68ecaf3ca4814 # pin@v4.2.0 + + - uses: actions/setup-python@42375524e23c412d93fb67b49958b491fce71c38 # v5 + with: + python-version: '3.x' + + - name: Set up chart-testing + uses: helm/chart-testing-action@v2.7.0 + + - name: Run chart-testing (lint) + run: ct lint --config ct.yaml + + - name: Create KIND Cluster + uses: helm/kind-action@a1b0e391336a6ee6713a0583f8c6240d70863de3 # pin@v1.12.0 + + - name: Run chart-testing (install) + run: ct install --config ct.yaml \ No newline at end of file diff --git a/api/openapi.json b/api/openapi.json index cde65b55..a6d16753 100644 --- a/api/openapi.json +++ b/api/openapi.json @@ -989,6 +989,55 @@ } } }, + "/api/v1/workspaces/{provider_id}": { + "get": { + "tags": [ + "CodeGate API", + "Workspaces" + ], + "summary": "List Workspaces By Provider", + "description": "List workspaces by provider ID.", + "operationId": "v1_list_workspaces_by_provider", + "parameters": [ + { + "name": "provider_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid", + "title": "Provider Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WorkspaceWithModel" + }, + "title": "Response V1 List Workspaces By Provider" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, "/api/v1/alerts_notification": { "get": { "tags": [ @@ -1478,6 +1527,16 @@ "type": "string", "title": "Name" }, + "config": { + "anyOf": [ + { + "$ref": "#/components/schemas/WorkspaceConfig" + }, + { + "type": "null" + } + ] + }, "rename_to": { "anyOf": [ { @@ -1590,6 +1649,17 @@ }, "MuxRule": { "properties": { + "provider_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Provider Name" + }, "provider_id": { "type": "string", "title": "Provider Id" @@ -1842,6 +1912,52 @@ "is_active" ], "title": "Workspace" + }, + "WorkspaceConfig": { + "properties": { + "system_prompt": { + "type": "string", + "title": "System Prompt" + }, + "muxing_rules": { + "items": { + "$ref": "#/components/schemas/MuxRule" + }, + "type": "array", + "title": "Muxing Rules" + } + }, + "type": "object", + "required": [ + "system_prompt", + "muxing_rules" + ], + "title": "WorkspaceConfig" + }, + "WorkspaceWithModel": { + "properties": { + "id": { + "type": "string", + "title": "Id" + }, + "name": { + "type": "string", + "pattern": "^[a-zA-Z0-9_-]+$", + "title": "Name" + }, + "provider_model_name": { + "type": "string", + "title": "Provider Model Name" + } + }, + "type": "object", + "required": [ + "id", + "name", + "provider_model_name" + ], + "title": "WorkspaceWithModel", + "description": "Returns a workspace ID with model name" } } } diff --git a/cr.yaml b/cr.yaml new file mode 100644 index 00000000..8c8f7546 --- /dev/null +++ b/cr.yaml @@ -0,0 +1 @@ +generate-release-notes: true \ No newline at end of file diff --git a/ct.yaml b/ct.yaml new file mode 100644 index 00000000..df3fdacb --- /dev/null +++ b/ct.yaml @@ -0,0 +1,5 @@ +chart-dirs: + - deploy/charts +validate-maintainers: false +remote: origin +target-branch: main \ No newline at end of file diff --git a/deploy/charts/codegate/.helmignore b/deploy/charts/codegate/.helmignore new file mode 100644 index 00000000..0e8a0eb3 --- /dev/null +++ b/deploy/charts/codegate/.helmignore @@ -0,0 +1,23 @@ +# Patterns to ignore when building packages. +# This supports shell glob matching, relative path matching, and +# negation (prefixed with !). Only one pattern per line. +.DS_Store +# Common VCS dirs +.git/ +.gitignore +.bzr/ +.bzrignore +.hg/ +.hgignore +.svn/ +# Common backup files +*.swp +*.bak +*.tmp +*.orig +*~ +# Various IDEs +.project +.idea/ +*.tmproj +.vscode/ diff --git a/deploy/charts/codegate/Chart.yaml b/deploy/charts/codegate/Chart.yaml new file mode 100644 index 00000000..171c7ce7 --- /dev/null +++ b/deploy/charts/codegate/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +name: codegate +description: A Helm chart for deploying Codegate onto Kubernetes +type: application +version: 0.0.1 +appVersion: "v0.1.22" diff --git a/deploy/charts/codegate/README.md b/deploy/charts/codegate/README.md new file mode 100644 index 00000000..47a72f5c --- /dev/null +++ b/deploy/charts/codegate/README.md @@ -0,0 +1,50 @@ +# Codegate + +![Version: 0.0.1](https://img.shields.io/badge/Version-0.0.1-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: v0.1.22](https://img.shields.io/badge/AppVersion-2.112.0-informational?style=flat-square) + +CodeGate is a local gateway that makes AI agents and coding assistants safer. + +## TL;DR + +```console +helm repo add codegate [] + +helm install codegate/codegate +``` + +## Usage + +The Codegate Chart is available in the following formats: +- [Chart Repository](https://helm.sh/docs/topics/chart_repository/) +- [OCI Artifacts](https://helm.sh/docs/topics/registries/) + +### Installing from Chart Repository + +The following command can be used to add the chart repository: + +```console +helm repo add codegate [] +``` + +Once the chart has been added, install one of the available charts: + +```console +helm install codegate/codegate +``` + +### Installing from an OCI Registry + +Charts are also available in OCI format. The list of available charts can be found [here](https://github.com/stacklok/codegate/deploy/charts). +Install one of the available charts: + +```shell +helm upgrade -i oci://ghcr.io/stacklok/codegate/codegate --version= +``` + +## Source Code + +* + +## Values + + diff --git a/deploy/charts/codegate/ci/default-values.yaml b/deploy/charts/codegate/ci/default-values.yaml new file mode 100644 index 00000000..e69de29b diff --git a/deploy/charts/codegate/templates/_helpers.tpl b/deploy/charts/codegate/templates/_helpers.tpl new file mode 100644 index 00000000..757dbcac --- /dev/null +++ b/deploy/charts/codegate/templates/_helpers.tpl @@ -0,0 +1,62 @@ +{{/* +Expand the name of the chart. +*/}} +{{- define "codegate.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Create a default fully qualified app name. +We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). +If release name contains chart name it will be used as a full name. +*/}} +{{- define "codegate.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- $name := default .Chart.Name .Values.nameOverride }} +{{- if contains $name .Release.Name }} +{{- .Release.Name | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} +{{- end }} + +{{/* +Create chart name and version as used by the chart label. +*/}} +{{- define "codegate.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Common labels +*/}} +{{- define "codegate.labels" -}} +helm.sh/chart: {{ include "codegate.chart" . }} +{{ include "codegate.selectorLabels" . }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{/* +Selector labels +*/}} +{{- define "codegate.selectorLabels" -}} +app.kubernetes.io/name: {{ include "codegate.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{/* +Create the name of the service account to use +*/}} +{{- define "codegate.serviceAccountName" -}} +{{- if .Values.serviceAccount.create }} +{{- default (include "codegate.fullname" .) .Values.serviceAccount.name }} +{{- else }} +{{- default "default" .Values.serviceAccount.name }} +{{- end }} +{{- end }} diff --git a/deploy/charts/codegate/templates/deployment.yaml b/deploy/charts/codegate/templates/deployment.yaml new file mode 100644 index 00000000..fae061e6 --- /dev/null +++ b/deploy/charts/codegate/templates/deployment.yaml @@ -0,0 +1,69 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "codegate.fullname" . }} + namespace: {{ .Release.Namespace | quote }} + labels: + {{- include "codegate.labels" . | nindent 4 }} +spec: + {{- if not .Values.autoscaling.enabled }} + replicas: {{ .Values.replicaCount }} + {{- end }} + selector: + matchLabels: + {{- include "codegate.selectorLabels" . | nindent 6 }} + template: + metadata: + {{- with .Values.podAnnotations }} + annotations: + {{- toYaml . | nindent 8 }} + {{- end }} + labels: + {{- include "codegate.labels" . | nindent 8 }} + {{- with .Values.podLabels }} + {{- toYaml . | nindent 8 }} + {{- end }} + spec: + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + serviceAccountName: {{ include "codegate.serviceAccountName" . }} + securityContext: + {{- toYaml .Values.podSecurityContext | nindent 8 }} + containers: + - name: {{ .Chart.Name }} + securityContext: + {{- toYaml .Values.securityContext | nindent 12 }} + image: "{{ .Values.image.repository }}:{{ .Values.image.tag}}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + ports: + - name: http + containerPort: {{ .Values.service.port }} + protocol: TCP + livenessProbe: + {{- toYaml .Values.livenessProbe | nindent 12 }} + readinessProbe: + {{- toYaml .Values.readinessProbe | nindent 12 }} + resources: + {{- toYaml .Values.resources | nindent 12 }} + {{- with .Values.volumeMounts }} + volumeMounts: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.volumes }} + volumes: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} diff --git a/deploy/charts/codegate/templates/hpa.yaml b/deploy/charts/codegate/templates/hpa.yaml new file mode 100644 index 00000000..01bc451b --- /dev/null +++ b/deploy/charts/codegate/templates/hpa.yaml @@ -0,0 +1,33 @@ +{{- if .Values.autoscaling.enabled }} +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: {{ include "codegate.fullname" . }} + namespace: {{ .Release.Namespace | quote }} + labels: + {{- include "codegate.labels" . | nindent 4 }} +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: {{ include "codegate.fullname" . }} + minReplicas: {{ .Values.autoscaling.minReplicas }} + maxReplicas: {{ .Values.autoscaling.maxReplicas }} + metrics: + {{- if .Values.autoscaling.targetCPUUtilizationPercentage }} + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }} + {{- end }} + {{- if .Values.autoscaling.targetMemoryUtilizationPercentage }} + - type: Resource + resource: + name: memory + target: + type: Utilization + averageUtilization: {{ .Values.autoscaling.targetMemoryUtilizationPercentage }} + {{- end }} +{{- end }} diff --git a/deploy/charts/codegate/templates/ingress.yaml b/deploy/charts/codegate/templates/ingress.yaml new file mode 100644 index 00000000..1267c98b --- /dev/null +++ b/deploy/charts/codegate/templates/ingress.yaml @@ -0,0 +1,44 @@ +{{- if .Values.ingress.enabled -}} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ include "codegate.fullname" . }} + namespace: {{ .Release.Namespace | quote }} + labels: + {{- include "codegate.labels" . | nindent 4 }} + {{- with .Values.ingress.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- with .Values.ingress.className }} + ingressClassName: {{ . }} + {{- end }} + {{- if .Values.ingress.tls }} + tls: + {{- range .Values.ingress.tls }} + - hosts: + {{- range .hosts }} + - {{ . | quote }} + {{- end }} + secretName: {{ .secretName }} + {{- end }} + {{- end }} + rules: + {{- range .Values.ingress.hosts }} + - host: {{ .host | quote }} + http: + paths: + {{- range .paths }} + - path: {{ .path }} + {{- with .pathType }} + pathType: {{ . }} + {{- end }} + backend: + service: + name: {{ include "codegate.fullname" $ }} + port: + number: {{ $.Values.service.port }} + {{- end }} + {{- end }} +{{- end }} diff --git a/deploy/charts/codegate/templates/pvc.yaml b/deploy/charts/codegate/templates/pvc.yaml new file mode 100644 index 00000000..2e78518f --- /dev/null +++ b/deploy/charts/codegate/templates/pvc.yaml @@ -0,0 +1,15 @@ +{{- if .Values.volumePersistence.enabled }} +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: {{ .Values.volumePersistence.pvcName }} + namespace: {{ .Release.Namespace | quote }} +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: {{ .Values.volumePersistence.resources.requests.storage }} + storageClassName: {{ .Values.volumePersistence.storageClassName }} + volumeMode: {{ .Values.volumePersistence.volumeMode }} +{{- end }} \ No newline at end of file diff --git a/deploy/charts/codegate/templates/service.yaml b/deploy/charts/codegate/templates/service.yaml new file mode 100644 index 00000000..6ac81de6 --- /dev/null +++ b/deploy/charts/codegate/templates/service.yaml @@ -0,0 +1,19 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "codegate.fullname" . }} + namespace: {{ .Release.Namespace | quote }} + labels: + {{- include "codegate.labels" . | nindent 4 }} +spec: + type: {{ .Values.service.type }} + ports: + - port: {{ .Values.service.port }} + targetPort: {{ .Values.service.port }} + protocol: TCP + name: http-api + {{- with .Values.extraServicePorts }} + {{- toYaml . | nindent 6 }} + {{- end }} + selector: + {{- include "codegate.selectorLabels" . | nindent 4 }} diff --git a/deploy/charts/codegate/templates/serviceaccount.yaml b/deploy/charts/codegate/templates/serviceaccount.yaml new file mode 100644 index 00000000..0e5adea8 --- /dev/null +++ b/deploy/charts/codegate/templates/serviceaccount.yaml @@ -0,0 +1,14 @@ +{{- if .Values.serviceAccount.create -}} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "codegate.serviceAccountName" . }} + namespace: {{ .Release.Namespace | quote }} + labels: + {{- include "codegate.labels" . | nindent 4 }} + {{- with .Values.serviceAccount.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +automountServiceAccountToken: {{ .Values.serviceAccount.automount }} +{{- end }} diff --git a/deploy/charts/codegate/values.yaml b/deploy/charts/codegate/values.yaml new file mode 100644 index 00000000..013fe504 --- /dev/null +++ b/deploy/charts/codegate/values.yaml @@ -0,0 +1,140 @@ +# This is to override the chart name. +nameOverride: "" +fullnameOverride: "" + +# This sets the container image more information can be found here: https://kubernetes.io/docs/concepts/containers/images/ +image: + repository: ghcr.io/stacklok/codegate + # This sets the pull policy for images. + pullPolicy: IfNotPresent + # Overrides the image tag whose default is the chart appVersion. + tag: "v0.1.22" + +# This is for the secretes for pulling an image from a private repository more information can be found here: https://kubernetes.io/docs/tasks/configure-pod-container/pull-image-private-registry/ +imagePullSecrets: [] + +# This will set the replicaset count more information can be found here: https://kubernetes.io/docs/concepts/workloads/controllers/replicaset/ +replicaCount: 1 + +# This section builds out the service account more information can be found here: https://kubernetes.io/docs/concepts/security/service-accounts/ +serviceAccount: + # Specifies whether a service account should be created + create: true + # Automatically mount a ServiceAccount's API credentials? + automount: true + # Annotations to add to the service account + annotations: {} + # The name of the service account to use. + # If not set and create is true, a name is generated using the fullname template + name: "codegate" + +# This is for setting Kubernetes Annotations to a Pod. +# For more information checkout: https://kubernetes.io/docs/concepts/overview/working-with-objects/annotations/ +podAnnotations: {} +# This is for setting Kubernetes Labels to a Pod. +# For more information checkout: https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/ +podLabels: {} + +podSecurityContext: {} + # fsGroup: 2000 + +securityContext: {} + # capabilities: + # drop: + # - ALL + # readOnlyRootFilesystem: true + # runAsNonRoot: true + # runAsUser: 1000 + +# This is for setting up a service more information can be found here: https://kubernetes.io/docs/concepts/services-networking/service/ +service: + # This sets the service type more information can be found here: https://kubernetes.io/docs/concepts/services-networking/service/#publishing-services-service-types + type: ClusterIP + # This sets the ports more information can be found here: https://kubernetes.io/docs/concepts/services-networking/service/#field-spec-ports + port: 8989 + + extraServicePorts: + - port: 9090 + targetPort: 9090 + protocol: TCP + name: http-dashboard + - port: 8990 + targetPort: 8990 + protocol: TCP + name: http-copilot-proxy + +# This block is for setting up the ingress for more information can be found here: https://kubernetes.io/docs/concepts/services-networking/ingress/ +ingress: + enabled: false + className: "" + annotations: {} + # kubernetes.io/ingress.class: nginx + # kubernetes.io/tls-acme: "true" + hosts: + - host: chart-example.local + paths: + - path: / + pathType: ImplementationSpecific + tls: [] + # - secretName: chart-example-tls + # hosts: + # - chart-example.local + +resources: {} + # We usually recommend not to specify default resources and to leave this as a conscious + # choice for the user. This also increases chances charts run on environments with little + # resources, such as Minikube. If you do want to specify resources, uncomment the following + # lines, adjust them as necessary, and remove the curly braces after 'resources:'. + # limits: + # cpu: 100m + # memory: 128Mi + # requests: + # cpu: 100m + # memory: 128Mi + +# This is to setup the liveness and readiness probes more information can be found here: https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-startup-probes/ +livenessProbe: + httpGet: + path: /health + port: http +readinessProbe: + httpGet: + path: /health + port: http + +# This section is for setting up autoscaling more information can be found here: https://kubernetes.io/docs/concepts/workloads/autoscaling/ +autoscaling: + enabled: false + minReplicas: 1 + maxReplicas: 100 + targetCPUUtilizationPercentage: 80 + # targetMemoryUtilizationPercentage: 80 + +# Additional volumes on the output Deployment definition. +volumes: +- name: codegate-volume + persistentVolumeClaim: + claimName: codegate-0 + +# Additional volumeMounts on the output Deployment definition. +volumeMounts: +- mountPath: /app/codegate-volume + name: codegate-volume + +# Creates a PVC for a PV volume for persisting codegate data +# Only 1 PV will be created because codegate is not a statefulset +volumePersistence: + enabled: true + pvcName: codegate-0 + resources: + requests: + storage: 10Gi + storageClassName: gp2 + volumeMode: Filesystem + + +nodeSelector: {} + +tolerations: [] + +affinity: {} diff --git a/docs/development.md b/docs/development.md index c1591b6e..04dc4128 100644 --- a/docs/development.md +++ b/docs/development.md @@ -26,6 +26,18 @@ from potential AI-related security risks. Key features include: deployment) - [Visual Studio Code](https://code.visualstudio.com/download) (recommended IDE) +Note that if you are using pyenv on macOS, you will need a Python build linked +against sqlite installed from Homebrew. macOS ships with sqlite, but it lacks +some required functionality needed in the project. This can be accomplished with: + +``` +# substitute for your version of choice +PYTHON_VERSION=3.12.9 +brew install sqlite +LDFLAGS="-L$(brew --prefix sqlite)/lib" CPPFLAGS="-I$(brew --prefix sqlite)/include" PYTHON_CONFIGURE_OPTS="--enable-loadable-sqlite-extensions" pyenv install -v $PYTHON_VERSION +poetry env use $PYTHON_VERSION +``` + ### Initial setup 1. Clone the repository: @@ -59,19 +71,6 @@ To install all dependencies for your local development environment, run npm install ``` -Note that if you are running some processes (specifically the package import -script) on macOS, you will need a Python build linked against sqlite installed -from Homebrew. macOS ships with sqlite, but it lacks some required -functionality. This can be accomplished with: - -``` -# substitute for your version of choice -PYTHON_VERSION=3.12.9 -brew install sqlite -LDFLAGS="-L$(brew --prefix sqlite)/lib" CPPFLAGS="-I$(brew --prefix sqlite)/include" PYTHON_CONFIGURE_OPTS="--enable-loadable-sqlite-extensions" pyenv install -v $PYTHON_VERSION -poetry env use $PYTHON_VERSION -``` - ### Running the development server Run the development server using: diff --git a/poetry.lock b/poetry.lock index 9189c2a8..6f4d89cc 100644 --- a/poetry.lock +++ b/poetry.lock @@ -236,13 +236,13 @@ aio = ["aiohttp (>=3.0)"] [[package]] name = "bandit" -version = "1.8.2" +version = "1.8.3" description = "Security oriented static analyser for python code." optional = false python-versions = ">=3.9" files = [ - {file = "bandit-1.8.2-py3-none-any.whl", hash = "sha256:df6146ad73dd30e8cbda4e29689ddda48364e36ff655dbfc86998401fcf1721f"}, - {file = "bandit-1.8.2.tar.gz", hash = "sha256:e00ad5a6bc676c0954669fe13818024d66b70e42cf5adb971480cf3b671e835f"}, + {file = "bandit-1.8.3-py3-none-any.whl", hash = "sha256:28f04dc0d258e1dd0f99dee8eefa13d1cb5e3fde1a5ab0c523971f97b289bcd8"}, + {file = "bandit-1.8.3.tar.gz", hash = "sha256:f5847beb654d309422985c36644649924e0ea4425c76dec2e89110b87506193a"}, ] [package.dependencies] @@ -4136,4 +4136,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.0" python-versions = ">=3.12,<3.13" -content-hash = "03195257f5063a78ed467e7dd711cb57a2d7db7f4b19b66e644a8c285fc1e40c" +content-hash = "04bcc29c963b6241e75fe9bb5337471401819c4119ddbedee8b72e2f070a7cb8" diff --git a/pyproject.toml b/pyproject.toml index ea74a0d2..ba19e2fe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,7 +46,7 @@ pytest = "==8.3.4" pytest-cov = "==6.0.0" black = "==25.1.0" ruff = "==0.9.6" -bandit = "==1.8.2" +bandit = "==1.8.3" build = "==1.2.2.post1" wheel = "==0.45.1" litellm = "==1.61.6" diff --git a/src/codegate/api/v1.py b/src/codegate/api/v1.py index c5ac57d5..ebd9be79 100644 --- a/src/codegate/api/v1.py +++ b/src/codegate/api/v1.py @@ -12,7 +12,7 @@ from codegate import __version__ from codegate.api import v1_models, v1_processing from codegate.db.connection import AlreadyExistsError, DbReader -from codegate.db.models import AlertSeverity +from codegate.db.models import AlertSeverity, WorkspaceWithModel from codegate.providers import crud as provendcrud from codegate.workspaces import crud @@ -532,6 +532,22 @@ async def set_workspace_muxes( return Response(status_code=204) +@v1.get( + "/workspaces/{provider_id}", + tags=["Workspaces"], + generate_unique_id_function=uniq_name, +) +async def list_workspaces_by_provider( + provider_id: UUID, +) -> List[WorkspaceWithModel]: + """List workspaces by provider ID.""" + try: + return await wscrud.workspaces_by_provider(provider_id) + + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + @v1.get("/alerts_notification", tags=["Dashboard"], generate_unique_id_function=uniq_name) async def stream_sse(): """ diff --git a/src/codegate/api/v1_models.py b/src/codegate/api/v1_models.py index 1b883df9..c608484c 100644 --- a/src/codegate/api/v1_models.py +++ b/src/codegate/api/v1_models.py @@ -5,6 +5,7 @@ import pydantic +import codegate.muxing.models as mux_models from codegate.db import models as db_models from codegate.extract_snippets.message_extractor import CodeSnippet from codegate.providers.base import BaseProvider @@ -59,9 +60,19 @@ def from_db_workspaces( ) -class CreateOrRenameWorkspaceRequest(pydantic.BaseModel): +class WorkspaceConfig(pydantic.BaseModel): + system_prompt: str + + muxing_rules: List[mux_models.MuxRule] + + +class FullWorkspace(pydantic.BaseModel): name: str + config: Optional[WorkspaceConfig] = None + + +class CreateOrRenameWorkspaceRequest(FullWorkspace): # If set, rename the workspace to this name. Note that # the 'name' field is still required and the workspace # workspace must exist. diff --git a/src/codegate/db/connection.py b/src/codegate/db/connection.py index 9c9abd79..123109e6 100644 --- a/src/codegate/db/connection.py +++ b/src/codegate/db/connection.py @@ -28,6 +28,7 @@ ProviderModel, Session, WorkspaceRow, + WorkspaceWithModel, WorkspaceWithSessionInfo, ) from codegate.db.token_usage import TokenUsageParser @@ -129,6 +130,7 @@ async def record_request(self, prompt_params: Optional[Prompt] = None) -> Option active_workspace = await DbReader().get_active_workspace() workspace_id = active_workspace.id if active_workspace else "1" prompt_params.workspace_id = workspace_id + sql = text( """ INSERT INTO prompts (id, timestamp, provider, request, type, workspace_id) @@ -302,7 +304,7 @@ async def record_context(self, context: Optional[PipelineContext]) -> None: await self.record_outputs(context.output_responses, initial_id) await self.record_alerts(context.alerts_raised, initial_id) logger.info( - f"Recorded context in DB. Output chunks: {len(context.output_responses)}. " + f"Updated context in DB. Output chunks: {len(context.output_responses)}. " f"Alerts: {len(context.alerts_raised)}." ) except Exception as e: @@ -720,6 +722,23 @@ async def get_workspace_by_name(self, name: str) -> Optional[WorkspaceRow]: ) return workspaces[0] if workspaces else None + async def get_workspaces_by_provider(self, provider_id: str) -> List[WorkspaceWithModel]: + sql = text( + """ + SELECT + w.id, w.name, m.provider_model_name + FROM workspaces w + JOIN muxes m ON w.id = m.workspace_id + WHERE m.provider_endpoint_id = :provider_id + AND w.deleted_at IS NULL + """ + ) + conditions = {"provider_id": provider_id} + workspaces = await self._exec_select_conditions_to_pydantic( + WorkspaceWithModel, sql, conditions, should_raise=True + ) + return workspaces + async def get_archived_workspace_by_name(self, name: str) -> Optional[WorkspaceRow]: sql = text( """ diff --git a/src/codegate/db/models.py b/src/codegate/db/models.py index 2dd7568c..8f2365a0 100644 --- a/src/codegate/db/models.py +++ b/src/codegate/db/models.py @@ -189,6 +189,14 @@ class WorkspaceWithSessionInfo(BaseModel): session_id: Optional[str] +class WorkspaceWithModel(BaseModel): + """Returns a workspace ID with model name""" + + id: str + name: WorkspaceNameStr + provider_model_name: str + + class ActiveWorkspace(BaseModel): """Returns a full active workspace object with the with the session information. diff --git a/src/codegate/muxing/adapter.py b/src/codegate/muxing/adapter.py index 5a4a70c1..e0678f97 100644 --- a/src/codegate/muxing/adapter.py +++ b/src/codegate/muxing/adapter.py @@ -136,7 +136,7 @@ def _format_antropic(self, chunk: str) -> str: def _format_as_openai_chunk(self, formatted_chunk: str) -> str: """Format the chunk as OpenAI chunk. This is the format how the clients expect the data.""" - chunk_to_send = f"data:{formatted_chunk}\n\n" + chunk_to_send = f"data: {formatted_chunk}\n\n" return chunk_to_send async def _format_streaming_response( diff --git a/src/codegate/muxing/models.py b/src/codegate/muxing/models.py index b26a38e7..4c822485 100644 --- a/src/codegate/muxing/models.py +++ b/src/codegate/muxing/models.py @@ -26,6 +26,8 @@ class MuxRule(pydantic.BaseModel): Represents a mux rule for a provider. """ + # Used for exportable workspaces + provider_name: Optional[str] = None provider_id: str model: str # The type of matcher to use diff --git a/src/codegate/pipeline/output.py b/src/codegate/pipeline/output.py index 6f990e03..16672a60 100644 --- a/src/codegate/pipeline/output.py +++ b/src/codegate/pipeline/output.py @@ -127,7 +127,10 @@ def _record_to_db(self) -> None: loop.create_task(self._db_recorder.record_context(self._input_context)) async def process_stream( - self, stream: AsyncIterator[ModelResponse], cleanup_sensitive: bool = True + self, + stream: AsyncIterator[ModelResponse], + cleanup_sensitive: bool = True, + finish_stream: bool = True, ) -> AsyncIterator[ModelResponse]: """ Process a stream through all pipeline steps @@ -167,7 +170,7 @@ async def process_stream( finally: # NOTE: Don't use await in finally block, it will break the stream # Don't flush the buffer if we assume we'll call the pipeline again - if cleanup_sensitive is False: + if cleanup_sensitive is False and finish_stream: self._record_to_db() return @@ -194,7 +197,8 @@ async def process_stream( yield chunk self._context.buffer.clear() - self._record_to_db() + if finish_stream: + self._record_to_db() # Cleanup sensitive data through the input context if cleanup_sensitive and self._input_context and self._input_context.sensitive: self._input_context.sensitive.secure_cleanup() diff --git a/src/codegate/providers/copilot/provider.py b/src/codegate/providers/copilot/provider.py index bf711210..4e1c6f1d 100644 --- a/src/codegate/providers/copilot/provider.py +++ b/src/codegate/providers/copilot/provider.py @@ -905,8 +905,16 @@ async def stream_iterator(): ) yield mr + # needs to be set as the flag gets reset on finish_data + finish_stream_flag = any( + choice.get("finish_reason") == "stop" + for record in list(self.stream_queue._queue) + for choice in record.get("content", {}).get("choices", []) + ) async for record in self.output_pipeline_instance.process_stream( - stream_iterator(), cleanup_sensitive=False + stream_iterator(), + cleanup_sensitive=False, + finish_stream=finish_stream_flag, ): chunk = record.model_dump_json(exclude_none=True, exclude_unset=True) sse_data = f"data: {chunk}\n\n".encode("utf-8") diff --git a/src/codegate/providers/litellmshim/generators.py b/src/codegate/providers/litellmshim/generators.py index 306f1900..8093d52f 100644 --- a/src/codegate/providers/litellmshim/generators.py +++ b/src/codegate/providers/litellmshim/generators.py @@ -17,9 +17,9 @@ async def sse_stream_generator(stream: AsyncIterator[Any]) -> AsyncIterator[str] # this might even allow us to tighten the typing of the stream chunk = chunk.model_dump_json(exclude_none=True, exclude_unset=True) try: - yield f"data:{chunk}\n\n" + yield f"data: {chunk}\n\n" except Exception as e: - yield f"data:{str(e)}\n\n" + yield f"data: {str(e)}\n\n" except Exception as e: yield f"data: {str(e)}\n\n" finally: diff --git a/src/codegate/providers/openai/provider.py b/src/codegate/providers/openai/provider.py index 6e936cf4..f4d3e8ed 100644 --- a/src/codegate/providers/openai/provider.py +++ b/src/codegate/providers/openai/provider.py @@ -18,8 +18,9 @@ class OpenAIProvider(BaseProvider): def __init__( self, pipeline_factory: PipelineFactory, + # Enable receiving other completion handlers from childs, i.e. OpenRouter and LM Studio + completion_handler: LiteLLmShim = LiteLLmShim(stream_generator=sse_stream_generator), ): - completion_handler = LiteLLmShim(stream_generator=sse_stream_generator) super().__init__( OpenAIInputNormalizer(), OpenAIOutputNormalizer(), diff --git a/src/codegate/providers/openrouter/provider.py b/src/codegate/providers/openrouter/provider.py index de65662d..dd934161 100644 --- a/src/codegate/providers/openrouter/provider.py +++ b/src/codegate/providers/openrouter/provider.py @@ -2,12 +2,14 @@ from typing import Dict from fastapi import Header, HTTPException, Request +from litellm import atext_completion from litellm.types.llms.openai import ChatCompletionRequest from codegate.clients.clients import ClientType from codegate.clients.detector import DetectClient from codegate.pipeline.factory import PipelineFactory from codegate.providers.fim_analyzer import FIMAnalyzer +from codegate.providers.litellmshim import LiteLLmShim, sse_stream_generator from codegate.providers.normalizer.completion import CompletionNormalizer from codegate.providers.openai import OpenAIProvider @@ -20,15 +22,45 @@ def normalize(self, data: Dict) -> ChatCompletionRequest: return super().normalize(data) def denormalize(self, data: ChatCompletionRequest) -> Dict: - if data.get("had_prompt_before", False): - del data["had_prompt_before"] - - return data + """ + Denormalize a FIM OpenRouter request. Force it to be an accepted atext_completion format. + """ + denormalized_data = super().denormalize(data) + # We are forcing atext_completion which expects to have a "prompt" key in the data + # Forcing it in case is not present + if "prompt" in data: + return denormalized_data + custom_prompt = "" + for msg_dict in denormalized_data.get("messages", []): + content_obj = msg_dict.get("content") + if not content_obj: + continue + if isinstance(content_obj, list): + for content_dict in content_obj: + custom_prompt += ( + content_dict.get("text", "") if isinstance(content_dict, dict) else "" + ) + elif isinstance(content_obj, str): + custom_prompt += content_obj + + # Erase the original "messages" key. Replace it by "prompt" + del denormalized_data["messages"] + denormalized_data["prompt"] = custom_prompt + + return denormalized_data class OpenRouterProvider(OpenAIProvider): def __init__(self, pipeline_factory: PipelineFactory): - super().__init__(pipeline_factory) + super().__init__( + pipeline_factory, + # We get FIM requests in /completions. LiteLLM is forcing /chat/completions + # which returns "choices":[{"delta":{"content":"some text"}}] + # instead of "choices":[{"text":"some text"}] expected by the client (Continue) + completion_handler=LiteLLmShim( + stream_generator=sse_stream_generator, fim_completion_func=atext_completion + ), + ) self._fim_normalizer = OpenRouterNormalizer() @property diff --git a/src/codegate/workspaces/crud.py b/src/codegate/workspaces/crud.py index 7423af5e..a81426a8 100644 --- a/src/codegate/workspaces/crud.py +++ b/src/codegate/workspaces/crud.py @@ -218,6 +218,13 @@ async def get_workspace_by_name(self, workspace_name: str) -> db_models.Workspac raise WorkspaceDoesNotExistError(f"Workspace {workspace_name} does not exist.") return workspace + async def workspaces_by_provider(self, provider_id: uuid) -> List[db_models.WorkspaceWithModel]: + """Get the workspaces by provider.""" + + workspaces = await self._db_reader.get_workspaces_by_provider(str(provider_id)) + + return workspaces + async def get_muxes(self, workspace_name: str) -> List[mux_models.MuxRule]: # Verify if workspace exists workspace = await self._db_reader.get_workspace_by_name(workspace_name) From 9ac90cb629a6b86199046c93b5135fa76e386c4e Mon Sep 17 00:00:00 2001 From: ChrisJBurns <29541485+ChrisJBurns@users.noreply.github.com> Date: Wed, 19 Feb 2025 11:09:03 +0000 Subject: [PATCH 2/4] forces replica count of 1 Signed-off-by: ChrisJBurns <29541485+ChrisJBurns@users.noreply.github.com> --- deploy/charts/codegate/templates/deployment.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/deploy/charts/codegate/templates/deployment.yaml b/deploy/charts/codegate/templates/deployment.yaml index fae061e6..6b3cd7aa 100644 --- a/deploy/charts/codegate/templates/deployment.yaml +++ b/deploy/charts/codegate/templates/deployment.yaml @@ -7,7 +7,8 @@ metadata: {{- include "codegate.labels" . | nindent 4 }} spec: {{- if not .Values.autoscaling.enabled }} - replicas: {{ .Values.replicaCount }} + # we hardcode to 1 at the moment as there is only a single file sqlite database + replicas: 1 {{- end }} selector: matchLabels: From 8060ed460046d80e943262bd5c9726b6367b6244 Mon Sep 17 00:00:00 2001 From: ChrisJBurns <29541485+ChrisJBurns@users.noreply.github.com> Date: Wed, 19 Feb 2025 11:45:04 +0000 Subject: [PATCH 3/4] fix: adds storage class for chart testing Signed-off-by: ChrisJBurns <29541485+ChrisJBurns@users.noreply.github.com> --- deploy/charts/codegate/ci/default-values.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/deploy/charts/codegate/ci/default-values.yaml b/deploy/charts/codegate/ci/default-values.yaml index e69de29b..e626cc3f 100644 --- a/deploy/charts/codegate/ci/default-values.yaml +++ b/deploy/charts/codegate/ci/default-values.yaml @@ -0,0 +1,2 @@ +volumePersistence: + storageClassName: standard \ No newline at end of file From 54aa17e8e95d5e567c702eeb19d350424366e7fb Mon Sep 17 00:00:00 2001 From: ChrisJBurns <29541485+ChrisJBurns@users.noreply.github.com> Date: Wed, 19 Feb 2025 11:49:28 +0000 Subject: [PATCH 4/4] adds empty line Signed-off-by: ChrisJBurns <29541485+ChrisJBurns@users.noreply.github.com> --- deploy/charts/codegate/ci/default-values.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deploy/charts/codegate/ci/default-values.yaml b/deploy/charts/codegate/ci/default-values.yaml index e626cc3f..0ded8b73 100644 --- a/deploy/charts/codegate/ci/default-values.yaml +++ b/deploy/charts/codegate/ci/default-values.yaml @@ -1,2 +1,2 @@ volumePersistence: - storageClassName: standard \ No newline at end of file + storageClassName: standard